diff --git a/README.md b/README.md index b570b3af84..7e88ef60cf 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ An editor, live and side-by-side diffs, a full git client with a three-pane merg - **[Repository Panel](https://docs.codeg.app/guide/repository)** — issues, pull requests, checks, and merges for GitHub, GitLab, Gitea, and Forgejo; any issue or PR can become a to-do - **[Automations](https://docs.codeg.app/guide/automations)** — save a configured composer and run it headlessly, on a cron schedule or on demand - **[Token Usage](https://docs.codeg.app/guide/token-usage)** — trends, cache hit rate, an activity heatmap, usage by folder, agent, model, and session, and what it would cost at API list prices +- **[Agent Accounts](docs/agent-accounts.md)**: named accounts on built-in agents, with separate sign-ins and conversation history. See the guide for platform support and backup limits. - **[Fork & Steer](https://docs.codeg.app/guide/workspace#follow-along-%E2%80%94-the-conversation)** — fork a conversation from a finished reply, or send a message into a turn that is still running, on agents that support it - **[Skills](https://docs.codeg.app/guide/skills) & [MCP](https://docs.codeg.app/guide/mcp)** — skill packs per agent, a local MCP scan, and registry search and install - **[Project Boot](https://docs.codeg.app/guide/project-boot)** — scaffold a new project visually, with a live preview diff --git a/docs/agent-accounts.md b/docs/agent-accounts.md new file mode 100644 index 0000000000..3df5f0a21d --- /dev/null +++ b/docs/agent-accounts.md @@ -0,0 +1,101 @@ +# Agent accounts + +Use **Settings > Agents > Accounts** to add a named account to a built-in agent. +Each account has its own folder and official sign-in. **Default** uses the +agent's existing account. Adding a named account keeps the built-in agent's +controls and conversation history. + +You can also choose **Add account** or **Manage accounts** beside a chat's +account picker. Setup opens in the chat, so your draft stays in place. + +Choose **Add account**, give it a name, and choose a connection method. Claude +Code and Codex offer account sign-in or an API key. API usage is billed +separately from subscriptions. Other supported agents use their own setup; +Antigravity offers its personal and business sign-in flows. + +Choose **Continue** to save the account and start setup. Desktop opens the +agent's official sign-in. In the web interface, copy the displayed command and +run it on the computer hosting Codeg. **Check sign-in** checks Claude Code or +Codex after you finish. Opening sign-in does not mean it has completed. +Then choose the named account in a chat's account menu. + +Use **Set up** to reconnect an existing account or change its connection +method. Close its chats before replacing an API key or changing Claude to +account sign-in. The connection settings below the Accounts card configure +**Default**; each named account keeps its own connection settings. + +To reuse an account folder, expand **Advanced: use an existing account folder** while +adding an account. This keeps its existing sign-in and history. Removing an +account keeps its folder and history. Add the same folder again to restore +the account binding. + +## Supported agents + +Named accounts are available for Claude Code, Codex, Gemini, OpenCode, Grok, +Hermes, CodeBuddy, Cline, Kimi Code, Pi, DeepSeek, Qoder and Antigravity. Cursor +supports named accounts on Windows and Linux; use Default on macOS. Kiro, +ZCode, TRAE CLI and Command Code do not yet support named accounts. OpenClaw +uses its configured gateway's account. + +OpenCode and Cursor keep the usual home folder and GitHub CLI configuration. +Other command-line tools that use their separate configuration folders may +need their own configuration or sign-in in each account. Codeg does not copy +their credentials from Default. + +## Conversation accounts + +A conversation keeps the account selected when it was created. Reopening or +resuming it uses that account. A missing or removed account never silently +falls back to Default. + +Claude Code conversations can switch accounts from their account picker when +no turn or background task is running. The conversation and transcript are +retained. The current model and mode carry over unless the target account has +its own saved choices. Turn off Claude Remote Control before switching accounts. +An unavailable requested model cancels the switch. The selected account's own +settings, permissions and project memory apply. + +Session task lists and legacy todos move with a native Claude session, including +updates and deletions. Replaced task files remain in the target account's +`.codeg-account-switch/` recovery folder. A custom task-list id can be shared +with other sessions, so native switching refuses it instead of replacing the +shared list. + +For another supported agent, choosing an account opens **Continue this +conversation**. Confirm the account to start a new native session with the saved +conversation available as context. The chat, draft, title, pin and tags remain +in place. + +## Continue with another agent + +Choose **Switch agent or account** in the composer or conversation menu. Select +an installed agent and an account, optionally add a note, then choose **Switch**. +The dialog identifies whether the selected agent resumes the native Claude +session or starts a new session with portable context. + +Portable context keeps the complete conversation saved by Codeg, including its +tool records and embedded images. Long histories stay available in pages through +a read-only context tool, rather than being shortened into a digest. Agents +that do not accept MCP servers use the local transcript. OpenClaw gateway +handoffs are not supported. Account sign-ins and credentials are never copied. + +Different agents have different tools, instructions and context limits. A +portable handoff does not transfer another provider's private session state. +The original agent's files remain intact, and the earlier conversation stays +visible in the same chat. If the new agent's first prompt fails, Codeg keeps the +context ready for the next send. + +## Backups and moving computers + +A Codeg backup includes account names, their folder paths and conversation +bindings, including saved handoff context. **Include conversation content** includes transcripts from the CLI +default folders. It does not include named-account folders, their transcripts, +or their sign-ins. Back up named-account folders separately before deleting +data or moving to another computer. + +Restoring a Codeg backup keeps the saved account paths. Restore the separate +folders to those paths to reopen their conversations, and sign in again if the +agent requires it. A missing folder produces an error instead of starting a +different account. Keep the original folders until you have verified the +restored history. Account folders can contain credentials, so protect those +separate backups accordingly. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e4e8395cfe..edcf0fd255 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -179,7 +179,7 @@ ignore = "0.4" # RFDs. (`unstable_end_turn_token_usage` stays off: codeg's token accounting # reads the agents' own transcripts, not the prompt response's `usage`.) agent-client-protocol = { version = "2.2.0", features = ["unstable_session_fork", "unstable_session_notices", "unstable_session_compaction"] } -tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt", "net", "rt-multi-thread"] } +tokio = { version = "1", features = ["process", "io-util", "io-std", "sync", "macros", "rt", "net", "rt-multi-thread"] } uuid = { version = "1", features = ["v4"] } # Version-5 UUIDs (browser profile data-store identifiers) by hand: uuid's # own `v5` feature would pull a second SHA-1 crate next to this one. @@ -346,4 +346,3 @@ temp-env = { version = "0.3", features = ["async_closure"] } tokio = { version = "1", features = ["test-util", "macros", "rt", "sync", "time"] } insta = { version = "1", features = ["json", "redactions"] } axum-test = { version = "17", features = ["ws"] } - diff --git a/src-tauri/src/acp/account_auth.rs b/src-tauri/src/acp/account_auth.rs new file mode 100644 index 0000000000..d184ee1d74 --- /dev/null +++ b/src-tauri/src/acp/account_auth.rs @@ -0,0 +1,596 @@ +//! Account-scoped setup through native credential stores. Secrets are input +//! only; status returns a small projection of the official CLI's response. +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::path::Path; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::acp::{agent_accounts, error::AcpError}; +use crate::db::{entities::agent_account, AppDatabase}; +use crate::models::AgentType; + +const OUTPUT_LIMIT: u64 = 64 * 1024; +const SETTINGS_LIMIT: u64 = 2 * 1024 * 1024; +static REVISION: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AccountAuthMethod { + SignIn, + ApiKey, + Native, +} + +pub fn methods(agent: AgentType) -> &'static [AccountAuthMethod] { + if matches!(agent, AgentType::ClaudeCode | AgentType::Codex) { + &[AccountAuthMethod::SignIn, AccountAuthMethod::ApiKey] + } else { + &[AccountAuthMethod::Native] + } +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AuthState { + SignedIn, + SignedOut, + Unavailable, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AccountAuthStatus { + pub state: AuthState, + pub method: Option, +} + +pub fn revision() -> u64 { + REVISION.load(Ordering::Relaxed) +} + +pub fn changed() { + REVISION.fetch_add(1, Ordering::Relaxed); +} + +pub fn ensure_plain(path: &Path, optional: bool) -> Result<(), AcpError> { + let metadata = match path.symlink_metadata() { + Ok(value) => value, + Err(error) if optional && error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => { + return Err(AcpError::protocol( + "Restore the account folder before changing sign-in.", + )) + } + }; + let linked = metadata.file_type().is_symlink(); + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(AcpError::protocol( + "Sign-in settings cannot use a linked file or folder.", + )); + } + } + if linked { + return Err(AcpError::protocol( + "Sign-in settings cannot use a linked file or folder.", + )); + } + if !metadata.is_file() && !metadata.is_dir() { + return Err(AcpError::protocol( + "Sign-in settings must use regular files and folders.", + )); + } + Ok(()) +} + +pub fn check_paths(agent: AgentType, home: &Path) -> Result<(), AcpError> { + ensure_plain(home, false)?; + for file in match agent { + AgentType::ClaudeCode => &[ + "settings.json", + "settings.local.json", + ".credentials.json", + "anthropic", + ][..], + AgentType::Codex => &["auth.json", "config.toml"][..], + _ => &[], + } { + ensure_plain(&home.join(file), true)?; + } + Ok(()) +} + +fn read_claude_settings(home: &Path) -> Result { + check_paths(AgentType::ClaudeCode, home)?; + let path = home.join("settings.json"); + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(json!({})), + Err(_) => { + return Err(AcpError::protocol( + "Could not read this account's settings.", + )) + } + }; + let mut bytes = Vec::new(); + file.take(SETTINGS_LIMIT + 1) + .read_to_end(&mut bytes) + .map_err(|_| AcpError::protocol("Could not read this account's settings."))?; + if bytes.len() as u64 > SETTINGS_LIMIT { + return Err(AcpError::protocol( + "This account's settings file is too large to update.", + )); + } + let value: Value = serde_json::from_slice(&bytes).map_err(|_| { + AcpError::protocol("Fix this account's settings.json before changing sign-in.") + })?; + if !value.is_object() || value.get("env").is_some_and(|env| !env.is_object()) { + return Err(AcpError::protocol( + "Fix this account's settings.json before changing sign-in.", + )); + } + Ok(value) +} + +/// Narrow patch, preserving permissions, model choices and unrelated settings. +/// The temporary file is private from creation and replaces the file atomically. +pub fn set_claude_key(home: &Path, key: Option<&str>) -> Result<(), AcpError> { + if let Some(key) = key { + validate_key(key)?; + } + let mut value = read_claude_settings(home)?; + let object = value.as_object_mut().expect("validated object"); + for key in ["apiKey", "api_key", "apiKeyHelper"] { + object.remove(key); + } + if key.is_none() { + object.remove("apiBaseUrl"); + object.remove("api_base_url"); + } + let env = object + .entry("env") + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("validated env object"); + for name in agent_accounts::CLAUDE_AUTH_ENV_KEYS { + if key.is_some() && *name == "ANTHROPIC_BASE_URL" { + continue; + } + env.remove(*name); + } + if let Some(key) = key { + env.insert("ANTHROPIC_API_KEY".into(), Value::String(key.trim().into())); + } + let mut temp = tempfile::NamedTempFile::new_in(home) + .map_err(|_| AcpError::protocol("Could not save this account's sign-in settings."))?; + serde_json::to_writer_pretty(&mut temp, &value) + .map_err(|_| AcpError::protocol("Could not save this account's sign-in settings."))?; + temp.write_all(b"\n") + .and_then(|_| temp.as_file().sync_all()) + .map_err(|_| AcpError::protocol("Could not save this account's sign-in settings."))?; + // Refuse a link created while the new file was being prepared too. + check_paths(AgentType::ClaudeCode, home)?; + temp.persist(home.join("settings.json")) + .map_err(|_| AcpError::protocol("Could not save this account's sign-in settings."))?; + Ok(()) +} + +pub fn validate_key(key: &str) -> Result<(), AcpError> { + let key = key.trim(); + if key.is_empty() + || key.len() > 16 * 1024 + || key.chars().any(|c| c.is_control() || c.is_whitespace()) + { + return Err(AcpError::protocol("Enter a valid API key.")); + } + Ok(()) +} + +pub async fn runtime_env( + db: &AppDatabase, + account: &agent_account::Model, + data_dir: &Path, +) -> Result, AcpError> { + let agent = AgentType::from_wire(&account.agent_type) + .ok_or_else(|| AcpError::protocol("Unknown agent account"))?; + let home = Path::new(&account.home_dir); + let mut env = crate::commands::acp::build_runtime_env_for_agent( + db, + agent, + None, + data_dir, + true, + Some(home), + ) + .await?; + env.extend( + agent_accounts::isolation_env_with_base(agent, home, &env).map_err(AcpError::protocol)?, + ); + Ok(env) +} + +/// Do not return CLI output to callers: failures can echo an API key, and +/// successful status output can contain account identity details. +async fn run_private_cli( + executable: &Path, + args: &[&str], + env: &BTreeMap, + home: &Path, + input: Option<&str>, +) -> Result<(bool, Vec, Vec), AcpError> { + let mut command = crate::process::tokio_command(executable); + command + .args(args) + .current_dir(home) + .kill_on_drop(true) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (key, value) in env { + if value.is_empty() { + command.env_remove(key); + } else { + command.env(key, value); + } + } + command + .env_remove(agent_accounts::ACCOUNT_ENV) + .env_remove(agent_accounts::ACCOUNT_HOME_ENV); + let mut child = command.spawn().map_err(|_| { + AcpError::protocol( + "Could not start the agent. Check its installation in Settings > Agents.", + ) + })?; + let mut stdin = child.stdin.take(); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let operation = async { + let send = async { + if let (Some(writer), Some(input)) = (&mut stdin, input) { + writer.write_all(input.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.shutdown().await?; + } + drop(stdin); + Ok::<_, std::io::Error>(()) + }; + let read = async |stream: tokio::process::ChildStdout| { + let mut bytes = Vec::new(); + stream + .take(OUTPUT_LIMIT + 1) + .read_to_end(&mut bytes) + .await?; + Ok::<_, std::io::Error>(bytes) + }; + let errors = async { + let mut bytes = Vec::new(); + stderr + .take(OUTPUT_LIMIT + 1) + .read_to_end(&mut bytes) + .await?; + Ok::<_, std::io::Error>(bytes) + }; + let (_, out, err, status) = tokio::try_join!(send, read(stdout), errors, child.wait())?; + if out.len() as u64 > OUTPUT_LIMIT || err.len() as u64 > OUTPUT_LIMIT { + return Err(std::io::Error::other("CLI output limit exceeded")); + } + Ok::<_, std::io::Error>((status.success(), out, err)) + }; + match tokio::time::timeout(Duration::from_secs(20), operation).await { + Ok(Ok(result)) => Ok(result), + _ => { + if let Some(pid) = child.id() { + let config = kill_tree::Config { + signal: "SIGKILL".into(), + ..Default::default() + }; + let _ = kill_tree::tokio::kill_tree_with_config(pid, &config).await; + } + let _ = child.kill().await; + Err(AcpError::protocol( + "The agent could not finish sign-in setup. Try again.", + )) + } + } +} + +pub async fn save_codex_key( + executable: &Path, + home: &Path, + env: &BTreeMap, + key: &str, +) -> Result<(), AcpError> { + validate_key(key)?; + check_paths(AgentType::Codex, home)?; + let (success, _, _) = run_private_cli( + executable, + &["login", "--with-api-key"], + env, + home, + Some(key.trim()), + ) + .await?; + if !success { + return Err(AcpError::protocol( + "Could not save the API key. Check the key and the agent's sign-in settings.", + )); + } + Ok(()) +} + +pub fn parse_status( + agent: AgentType, + success: bool, + stdout: &[u8], + stderr: &[u8], +) -> AccountAuthStatus { + let unavailable = AccountAuthStatus { + state: AuthState::Unavailable, + method: None, + }; + if agent == AgentType::ClaudeCode { + let Ok(value) = serde_json::from_slice::(stdout) else { + return unavailable; + }; + return match value.get("loggedIn").and_then(Value::as_bool) { + Some(true) if success => AccountAuthStatus { + state: AuthState::SignedIn, + method: Some(match value.get("authMethod").and_then(Value::as_str) { + Some("api_key" | "api_key_helper") => AccountAuthMethod::ApiKey, + Some("claude.ai" | "oauth_token") => AccountAuthMethod::SignIn, + _ => AccountAuthMethod::Native, + }), + }, + Some(false) => AccountAuthStatus { + state: AuthState::SignedOut, + method: None, + }, + _ => unavailable, + }; + } + let text = format!( + "{}\n{}", + String::from_utf8_lossy(stdout), + String::from_utf8_lossy(stderr) + ) + .to_ascii_lowercase(); + if success && text.contains("logged in using") { + AccountAuthStatus { + state: AuthState::SignedIn, + method: Some(if text.contains("api key") { + AccountAuthMethod::ApiKey + } else if text.contains("chatgpt") { + AccountAuthMethod::SignIn + } else { + AccountAuthMethod::Native + }), + } + } else if !success && text.lines().any(|line| line.trim() == "not logged in") { + AccountAuthStatus { + state: AuthState::SignedOut, + method: None, + } + } else { + unavailable + } +} + +pub async fn check( + agent: AgentType, + executable: &Path, + home: &Path, + env: &BTreeMap, +) -> Result { + let args: &[&str] = match agent { + AgentType::ClaudeCode => &["auth", "status"], + AgentType::Codex => &["login", "status"], + _ => { + return Ok(AccountAuthStatus { + state: AuthState::Unavailable, + method: None, + }) + } + }; + let (success, out, err) = run_private_cli(executable, args, env, home, None).await?; + if agent == AgentType::ClaudeCode { + if let Some(directory) = serde_json::from_slice::(&out) + .ok() + .and_then(|value| { + value + .get("configDirectory") + .and_then(Value::as_str) + .map(str::to_owned) + }) + { + if !crate::parsers::path_eq_for_matching(&directory, &home.to_string_lossy()) { + return Ok(AccountAuthStatus { + state: AuthState::Unavailable, + method: None, + }); + } + } + } + Ok(parse_status(agent, success, &out, &err)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn account_auth_patch_preserves_preferences_and_separates_api_from_subscription() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let original = json!({"permissions":{"allow":["Read"]},"model":"sonnet", "apiKeyHelper":"old-helper", "env":{"ANTHROPIC_AUTH_TOKEN":"old-token", "CLAUDE_CODE_USE_VERTEX":"1", "ANTHROPIC_PROFILE":"old-profile", "ANTHROPIC_BASE_URL":"https://api.example.test", "EDITOR":"vim"}}); + std::fs::write(&path, original.to_string()).unwrap(); + set_claude_key(dir.path(), Some("fixture-key")).unwrap(); + let api: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(api["permissions"], original["permissions"]); + assert_eq!(api["model"], "sonnet"); + assert_eq!(api["env"]["EDITOR"], "vim"); + assert_eq!(api["env"]["ANTHROPIC_API_KEY"], "fixture-key"); + assert_eq!(api["env"]["ANTHROPIC_BASE_URL"], "https://api.example.test"); + assert!(api.get("apiKeyHelper").is_none()); + for key in [ + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_USE_VERTEX", + "ANTHROPIC_PROFILE", + ] { + assert!(api["env"].get(key).is_none()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, + 0 + ); + } + set_claude_key(dir.path(), None).unwrap(); + let subscription: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(subscription["env"], json!({"EDITOR":"vim"})); + assert_eq!(subscription["permissions"], original["permissions"]); + } + + #[test] + fn account_auth_refuses_invalid_settings_and_keys_without_overwriting() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + for original in ["{broken", "[]", "{\"env\":null}"] { + std::fs::write(&path, original).unwrap(); + assert!(set_claude_key(dir.path(), Some("fixture-key")).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + } + std::fs::write(&path, "{}").unwrap(); + for key in ["", " ", "key\nother", "key other"] { + assert!(set_claude_key(dir.path(), Some(key)).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "{}"); + } + } + + #[cfg(unix)] + #[test] + fn account_auth_refuses_linked_native_stores_without_touching_the_target() { + use std::os::unix::fs::symlink; + let dir = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let original = other.path().join("settings.json"); + std::fs::write(&original, "{}").unwrap(); + symlink(&original, dir.path().join("settings.json")).unwrap(); + assert!(set_claude_key(dir.path(), Some("fixture-key")).is_err()); + assert_eq!(std::fs::read_to_string(original).unwrap(), "{}"); + } + + #[test] + fn account_auth_status_never_projects_identity_or_errors_as_signed_out() { + let status = parse_status(AgentType::ClaudeCode, true, br#"{"loggedIn":true,"authMethod":"api_key","email":"private@example.test","apiKey":"fixture-secret"}"#, b""); + assert_eq!(status.method, Some(AccountAuthMethod::ApiKey)); + assert_eq!( + serde_json::to_value(status).unwrap(), + json!({"state":"signed_in", "method":"api_key"}) + ); + assert_eq!( + parse_status( + AgentType::ClaudeCode, + false, + b"error with fixture-secret", + b"" + ) + .state, + AuthState::Unavailable + ); + assert_eq!( + parse_status(AgentType::ClaudeCode, false, br#"{"loggedIn":false}"#, b"").state, + AuthState::SignedOut + ); + assert_eq!( + parse_status( + AgentType::Codex, + true, + b"", + b"Logged in using an API key - fixture-secret" + ) + .method, + Some(AccountAuthMethod::ApiKey) + ); + assert_eq!( + parse_status(AgentType::Codex, true, b"", b"Logged in using ChatGPT").method, + Some(AccountAuthMethod::SignIn) + ); + assert_eq!( + parse_status(AgentType::Codex, false, b"", b"Not logged in\n").state, + AuthState::SignedOut + ); + assert_eq!( + parse_status( + AgentType::Codex, + false, + b"", + b"Keyring error: not logged in? fixture-secret" + ) + .state, + AuthState::Unavailable + ); + } + + #[cfg(unix)] + fn fake_cli(home: &Path, script: &str) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let executable = home.join("fake-cli"); + std::fs::write(&executable, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + executable + } + + #[cfg(unix)] + #[tokio::test] + async fn account_auth_codex_key_goes_only_to_stdin_in_its_own_home() { + let dir = tempfile::tempdir().unwrap(); + let executable = fake_cli(dir.path(), "printf '%s\\n' \"$@\" > args\nprintf '%s' \"$CODEX_HOME\" > scope\ncat > input\nprintf '%s' 'fixture-secret' >&2\nexit 1"); + let env = agent_accounts::isolation_env(AgentType::Codex, dir.path()).unwrap(); + let error = save_codex_key(&executable, dir.path(), &env, "fixture-secret") + .await + .unwrap_err(); + assert!(!error.to_string().contains("fixture-secret")); + assert_eq!( + std::fs::read_to_string(dir.path().join("args")).unwrap(), + "login\n--with-api-key\n" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("input")).unwrap(), + "fixture-secret\n" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("scope")).unwrap(), + dir.path().to_str().unwrap() + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn account_auth_rejects_status_from_another_claude_home() { + let dir = tempfile::tempdir().unwrap(); + let executable = fake_cli(dir.path(), "printf '%s' '{\"loggedIn\":true,\"authMethod\":\"claude.ai\",\"configDirectory\":\"/another/home\"}'"); + let env = agent_accounts::isolation_env(AgentType::ClaudeCode, dir.path()).unwrap(); + assert_eq!( + env.get("ANTHROPIC_CONFIG_DIR"), + Some(&dir.path().join("anthropic").to_string_lossy().into_owned()) + ); + let result = check(AgentType::ClaudeCode, &executable, dir.path(), &env) + .await + .unwrap(); + assert_eq!(result.state, AuthState::Unavailable); + } +} diff --git a/src-tauri/src/acp/account_skills.rs b/src-tauri/src/acp/account_skills.rs new file mode 100644 index 0000000000..c2f9022d6e --- /dev/null +++ b/src-tauri/src/acp/account_skills.rs @@ -0,0 +1,381 @@ +//! Shared skill installations remain one source. Account homes link to that +//! source; files already owned by an adopted account are never overwritten. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::models::AgentType; + +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind")] +enum Managed { + Directory { source: PathBuf }, + Markdown { digest: String }, +} + +type Manifest = BTreeMap; +static SYNC_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Positional against `skill_storage_spec`'s ordered roots. `None` means the +/// CLI continues to read that shared OS-home root without relocating it. +fn destinations(agent: AgentType) -> &'static [Option<&'static str>] { + match agent { + AgentType::ClaudeCode | AgentType::Grok | AgentType::Hermes | AgentType::CodeBuddy => { + &[Some("skills")] + } + AgentType::Codex => &[Some("skills"), Some("skills/.system"), None], + AgentType::OpenCode => &[Some("config/opencode/skills"), Some(".agents/skills")], + // Gemini 0.60's Storage.getGlobalAgentsDir and getGlobalGeminiDir both + // use its GEMINI_CLI_HOME-aware homedir helper. + AgentType::Gemini => &[Some(".gemini/skills"), Some(".agents/skills")], + AgentType::Cline => &[None, None], + AgentType::KimiCode | AgentType::Pi | AgentType::DeepSeek | AgentType::Qoder => { + &[Some("skills"), None] + } + AgentType::Antigravity => &[Some("config/skills"), Some("antigravity-cli/skills")], + AgentType::Cursor if cfg!(target_os = "macos") => &[ + Some(".cursor/skills"), + Some(".agents/skills"), + Some(".cursor/skills-cursor"), + ], + AgentType::Cursor => &[None, None, None], + _ => &[], + } +} + +pub fn sync(agent: AgentType, home: &Path) -> Result<(), String> { + let spec = + crate::commands::acp::skill_storage_spec(agent).ok_or("Unknown skill storage layout")?; + let targets = destinations(agent); + if spec.global_dirs.len() != targets.len() { + return Err(format!( + "The {agent} skill layout has changed. Update the account mapping before continuing." + )); + } + let pairs = spec + .global_dirs + .iter() + .zip(targets) + .filter_map(|(source, target)| target.map(|target| (source.clone(), home.join(target)))) + .collect::>(); + sync_roots(home, &pairs).map_err(|e| format!("Could not share account skills: {e}")) +} + +fn digest(path: &Path) -> std::io::Result { + Ok(format!("{:x}", Sha256::digest(fs::read(path)?))) +} + +fn is_link(path: &Path) -> bool { + #[cfg(windows)] + if junction::exists(path).unwrap_or(false) { + return true; + } + fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) +} + +fn link_target(path: &Path) -> std::io::Result { + #[cfg(windows)] + if junction::exists(path)? { + return junction::get_target(path); + } + fs::read_link(path) +} + +fn same_path(a: &Path, b: &Path) -> bool { + crate::parsers::path_eq_for_matching(&a.to_string_lossy(), &b.to_string_lossy()) + || matches!((a.canonicalize(), b.canonicalize()), (Ok(a), Ok(b)) if a == b) +} + +fn remove_link(path: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + // RemoveDirectory removes the directory link itself without following + // its target. junction::delete only clears the reparse point and + // leaves an empty directory, which would mask future shared skills. + fs::remove_dir(path) + } + #[cfg(not(windows))] + { + fs::remove_file(path) + } +} + +fn create_link(source: &Path, target: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + junction::create(source, target) + } + #[cfg(unix)] + { + std::os::unix::fs::symlink(source, target) + } +} + +fn invalid(message: &str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidInput, message) +} + +fn relative(home: &Path, target: &Path) -> std::io::Result { + let path = target + .strip_prefix(home) + .map_err(|_| invalid("Skill destination escaped the account folder"))?; + if path.as_os_str().is_empty() + || path + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err(invalid("Invalid account skill destination")); + } + Ok(path.to_path_buf()) +} + +fn linked_parent(home: &Path, target: &Path) -> bool { + target + .parent() + .into_iter() + .flat_map(Path::ancestors) + .take_while(|path| *path != home) + .any(is_link) +} + +fn materialize( + home: &Path, + source: &Path, + target: &Path, + old: &Manifest, + next: &mut Manifest, +) -> std::io::Result<()> { + let key = relative(home, target)?; + if linked_parent(home, target) { + return Ok(()); + } + if !source.try_exists()? { + return Ok(()); + } + let source = source.canonicalize()?; + if target.canonicalize().is_ok_and(|path| path == source) { + if matches!(old.get(&key), Some(Managed::Directory { .. })) { + next.insert(key, Managed::Directory { source }); + } + return Ok(()); + } + // Do not follow an account-local link to mutate a directory it owns. + if is_link(target) { + return Ok(()); + } + let canonical_target = home.canonicalize()?.join(&key); + if source.starts_with(&canonical_target) || canonical_target.starts_with(&source) { + return Err(invalid("Account and shared skill folders overlap")); + } + if source.is_dir() { + if !target.try_exists()? { + fs::create_dir_all( + target + .parent() + .ok_or_else(|| invalid("Missing skill parent"))?, + )?; + create_link(&source, target)?; + next.insert(key, Managed::Directory { source }); + } else if target.is_dir() && !target.join("SKILL.md").is_file() { + for entry in fs::read_dir(source)? { + let entry = entry?; + materialize( + home, + &entry.path(), + &target.join(entry.file_name()), + old, + next, + )?; + } + } + } else if source.extension().is_some_and(|e| e == "md") { + let previous_matches = match old.get(&key) { + Some(Managed::Markdown { digest: expected }) => { + digest(target).is_ok_and(|actual| &actual == expected) + } + _ => false, + }; + if !target.try_exists()? || previous_matches { + fs::create_dir_all( + target + .parent() + .ok_or_else(|| invalid("Missing skill parent"))?, + )?; + fs::copy(&source, target)?; + next.insert( + key, + Managed::Markdown { + digest: digest(target)?, + }, + ); + } + } + Ok(()) +} + +fn sync_roots(home: &Path, roots: &[(PathBuf, PathBuf)]) -> std::io::Result<()> { + if roots.is_empty() { + return Ok(()); + } + let _guard = SYNC_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let manifest = home.join(".codeg-shared-skills.json"); + let old: Manifest = match fs::read(&manifest) { + Ok(raw) => { + serde_json::from_slice(&raw).map_err(|_| invalid("Invalid shared skill manifest"))? + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(e) => return Err(e), + }; + let mut next = BTreeMap::new(); + for (source, target) in roots { + materialize(home, source, target, &old, &mut next)?; + } + for (key, entry) in old { + if next.contains_key(&key) { + continue; + } + let target = home.join(&key); + relative(home, &target)?; + if linked_parent(home, &target) { + continue; + } + match entry { + Managed::Directory { source } + if is_link(&target) + && link_target(&target).is_ok_and(|actual| same_path(&actual, &source)) => + { + remove_link(&target)? + } + Managed::Markdown { digest: expected } + if !is_link(&target) && digest(&target).is_ok_and(|actual| actual == expected) => + { + fs::remove_file(target)? + } + _ => {} // A local replacement belongs to the account, not this sync. + } + } + use std::io::Write; + let mut temporary = tempfile::NamedTempFile::new_in(home)?; + temporary.write_all( + &serde_json::to_vec_pretty(&next) + .map_err(|_| invalid("Could not serialize skill manifest"))?, + )?; + temporary.persist(manifest).map_err(|e| e.error)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_account_covers_the_entire_existing_skill_layout() { + for agent in crate::models::agent::BUILTIN_AGENT_TYPES { + if crate::acp::agent_accounts::layout(*agent).is_none() { + continue; + } + assert_eq!( + destinations(*agent).len(), + crate::commands::acp::skill_storage_spec(*agent) + .unwrap() + .global_dirs + .len(), + "{agent}" + ); + } + } + + #[test] + fn linked_skills_follow_edits_without_copying_an_account_file() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("account"); + let source = dir.path().join("shared"); + fs::create_dir_all(source.join("example")).unwrap(); + fs::create_dir_all(&home).unwrap(); + fs::write(source.join("example/SKILL.md"), "first").unwrap(); + fs::write(home.join("auth.json"), "private fixture").unwrap(); + let roots = [(source.clone(), home.join("skills"))]; + sync_roots(&home, &roots).unwrap(); + fs::write(source.join("example/SKILL.md"), "second").unwrap(); + assert_eq!( + fs::read_to_string(home.join("skills/example/SKILL.md")).unwrap(), + "second" + ); + assert_eq!( + fs::read_to_string(home.join("auth.json")).unwrap(), + "private fixture" + ); + sync_roots(&home, &roots).unwrap(); + assert!(source.join("example/SKILL.md").is_file()); + } + + #[test] + fn local_overrides_survive_and_only_owned_links_are_removed() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("account"); + let source = dir.path().join("shared"); + for path in [ + source.join("local"), + source.join("added"), + home.join("skills/local"), + ] { + fs::create_dir_all(path).unwrap(); + } + fs::write(source.join("local/SKILL.md"), "shared").unwrap(); + fs::write(source.join("added/SKILL.md"), "added").unwrap(); + fs::write(home.join("skills/local/SKILL.md"), "local").unwrap(); + let roots = [(source.clone(), home.join("skills"))]; + sync_roots(&home, &roots).unwrap(); + assert_eq!( + fs::read_to_string(home.join("skills/local/SKILL.md")).unwrap(), + "local" + ); + fs::remove_dir_all(source.join("added")).unwrap(); + sync_roots(&home, &roots).unwrap(); + assert!(fs::symlink_metadata(home.join("skills/added")).is_err()); + assert_eq!( + fs::read_to_string(home.join("skills/local/SKILL.md")).unwrap(), + "local" + ); + } + + #[test] + fn adopted_parent_links_are_never_written_through() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("account"); + let outside = dir.path().join("outside"); + let source = dir.path().join("shared"); + for path in [&home, &outside, &source] { + fs::create_dir_all(path).unwrap(); + } + fs::write(source.join("SKILL.md"), "shared").unwrap(); + create_link(&outside, &home.join("config")).unwrap(); + sync_roots(&home, &[(source, home.join("config/skills"))]).unwrap(); + assert!(!outside.join("skills").exists()); + } + + #[test] + fn removing_an_owned_link_keeps_the_shared_directory_and_files() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("account"); + let source = dir.path().join("shared"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("SKILL.md"), "keep shared content").unwrap(); + sync_roots(&home, &[(source.clone(), home.join("skills"))]).unwrap(); + sync_roots(&home, &[(source.clone(), home.join("other-skills"))]).unwrap(); + assert!(fs::symlink_metadata(home.join("skills")).is_err()); + assert_eq!( + fs::read_to_string(source.join("SKILL.md")).unwrap(), + "keep shared content" + ); + assert_eq!( + fs::read_to_string(home.join("other-skills/SKILL.md")).unwrap(), + "keep shared content" + ); + } +} diff --git a/src-tauri/src/acp/account_transfer.rs b/src-tauri/src/acp/account_transfer.rs new file mode 100644 index 0000000000..9ab7eb92a1 --- /dev/null +++ b/src-tauri/src/acp/account_transfer.rs @@ -0,0 +1,605 @@ +//! Lossless Claude session copies for an explicit account switch. Authentication +//! and account settings never travel. A destination with divergent history is +//! refused; previous copies are retained in a private recovery directory. + +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; + +fn invalid(message: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message) +} + +/// A custom task-list id can be shared with other sessions. Moving just this +/// session cannot safely claim or replace that shared list in another home. +pub(crate) fn ensure_session_task_lists( + source: &Path, + target: &Path, + cwd: &Path, + sid: &str, + environments: &[&std::collections::BTreeMap], +) -> io::Result<()> { + const KEY: &str = "CLAUDE_CODE_TASK_LIST_ID"; + let allowed = |id: &str| id.trim().is_empty() || id == sid; + let refuse = || { + invalid("This session uses a custom task list. Keep its current account or start a new chat with the selected account.") + }; + for env in environments { + if let Some(id) = env.get(KEY).cloned().or_else(|| std::env::var(KEY).ok()) { + if !allowed(&id) { + return Err(refuse()); + } + } + } + for path in [ + source.join("settings.json"), + target.join("settings.json"), + cwd.join(".claude/settings.json"), + cwd.join(".claude/settings.local.json"), + ] { + match fs::read(&path) { + Ok(bytes) => { + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|_| invalid("Claude settings could not be read. Fix the settings before switching accounts."))?; + if value + .get("env") + .and_then(|e| e.get(KEY)) + .and_then(|v| v.as_str()) + .is_some_and(|id| !allowed(id)) + { + return Err(refuse()); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +fn plain(path: &Path) -> io::Result { + let meta = fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(invalid("Session transfer cannot follow symbolic links.")); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if meta.file_attributes() & 0x400 != 0 { + return Err(invalid( + "Session transfer cannot follow directory junctions.", + )); + } + } + Ok(meta) +} + +fn ensure_dir(path: &Path) -> io::Result<()> { + if path.symlink_metadata().is_ok() { + if !plain(path)?.is_dir() { + return Err(invalid("A session directory is not a directory.")); + } + } else { + let parent = path + .parent() + .ok_or_else(|| invalid("Invalid account folder."))?; + ensure_dir(parent)?; + fs::create_dir(path)?; + } + Ok(()) +} + +/// Compare incrementally: a long conversation need not fit in memory twice. +pub(crate) fn is_prefix(older: &Path, newer: &Path) -> io::Result { + if !plain(older)?.is_file() || !plain(newer)?.is_file() { + return Ok(false); + } + let mut a = fs::File::open(older)?; + let mut b = fs::File::open(newer)?; + let mut left = [0; 65536]; + let mut right = [0; 65536]; + loop { + let n = a.read(&mut left)?; + if n == 0 { + return Ok(true); + } + if b.read_exact(&mut right[..n]).is_err() || left[..n] != right[..n] { + return Ok(false); + } + } +} + +fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> { + let meta = plain(src)?; + if meta.is_dir() { + fs::create_dir(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + copy_tree(&entry.path(), &dst.join(entry.file_name()))?; + } + } else if meta.is_file() { + fs::copy(src, dst)?; + } else { + return Err(invalid("Unsupported session file type.")); + } + Ok(()) +} + +pub(crate) fn tree_is_older(older: &Path, newer: &Path) -> io::Result { + if plain(older)?.is_dir() { + if !plain(newer)?.is_dir() { + return Ok(false); + } + for entry in fs::read_dir(older)? { + let entry = entry?; + let new = newer.join(entry.file_name()); + if !new.exists() || !tree_is_older(&entry.path(), &new)? { + return Ok(false); + } + } + Ok(true) + } else { + is_prefix(older, newer) + } +} + +fn remove_tree(path: &Path) -> io::Result<()> { + if plain(path)?.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } +} + +#[derive(Debug)] +struct Entry { + target: PathBuf, + backup: Option, +} + +#[derive(Debug)] +pub struct ClaudeTransfer { + entries: Vec, + /// This switch's private staging directory: the destination's previous + /// copies (`old-*`) and any new copy not yet moved into place. It is only + /// needed until the switch is committed or fully rolled back. + stage: PathBuf, + // Mutable task lists are not append-only. Retain replaced task state for + // recovery instead of silently deleting a target's earlier task edits. + retained: Vec, +} + +impl ClaudeTransfer { + /// Call only after the target process has stopped. Recovery copies are + /// retained if anything fails, so an interrupted rollback cannot lose data. + pub fn rollback(&self) -> io::Result<()> { + for entry in self.entries.iter().rev() { + if entry.target.symlink_metadata().is_ok() { + remove_tree(&entry.target)?; + } + if let Some(backup) = &entry.backup { + copy_tree(backup, &entry.target)?; + } + } + Ok(()) + } + + /// Delete the staging copies once nothing can need them: the switch was + /// committed, or `rollback` completed. Never call it after a failed + /// rollback, when they are the only recovery copy. Best effort: a leftover + /// directory costs disk space, never history. + pub fn discard_staging(&self) { + if self.retained.is_empty() { + discard_stage(&self.stage); + } else if let Ok(entries) = fs::read_dir(&self.stage) { + for entry in entries.flatten() { + if !self.retained.contains(&entry.path()) { + let _ = remove_tree(&entry.path()); + } + } + } + } +} + +fn discard_stage(stage: &Path) { + if let Err(e) = fs::remove_dir_all(stage) { + if e.kind() != io::ErrorKind::NotFound { + tracing::warn!( + "[account-switch] could not remove staging copies at {}: {e}", + stage.display() + ); + } + } + // The recovery folder holds nothing but staging directories. Remove it once + // empty; another switch's retained recovery copy keeps it in place. + if let Some(recovery) = stage.parent() { + let _ = fs::remove_dir(recovery); + } +} + +struct CopyPair { + source: Option, + target: PathBuf, + mutable: bool, +} + +fn stage_copies(pairs: &[CopyPair], stage: &Path) -> io::Result> { + let mut retained = Vec::new(); + let mut recovery = Vec::new(); + for (i, pair) in pairs.iter().enumerate() { + let to = &pair.target; + if let Some(from) = &pair.source { + if to.symlink_metadata().is_ok() && !pair.mutable && !tree_is_older(to, from)? { + return Err(invalid( + "The target account has different session files. Both copies were kept.", + )); + } + copy_tree(from, &stage.join(format!("new-{i}")))?; + } + if to.symlink_metadata().is_ok() { + let backup = stage.join(format!("old-{i}")); + copy_tree(to, &backup)?; + if pair.mutable { + recovery.push(serde_json::json!({ "original": to, "backup": format!("old-{i}") })); + retained.push(backup); + } + } + ensure_dir(to.parent().unwrap())?; + } + if !recovery.is_empty() { + let manifest = stage.join("task-recovery.json"); + fs::write(&manifest, serde_json::to_vec_pretty(&recovery)?)?; + retained.push(manifest); + } + Ok(retained) +} + +pub fn copy_claude_session(source: &Path, target: &Path, sid: &str) -> io::Result { + if sid.is_empty() + || !sid + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) + { + return Err(invalid("Invalid session id.")); + } + plain(source)?; + ensure_dir(target)?; + if source.canonicalize()? == target.canonicalize()? { + return Err(invalid("These accounts use the same session folder.")); + } + let source_projects = source.join("projects"); + plain(&source_projects)?; + let mut matches = 0; + for entry in fs::read_dir(&source_projects)? { + if entry?.path().join(format!("{sid}.jsonl")).exists() { + matches += 1; + } + } + if matches > 1 { + return Err(invalid("The source account has multiple copies of this session. Resolve them before switching.")); + } + let src = + crate::parsers::claude::find_session_file_in(&source_projects, sid).ok_or_else(|| { + invalid("The original transcript is missing. Restore it before switching accounts.") + })?; + plain(src.parent().unwrap())?; + if !plain(&src)?.is_file() || fs::metadata(&src)?.len() == 0 { + return Err(invalid("The original transcript is empty.")); + } + let project = src.parent().and_then(Path::file_name).unwrap(); + let projects = target.join("projects"); + ensure_dir(&projects)?; + let dst_dir = projects.join(project); + ensure_dir(&dst_dir)?; + let dst = dst_dir.join(format!("{sid}.jsonl")); + // The resolver scans all project folders. A second same-id transcript in + // another folder would make load ambiguous, even if this path is absent. + for entry in fs::read_dir(&projects)? { + let entry = entry?; + if entry.path() != dst_dir && entry.path().join(format!("{sid}.jsonl")).exists() { + return Err(invalid( + "The target account already has this session in another project.", + )); + } + } + if dst.symlink_metadata().is_ok() && !is_prefix(&dst, &src)? { + return Err(invalid("The target account has different or newer history. Switch was cancelled to keep both copies.")); + } + let mut pairs = vec![CopyPair { + source: Some(src.clone()), + target: dst.clone(), + mutable: false, + }]; + // Claude stores tool results and sub-agent transcripts beside the JSONL; + // file-history holds the session's original file versions for rewind. + for (from, to) in [ + (src.with_file_name(sid), dst_dir.join(sid)), + ( + source.join("file-history").join(sid), + target.join("file-history").join(sid), + ), + ] { + if from.symlink_metadata().is_ok() { + pairs.push(CopyPair { + source: Some(from), + target: to, + mutable: false, + }); + } else if to.symlink_metadata().is_ok() { + return Err(invalid( + "The target account has session files missing from the source.", + )); + } + } + // TaskCreate/TaskUpdate rewrite these JSON files; TodoWrite uses the + // legacy per-session files. Move only this session's lists, never shared + // CLAUDE_CODE_TASK_LIST_ID lists or another session's tasks. + let mut mutable = vec![( + source.join("tasks").join(sid), + target.join("tasks").join(sid), + )]; + let mut names = std::collections::BTreeSet::new(); + for root in [source, target] { + for name in ["tasks", "todos", "file-history"] { + let dir = root.join(name); + if dir.symlink_metadata().is_ok() { + plain(&dir)?; + } + } + let todos = root.join("todos"); + if todos.is_dir() { + for entry in fs::read_dir(&todos)? { + let name = entry?.file_name(); + let text = name.to_string_lossy(); + if text.starts_with(&format!("{sid}-agent-")) && text.ends_with(".json") { + names.insert(name); + } + } + } + } + for name in names { + mutable.push(( + source.join("todos").join(&name), + target.join("todos").join(name), + )); + } + for (from, to) in mutable { + let source_exists = from.symlink_metadata().is_ok(); + let target_exists = to.symlink_metadata().is_ok(); + if target_exists && !dst.exists() { + return Err(invalid("The target has task files without matching conversation history. Both copies were kept.")); + } + if source_exists || target_exists { + pairs.push(CopyPair { + source: source_exists.then_some(from), + target: to, + mutable: true, + }); + } + } + let recovery = target.join(".codeg-account-switch"); + ensure_dir(&recovery)?; + let stage = recovery.join(uuid::Uuid::new_v4().to_string()); + fs::create_dir(&stage)?; + // Finish every read/copy (including symlink checks) before replacing any + // destination. Only session-specific paths enter this staging directory. + let retained = match stage_copies(&pairs, &stage) { + Ok(retained) => retained, + Err(error) => { + discard_stage(&stage); + return Err(error); + } + }; + let mut transfer = ClaudeTransfer { + entries: Vec::new(), + stage: stage.clone(), + retained, + }; + for (i, pair) in pairs.iter().enumerate() { + let to = &pair.target; + let old = stage.join(format!("old-{i}")); + transfer.entries.push(Entry { + target: to.clone(), + backup: old.exists().then_some(old), + }); + let install = (|| { + if to.symlink_metadata().is_ok() { + remove_tree(to)?; + } + if pair.source.is_some() { + fs::rename(stage.join(format!("new-{i}")), to)?; + } + Ok::<_, io::Error>(()) + })(); + if let Err(e) = install { + // A failed rollback returns here with the staging copies intact. + transfer.rollback()?; + transfer.discard_staging(); + return Err(e); + } + } + Ok(transfer) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(root: &Path, text: &str) -> PathBuf { + fs::create_dir_all(root.join("projects/project/sid")).unwrap(); + let p = root.join("projects/project/sid.jsonl"); + fs::write(&p, text).unwrap(); + fs::write( + root.join("projects/project/sid/tool.txt"), + "full tool result", + ) + .unwrap(); + p + } + + #[test] + fn mutable_session_tasks_round_trip_and_keep_recovery_copies() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + fixture(a.path(), "first\n"); + fs::create_dir_all(a.path().join("tasks/sid")).unwrap(); + fs::create_dir_all(a.path().join("todos")).unwrap(); + fs::write(a.path().join("tasks/sid/1.json"), r#"{"status":"pending"}"#).unwrap(); + fs::write(a.path().join("todos/sid-agent-main.json"), "old plan").unwrap(); + fs::write(a.path().join("todos/other-agent-main.json"), "unrelated").unwrap(); + copy_claude_session(a.path(), b.path(), "sid") + .unwrap() + .discard_staging(); + assert!(!b.path().join("todos/other-agent-main.json").exists()); + fixture(b.path(), "first\nsecond\n"); + fs::write( + b.path().join("tasks/sid/1.json"), + r#"{"status":"completed"}"#, + ) + .unwrap(); + fs::remove_file(b.path().join("todos/sid-agent-main.json")).unwrap(); + let back = copy_claude_session(b.path(), a.path(), "sid").unwrap(); + assert_eq!( + fs::read_to_string(a.path().join("tasks/sid/1.json")).unwrap(), + r#"{"status":"completed"}"# + ); + assert!(!a.path().join("todos/sid-agent-main.json").exists()); + back.rollback().unwrap(); + assert_eq!( + fs::read_to_string(a.path().join("tasks/sid/1.json")).unwrap(), + r#"{"status":"pending"}"# + ); + assert_eq!( + fs::read_to_string(a.path().join("todos/sid-agent-main.json")).unwrap(), + "old plan" + ); + let committed = copy_claude_session(b.path(), a.path(), "sid").unwrap(); + let stage = committed.stage.clone(); + committed.discard_staging(); + assert!(stage.join("task-recovery.json").exists()); + assert!( + !stage.join("old-0").exists(), + "do not retain another whole transcript" + ); + assert_eq!( + fs::read_to_string(a.path().join("todos/other-agent-main.json")).unwrap(), + "unrelated" + ); + } + + #[test] + fn custom_task_list_is_not_silently_replaced_by_an_empty_session_list() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + let cwd = tempfile::tempdir().unwrap(); + let env = std::collections::BTreeMap::from([( + "CLAUDE_CODE_TASK_LIST_ID".into(), + "shared-project".into(), + )]); + assert!(ensure_session_task_lists(a.path(), b.path(), cwd.path(), "sid", &[&env]).is_err()); + fs::write( + a.path().join("settings.json"), + r#"{"env":{"CLAUDE_CODE_TASK_LIST_ID":"shared-project"}}"#, + ) + .unwrap(); + assert!(ensure_session_task_lists(a.path(), b.path(), cwd.path(), "sid", &[]).is_err()); + } + + #[test] + fn round_trip_keeps_full_history_sidecars_and_credentials() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + let original = fixture(a.path(), "first\n"); + fs::write(a.path().join("auth.json"), "source-auth").unwrap(); + fs::write(b.path().join("auth.json"), "target-auth").unwrap(); + copy_claude_session(a.path(), b.path(), "sid").unwrap(); + let newer = fixture(b.path(), "first\nsecond\n"); + let back = copy_claude_session(b.path(), a.path(), "sid").unwrap(); + assert_eq!(fs::read(&original).unwrap(), fs::read(&newer).unwrap()); + assert_eq!( + fs::read_to_string(a.path().join("auth.json")).unwrap(), + "source-auth" + ); + assert_eq!( + fs::read_to_string(b.path().join("auth.json")).unwrap(), + "target-auth" + ); + back.rollback().unwrap(); + assert_eq!(fs::read_to_string(original).unwrap(), "first\n"); + assert_eq!(fs::read_to_string(newer).unwrap(), "first\nsecond\n"); + } + + #[test] + fn divergent_or_missing_history_never_overwrites_target() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + fixture(a.path(), "source\n"); + let target = fixture(b.path(), "independent\n"); + assert!(copy_claude_session(a.path(), b.path(), "sid").is_err()); + assert!(copy_claude_session(a.path(), b.path(), "../sid").is_err()); + assert!(copy_claude_session(a.path(), b.path(), ".").is_err()); + assert!(copy_claude_session(a.path(), b.path(), "missing").is_err()); + assert_eq!(fs::read_to_string(target).unwrap(), "independent\n"); + } + + #[cfg(unix)] + #[test] + fn refuses_links_before_changing_any_destination() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + fixture(a.path(), "first\n"); + std::os::unix::fs::symlink("/etc", a.path().join("projects/project/sid/link")).unwrap(); + assert!(copy_claude_session(a.path(), b.path(), "sid").is_err()); + assert!(!b.path().join("projects/project/sid.jsonl").exists()); + assert!(!b.path().join(".codeg-account-switch").exists()); + } + + /// Every switch stages full copies of the transcript and of whatever the + /// destination held before. Once the switch is committed, or a rollback has + /// put the previous copy back, those copies are dead weight; keeping them + /// grew the account folders by a whole transcript on every round trip. + #[test] + fn finished_switches_leave_no_staging_copies() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + fixture(a.path(), "first\n"); + copy_claude_session(a.path(), b.path(), "sid") + .unwrap() + .discard_staging(); + assert!(!b.path().join(".codeg-account-switch").exists()); + + // Back to A, where an older copy is staged as the rollback source. + let newer = fixture(b.path(), "first\nsecond\n"); + let back = copy_claude_session(b.path(), a.path(), "sid").unwrap(); + back.rollback().unwrap(); + back.discard_staging(); + assert!(!a.path().join(".codeg-account-switch").exists()); + assert_eq!( + fs::read_to_string(a.path().join("projects/project/sid.jsonl")).unwrap(), + "first\n" + ); + assert_eq!(fs::read_to_string(newer).unwrap(), "first\nsecond\n"); + } + + /// A refusal found while staging (here a sidecar that differs at the + /// destination) happens before any destination changed, so its partial + /// copies are discarded with it. + #[test] + fn a_refused_switch_leaves_no_staging_copies() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + fixture(a.path(), "first\nsecond\n"); + fixture(b.path(), "first\n"); + fs::write( + b.path().join("projects/project/sid/tool.txt"), + "changed here", + ) + .unwrap(); + assert!(copy_claude_session(a.path(), b.path(), "sid").is_err()); + assert!(!b.path().join(".codeg-account-switch").exists()); + assert_eq!( + fs::read_to_string(b.path().join("projects/project/sid/tool.txt")).unwrap(), + "changed here" + ); + } +} diff --git a/src-tauri/src/acp/agent_accounts.rs b/src-tauri/src/acp/agent_accounts.rs new file mode 100644 index 0000000000..872652c85a --- /dev/null +++ b/src-tauri/src/acp/agent_accounts.rs @@ -0,0 +1,701 @@ +//! Built-in accounts select storage, never a different AgentType. Keeping the +//! native type preserves protocol capabilities, steering and transcript parsing. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::models::agent::{AgentType, BUILTIN_AGENT_TYPES}; + +pub const ACCOUNT_ENV: &str = "CODEG_AGENT_ACCOUNT_ID"; +pub const ACCOUNT_HOME_ENV: &str = "CODEG_AGENT_ACCOUNT_HOME"; + +/// Authentication selectors must not carry Default into a named account. +/// Model preferences are handled separately, so family-wide model discovery +/// still works when it was not installed by a Model Provider binding. +pub(crate) const CLAUDE_AUTH_ENV_KEYS: &[&str] = &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + "ANTHROPIC_PROFILE", + "ANTHROPIC_CONFIG_DIR", + "ANTHROPIC_FEDERATION_RULE_ID", + "ANTHROPIC_ORGANIZATION_ID", + "ANTHROPIC_SERVICE_ACCOUNT_ID", + "ANTHROPIC_WORKSPACE_ID", + "ANTHROPIC_IDENTITY_TOKEN", + "ANTHROPIC_IDENTITY_TOKEN_FILE", + "AWS_BEARER_TOKEN_BEDROCK", +]; + +#[derive(Clone, Copy)] +pub struct AccountLayout { + pub env_key: &'static str, + pub config_suffix: &'static str, + pub transcript_suffix: &'static str, + pub login: &'static [&'static str], +} + +/// Directory layouts come from the native resolvers in `parsers`. Home +/// overrides are applied to the child only; the host process is never changed. +pub fn layout(agent: AgentType) -> Option { + let (env_key, config_suffix, transcript_suffix, login): (_, _, _, &[&str]) = match agent { + AgentType::ClaudeCode => ( + "CLAUDE_CONFIG_DIR", + "", + "projects", + &["claude", "auth", "login"], + ), + AgentType::Codex => ("CODEX_HOME", "", "sessions", &["codex", "login"]), + AgentType::Grok => ("GROK_HOME", "", "sessions", &["grok", "login"]), + AgentType::Gemini => ("GEMINI_CLI_HOME", ".gemini", ".gemini", &["gemini"]), + // OpenCode's config override alone does not isolate auth or history. + AgentType::OpenCode => ( + "XDG_DATA_HOME", + "config/opencode", + "data/opencode", + &["opencode", "auth", "login"], + ), + AgentType::Hermes => ("HERMES_HOME", "", "", &["hermes", "setup"]), + AgentType::CodeBuddy => ("CODEBUDDY_CONFIG_DIR", "", "projects", &["codebuddy"]), + AgentType::Cline => ("CLINE_DIR", "", "", &["cline", "auth"]), + AgentType::KimiCode => ("KIMI_CODE_HOME", "", "sessions", &["kimi", "login"]), + AgentType::Pi => ("PI_CODING_AGENT_DIR", "", "sessions", &["pi"]), + AgentType::DeepSeek => ("DSH_HOME", "", "sessions", &["deepseek-acp", "--setup"]), + AgentType::Qoder => ("QODER_CONFIG_DIR", "", "projects", &["qoder", "login"]), + AgentType::Antigravity => ( + "GEMINI_HOME", + "antigravity-acp", + "antigravity-acp/conversations", + &[], + ), + AgentType::Cursor => ( + "CURSOR_CONFIG_DIR", + ".cursor", + ".cursor/chats", + &["cursor-agent", "login"], + ), + AgentType::OpenClaw + | AgentType::Kiro + | AgentType::ZCode + | AgentType::Trae + | AgentType::CommandCode + | AgentType::Custom(_) => return None, + }; + Some(AccountLayout { + env_key, + config_suffix, + transcript_suffix, + login, + }) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountCapability { + pub agent_type: AgentType, + pub supported: bool, + pub reason: Option<&'static str>, + pub login_kind: Option<&'static str>, + pub config_subdir: Option<&'static str>, + pub can_switch_conversation: bool, + pub isolates_tool_settings: bool, + pub auth_methods: Vec, + pub can_check_auth: bool, +} + +/// Keep storage layouts available for reading older conversations even when +/// this platform cannot safely launch a new named-account session. +pub fn unsupported_reason(agent: AgentType) -> Option<&'static str> { + match agent { + AgentType::OpenClaw => Some("Sessions use the configured OpenClaw gateway's account."), + AgentType::Cursor if cfg!(target_os = "macos") => Some( + "Named Cursor accounts are not supported on macOS. Use the default Cursor account.", + ), + _ if layout(agent).is_none() => { + Some("Named accounts are not yet supported for this agent.") + } + _ => None, + } +} + +pub fn capabilities() -> Vec { + BUILTIN_AGENT_TYPES + .iter() + .map(|&agent_type| AccountCapability { + agent_type, + auth_methods: if unsupported_reason(agent_type).is_none() { + crate::acp::account_auth::methods(agent_type).to_vec() + } else { + Vec::new() + }, + can_check_auth: matches!(agent_type, AgentType::ClaudeCode | AgentType::Codex), + can_switch_conversation: agent_type == AgentType::ClaudeCode, + isolates_tool_settings: matches!(agent_type, AgentType::OpenCode | AgentType::Cursor), + supported: unsupported_reason(agent_type).is_none(), + config_subdir: layout(agent_type) + .and_then(|spec| (!spec.config_suffix.is_empty()).then_some(spec.config_suffix)), + login_kind: layout(agent_type) + .filter(|_| unsupported_reason(agent_type).is_none()) + .map(|spec| { + if spec.login.is_empty() { + "browser" + } else { + "terminal" + } + }), + reason: unsupported_reason(agent_type), + }) + .collect() +} + +pub fn credential_env_keys(agent: AgentType) -> Vec<&'static str> { + let extra: &[&str] = match agent { + AgentType::ClaudeCode => CLAUDE_AUTH_ENV_KEYS, + AgentType::Codex => &["OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_API_KEY"], + AgentType::Grok => &["XAI_API_KEY", "XAI_BASE_URL", "GROK_API_KEY"], + AgentType::Gemini => &[ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + ], + AgentType::Antigravity => &[ + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "AGY_AUTH_METHOD", + ], + AgentType::CodeBuddy => &["CODEBUDDY_API_KEY", "CODEBUDDY_AUTH_TOKEN"], + AgentType::Cline => &[ + "CLINE_API_KEY", + "CLINE_BASE_URL", + "CLINE_PROVIDER", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + ], + AgentType::KimiCode => &["KIMI_API_KEY", "KIMI_CODE_API_KEY"], + AgentType::Qoder => &["QODER_PERSONAL_ACCESS_TOKEN"], + AgentType::DeepSeek => &["DEEPSEEK_API_KEY"], + AgentType::Cursor => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", "CURSOR_API_BASE_URL"], + AgentType::OpenCode | AgentType::Pi | AgentType::Hermes => &[ + "OPENCODE_API_KEY", + "OPENCODE_GO_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "XAI_API_KEY", + ], + _ => &[], + }; + let (base_url, credential, model) = crate::commands::acp::agent_env_keys(agent); + let account_overrides: &[&str] = if agent == AgentType::OpenCode { + &["OPENCODE_AUTH_CONTENT", "OPENCODE_DB"] + } else { + &[] + }; + // Follow the same provider keys as the settings writer, including less + // obvious names such as KIMI_MODEL_API_KEY. The native account may restore + // its own values after this inherited/default-account scrub. + extra + .iter() + .chain(account_overrides.iter()) + .copied() + .chain([base_url, credential, model]) + .collect() +} + +impl AccountLayout { + pub fn config_dir(self, home: &Path) -> PathBuf { + home.join(self.config_suffix) + } + pub fn transcript_dir(self, home: &Path) -> PathBuf { + home.join(self.transcript_suffix) + } +} + +pub fn isolation_env(agent: AgentType, home: &Path) -> Result, String> { + isolation_env_with_base(agent, home, &BTreeMap::new()) +} + +pub fn isolation_env_with_base( + agent: AgentType, + home: &Path, + base: &BTreeMap, +) -> Result, String> { + if let Some(reason) = unsupported_reason(agent) { + return Err(reason.into()); + } + let layout = + layout(agent).ok_or_else(|| format!("{agent} does not support isolated accounts"))?; + if !home.is_absolute() { + return Err("Choose an absolute account folder".into()); + } + let mut env = BTreeMap::from([( + layout.env_key.to_owned(), + home.to_string_lossy().into_owned(), + )]); + if agent == AgentType::ClaudeCode { + // Anthropic profiles live outside CLAUDE_CONFIG_DIR unless this is + // explicit. An ambient active federation profile can outrank /login. + env.insert( + "ANTHROPIC_CONFIG_DIR".into(), + home.join("anthropic").to_string_lossy().into_owned(), + ); + } + if agent == AgentType::OpenCode { + for (key, suffix) in [ + ("XDG_CONFIG_HOME", "config"), + ("XDG_DATA_HOME", "data"), + ("XDG_STATE_HOME", "state"), + ("XDG_CACHE_HOME", "cache"), + ("OPENCODE_CONFIG_DIR", "config/opencode"), + ] { + env.insert(key.into(), home.join(suffix).to_string_lossy().into_owned()); + } + // XDG roots isolate OpenCode's auth, state and config. Keep the user's + // home for shell tools such as git, cargo and npm launched by the agent. + env.insert("OPENCODE_CONFIG".into(), String::new()); + env.insert("OPENCODE_CONFIG_CONTENT".into(), String::new()); + env.insert("OPENCODE_MODELS_PATH".into(), String::new()); + // A blank launch value removes the inherited variable at spawn. + env.insert("OPENCODE_AUTH_CONTENT".into(), String::new()); + env.insert("OPENCODE_DB".into(), String::new()); + } + if agent == AgentType::Cline { + env.insert( + "CLINE_PROVIDER_SETTINGS_PATH".into(), + home.join("settings/providers.json") + .to_string_lossy() + .into_owned(), + ); + } + if agent == AgentType::Pi { + env.insert( + "PI_CODING_AGENT_SESSION_DIR".into(), + layout.transcript_dir(home).to_string_lossy().into_owned(), + ); + } + if agent == AgentType::DeepSeek { + env.insert( + "DEEPSEEK_ACP_SESSIONS_ROOT".into(), + layout.transcript_dir(home).to_string_lossy().into_owned(), + ); + } + if agent == AgentType::Antigravity { + env.insert("AGY_ACP_FORCE_FILE_STORAGE".into(), "1".into()); + } + if agent == AgentType::Cursor { + // CURSOR_CONFIG_DIR moves chats/config, but not credentials. Cursor's + // file credential store uses APPDATA on Windows and XDG_CONFIG_HOME + // on Linux. macOS requires replacing HOME, so named-account launches + // are refused above; changing HOME would also redirect terminal tools. + env.insert( + "CURSOR_CONFIG_DIR".into(), + home.join(".cursor").to_string_lossy().into_owned(), + ); + env.insert("AGENT_CLI_CREDENTIAL_STORE".into(), "file".into()); + if cfg!(windows) { + env.insert( + "APPDATA".into(), + home.join("appdata").to_string_lossy().into_owned(), + ); + } else { + env.insert( + "XDG_CONFIG_HOME".into(), + home.join(".config").to_string_lossy().into_owned(), + ); + } + } + if matches!(agent, AgentType::OpenCode | AgentType::Cursor) { + // gh follows XDG_CONFIG_HOME before APPDATA/HOME. Pin the folder it + // would have used before changing the agent's roots, honoring both + // saved launch settings and an explicit GH_CONFIG_DIR. No files move. + if let Some(path) = github_config_dir(base) { + env.insert("GH_CONFIG_DIR".into(), path.to_string_lossy().into_owned()); + } + } + Ok(env) +} + +fn github_config_dir(env: &BTreeMap) -> Option { + env_path(env, "GH_CONFIG_DIR") + .or_else(|| env_path(env, "XDG_CONFIG_HOME").map(|path| path.join("gh"))) + .or_else(|| { + cfg!(windows) + .then(|| env_path(env, "APPDATA").map(|path| path.join("GitHub CLI"))) + .flatten() + }) + .or_else(|| { + crate::acp::file_system_runtime::child_home_dir(env) + .map(|home| home.join(".config/gh")) + }) +} + +/// Native choices in this account outrank the family's default launch choices. +pub fn apply_native_account_env(agent: AgentType, home: &Path, env: &mut BTreeMap) { + if agent != AgentType::Antigravity { + return; + } + let path = home.join("antigravity-acp/settings.json"); + let Some(value) = std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + else { + return; + }; + for (pointer, key) in [ + ("/auth/type", "AGY_AUTH_METHOD"), + ("/gcp/project", "GOOGLE_CLOUD_PROJECT"), + ("/gcp/location", "GOOGLE_CLOUD_LOCATION"), + ] { + if let Some(value) = value.pointer(pointer).and_then(serde_json::Value::as_str) { + env.insert(key.into(), value.into()); + } + } +} + +fn env_path(env: &BTreeMap, key: &str) -> Option { + match env.get(key) { + Some(value) => (!value.is_empty()).then(|| PathBuf::from(value)), + None => std::env::var_os(key) + .filter(|v| !v.is_empty()) + .map(PathBuf::from), + } +} + +fn same_path(a: &Path, b: &Path) -> bool { + crate::parsers::path_eq_for_matching(&a.to_string_lossy(), &b.to_string_lossy()) + || matches!((a.canonicalize(), b.canonicalize()), (Ok(a), Ok(b)) if a == b) +} + +fn storage_roots(agent: AgentType, env: &BTreeMap) -> Vec { + let mut roots = crate::acp::file_system_runtime::agent_data_roots(agent, env); + let child_home = crate::acp::file_system_runtime::child_home_dir(env).unwrap_or_default(); + if agent == AgentType::OpenCode { + roots.push(env_path(env, "OPENCODE_CONFIG_DIR").unwrap_or_else(|| { + env_path(env, "XDG_CONFIG_HOME") + .unwrap_or_else(|| child_home.join(".config")) + .join("opencode") + })); + } + if agent == AgentType::Cursor { + roots.push(if cfg!(windows) { + env_path(env, "APPDATA") + .unwrap_or_else(|| child_home.join("AppData/Roaming")) + .join("Cursor") + } else if cfg!(target_os = "macos") { + child_home.join(".cursor") + } else { + env_path(env, "XDG_CONFIG_HOME") + .unwrap_or_else(|| child_home.join(".config")) + .join("cursor") + }); + } + roots +} + +pub fn aliases_default_account( + agent: AgentType, + home: &Path, + default_env: &BTreeMap, +) -> bool { + if home.parent().is_none() || dirs::home_dir().is_some_and(|default| same_path(home, &default)) + { + return true; + } + let Ok(account_env) = isolation_env(agent, home) else { + return true; + }; + let defaults = storage_roots(agent, default_env); + storage_roots(agent, &account_env) + .iter() + .any(|root| defaults.iter().any(|default| same_path(root, default))) +} + +/// A storage identity also covers the old custom-account wrappers. It is used +/// only to prevent two processes writing one native session, never to grant a +/// custom agent native capabilities or to rebind an existing conversation. +pub fn storage_identity( + agent: AgentType, + env: &BTreeMap, +) -> Option<(AgentType, PathBuf)> { + if let AgentType::Custom(id) = agent { + use crate::acp::family_isolator::{self, IsolatorFamily}; + let meta = crate::acp::custom_registry::get(id)?; + let mut values = family_isolator::distribution_env(&meta.distribution); + values.extend(env.clone()); + let (family, _) = family_isolator::isolator_from_env(&values)?; + let native = match family { + IsolatorFamily::Claude => AgentType::ClaudeCode, + IsolatorFamily::Codex => AgentType::Codex, + IsolatorFamily::Grok => AgentType::Grok, + IsolatorFamily::Gemini => AgentType::Gemini, + IsolatorFamily::OpenCode => AgentType::OpenCode, + }; + return storage_identity(native, &values); + } + layout(agent)?; + let roots = crate::acp::file_system_runtime::agent_data_roots(agent, env); + let mut root = if matches!(agent, AgentType::Pi | AgentType::DeepSeek) { + roots.last()?.clone() + } else { + roots.first()?.clone() + }; + if agent == AgentType::Antigravity { + root.push("antigravity-acp"); + } + Some((agent, root)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_builtin_has_an_explicit_account_capability() { + let rows = capabilities(); + assert_eq!(rows.len(), BUILTIN_AGENT_TYPES.len()); + for row in rows { + assert_eq!(row.supported, row.reason.is_none()); + } + } + + #[test] + fn accounts_keep_the_native_type_and_separate_homes() { + let dir = tempfile::tempdir().unwrap(); + for &agent in BUILTIN_AGENT_TYPES { + if unsupported_reason(agent).is_some() { + continue; + } + let Some(layout) = layout(agent) else { + continue; + }; + let a = dir.path().join("a"); + let b = dir.path().join("b"); + let env_a = isolation_env(agent, &a).unwrap(); + let env_b = isolation_env(agent, &b).unwrap(); + assert_ne!(env_a, env_b, "{agent}"); + assert!(layout.transcript_dir(&a).starts_with(&a)); + assert!(layout.config_dir(&b).starts_with(&b)); + assert!(!agent.is_custom()); + } + } + + #[test] + fn opencode_isolates_credentials_as_well_as_configuration() { + let dir = tempfile::tempdir().unwrap(); + let env = isolation_env(AgentType::OpenCode, dir.path()).unwrap(); + assert!(!env.contains_key("HOME")); + assert!(!env.contains_key("USERPROFILE")); + assert_eq!(env.get("OPENCODE_AUTH_CONTENT").map(String::as_str), Some("")); + assert_eq!(env.get("OPENCODE_DB").map(String::as_str), Some("")); + assert_eq!( + PathBuf::from(&env["XDG_DATA_HOME"]), + dir.path().join("data") + ); + assert_eq!( + PathBuf::from(&env["OPENCODE_CONFIG_DIR"]), + dir.path().join("config/opencode") + ); + } + + #[test] + fn account_isolation_keeps_the_original_github_cli_config() { + let dir = tempfile::tempdir().unwrap(); + let inherited = dir.path().join("original-xdg"); + let explicit = dir.path().join("explicit-gh"); + let mut base = BTreeMap::from([ + ("GH_CONFIG_DIR".into(), String::new()), + ("XDG_CONFIG_HOME".into(), inherited.to_string_lossy().into_owned()), + ]); + for agent in [AgentType::OpenCode, AgentType::Cursor] { + if unsupported_reason(agent).is_some() { continue; } + let isolated = isolation_env_with_base(agent, &dir.path().join("account"), &base).unwrap(); + assert_eq!(PathBuf::from(&isolated["GH_CONFIG_DIR"]), inherited.join("gh")); + } + base.insert("GH_CONFIG_DIR".into(), explicit.to_string_lossy().into_owned()); + let isolated = isolation_env_with_base(AgentType::OpenCode, dir.path(), &base).unwrap(); + assert_eq!(PathBuf::from(&isolated["GH_CONFIG_DIR"]), explicit); + } + + #[test] + fn cursor_relocates_its_credential_store_not_only_its_config() { + let dir = tempfile::tempdir().unwrap(); + if cfg!(target_os = "macos") { + let error = isolation_env(AgentType::Cursor, dir.path()).unwrap_err(); + assert!(error.contains("default Cursor account")); + let capability = capabilities() + .into_iter() + .find(|row| row.agent_type == AgentType::Cursor) + .unwrap(); + assert!(!capability.supported); + assert!(capability.login_kind.is_none()); + // Existing account transcripts remain readable on every platform. + assert!(layout(AgentType::Cursor).is_some()); + return; + } + let env = isolation_env(AgentType::Cursor, dir.path()).unwrap(); + assert!(!env.contains_key("HOME")); + assert!(!env.contains_key("USERPROFILE")); + assert_eq!(env["AGENT_CLI_CREDENTIAL_STORE"], "file"); + assert_eq!( + PathBuf::from(&env["CURSOR_CONFIG_DIR"]), + dir.path().join(".cursor") + ); + let key = if cfg!(windows) { + "APPDATA" + } else { + "XDG_CONFIG_HOME" + }; + assert!(PathBuf::from(&env[key]).starts_with(dir.path())); + } + + #[test] + fn configured_default_home_cannot_be_added_as_an_extra_account() { + let dir = tempfile::tempdir().unwrap(); + let main = dir.path().join("main"); + let work = dir.path().join("work"); + std::fs::create_dir_all(&main).unwrap(); + std::fs::create_dir_all(&work).unwrap(); + let env = BTreeMap::from([("CODEX_HOME".into(), main.display().to_string())]); + assert!(aliases_default_account(AgentType::Codex, &main, &env)); + assert!(!aliases_default_account(AgentType::Codex, &work, &env)); + } + + #[test] + fn native_account_launch_permissions_follow_the_selected_home() { + let dir = tempfile::tempdir().unwrap(); + let first = dir.path().join("first"); + let second = dir.path().join("second"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + std::fs::write( + first.join("config.toml"), + "sandbox_mode = 'read-only'\n[ui]\npermission_mode = 'plan'\n", + ) + .unwrap(); + std::fs::write(second.join("config.toml"), "sandbox_mode = 'danger-full-access'\napproval_policy = 'never'\n[ui]\npermission_mode = 'acceptEdits'\n").unwrap(); + assert_eq!( + crate::commands::acp::codex_launch_initial_agent_mode( + &isolation_env(AgentType::Codex, &first).unwrap() + ) + .as_deref(), + Some("read-only") + ); + assert_eq!( + crate::commands::acp::codex_launch_initial_agent_mode( + &isolation_env(AgentType::Codex, &second).unwrap() + ) + .as_deref(), + Some("agent-full-access") + ); + assert_eq!( + crate::commands::acp::grok_launch_permission_mode( + &isolation_env(AgentType::Grok, &first).unwrap() + ) + .as_deref(), + Some("plan") + ); + assert_eq!( + crate::commands::acp::grok_launch_permission_mode( + &isolation_env(AgentType::Grok, &second).unwrap() + ) + .as_deref(), + Some("acceptEdits") + ); + } + + #[test] + fn native_history_and_titles_stay_in_the_selected_account() { + use serde_json::json; + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a"); + let b = dir.path().join("b"); + let empty = dir.path().join("default"); + std::fs::create_dir_all(&empty).unwrap(); + let session = uuid::Uuid::new_v4().to_string(); + for (home, text) in [(&a, "alpha account"), (&b, "beta account")] { + std::fs::create_dir_all(home.join("projects/work")).unwrap(); + std::fs::create_dir_all(home.join("sessions/2026/10/11")).unwrap(); + let claude = json!({"type":"user", "timestamp":"2026-10-11T00:00:00Z", "uuid":"u1", "cwd":"/project", "message":{"role":"user","content":text}}); + std::fs::write( + home.join("projects/work").join(format!("{session}.jsonl")), + format!("{claude}\n"), + ) + .unwrap(); + let header = json!({"type":"session_meta", "timestamp":"2026-10-11T00:00:00Z", "payload":{"id":session,"cwd":"/project"}}); + let prompt = json!({"type":"event_msg", "timestamp":"2026-10-11T00:00:01Z", "payload":{"type":"user_message","message":text}}); + std::fs::write( + home.join("sessions/2026/10/11") + .join(format!("rollout-2026-10-11T00-00-00-{session}.jsonl")), + format!("{header}\n{prompt}\n"), + ) + .unwrap(); + std::fs::write( + home.join("session_index.jsonl"), + format!("{}\n", json!({"id":session,"thread_name":text})), + ) + .unwrap(); + } + { + for agent in [AgentType::ClaudeCode, AgentType::Codex] { + for (home, wanted, other) in [ + (&a, "alpha account", "beta account"), + (&b, "beta account", "alpha account"), + ] { + let detail = crate::parsers::build_account_parser(agent, Some(home), &crate::parsers::AgentEnv::inherited()) + .unwrap() + .get_conversation(&session) + .unwrap(); + let text = serde_json::to_string(&detail).unwrap(); + assert!(text.contains(wanted), "{agent}"); + assert!(!text.contains(other), "{agent}"); + } + assert!(crate::parsers::build_account_parser(agent, Some(&empty), &crate::parsers::AgentEnv::inherited()) + .unwrap() + .get_conversation(&session) + .is_err()); + } + } + } + + #[test] + fn antigravity_database_reader_uses_the_account_store() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("a"); + let other = dir.path().join("b"); + let store = home.join("antigravity-acp/conversations"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::write( + store.join("agy-fixture-001.db"), + include_bytes!("../../tests/fixtures/antigravity/agy-fixture-001.db"), + ) + .unwrap(); + std::fs::write( + store.join("agy-fixture-001.meta"), + include_bytes!("../../tests/fixtures/antigravity/agy-fixture-001.meta"), + ) + .unwrap(); + assert!( + !crate::parsers::build_account_parser(AgentType::Antigravity, Some(&home), &crate::parsers::AgentEnv::inherited()) + .unwrap() + .list_conversations() + .unwrap() + .is_empty() + ); + assert!( + crate::parsers::build_account_parser(AgentType::Antigravity, Some(&other), &crate::parsers::AgentEnv::inherited()) + .unwrap() + .list_conversations() + .unwrap() + .is_empty() + ); + } +} diff --git a/src-tauri/src/acp/agent_mentions.rs b/src-tauri/src/acp/agent_mentions.rs index 08d3a60409..cc7ae2d6a9 100644 --- a/src-tauri/src/acp/agent_mentions.rs +++ b/src-tauri/src/acp/agent_mentions.rs @@ -18,7 +18,7 @@ const ROUTE_FRAME_KIND: &str = "codeg_internal_agent_routes"; /// is a prefix of every rendered body; a field reorder that broke that is what /// `the_body_anchor_is_what_the_renderer_starts_with` guards. const ROUTE_FRAME_BODY_PREFIX: &str = "{\"kind\":\"codeg_internal_agent_routes\""; -const ROUTE_FRAME_VERSION: u8 = 3; +const ROUTE_FRAME_VERSION: u8 = 4; const MAX_ROUTE_FRAME_BYTES: usize = 16 * 1024; /// Distinct agents one frame may route, applied AFTER deduplication. const MAX_AGENT_ROUTES: usize = 16; @@ -52,7 +52,7 @@ const MAX_ASSISTANT_BRIEF_FRAME_BYTES: usize = 256 * 1024; /// separator in. const MAX_INTERNAL_FRAME_BYTES: usize = MAX_ASSISTANT_BRIEF_FRAME_BYTES; -/// One routed agent inside the frame — nothing but the wire slug. +/// One routed agent and, optionally, the exact native account the user selected. /// /// The frame is derived from the prompt's own visible `codeg://agent/...` /// links, so there is no out-of-band claim to carry or verify. Serializing as @@ -62,6 +62,8 @@ const MAX_INTERNAL_FRAME_BYTES: usize = MAX_ASSISTANT_BRIEF_FRAME_BYTES; #[serde(rename_all = "camelCase", deny_unknown_fields)] struct AgentRoute { agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_account_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -79,13 +81,13 @@ fn agent_reference_regex() -> &'static Regex { // limited to ASCII alphanumeric, `-`, `_`, and `.`. Requiring a visible // label keeps this in lockstep with the frontend reference tokenizer. static PATTERN: LazyLock = LazyLock::new(|| { - Regex::new(r"\[(?:\\.|[^\]\\\r\n])+\]\(codeg://agent/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\)") + Regex::new(r"\[(?:\\.|[^\]\\\r\n])+\]\(codeg://agent/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})(?:\?account=([A-Za-z0-9][A-Za-z0-9._-]{0,127}))?\)") .expect("agent reference regex is valid") }); &PATTERN } -fn visible_agent_reference_types(blocks: &[PromptInputBlock]) -> Vec { +fn visible_agent_reference_types(blocks: &[PromptInputBlock]) -> Vec { let mut references = Vec::new(); for block in blocks { let PromptInputBlock::Text { text } = block else { @@ -96,7 +98,10 @@ fn visible_agent_reference_types(blocks: &[PromptInputBlock]) -> Vec { return references; } if let Some(agent_type) = capture.get(1) { - references.push(agent_type.as_str().to_string()); + references.push(AgentRoute { + agent_type: agent_type.as_str().to_string(), + agent_account_id: capture.get(2).map(|id| id.as_str().to_string()), + }); } } } @@ -135,12 +140,11 @@ pub(crate) fn append_agent_routes(blocks: &mut Vec, delegation let mut seen = BTreeSet::new(); let routes: Vec = visible_agent_reference_types(blocks) .into_iter() - .filter(|agent_type| valid_agent_wire_syntax(agent_type)) + .filter(|route| valid_agent_wire_syntax(&route.agent_type)) // Dedup first, then bound: a prompt repeating one agent 200 times still // routes it, and the cap only bites on distinct agents. - .filter(|agent_type| seen.insert(agent_type.clone())) + .filter(|route| seen.insert((route.agent_type.clone(), route.agent_account_id.clone()))) .take(MAX_AGENT_ROUTES) - .map(|agent_type| AgentRoute { agent_type }) .collect(); if routes.is_empty() { return; @@ -167,12 +171,9 @@ pub(crate) fn append_agent_routes(blocks: &mut Vec, delegation /// history. Any wording change must therefore bump [`ROUTE_FRAME_VERSION`]; /// `route_frame_wording_is_pinned_to_its_version` guards that. /// -/// No compatibility renderer exists yet: `parse_internal_agent_routes_body` accepts -/// the current version only, so a bump deliberately abandons the frames already -/// on disk. That is a pre-release call — there is no shipped history worth -/// preserving. Once there is, a wording change also has to keep the previous -/// version's renderer alive for PARSING, and dispatch on the candidate's own -/// `version` when round-tripping, so old transcripts keep cleaning up. +/// Parsing retains the exact v3 renderer as well as the current v4 account +/// renderer. Any future version must preserve these renderers for stored +/// transcripts and dispatch on the candidate's own version when round-tripping. /// /// The instruction is narrow on purpose. Agents already discover /// `delegate_to_agent` from the companion's tool schema; what they do wrong is @@ -199,6 +200,19 @@ fn render_internal_agent_routes(frame: &InternalAgentRoutes) -> String { fn render_internal_agent_routes_body(frame: &InternalAgentRoutes) -> String { let descriptor = serde_json::to_string(frame).expect("internal route frame is serializable"); let routes = serde_json::to_string(&frame.routes).expect("agent routes are serializable"); + // v3 frames are already stored in transcripts. Retain their exact renderer + // so adding account routing never exposes old internal instructions. + if frame.version == 4 { + return format!( + "{descriptor}\n\ +Codeg composer routing metadata (authoritative): {routes}\n\ +When you delegate any of this work, route it through the `delegate_to_agent` \ +tool from the `codeg-mcp` server with `agent_type` taken from the matching \ +route and `agent_account_id` set to that route's `agentAccountId` when present: \ +do not substitute your own native sub-agent, task, or spawn mechanism, \ +and do not route it through any other delegation tool.\n" + ); + } format!( "{descriptor}\n\ Codeg composer routing metadata (authoritative): {routes}\n\ @@ -235,14 +249,21 @@ fn parse_internal_agent_routes_body(candidate: &str) -> Option MAX_AGENT_ROUTES || frame .routes .iter() - .any(|route| !valid_agent_wire_syntax(&route.agent_type)) + .any(|route| { + !valid_agent_wire_syntax(&route.agent_type) + || (frame.version == 3 && route.agent_account_id.is_some()) + || route.agent_account_id.as_deref().is_some_and(|id| { + id.is_empty() || id.len() > 128 + || !id.bytes().all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + }) + }) || render_internal_agent_routes_body(&frame) != candidate { return None; @@ -833,6 +854,54 @@ pub(crate) fn strip_route_separator_from_prompt(blocks: &mut [PromptInputBlock]) mod tests { use super::*; + #[test] + fn named_accounts_keep_native_routes_distinct_and_round_trip() { + let visible = "[@Work](codeg://agent/claude_code?account=work) \ +[@Personal](codeg://agent/claude_code?account=personal) \ +[@Default](codeg://agent/claude_code?account=default) \ +[@Again](codeg://agent/claude_code?account=work) \ +[@Legacy](codeg://agent/custom:claude-code-2)"; + let mut blocks = vec![PromptInputBlock::Text { text: visible.into() }]; + append_agent_routes(&mut blocks, true); + let routing = text(&blocks[1]); + let frame = parse_internal_agent_routes_body(routing.trim_matches(ROUTE_FRAME_SEPARATOR)).unwrap(); + assert_eq!(frame.routes.len(), 4); + assert_eq!(frame.routes[0].agent_type, "claude_code"); + assert_eq!(frame.routes[0].agent_account_id.as_deref(), Some("work")); + assert_eq!(frame.routes[2].agent_account_id.as_deref(), Some("default")); + assert_eq!(frame.routes[3].agent_account_id, None); + assert!(routing.contains("`agent_account_id`")); + assert_eq!(strip_internal_frames(&format!("{visible}\n{routing}")), visible); + } + + #[test] + fn stored_v3_routes_still_strip_after_account_routes_are_added() { + let old = concat!( + "\u{001e}{\"kind\":\"codeg_internal_agent_routes\",\"version\":3,\"nonce\":\"00000000-0000-0000-0000-000000000001\"}\n", + "Codeg composer routing metadata (authoritative): [{\"agentType\":\"codex\"}]\n", + "When you delegate any of this work, route it through the `delegate_to_agent` ", + "tool from the `codeg-mcp` server with `agent_type` taken from the matching ", + "route: do not substitute your own native sub-agent, task, or spawn mechanism, ", + "and do not route it through any other delegation tool.\n\u{001e}" + ); + assert!(parse_internal_agent_routes_body(old.trim_matches(ROUTE_FRAME_SEPARATOR)).is_some()); + assert_eq!(strip_internal_frames(&format!("hello\n{old}")), "hello"); + assert_eq!(strip_internal_frames(old.trim_matches('\u{001e}')), ""); + } + + #[test] + fn account_and_assistant_mentions_keep_both_routes_and_clean_history() { + let visible = "Ask [@Work](codeg://agent/claude_code?account=work) and [@Reviewer](codeg://assistant/3)."; + let mut blocks = vec![PromptInputBlock::Text { text: visible.into() }]; + append_agent_routes(&mut blocks, true); + append_assistant_routes(&mut blocks, true); + assert_eq!(blocks.len(), 3); + assert!(text(&blocks[1]).contains(r#""agentAccountId":"work""#)); + assert!(text(&blocks[2]).contains(r#""assistantId":3"#)); + let serialized = blocks.iter().map(text).collect::>().join("\n"); + assert_eq!(strip_internal_frames(&serialized), visible); + } + fn text(block: &PromptInputBlock) -> &str { match block { PromptInputBlock::Text { text } => text, @@ -844,6 +913,7 @@ mod tests { fn wire_shape_retains_camel_case_agent_type() { let value = serde_json::to_value(AgentRoute { agent_type: "antigravity".into(), + agent_account_id: None, }) .unwrap(); assert_eq!(value, serde_json::json!({"agentType": "antigravity"})); @@ -911,15 +981,14 @@ mod tests { // The instruction text is part of the frame's byte-exact identity — // parsing only accepts a candidate the CURRENT renderer reproduces // verbatim — so a reworded frame stops being strippable and surfaces in - // every transcript already written with the old wording. There is no - // compat renderer: the version bump this test pins is a deliberate - // marker for that break, not a fix for it. + // every transcript already written with the old wording. Keep the + // version pinned here and preserve the older renderer for parsing. let mut blocks = vec![PromptInputBlock::Text { text: "ask [@Codex](codeg://agent/codex)".into(), }]; append_agent_routes(&mut blocks, true); let routing = text(&blocks[1]); - assert_eq!(ROUTE_FRAME_VERSION, 3); + assert_eq!(ROUTE_FRAME_VERSION, 4); assert!(routing.contains( "do not substitute your own native sub-agent, task, or spawn mechanism, \ and do not route it through any other delegation tool." diff --git a/src-tauri/src/acp/background_watch.rs b/src-tauri/src/acp/background_watch.rs index d628253823..65a42ca61e 100644 --- a/src-tauri/src/acp/background_watch.rs +++ b/src-tauri/src/acp/background_watch.rs @@ -494,6 +494,7 @@ async fn run_watch( let wire_title_wake = Arc::clone(&state.read().await.wire_title_wake); loop { wait_for_poll(ws.poll_delay(), &wire_title_wake).await; + ws.account_projects_dir = state.read().await.account_home.as_ref().map(|home| home.join("projects")); let (session_id, session_changed_at, is_prompting, turn_ended_abnormally) = { let s = state.read().await; @@ -647,6 +648,7 @@ enum Mode { } pub(crate) struct WatchState { + account_projects_dir: Option, session_id: Option, file: Option, /// Bytes consumed through the last complete line — the emitted watermark. @@ -775,6 +777,7 @@ pub(crate) struct WatchState { impl WatchState { pub(crate) fn new() -> Self { Self { + account_projects_dir: None, session_id: None, file: None, committed: 0, @@ -999,7 +1002,11 @@ impl WatchState { // this watch rolled onto instead. if self.file.is_none() { let lookup_id = self.rolled_over_id.clone().unwrap_or_else(|| session_id.clone()); - if let Some(f) = find_session_file_in(projects_dir, &lookup_id) { + let located = match &self.account_projects_dir { + Some(base) => find_session_file_in(base, &lookup_id), + None => find_session_file_in(projects_dir, &lookup_id), + }; + if let Some(f) = located { self.adopt_file(f); } if let Some(f) = &self.file { diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index 4ef98966ec..f566158a53 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -1497,22 +1497,78 @@ fn tag_mcp_suspect( struct ConnectionCleanupGuard { connections: Arc>>, connection_id: String, + /// The manager's list of children that may still be running. An entry + /// still mapped here means the connection ended on its own (every explicit + /// teardown removes it first and parks the child itself), so it is parked + /// the same way: the process is only signalled as the task ends and can + /// keep writing its transcript until it is reaped. + draining: Arc>, + /// The runtime the connection was spawned on. The guard drops on the + /// connection's own thread after `block_on` has returned — outside any + /// runtime context, where `tokio::spawn` would panic — so the deferred + /// cleanup is spawned through this handle. + runtime: tokio::runtime::Handle, } impl Drop for ConnectionCleanupGuard { fn drop(&mut self) { + // The map lock is held across the handoff into `draining`, as every + // other teardown does, so no observer can find the child in neither. if let Ok(mut guard) = self.connections.try_lock() { - guard.remove(&self.connection_id); - return; + let parked = match guard.get(&self.connection_id) { + None => return, + Some(connection) => { + match (self.draining.try_lock(), connection.state.try_read()) { + (Ok(mut draining), Ok(state)) => { + crate::acp::manager::park_ended_child( + &mut draining, + connection, + &state, + ); + true + } + _ => false, + } + } + }; + if parked { + guard.remove(&self.connection_id); + return; + } } let connections = self.connections.clone(); + let draining = self.draining.clone(); let connection_id = std::mem::take(&mut self.connection_id); - tokio::spawn(async move { - connections.lock().await.remove(&connection_id); + self.runtime.spawn(async move { + let mut guard = connections.lock().await; + let Some(connection) = guard.get(&connection_id) else { + return; + }; + let mut draining = draining.lock().await; + let state = connection.state.read().await; + crate::acp::manager::park_ended_child(&mut draining, connection, &state); + drop(state); + guard.remove(&connection_id); }); } } +/// Drop the cleanup guard a connection task holds, as its end does. +#[cfg(test)] +pub(crate) fn end_connection_task_for_test( + connections: Arc>>, + connection_id: &str, + draining: Arc>, + runtime: tokio::runtime::Handle, +) { + drop(ConnectionCleanupGuard { + connections, + connection_id: connection_id.to_string(), + draining, + runtime, + }); +} + /// Represents a single active ACP agent connection. pub struct AgentConnection { pub id: String, @@ -2012,6 +2068,7 @@ async fn build_agent( cwd: &Path, stderr_tail: &Arc, scratch: Option<&Path>, + account_home: Option<&Path>, ) -> Result { // A conversation can outlive the custom-agent definition it was started // with (the user deleted it in settings). `get_agent_meta` cannot report @@ -2067,7 +2124,7 @@ async fn build_agent( // a notice shown once the connection is up comes after the code it // warns about has already run. if let Some(message) = - crate::commands::acp::pi_project_trust_launch_block(cwd, runtime_env) + crate::commands::acp::pi_project_trust_launch_block(cwd, runtime_env, account_home) { return Err(AcpError::PiProjectTrustRequired(message)); } @@ -2076,7 +2133,7 @@ async fn build_agent( // `grok_launch_permission_mode` below) so the policy helper stays a // pure function over the env list. let codex_initial_mode = if agent_type == AgentType::Codex { - crate::commands::acp::codex_launch_initial_agent_mode() + crate::commands::acp::codex_launch_initial_agent_mode(runtime_env) } else { None }; @@ -2134,7 +2191,7 @@ async fn build_agent( // launch flag, not a live `session/set_mode`, is the control point.) if agent_type == AgentType::Grok { parts.push("--no-auto-update".into()); - if let Some(mode) = crate::commands::acp::grok_launch_permission_mode() { + if let Some(mode) = crate::commands::acp::grok_launch_permission_mode(runtime_env) { parts.push("--permission-mode".into()); parts.push(mode); } @@ -2549,15 +2606,16 @@ const ACP_CONNECTION_STACK_SIZE: usize = 8 * 1024 * 1024; /// exits (timeout, error, or clean disconnect), so the manager never /// leaks stale entries after a connection tears down. #[allow(clippy::too_many_arguments)] -pub async fn spawn_agent_connection( +pub(crate) async fn spawn_agent_connection( connection_id: String, agent_type: AgentType, working_dir: Option, session_id: Option, - runtime_env: BTreeMap, + mut runtime_env: BTreeMap, owner_window_label: String, emitter: EventEmitter, connections: Arc>>, + draining: Arc>, preferred_mode_id: Option, preferred_config_values: BTreeMap, delegation_injection: Option, @@ -2574,6 +2632,12 @@ pub async fn spawn_agent_connection( owner_window_label.clone(), None, // folder_id 由后续 prompt handler 在首次 send 时绑定 (Phase 2) ); + initial_state.storage_identity = crate::acp::agent_accounts::storage_identity(agent_type, &runtime_env); + initial_state.requested_session_id = session_id.clone(); + initial_state.agent_account_id = runtime_env.remove(crate::acp::agent_accounts::ACCOUNT_ENV); + let account_home = runtime_env.remove(crate::acp::agent_accounts::ACCOUNT_HOME_ENV).map(PathBuf::from); + initial_state.account_home = account_home.clone(); + initial_state.handoff_context_id = runtime_env.remove(crate::acp::handoff_context::CONTEXT_ENV); // Install the SessionStarted dedup signal BEFORE wrapping into Arc so the // first event (StatusChanged{Connecting} below) doesn't race with the @@ -2642,6 +2706,7 @@ pub async fn spawn_agent_connection( &launch_cwd, &stderr_tail, scratch.as_ref().map(|s| s.path()), + account_home.as_deref(), ) .await { @@ -2734,7 +2799,7 @@ pub async fn spawn_agent_connection( // volatile keys) plus the agent's native config file content, so a later // settings save can be compared against it to detect a stale running session. let config_fingerprint = - crate::commands::acp::fingerprint_config(agent_type, &runtime_env); + crate::commands::acp::fingerprint_account_config(agent_type, &runtime_env, account_home.as_deref()); // Insert the entry BEFORE spawning the background task so that a // fast-failing `run_connection` can never remove it before it was @@ -2774,6 +2839,8 @@ pub async fn spawn_agent_connection( let cleanup_guard = ConnectionCleanupGuard { connections: cleanup_connections, connection_id: cleanup_connection_id, + draining, + runtime: connection_rt.clone(), }; let connection_thread = std::thread::Builder::new() .name(format!("acp-conn-{conn_id}")) @@ -5297,7 +5364,7 @@ async fn current_grok_model_id(state: &Arc>) -> Option>) -> Option { - let (model, reported) = { + let (model, reported, account_home) = { let st = state.read().await; let model = current_grok_model_id_from_opts(st.config_options.as_deref()?)?; let reported = st @@ -5305,11 +5372,11 @@ async fn grok_current_model_context_window(state: &Arc>) -> .as_ref() .and_then(|specs| specs.get(&model)) .and_then(|spec| spec.context_window); - (model, reported) + (model, reported, st.account_home.clone()) }; // Lock released: the catalog lookup below touches the filesystem. reported.or_else(|| { - grok_offline_context_window(&model, &crate::parsers::grok::resolve_grok_home_dir()) + grok_offline_context_window(&model, &account_home.unwrap_or_else(crate::parsers::grok::resolve_grok_home_dir)) }) } @@ -7987,6 +8054,16 @@ async fn run_connection( } } + if let Some(context_id) = state.read().await.handoff_context_id.clone() { + if agent_supports_mcp && agent_delivers_wire_mcp(agent_type) { + let binary = locate_codeg_mcp_binary().ok_or_else(|| agent_client_protocol::util::internal_error("The conversation context tool is not installed. Reinstall Codeg before switching agents."))?; + let directory = crate::acp::handoff_context::context_directory(&context_id).map_err(agent_client_protocol::util::internal_error)?; + mcp_servers.push(McpServer::Stdio(McpServerStdio::new("codeg-context", binary).args(vec![ + "--conversation-context".into(), directory.to_string_lossy().into_owned(), std::process::id().to_string(), + ]))); + } + } + // Emit fork support capability emit_with_state( &state, @@ -8032,6 +8109,7 @@ async fn run_connection( ); match unless_cancelled(&handshake, send_resume_session(&cx, resume_req)).await? { Ok((resume_resp, models_raw)) => { + state.write().await.restored_session_id = Some(sid.clone()); let initial_config_options = resume_resp.config_options.clone(); let new_resp = NewSessionResponse::new(SessionId::new(sid.clone())) .modes(resume_resp.modes) @@ -8200,6 +8278,7 @@ async fn run_connection( match load_result { Ok(load_resp) => { + state.write().await.restored_session_id = Some(sid.clone()); let initial_config_options = load_resp.config_options.clone(); let new_resp = NewSessionResponse::new(SessionId::new(sid.clone())) .modes(load_resp.modes) @@ -29505,10 +29584,13 @@ mod tests { ) .connect_with(agent_end, async move |cx: ConnectionTo| { cx.incoming_closed().await; - let _ = closed_tx.send(()); Ok(()) }) .await; + // Transport shutdown can finish connect_with before its main + // callback observes incoming_closed. Report completion outside + // that callback so either shutdown path wakes the test. + let _ = closed_tx.send(()); }); Self { seen, diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 979240e222..f946ef702e 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -141,6 +141,8 @@ pub struct ChildResumeContext { pub child_conversation_id: i32, pub status: TaskStatus, pub agent_type: AgentType, + /// Persisted child binding; None explicitly means Default, never inheritance. + pub agent_account_id: Option, /// Parent conversation scope — same ownership rule as /// [`ChildStatusRecord::parent_id`]. pub parent_id: Option, @@ -1378,6 +1380,8 @@ struct ToolCallTracker { #[derive(Clone, Debug, PartialEq, Eq)] pub struct DelegationMatchKey { pub target: crate::acp::delegation::types::DelegationTarget, + /// Raw MCP argument, before resolving inheritance or the Default sentinel. + pub agent_account_id: Option, pub task: String, pub working_dir: Option, } @@ -2466,6 +2470,7 @@ impl DelegationBroker { if req.parent_tool_use_id.is_empty() { let key = DelegationMatchKey { target: req.target(), + agent_account_id: req.agent_account_id.clone(), task: req.task.clone(), working_dir: req.requested_working_dir.clone(), }; @@ -2498,6 +2503,7 @@ impl DelegationBroker { if req.parent_tool_use_id.is_empty() { let match_key = DelegationMatchKey { target: req.target(), + agent_account_id: req.agent_account_id.clone(), task: req.task.clone(), working_dir: req.requested_working_dir.clone(), }; @@ -2537,6 +2543,9 @@ impl DelegationBroker { if let Some(dir) = req.requested_working_dir.as_deref() { raw_input.insert("working_dir".into(), serde_json::Value::String(dir.into())); } + if let Some(id) = req.agent_account_id.as_deref() { + raw_input.insert("agent_account_id".into(), serde_json::Value::String(id.into())); + } self.meta_writer .write_tool_call_identity( &req.parent_connection_id, @@ -2631,6 +2640,7 @@ impl DelegationBroker { .spawn( &req.parent_connection_id, req.agent_type, + req.agent_account_id.clone(), req.working_dir.clone(), preferred_mode_id, preferred_config_values, @@ -4313,6 +4323,7 @@ impl DelegationBroker { .spawn_for_resume( &req.parent_connection_id, ctx.agent_type, + ctx.agent_account_id.clone(), Some(working_dir), &external_id, preferred_mode_id, @@ -4924,6 +4935,7 @@ impl ChildStatusLookup for DbChildStatusLookup { child_conversation_id: summary.id, status: task_status_from_row(&summary.status), agent_type: summary.agent_type, + agent_account_id: summary.agent_account_id, parent_id: summary.parent_id, parent_tool_use_id: summary.parent_tool_use_id, external_id: summary.external_id, @@ -4965,6 +4977,7 @@ mod tests { parent_conversation_id: parent_conv, parent_tool_use_id: tool_use.into(), agent_type: AgentType::ClaudeCode, + agent_account_id: None, task: "do x".into(), working_dir: None, requested_working_dir: None, @@ -6195,6 +6208,7 @@ mod tests { fn key_for(agent_type: AgentType, task: &str) -> DelegationMatchKey { DelegationMatchKey { target: crate::acp::delegation::types::DelegationTarget::Agent(agent_type), + agent_account_id: None, task: task.to_string(), working_dir: None, } @@ -6203,6 +6217,7 @@ mod tests { fn key_with_dir(task: &str, working_dir: &str) -> DelegationMatchKey { DelegationMatchKey { target: crate::acp::delegation::types::DelegationTarget::Agent(AgentType::Codex), + agent_account_id: None, task: task.to_string(), working_dir: Some(working_dir.to_string()), } @@ -6247,6 +6262,19 @@ mod tests { .is_none()); } + #[tokio::test] + async fn parallel_same_agent_and_task_different_accounts_do_not_swap() { + let broker = DelegationBroker::new(Arc::new(MockSpawner::new()), shallow_lookup()); + let mut work = task_key("review this"); + work.agent_account_id = Some("work".into()); + let mut personal = work.clone(); + personal.agent_account_id = Some("personal".into()); + broker.register_pending_tool_call_with_key("p", "tc-work".into(), Some(work.clone())).await; + broker.register_pending_tool_call_with_key("p", "tc-personal".into(), Some(personal.clone())).await; + assert_eq!(broker.take_matching_tool_call("p", &personal).await.as_deref(), Some("tc-personal")); + assert_eq!(broker.take_matching_tool_call("p", &work).await.as_deref(), Some("tc-work")); + } + #[tokio::test] async fn parallel_same_task_different_agent_do_not_swap() { // Regression for Codex review: two parallel calls with the SAME task @@ -7507,6 +7535,7 @@ mod tests { "tc-keyed".into(), Some(DelegationMatchKey { target: crate::acp::delegation::types::DelegationTarget::Agent(AgentType::ClaudeCode), + agent_account_id: None, task: "do x".into(), working_dir: None, }), @@ -9487,6 +9516,7 @@ mod tests { enable_delegation(&broker).await; let key = DelegationMatchKey { target: crate::acp::delegation::types::DelegationTarget::Agent(AgentType::ClaudeCode), + agent_account_id: None, task: "do x".into(), working_dir: None, }; @@ -9727,6 +9757,7 @@ mod tests { child_conversation_id: 42, status, agent_type: AgentType::ClaudeCode, + agent_account_id: None, parent_id: Some(1), parent_tool_use_id: Some("pt-orig".into()), external_id: Some("ext-session-1".into()), @@ -9786,7 +9817,9 @@ mod tests { /// on the unchanged id finishes it normally. #[tokio::test] async fn resume_after_interruption_runs_under_same_id_then_completes() { - let (mock, _lookup, broker) = resume_harness(Some(resume_ctx(TaskStatus::Canceled))).await; + let mut context = resume_ctx(TaskStatus::Canceled); + context.agent_account_id = Some("child-work-account".into()); + let (mock, _lookup, broker) = resume_harness(Some(context)).await; mock.queue_resume_spawn(Ok(ResumedSpawn::fresh("child-conn-2"))).await; mock.queue_resume_send(Ok(())).await; @@ -9803,6 +9836,7 @@ mod tests { assert_eq!(spawns.len(), 1); assert_eq!(spawns[0].external_session_id, "ext-session-1"); assert_eq!(spawns[0].working_dir.as_deref(), Some("/work")); + assert_eq!(spawns[0].agent_account_id.as_deref(), Some("child-work-account")); drop(spawns); // The continuation prompt targeted the SAME child row and carried the // reason as bounded context. @@ -10254,7 +10288,9 @@ mod tests { }) .await; - let ack = broker.start_delegation(assistant_request(1, "pt-a")).await; + let mut request = assistant_request(1, "pt-a"); + request.agent_account_id = Some("work-account".into()); + let ack = broker.start_delegation(request).await; assert_eq!(ack.status, TaskStatus::Running); assert_eq!(ack.assistant_id, Some(3)); assert_eq!(ack.assistant_name.as_deref(), Some("Reviewer")); @@ -10262,6 +10298,7 @@ mod tests { let spawn = mock.spawn_args.lock().await[0].clone(); assert_eq!(spawn.agent_type, AgentType::Codex); + assert_eq!(spawn.agent_account_id.as_deref(), Some("work-account")); assert_eq!(spawn.preferred_mode_id.as_deref(), Some("read-only")); assert_eq!( spawn.preferred_config_values, @@ -10387,6 +10424,7 @@ mod tests { async fn resuming_an_assistant_child_replays_its_snapshot() { let ctx = ChildResumeContext { agent_type: AgentType::Codex, + agent_account_id: Some("saved-work-account".into()), assistant: ResumeAssistant::Snapshot(reviewer()), ..resume_ctx(TaskStatus::Canceled) }; @@ -10408,6 +10446,7 @@ mod tests { let spawn = mock.resume_spawn_args.lock().await[0].clone(); assert_eq!(spawn.agent_type, AgentType::Codex); + assert_eq!(spawn.agent_account_id.as_deref(), Some("saved-work-account")); assert_eq!(spawn.preferred_mode_id.as_deref(), Some("read-only")); assert_eq!( spawn.preferred_config_values, diff --git a/src-tauri/src/acp/delegation/listener.rs b/src-tauri/src/acp/delegation/listener.rs index 440dd52f2a..3562df16fa 100644 --- a/src-tauri/src/acp/delegation/listener.rs +++ b/src-tauri/src/acp/delegation/listener.rs @@ -1294,6 +1294,13 @@ impl DelegationListener { return report_failed("invalid_working_dir", "missing or empty task"); } }; + let agent_account_id = match req.input.get("agent_account_id") { + None => None, + Some(serde_json::Value::String(id)) if !id.trim().is_empty() && id.len() <= 128 => { + Some(id.clone()) + } + _ => return report_failed("invalid_agent_account", "Choose a valid agent account."), + }; // The `working_dir` the LLM explicitly passed (before defaulting), // used by the broker's correlation key. `None` when omitted — // symmetric with the ACP `raw_input`, which also omits it then. @@ -1311,6 +1318,7 @@ impl DelegationListener { parent_conversation_id, parent_tool_use_id: req.parent_tool_use_id, agent_type, + agent_account_id, task, working_dir, requested_working_dir, @@ -2309,6 +2317,37 @@ mod tests { assert_eq!(report.error_code.as_deref(), Some("invalid_agent_type")); } + #[tokio::test] + async fn account_arguments_reach_the_native_spawner_without_normalization() { + let mock = Arc::new(MockSpawner::new()); + let broker = make_broker(mock.clone()).await; + let tokens = Arc::new(TokenRegistry::default()); + tokens.register("tok".into(), TokenEntry { + parent_connection_id: "parent-conn".into(), + working_dir: std::env::temp_dir(), + }).await; + let listener = make_listener(broker, tokens, Some(1)); + for account in [None, Some("default"), Some("work-account")] { + mock.queue_spawn(Ok("account-child".into())).await; + mock.queue_send(Ok(42)).await; + let mut input = json!({"agent_type":"claude_code", "task":"review"}); + if let Some(id) = account { input["agent_account_id"] = json!(id); } + let report = listener.process(make_request(input).await).await; + assert_eq!(report.status, TaskStatus::Running); + let args = mock.spawn_args.lock().await; + let last = args.last().unwrap(); + assert_eq!(last.agent_type, AgentType::ClaudeCode); + assert_eq!(last.agent_account_id.as_deref(), account); + } + for invalid in [json!(null), json!(false), json!(""), json!(" "), json!({"id":"work"})] { + let report = listener.process(make_request(json!({ + "agent_type":"claude_code", "task":"review", "agent_account_id":invalid, + })).await).await; + assert_eq!(report.error_code.as_deref(), Some("invalid_agent_account")); + } + assert_eq!(mock.spawn_args.lock().await.len(), 3); + } + /// Full async round-trip through the listener: `delegate_to_agent` returns a /// Running ack, the lifecycle resolves the child via `complete_call`, and a /// follow-up `get_delegation_status` collects the Completed result. @@ -2408,6 +2447,7 @@ mod tests { parent_conversation_id: 1, parent_tool_use_id: "pt-1".into(), agent_type: AgentType::Codex, + agent_account_id: None, task: "do x".into(), working_dir: None, requested_working_dir: None, @@ -2562,6 +2602,7 @@ mod tests { parent_conversation_id: 1, parent_tool_use_id: tool_use.into(), agent_type: AgentType::Codex, + agent_account_id: None, task: "do x".into(), working_dir: None, requested_working_dir: None, @@ -2665,6 +2706,7 @@ mod tests { parent_conversation_id: 1, parent_tool_use_id: "pt-1".into(), agent_type: AgentType::Codex, + agent_account_id: None, task: "do x".into(), working_dir: None, requested_working_dir: None, @@ -2717,6 +2759,7 @@ mod tests { parent_conversation_id: 1, parent_tool_use_id: "pt-cancel".into(), agent_type: AgentType::Codex, + agent_account_id: None, task: "do x".into(), working_dir: None, requested_working_dir: None, @@ -2782,6 +2825,7 @@ mod tests { child_conversation_id: 42, status: Ts::Canceled, agent_type: AgentType::Codex, + agent_account_id: None, parent_id: Some(1), parent_tool_use_id: Some("pt-orig".into()), external_id: Some("ext-1".into()), diff --git a/src-tauri/src/acp/delegation/spawner.rs b/src-tauri/src/acp/delegation/spawner.rs index 739a63b588..72e617483e 100644 --- a/src-tauri/src/acp/delegation/spawner.rs +++ b/src-tauri/src/acp/delegation/spawner.rs @@ -109,6 +109,9 @@ pub trait ConnectionSpawner: Send + Sync { /// `owner_window_label` (both required by `ConnectionManager::spawn_agent`) /// without leaking those types into the broker. If `working_dir` is /// `None`, the impl may fall back to the parent connection's `working_dir`. + /// `agent_account_id` is the raw request: None inherits a same-agent + /// parent's account, "default" selects Default, and any other ID is + /// resolved through the native account registry before launching. /// /// Returns the new connection id (codeg-internal UUID, not the ACP /// session id assigned by the agent). @@ -116,6 +119,7 @@ pub trait ConnectionSpawner: Send + Sync { &self, parent_connection_id: &str, agent_type: AgentType, + agent_account_id: Option, working_dir: Option, preferred_mode_id: Option, preferred_config_values: BTreeMap, @@ -146,10 +150,14 @@ pub trait ConnectionSpawner: Send + Sync { /// The returned [`ResumedSpawn::reused`] records whether the manager /// dedup-reused an already-live connection instead of spawning — the /// broker's failure teardown must not disconnect a reused one. + /// `agent_account_id` is the saved CHILD binding. None means Default and + /// must never inherit a named account from the parent. + #[allow(clippy::too_many_arguments)] async fn spawn_for_resume( &self, parent_connection_id: &str, agent_type: AgentType, + agent_account_id: Option, working_dir: Option, external_session_id: &str, preferred_mode_id: Option, @@ -241,6 +249,7 @@ pub mod mock { pub struct SpawnCallArgs { pub parent_connection_id: String, pub agent_type: AgentType, + pub agent_account_id: Option, pub working_dir: Option, pub preferred_mode_id: Option, pub preferred_config_values: BTreeMap, @@ -258,6 +267,7 @@ pub mod mock { pub struct ResumeSpawnCallArgs { pub parent_connection_id: String, pub agent_type: AgentType, + pub agent_account_id: Option, pub working_dir: Option, pub external_session_id: String, pub preferred_mode_id: Option, @@ -327,6 +337,7 @@ pub mod mock { &self, parent_connection_id: &str, agent_type: AgentType, + agent_account_id: Option, working_dir: Option, preferred_mode_id: Option, preferred_config_values: BTreeMap, @@ -334,6 +345,7 @@ pub mod mock { self.spawn_args.lock().await.push(SpawnCallArgs { parent_connection_id: parent_connection_id.to_string(), agent_type, + agent_account_id, working_dir, preferred_mode_id, preferred_config_values, @@ -383,6 +395,7 @@ pub mod mock { &self, parent_connection_id: &str, agent_type: AgentType, + agent_account_id: Option, working_dir: Option, external_session_id: &str, preferred_mode_id: Option, @@ -391,6 +404,7 @@ pub mod mock { self.resume_spawn_args.lock().await.push(ResumeSpawnCallArgs { parent_connection_id: parent_connection_id.to_string(), agent_type, + agent_account_id, working_dir, external_session_id: external_session_id.to_string(), preferred_mode_id, @@ -468,6 +482,7 @@ pub mod mock { .spawn( "parent-1", AgentType::ClaudeCode, + None, Some("/tmp".into()), None, BTreeMap::new(), @@ -476,7 +491,7 @@ pub mod mock { .unwrap(); assert_eq!(r1, "child-1"); let r2 = m - .spawn("parent-1", AgentType::Codex, None, None, BTreeMap::new()) + .spawn("parent-1", AgentType::Codex, None, None, None, BTreeMap::new()) .await .unwrap_err(); assert!(matches!(r2, SpawnerError::Spawn(_))); @@ -491,6 +506,7 @@ pub mod mock { AgentType::ClaudeCode, None, None, + None, BTreeMap::new(), ) .await @@ -510,6 +526,7 @@ pub mod mock { m.spawn( "p1", AgentType::ClaudeCode, + None, Some("/work".into()), Some("auto".into()), cfg.clone(), diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index e0271ee89a..f8bda888a7 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -34,6 +34,10 @@ ], "description": "Which local agent runs the sub-task. Pick the one best suited to the work — or, when the user's message names an agent via `@AgentName` / `codeg://agent/`, use that exact `` slug (e.g. `codeg://agent/claude_code` means `claude_code`)." }, + "agent_account_id": { + "type": "string", + "description": "The exact account ID from an account-specific mention (codeg://agent/?account=). Pass 'default' to explicitly use the agent's Default account. Omit to inherit the parent's account for the same agent, or Default for a different agent. Keep agent_type as the native agent slug; never replace a named native account with a custom agent." + }, "task": { "type": "string", "description": "The complete, self-contained prompt for the sub-agent. It has no access to this conversation or your context, so spell out everything: the goal, the relevant background and absolute file paths, any constraints, and exactly what to return." diff --git a/src-tauri/src/acp/delegation/types.rs b/src-tauri/src/acp/delegation/types.rs index 1ab4ceb7fb..a531b82a91 100644 --- a/src-tauri/src/acp/delegation/types.rs +++ b/src-tauri/src/acp/delegation/types.rs @@ -170,6 +170,9 @@ pub struct DelegationRequest { pub parent_conversation_id: i32, pub parent_tool_use_id: String, pub agent_type: AgentType, + /// Omitted: inherit a same-agent parent's account. "default": use Default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_account_id: Option, pub task: String, pub working_dir: Option, /// The `working_dir` exactly as the LLM passed it in the diff --git a/src-tauri/src/acp/family_isolator.rs b/src-tauri/src/acp/family_isolator.rs new file mode 100644 index 0000000000..6d5d12697c --- /dev/null +++ b/src-tauri/src/acp/family_isolator.rs @@ -0,0 +1,455 @@ +//! Isolated extra-account homes for built-in agent families. +//! +//! Codeg has one built-in agent per family (`claude_code`, `codex`, …) whose +//! MCP/auth files live in the default home (`~/.claude`, `~/.codex`, …). Extra +//! subscriptions are registered as custom ACP agents whose launch `spec.env` +//! sets that family's official isolator (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, …). +//! +//! Settings → MCP still targets the family row. This module is how writers +//! discover the extra homes so they stay in lock-step without extra checkboxes +//! and without copying `auth.json` / `.credentials.json`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::acp::custom_registry::{CustomAgentDef, CustomAgentSpec}; +use crate::acp::registry::AgentDistribution; +use crate::models::agent::AgentType; + +/// Families whose official CLI honors a home-override env var. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum IsolatorFamily { + Claude, + Codex, + Grok, + Gemini, + OpenCode, +} + +impl IsolatorFamily { + pub fn as_str(self) -> &'static str { + match self { + Self::Claude => "claude", + Self::Codex => "codex", + Self::Grok => "grok", + Self::Gemini => "gemini", + Self::OpenCode => "opencode", + } + } + + pub fn isolator_key(self) -> &'static str { + match self { + Self::Claude => "CLAUDE_CONFIG_DIR", + Self::Codex => "CODEX_HOME", + Self::Grok => "GROK_HOME", + Self::Gemini => "GEMINI_CLI_HOME", + Self::OpenCode => "OPENCODE_CONFIG_DIR", + } + } + + /// The built-in agent whose CLI an extra slot runs. Sign-in goes through + /// that agent's native account login (`agent_accounts::layout`). + pub fn agent_type(self) -> AgentType { + match self { + Self::Claude => AgentType::ClaudeCode, + Self::Codex => AgentType::Codex, + Self::Grok => AgentType::Grok, + Self::Gemini => AgentType::Gemini, + Self::OpenCode => AgentType::OpenCode, + } + } + + pub fn default_home(self) -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + match self { + Self::Claude => home.join(".claude"), + Self::Codex => home.join(".codex"), + Self::Grok => home.join(".grok"), + Self::Gemini => home.join(".gemini"), + Self::OpenCode => home.join(".config").join("opencode"), + } + } +} + +/// Merge every channel env map on a custom-agent spec. Extra slots created +/// through the ACP save path put isolators on `npx.env` (or uvx/binary). +pub fn spec_env_map(spec: &CustomAgentSpec) -> BTreeMap { + let mut out = BTreeMap::new(); + if let Some(npx) = &spec.npx { + out.extend(npx.env.clone()); + } + if let Some(uvx) = &spec.uvx { + out.extend(uvx.env.clone()); + } + for bin in spec.binary.values() { + out.extend(bin.env.clone()); + } + out +} + +/// Detect the isolated family home from a launch env map. +/// +/// Gemini uses `GEMINI_CLI_HOME`, the parent of `.gemini`. +/// Blank values are ignored. Auth-file paths are never returned. +pub fn isolator_from_env(env: &BTreeMap) -> Option<(IsolatorFamily, PathBuf)> { + isolator_from_env_filtered(env, None) +} + +fn isolator_from_env_filtered( + env: &BTreeMap, + only: Option, +) -> Option<(IsolatorFamily, PathBuf)> { + // GEMINI_CLI_HOME is the parent of the settings directory. + let candidates: &[(IsolatorFamily, &str, bool)] = &[ + (IsolatorFamily::Claude, "CLAUDE_CONFIG_DIR", false), + (IsolatorFamily::Codex, "CODEX_HOME", false), + (IsolatorFamily::Grok, "GROK_HOME", false), + (IsolatorFamily::Gemini, "GEMINI_CLI_HOME", true), + (IsolatorFamily::OpenCode, "OPENCODE_CONFIG_DIR", false), + ]; + for (family, key, join_gemini) in candidates { + if let Some(only) = only { + if only != *family { + continue; + } + } + let Some(raw) = env.get(*key).map(|s| s.trim()).filter(|s| !s.is_empty()) else { + continue; + }; + let mut path = PathBuf::from(raw); + if *join_gemini { + path.push(".gemini"); + } + return Some((*family, path)); + } + None +} + +fn paths_equivalent(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + match (left.canonicalize(), right.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +fn is_default_home(family: IsolatorFamily, home: &Path) -> bool { + paths_equivalent(home, &family.default_home()) +} + +/// Extra homes for one family, from already-loaded custom-agent defs. No DB. +pub fn extra_homes_for_family(family: IsolatorFamily, defs: &[CustomAgentDef]) -> Vec { + let mut homes = Vec::new(); + for def in defs { + let env = spec_env_map(&def.spec); + if let Some((_, home)) = isolator_from_env_filtered(&env, Some(family)) { + if !is_default_home(family, &home) { + homes.push(home); + } + } + } + homes.sort(); + homes.dedup(); + homes +} + +pub(crate) fn distribution_env(dist: &AgentDistribution) -> BTreeMap { + let pairs = match dist { + AgentDistribution::Npx { env, .. } + | AgentDistribution::Binary { env, .. } + | AgentDistribution::System { env, .. } + | AgentDistribution::Uvx { env, .. } => *env, + }; + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +/// Extra homes published in the in-memory custom-agent registry (hydrated +/// from `custom_agent` at boot and after every save). Sync, no DB. +pub fn extra_homes_from_live_registry(family: IsolatorFamily) -> Vec { + let mut homes = Vec::new(); + for agent in crate::acp::custom_registry::all() { + let AgentType::Custom(id) = agent else { + continue; + }; + let Some(meta) = crate::acp::custom_registry::get(id) else { + continue; + }; + let env = distribution_env(&meta.distribution); + if let Some((_, home)) = isolator_from_env_filtered(&env, Some(family)) { + if !is_default_home(family, &home) { + homes.push(home); + } + } + } + homes.sort(); + homes.dedup(); + homes +} + +/// Official login plan for an extra slot. Never copies tokens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtraSlotLogin { + pub family: IsolatorFamily, + pub isolator_key: &'static str, + pub home: PathBuf, + pub env: BTreeMap, +} + +pub fn login_plan_from_env(env: &BTreeMap) -> Option { + let (family, home) = isolator_from_env(env)?; + if is_default_home(family, &home) { + return None; + } + let home = if family == IsolatorFamily::Gemini { + home.parent()?.to_path_buf() + } else { + home + }; + let mut login_env = BTreeMap::from([(family.isolator_key().into(), home.to_string_lossy().into_owned())]); + if family == IsolatorFamily::OpenCode { + // OPENCODE_CONFIG_DIR does not move auth.json. Only offer isolated + // sign-in when the selected distribution isolates its data as well. + let data = env.get("XDG_DATA_HOME").filter(|s| !s.trim().is_empty())?; + let default_data = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from) + .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share")); + if paths_equivalent(Path::new(data), &default_data) { return None; } + for key in ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"] { + if let Some(value) = env.get(key).filter(|s| !s.trim().is_empty()) { + login_env.insert(key.into(), value.clone()); + } + } + } + Some(ExtraSlotLogin { + family, + isolator_key: family.isolator_key(), + home, + env: login_env, + }) +} + +pub fn login_plan_from_def(def: &CustomAgentDef) -> Option { + use crate::acp::custom_registry::CustomDistributionKind; + let env = match def.distribution_kind { + CustomDistributionKind::Npx => &def.spec.npx.as_ref()?.env, + CustomDistributionKind::Uvx => &def.spec.uvx.as_ref()?.env, + CustomDistributionKind::Binary => &def.spec.binary.get(crate::acp::registry::current_platform())?.env, + }; + login_plan_from_env(env) +} + +/// Build the command line `open_external_terminal_impl` will run. Rejects +/// newlines in any value (same rule as the terminal opener), the isolated +/// home included. +pub fn shell_command_with_env(env: &BTreeMap, args: &[&str]) -> Result { + command_for_platform(env, args, cfg!(windows)) +} + +fn command_for_platform(env: &BTreeMap, args: &[&str], windows: bool) -> Result { + if args.is_empty() || env.iter().any(|(key, value)| key.is_empty() || !key.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') || key.as_bytes()[0].is_ascii_digit() || value.contains(['\n', '\r', '\0'])) || args.iter().any(|arg| arg.contains(['\n', '\r', '\0'])) { + return Err("Invalid login command or account environment".into()); + } + if windows { + use base64::Engine; + let mut script = env.iter().map(|(key, value)| { + if value.is_empty() { format!("$env:{key} = $null; ") } else { format!("$env:{key} = '{}'; ", value.replace('\'', "''")) } + }).collect::(); + script.push_str("& "); + script.push_str(&args.iter().map(|arg| format!("'{}'", arg.replace('\'', "''"))).collect::>().join(" ")); + let bytes: Vec = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); + Ok(format!("powershell.exe -NoProfile -EncodedCommand {}", base64::engine::general_purpose::STANDARD.encode(bytes))) + } else { + let exports = env.iter().map(|(key, value)| { + if value.is_empty() { format!("unset {key}") } else { format!("export {key}={}", shell_single_quote(value)) } + }).collect::>().join(" && "); + let command = args.iter().map(|arg| { + if !arg.is_empty() && arg.bytes().all(|c| c.is_ascii_alphanumeric() || b"_./:-".contains(&c)) { (*arg).to_owned() } else { shell_single_quote(arg) } + }).collect::>().join(" "); + Ok(if exports.is_empty() { command } else { format!("{exports} && {command}") }) + } +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::custom_registry::{CustomDistributionKind, NpxSpec}; + + fn def_with_env(id: &str, env: BTreeMap) -> CustomAgentDef { + CustomAgentDef { + registry_id: id.to_string(), + name: id.to_string(), + description: String::new(), + version: "1.0.0".into(), + distribution_kind: CustomDistributionKind::Npx, + spec: CustomAgentSpec { + npx: Some(NpxSpec { + package: "example@1.0.0".into(), + args: Vec::new(), + env, + cmd: None, + node_required: None, + }), + ..Default::default() + }, + icon_url: None, + skills_shared_store: false, + skills_dir: None, + source: Default::default(), + version_probe: None, + supports_mcp: true, + } + } + + #[test] + fn isolator_from_env_reads_family_keys() { + let mut env = BTreeMap::new(); + env.insert("CLAUDE_CONFIG_DIR".into(), "/tmp/a".into()); + let (family, home) = isolator_from_env(&env).expect("claude"); + assert_eq!(family, IsolatorFamily::Claude); + assert_eq!(home, PathBuf::from("/tmp/a")); + + let mut env = BTreeMap::new(); + env.insert("GEMINI_CLI_HOME".into(), "/tmp/h".into()); + let (family, home) = isolator_from_env(&env).expect("gemini parent"); + assert_eq!(family, IsolatorFamily::Gemini); + assert_eq!(home, PathBuf::from("/tmp/h").join(".gemini")); + + let mut env = BTreeMap::new(); + env.insert("GEMINI_CONFIG_DIR".into(), "/tmp/g".into()); + env.insert("GEMINI_CLI_HOME".into(), "/tmp/h".into()); + let (family, home) = isolator_from_env(&env).expect("official home wins"); + assert_eq!(family, IsolatorFamily::Gemini); + assert_eq!(home, PathBuf::from("/tmp/h").join(".gemini")); + env.remove("GEMINI_CLI_HOME"); + assert!(isolator_from_env(&env).is_none()); + + let mut env = BTreeMap::new(); + env.insert("CLAUDE_CONFIG_DIR".into(), " ".into()); + assert!(isolator_from_env(&env).is_none()); + assert!(isolator_from_env(&BTreeMap::new()).is_none()); + } + + #[test] + fn extra_homes_for_family_filters_and_skips_default() { + let defs = vec![ + def_with_env( + "codex-2", + BTreeMap::from([("CODEX_HOME".into(), "/p/codex-2".into())]), + ), + def_with_env( + "grok-2", + BTreeMap::from([("GROK_HOME".into(), "/p/grok-2".into())]), + ), + def_with_env( + "codex-default", + BTreeMap::from([( + "CODEX_HOME".into(), + IsolatorFamily::Codex + .default_home() + .to_string_lossy() + .into(), + )]), + ), + ]; + let homes = extra_homes_for_family(IsolatorFamily::Codex, &defs); + assert_eq!(homes, vec![PathBuf::from("/p/codex-2")]); + let grok = extra_homes_for_family(IsolatorFamily::Grok, &defs); + assert_eq!(grok, vec![PathBuf::from("/p/grok-2")]); + } + + #[test] + fn login_plan_sets_isolator_and_official_args() { + let def = def_with_env( + "codex-2", + BTreeMap::from([("CODEX_HOME".into(), "/tmp/c2".into())]), + ); + let plan = login_plan_from_def(&def).expect("plan"); + assert_eq!(plan.family, IsolatorFamily::Codex); + assert_eq!(plan.isolator_key, "CODEX_HOME"); + assert_eq!(plan.home, PathBuf::from("/tmp/c2")); + // The slot signs in with its family's native account login. + let login = crate::acp::agent_accounts::layout(plan.family.agent_type()) + .unwrap() + .login; + assert_eq!(login, &["codex", "login"]); + let cmd = shell_command_with_env(&plan.env, login).expect("cmd"); + if cfg!(windows) { + assert!(cmd.starts_with("powershell.exe -NoProfile -EncodedCommand ")); + } else { + assert!(cmd.contains("CODEX_HOME")); + assert!(cmd.contains("codex login")); + } + assert!(!cmd.contains('\n')); + } + + #[test] + fn shell_export_rejects_newlines_in_home() { + let env = BTreeMap::from([("CLAUDE_CONFIG_DIR".into(), "/tmp/bad\nhome".into())]); + assert!(shell_command_with_env(&env, &["claude", "auth", "login"]).is_err()); + } + + #[test] + fn isolator_never_returns_an_auth_file_path() { + let mut env = BTreeMap::new(); + env.insert("CLAUDE_CONFIG_DIR".into(), "/profiles/claude-2".into()); + env.insert( + "ANTHROPIC_AUTH_TOKEN".into(), + "/profiles/other/auth.json".into(), + ); + let (_, home) = isolator_from_env(&env).expect("home"); + assert_eq!(home, PathBuf::from("/profiles/claude-2")); + assert!(!home.ends_with("auth.json")); + } + + #[test] + fn opencode_sign_in_requires_separate_credential_storage() { + let root = tempfile::tempdir().unwrap(); + let mut env = BTreeMap::from([("OPENCODE_CONFIG_DIR".into(), root.path().join("config").display().to_string())]); + assert!(login_plan_from_env(&env).is_none()); + env.insert("XDG_DATA_HOME".into(), root.path().join("data").display().to_string()); + let plan = login_plan_from_env(&env).unwrap(); + assert_eq!(plan.env.get("XDG_DATA_HOME"), env.get("XDG_DATA_HOME")); + assert!(command_for_platform(&plan.env, &["opencode", "auth", "login"], false).unwrap().contains("XDG_DATA_HOME")); + } + + #[test] + fn default_home_is_not_offered_as_an_isolated_account() { + for family in [IsolatorFamily::Claude, IsolatorFamily::Codex, IsolatorFamily::Grok, IsolatorFamily::Gemini, IsolatorFamily::OpenCode] { + let home = family.default_home(); + let value = if family == IsolatorFamily::Gemini { home.parent().unwrap() } else { &home }; + let env = BTreeMap::from([(family.isolator_key().into(), value.display().to_string())]); + assert!(login_plan_from_env(&env).is_none(), "{family:?}"); + } + } + + #[test] + fn gemini_sign_in_and_mcp_use_the_same_directory() { + let env = BTreeMap::from([("GEMINI_CLI_HOME".into(), "/profiles/google".into())]); + let (_, config) = isolator_from_env(&env).unwrap(); + let plan = login_plan_from_env(&env).unwrap(); + assert_eq!(config, plan.home.join(".gemini")); + assert_eq!(plan.family.agent_type(), AgentType::Gemini); + } + + #[test] + fn windows_login_keeps_shell_metacharacters_inside_an_encoded_literal() { + use base64::Engine; + let value = "C:\\profiles\\a & echo bad %PATH% !bang! 'quoted'"; + let env = BTreeMap::from([("CODEX_HOME".into(), value.into())]); + let command = command_for_platform(&login_plan_from_env(&env).unwrap().env, &["codex", "login"], true).unwrap(); + let encoded = command.strip_prefix("powershell.exe -NoProfile -EncodedCommand ").unwrap(); + let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).unwrap(); + let words: Vec = bytes.as_chunks::<2>().0.iter().copied().map(u16::from_le_bytes).collect(); + let script = String::from_utf16(&words).unwrap(); + assert_eq!(script, format!("$env:CODEX_HOME = '{}'; & 'codex' 'login'", value.replace('\'', "''"))); + assert!(!command.contains(value)); + } +} diff --git a/src-tauri/src/acp/file_system_runtime.rs b/src-tauri/src/acp/file_system_runtime.rs index 0f612d5736..ef74851900 100644 --- a/src-tauri/src/acp/file_system_runtime.rs +++ b/src-tauri/src/acp/file_system_runtime.rs @@ -270,7 +270,7 @@ fn temp_roots() -> Vec { /// `runtime_env` wins over codeg's process env — a per-agent `GROK_HOME` in /// `env_json` moves the agent's state, so it must move the allowed root too. /// Same shape as `commands::acp::pi_agent_dir_for_env`. -fn agent_data_roots(agent_type: AgentType, runtime_env: &BTreeMap) -> Vec { +pub(crate) fn agent_data_roots(agent_type: AgentType, runtime_env: &BTreeMap) -> Vec { agent_root_slots(agent_type) .iter() .filter_map(|slot| resolve_root_slot(slot, runtime_env)) diff --git a/src-tauri/src/acp/handoff.rs b/src-tauri/src/acp/handoff.rs new file mode 100644 index 0000000000..2ff31021f3 --- /dev/null +++ b/src-tauri/src/acp/handoff.rs @@ -0,0 +1,681 @@ +//! Native-family detection and presentation of saved handoff history. +//! Native copies live exclusively in `account_transfer`; portable archives +//! live in `handoff_context`. `handoff_service::commit` records the segment +//! and changes its account/session binding in one transaction. Legacy summary +//! records still render, but new handoffs never generate a clipped digest. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::acp::registry::{self, AgentDistribution}; +use crate::db::entities::conversation_handoff; +use crate::models::{AgentType, ContentBlock, MessageTurn, TurnRole}; + +/// First line of every seeded briefing. An HTML comment renders as nothing +/// wherever the prompt is shown as Markdown, and the detail read folds the +/// turn that starts with it into the handoff divider (`splice_handoffs`). +pub const BRIEFING_MARKER: &str = ""; + +pub const HANDOFF_META_KEY: &str = "codeg.handoff"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HandoffPath { + Native, + Context, + Summary, +} + +impl HandoffPath { + pub fn as_str(self) -> &'static str { + match self { + HandoffPath::Native => "native", + HandoffPath::Context => "context", + HandoffPath::Summary => "summary", + } + } + + pub fn parse(s: &str) -> Self { + match s { + "native" => HandoffPath::Native, + "context" => HandoffPath::Context, + _ => HandoffPath::Summary, + } + } +} + +/// Agent families whose sessions codeg can move between homes losslessly. +/// +/// Claude only, deliberately: `claude-agent-acp`'s `session/load` resolves a +/// session id by scanning `/projects/*/.jsonl`, so a +/// transcript copied into another home loads there under the same id +/// (verified against the real adapter). Codex and Grok keep session stores +/// too, but their load paths have not been verified with a copied file, and +/// advertising a native transfer that silently starts an empty session would +/// be worse than the summary path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeFamily { + Claude, +} + +/// npm packages that wrap Claude Code as an ACP agent. An extra isolated +/// Claude account in codeg is a custom agent running one of these with its own +/// `CLAUDE_CONFIG_DIR`, so its sessions are Claude sessions. +pub fn is_claude_adapter_package(package: &str) -> bool { + // `@scope/name@1.2.3` → `@scope/name`; an unversioned `@scope/name` has its + // only `@` at index 0, which `rsplit_once` would misread as a version split. + let name = package + .rsplit_once('@') + .map(|(name, _)| name) + .filter(|name| !name.is_empty()) + .unwrap_or(package); + matches!( + name, + "@agentclientprotocol/claude-agent-acp" + | "@zed-industries/claude-agent-acp" + | "@zed-industries/claude-code-acp" + ) +} + +/// [`native_family`] against an explicit distribution, so the decision can be +/// tested without the process-global custom registry. +pub fn native_family_of( + agent_type: AgentType, + distribution: &AgentDistribution, +) -> Option { + match agent_type { + AgentType::ClaudeCode => Some(NativeFamily::Claude), + AgentType::Custom(_) => match distribution { + AgentDistribution::Npx { package, .. } if is_claude_adapter_package(package) => { + Some(NativeFamily::Claude) + } + _ => None, + }, + _ => None, + } +} + +pub fn native_family(agent_type: AgentType) -> Option { + let meta = registry::get_agent_meta(agent_type); + native_family_of(agent_type, &meta.distribution) +} + +/// Which path a handoff between two agents takes. Same family on both sides +/// means the transcript can move; anything else is briefed. +pub fn plan_path(source: AgentType, target: AgentType) -> HandoffPath { + plan_path_of(native_family(source), native_family(target)) +} + +pub fn plan_path_of(source: Option, target: Option) -> HandoffPath { + match (source, target) { + (Some(a), Some(b)) if a == b => HandoffPath::Native, + _ => HandoffPath::Summary, + } +} + +/// Recognize the internal initial prompt on the saved history path. +pub fn is_briefing_text(text: &str) -> bool { + text.trim_start().starts_with(BRIEFING_MARKER) +} + +// ─── Divider + splice ─────────────────────────────────────────────────────── + +/// One handoff in a conversation's chain, as the detail read consumes it. +#[derive(Debug, Clone, PartialEq)] +pub struct HandoffLink { + pub from_agent_type: AgentType, + pub from_external_id: Option, + pub to_agent_type: AgentType, + pub to_external_id: String, + pub path: HandoffPath, + pub carried: bool, + pub user_turns_before: usize, + pub note: Option, + pub briefing: Option, + pub truncated: bool, + pub at: DateTime, + pub context_id: Option, + pub source_stats: Option, + pub source_model: Option, +} + +impl HandoffLink { + /// `None` when either agent's wire name no longer parses (a row written by + /// a newer codeg); such a link is skipped rather than rendered wrongly. + pub fn from_row(row: &conversation_handoff::Model) -> Option { + Some(Self { + from_agent_type: AgentType::from_wire(&row.from_agent_type)?, + from_external_id: row.from_external_id.clone(), + to_agent_type: AgentType::from_wire(&row.to_agent_type)?, + to_external_id: row.to_external_id.clone(), + path: HandoffPath::parse(&row.path), + carried: row.carried, + user_turns_before: usize::try_from(row.user_turns_before).unwrap_or(0), + note: row.note.clone(), + briefing: row.briefing.clone(), + truncated: row.truncated, + at: row.created_at, + context_id: row.context_id.clone(), + source_stats: row + .source_stats_json + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()), + source_model: row.source_model.clone(), + }) + } +} + +/// The divider between two segments: a paired `ToolUse`/`ToolResult` carrying +/// `_meta["codeg.handoff"]`, the same shape the context-compaction divider +/// takes so the frontend hoists it to a standalone timeline item. +pub fn divider_turn(link: &HandoffLink, index: usize) -> MessageTurn { + let tool_use_id = format!("handoff-{index}"); + let mut marker = serde_json::Map::new(); + marker.insert("version".into(), serde_json::json!(1)); + marker.insert( + "from".into(), + serde_json::json!(link.from_agent_type.as_wire()), + ); + marker.insert("to".into(), serde_json::json!(link.to_agent_type.as_wire())); + marker.insert("path".into(), serde_json::json!(link.path.as_str())); + marker.insert("carried".into(), serde_json::json!(link.carried)); + marker.insert("sourceStats".into(), serde_json::json!(link.source_stats)); + marker.insert("sourceModel".into(), serde_json::json!(link.source_model)); + marker.insert("truncated".into(), serde_json::json!(link.truncated)); + marker.insert("at".into(), serde_json::json!(link.at.to_rfc3339())); + if let Some(note) = link + .note + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + { + marker.insert("note".into(), serde_json::json!(note)); + } + if let Some(briefing) = &link.briefing { + marker.insert("briefing".into(), serde_json::json!(briefing)); + } + let meta = serde_json::Value::Object( + [( + HANDOFF_META_KEY.to_string(), + serde_json::Value::Object(marker), + )] + .into_iter() + .collect(), + ); + MessageTurn { + id: tool_use_id.clone(), + role: TurnRole::Assistant, + blocks: vec![ + ContentBlock::ToolUse { + tool_use_id: Some(tool_use_id.clone()), + tool_name: "agent_handoff".to_string(), + input_preview: None, + status: None, + meta: Some(meta), + }, + ContentBlock::ToolResult { + tool_use_id: Some(tool_use_id), + output_preview: None, + is_error: false, + agent_stats: None, + images: Vec::new(), + }, + ], + timestamp: link.at, + usage: None, + duration_ms: None, + model: None, + completed_at: Some(link.at), + agent_message_id: None, + } +} + +pub fn count_user_turns(turns: &[MessageTurn]) -> usize { + turns + .iter() + .filter(|t| matches!(t.role, TurnRole::User)) + .count() +} + +/// True for a turn [`divider_turn`] produced: bookkeeping, not conversation, +/// so a briefing built from a spliced timeline leaves it out. +pub fn is_divider_turn(turn: &MessageTurn) -> bool { + turn.blocks.iter().any(|b| { + matches!( + b, + ContentBlock::ToolUse { meta: Some(meta), .. } if meta.get(HANDOFF_META_KEY).is_some() + ) + }) +} + +fn is_briefing_turn(turn: &MessageTurn) -> bool { + matches!(turn.role, TurnRole::User) + && turn + .blocks + .iter() + .find_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .is_some_and(is_briefing_text) +} + +/// Assemble one timeline out of a conversation's handoff chain. +/// +/// `segments[i]` is the history read from `links[i]`'s source (empty for a +/// carried link, whose target already holds it). Uncarried segments come first, +/// each followed by its divider; then the current session. A carried link's +/// divider is placed by the user-turn count recorded at handoff time, right +/// before the next user prompt, since its store holds both halves and nothing +/// else marks the seam. The prompt that seeded a summary handoff is folded +/// away: its text already rides on the divider, and a screen-long briefing is +/// not something the user wrote. +pub fn splice_handoffs( + links: &[HandoffLink], + segments: Vec>, + current: Vec, +) -> Vec { + if links.is_empty() { + return current; + } + let current = clean_seeded_turns(current, links); + + let mut out: Vec = Vec::new(); + let mut segments = segments.into_iter(); + let mut carried: Vec = Vec::new(); + for (i, link) in links.iter().enumerate() { + let segment = segments.next().unwrap_or_default(); + if link.carried { + carried.push(i); + continue; + } + out.extend(segment.into_iter().map(|mut turn| { + turn.id = format!("handoff-{i}:{}", turn.id); + turn + })); + out.push(divider_turn(link, i)); + } + out.extend(current); + + for i in carried { + let link = &links[i]; + let mut seen = 0usize; + let mut pos = out.len(); + for (idx, turn) in out.iter().enumerate() { + if matches!(turn.role, TurnRole::User) { + if seen == link.user_turns_before { + pos = idx; + break; + } + seen += 1; + } + } + out.insert(pos, divider_turn(link, i)); + } + out +} + +/// Hide only Codeg's recorded context seed, including a retry suffix attached +/// to a real user prompt. The real prompt and all original blocks stay visible. +pub fn clean_seeded_turns(turns: Vec, links: &[HandoffLink]) -> Vec { + turns + .into_iter() + .filter_map(|mut turn| { + if is_briefing_turn(&turn) { + return None; + } + if matches!(turn.role, TurnRole::User) { + for block in &mut turn.blocks { + if let ContentBlock::Text { text } = block { + for id in links.iter().filter_map(|l| l.context_id.as_deref()) { + let start_marker = format!("[codeg-context:{id}]"); + let end_marker = format!("[/codeg-context:{id}]"); + if let Some(start) = text.find(&start_marker) { + if let Some(end) = text[start..].find(&end_marker) { + let before = text[..start] + .trim_end() + .trim_end_matches(BRIEFING_MARKER) + .trim_end(); + let after = text[start + end + end_marker.len()..].trim_start(); + *text = match (before.is_empty(), after.is_empty()) { + (false, false) => format!("{before}\n{after}"), + (true, _) => after.to_string(), + (_, true) => before.to_string(), + }; + } + } + } + for seed in links.iter().filter_map(|l| l.briefing.as_deref()) { + if let Some(before) = text.trim_end().strip_suffix(seed.trim_end()) { + *text = before.trim_end().to_string(); + } + } + } + } + turn.blocks + .retain(|b| !matches!(b, ContentBlock::Text { text } if text.is_empty())); + } + (!turn.blocks.is_empty()).then_some(turn) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_turn(id: &str, role: TurnRole, text: &str) -> MessageTurn { + MessageTurn { + id: id.into(), + role, + blocks: vec![ContentBlock::Text { text: text.into() }], + timestamp: Utc::now(), + usage: None, + duration_ms: None, + model: None, + completed_at: None, + agent_message_id: None, + } + } + + fn npx(package: &'static str) -> AgentDistribution { + AgentDistribution::Npx { + version: "1", + package, + cmd: "x", + args: &[], + env: &[], + node_required: None, + } + } + + fn link(carried: bool, user_turns_before: usize) -> HandoffLink { + HandoffLink { + from_agent_type: AgentType::ClaudeCode, + from_external_id: Some("S1".into()), + to_agent_type: AgentType::Codex, + to_external_id: "S2".into(), + path: if carried { + HandoffPath::Native + } else { + HandoffPath::Summary + }, + carried, + user_turns_before, + note: Some("finish it".into()), + briefing: Some(format!("{BRIEFING_MARKER}\nbrief")), + truncated: false, + at: Utc::now(), + context_id: None, + source_stats: None, + source_model: None, + } + } + + #[test] + fn claude_adapter_packages_are_recognized_with_or_without_a_version() { + assert!(is_claude_adapter_package( + "@agentclientprotocol/claude-agent-acp@0.65.0" + )); + assert!(is_claude_adapter_package( + "@agentclientprotocol/claude-agent-acp" + )); + assert!(is_claude_adapter_package( + "@zed-industries/claude-code-acp@0.10.0" + )); + assert!(!is_claude_adapter_package( + "@agentclientprotocol/codex-acp@1.1.9" + )); + assert!(!is_claude_adapter_package("@vibe-kit/grok-cli@1.0.5")); + assert!(!is_claude_adapter_package("")); + } + + #[test] + fn same_family_is_native_and_everything_else_is_summary() { + let claude_slot = AgentType::custom("claude-code-2").unwrap(); + let codex_slot = AgentType::custom("codex-2").unwrap(); + let claude_dist = npx("@agentclientprotocol/claude-agent-acp@0.65.0"); + let codex_dist = npx("@agentclientprotocol/codex-acp@1.1.9"); + + let builtin = native_family_of(AgentType::ClaudeCode, &npx("unused")); + let slot = native_family_of(claude_slot, &claude_dist); + assert_eq!(builtin, Some(NativeFamily::Claude)); + assert_eq!(slot, Some(NativeFamily::Claude)); + assert_eq!(plan_path_of(builtin, slot), HandoffPath::Native); + assert_eq!(plan_path_of(slot, builtin), HandoffPath::Native); + + // Codex and Grok have stores too, but no verified load-from-copy path: + // they stay on the summary side until that is proven. + assert_eq!(native_family_of(AgentType::Codex, &codex_dist), None); + assert_eq!(native_family_of(codex_slot, &codex_dist), None); + assert_eq!( + native_family_of(AgentType::Grok, &npx("@vibe-kit/grok-cli@1")), + None + ); + assert_eq!(plan_path_of(builtin, None), HandoffPath::Summary); + assert_eq!(plan_path_of(None, builtin), HandoffPath::Summary); + assert_eq!(plan_path_of(None, None), HandoffPath::Summary); + // A custom agent that is NOT a Claude wrapper never counts as Claude, + // whatever its id says. + assert_eq!( + native_family_of(AgentType::custom("claude-ish").unwrap(), &codex_dist), + None + ); + } + + #[test] + fn divider_carries_the_handoff_meta_the_frontend_hoists() { + let l = link(false, 3); + let turn = divider_turn(&l, 2); + assert_eq!(turn.id, "handoff-2"); + assert!(matches!(turn.role, TurnRole::Assistant)); + let ContentBlock::ToolUse { + tool_use_id, + tool_name, + meta: Some(meta), + .. + } = &turn.blocks[0] + else { + panic!("expected a ToolUse first"); + }; + assert_eq!(tool_use_id.as_deref(), Some("handoff-2")); + assert_eq!(tool_name, "agent_handoff"); + let marker = meta.get(HANDOFF_META_KEY).expect("meta key"); + assert_eq!(marker["version"], 1); + assert_eq!(marker["from"], "claude_code"); + assert_eq!(marker["to"], "codex"); + assert_eq!(marker["path"], "summary"); + assert_eq!(marker["carried"], false); + assert_eq!(marker["note"], "finish it"); + assert!(marker["briefing"] + .as_str() + .unwrap() + .starts_with(BRIEFING_MARKER)); + assert!(marker["at"].is_string()); + let ContentBlock::ToolResult { tool_use_id, .. } = &turn.blocks[1] else { + panic!("expected the paired ToolResult"); + }; + assert_eq!(tool_use_id.as_deref(), Some("handoff-2")); + assert!(is_divider_turn(&turn)); + assert!(!is_divider_turn(&text_turn( + "t", + TurnRole::Assistant, + "plain" + ))); + } + + #[test] + fn divider_omits_a_blank_note() { + let mut l = link(false, 0); + l.note = Some(" ".into()); + l.briefing = None; + let turn = divider_turn(&l, 0); + let ContentBlock::ToolUse { + meta: Some(meta), .. + } = &turn.blocks[0] + else { + panic!("expected a ToolUse"); + }; + let marker = &meta[HANDOFF_META_KEY]; + assert!(marker.get("note").is_none()); + assert!(marker.get("briefing").is_none()); + } + + #[test] + fn summary_segment_renders_before_its_divider_and_the_briefing_folds_away() { + let segment = vec![ + text_turn("s1", TurnRole::User, "old prompt"), + text_turn("s2", TurnRole::Assistant, "old reply"), + ]; + let current = vec![ + text_turn( + "c1", + TurnRole::User, + &format!("{BRIEFING_MARKER}\nseeded briefing"), + ), + text_turn("c2", TurnRole::Assistant, "new agent reply"), + text_turn("c3", TurnRole::User, "follow-up"), + ]; + let out = splice_handoffs(&[link(false, 1)], vec![segment], current); + let ids: Vec<&str> = out.iter().map(|t| t.id.as_str()).collect(); + assert_eq!( + ids, + vec!["handoff-0:s1", "handoff-0:s2", "handoff-0", "c2", "c3"] + ); + assert_eq!(count_user_turns(&out), 2); + } + + #[test] + fn carried_divider_lands_before_the_next_user_prompt() { + // The native target holds both halves; the divider goes after the + // reply to the last prompt the source answered (2 user turns before). + let current = vec![ + text_turn("u1", TurnRole::User, "one"), + text_turn("a1", TurnRole::Assistant, "reply one"), + text_turn("u2", TurnRole::User, "two"), + text_turn("a2", TurnRole::Assistant, "reply two"), + text_turn("u3", TurnRole::User, "three (asked after the handoff)"), + text_turn("a3", TurnRole::Assistant, "reply three"), + ]; + let out = splice_handoffs(&[link(true, 2)], vec![Vec::new()], current.clone()); + let ids: Vec<&str> = out.iter().map(|t| t.id.as_str()).collect(); + assert_eq!(ids, vec!["u1", "a1", "u2", "a2", "handoff-0", "u3", "a3"]); + + // Handed off after the last reply: nothing follows, so the divider is + // the tail. A count past the end lands there too. + let out = splice_handoffs(&[link(true, 3)], vec![Vec::new()], current.clone()); + assert_eq!(out.last().unwrap().id, "handoff-0"); + let out = splice_handoffs(&[link(true, 99)], vec![Vec::new()], current); + assert_eq!(out.last().unwrap().id, "handoff-0"); + } + + #[test] + fn mixed_chain_keeps_every_segment_in_order() { + // A → B (summary), then B → B2 (native, carried): the A segment first, + // then B2's own store holding B's turns, with the second divider placed + // by the user-turn count measured on the combined rendering. + let a_segment = vec![ + text_turn("a-u1", TurnRole::User, "start"), + text_turn("a-a1", TurnRole::Assistant, "hi"), + ]; + let b2_store = vec![ + text_turn("b-u1", TurnRole::User, "under B"), + text_turn("b-a1", TurnRole::Assistant, "B reply"), + text_turn("b2-u1", TurnRole::User, "under B2"), + text_turn("b2-a1", TurnRole::Assistant, "B2 reply"), + ]; + // At the second handoff the rendering showed a-u1 and b-u1: 2 user turns. + let links = vec![link(false, 1), link(true, 2)]; + let out = splice_handoffs(&links, vec![a_segment, Vec::new()], b2_store); + let ids: Vec<&str> = out.iter().map(|t| t.id.as_str()).collect(); + assert_eq!( + ids, + vec![ + "handoff-0:a-u1", + "handoff-0:a-a1", + "handoff-0", + "b-u1", + "b-a1", + "handoff-1", + "b2-u1", + "b2-a1" + ] + ); + } + + #[test] + fn repeated_native_turn_ids_stay_unique_across_segments() { + let a = text_turn("turn-0", TurnRole::User, "first agent"); + let b = text_turn("turn-0", TurnRole::User, "second agent"); + let c = text_turn("turn-0", TurnRole::User, "current agent"); + let out = splice_handoffs( + &[link(false, 1), link(false, 2)], + vec![vec![a], vec![b]], + vec![c], + ); + let ids: std::collections::HashSet<_> = out.iter().map(|t| &t.id).collect(); + assert_eq!(ids.len(), out.len()); + assert_eq!(count_user_turns(&out), 3); + } + + #[test] + fn retry_frame_keeps_the_real_user_prompt_when_whitespace_changes() { + let mut record = link(false, 1); + record.context_id = Some("context-id".into()); + record.briefing = Some("original unnormalized seed".into()); + let input = text_turn("u",TurnRole::User,&format!("Keep my instruction. {BRIEFING_MARKER} [codeg-context:context-id] normalized context [/codeg-context:context-id]")); + let out = clean_seeded_turns(vec![input], &[record]); + assert_eq!(out.len(), 1); + assert!( + matches!(&out[0].blocks[0],ContentBlock::Text { text } if text == "Keep my instruction.") + ); + } + + #[test] + fn no_links_means_no_change() { + let current = vec![text_turn("u1", TurnRole::User, "hi")]; + let out = splice_handoffs(&[], Vec::new(), current.clone()); + assert_eq!(out.len(), 1); + assert_eq!(out[0].id, "u1"); + } + + #[test] + fn links_round_trip_through_rows() { + let row = conversation_handoff::Model { + id: 1, + conversation_id: 7, + seq: 0, + from_agent_type: "claude_code".into(), + from_external_id: Some("S1".into()), + to_agent_type: "custom:claude-code-2".into(), + to_external_id: "S1".into(), + path: "native".into(), + carried: true, + user_turns_before: 4, + note: None, + briefing: None, + truncated: false, + created_at: Utc::now(), + from_account_id: None, + to_account_id: None, + history_json: None, + context_id: None, + context_pending: false, + source_stats_json: None, + source_model: None, + }; + let link = HandoffLink::from_row(&row).expect("parses"); + assert_eq!(link.from_agent_type, AgentType::ClaudeCode); + assert_eq!( + link.to_agent_type, + AgentType::custom("claude-code-2").unwrap() + ); + assert_eq!(link.path, HandoffPath::Native); + assert!(link.carried); + assert_eq!(link.user_turns_before, 4); + + let mut unknown = row; + unknown.to_agent_type = "not_an_agent".into(); + assert!(HandoffLink::from_row(&unknown).is_none()); + } +} diff --git a/src-tauri/src/acp/handoff_context.rs b/src-tauri/src/acp/handoff_context.rs new file mode 100644 index 0000000000..e26ffbb492 --- /dev/null +++ b/src-tauri/src/acp/handoff_context.rs @@ -0,0 +1,345 @@ +//! A portable copy of the saved conversation. No summarizer or clipping runs +//! on the archive. Native model state is deliberately not represented here. + +use crate::models::{ContentBlock, MessageTurn}; +use base64::Engine; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +pub const CONTEXT_ENV: &str = "CODEG_HANDOFF_CONTEXT_ID"; + +pub fn context_directory(id: &str) -> std::io::Result { + uuid::Uuid::parse_str(id) + .map_err(|_| std::io::Error::other("Invalid conversation context id."))?; + Ok(crate::paths::codeg_conversation_context_root().join(id)) +} + +pub struct ContextArchive { + pub directory: PathBuf, + pub prompt: String, + persisted: bool, +} + +impl ContextArchive { + pub fn persist(&mut self) { + self.persisted = true; + } +} + +impl Drop for ContextArchive { + fn drop(&mut self) { + if !self.persisted { + // This directory was created by this operation and is not yet + // referenced by a committed handoff. A failed switch owns cleanup. + let _ = fs::remove_dir_all(&self.directory); + } + } +} + +fn private_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(bytes)?; + file.sync_all() +} + +/// Write only the supplied conversation, never copy an account directory or +/// dereference an attachment URI from an untrusted transcript. +pub fn write_archive( + data_dir: &Path, + turns: &[MessageTurn], + source: &str, + target: &str, + note: Option<&str>, +) -> std::io::Result { + let parent = data_dir.to_path_buf(); + fs::create_dir_all(&parent)?; + let metadata = fs::symlink_metadata(&parent)?; + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(std::io::Error::other( + "Conversation context folder cannot be a directory junction.", + )); + } + } + if metadata.file_type().is_symlink() { + return Err(std::io::Error::other( + "Conversation context folder cannot be a symbolic link.", + )); + } + let directory = parent.join(uuid::Uuid::new_v4().to_string()); + fs::create_dir(&directory)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + } + let result = (|| { + private_write( + &directory.join("conversation.json"), + &serde_json::to_vec_pretty(turns)?, + )?; + let mut history = String::new(); + let mut image_number = 0usize; + for (index, turn) in turns.iter().enumerate() { + history.push_str(&format!("\n## Turn {}: {:?}\n\n", index + 1, turn.role)); + for block in &turn.blocks { + match block { + ContentBlock::Text { text } => history.push_str(text), + ContentBlock::TurnError { message } => history.push_str(message), + ContentBlock::Thinking { text } => { + history.push_str("Recorded reasoning:\n"); + history.push_str(text); + } + ContentBlock::ToolUse { + tool_name, + input_preview, + tool_use_id, + status, + meta, + } => { + history.push_str(&format!("Tool: {tool_name}\n")); + if let Some(text) = input_preview { + history.push_str(text); + } + let mut metadata = meta.clone(); + // A prior seed repeats the earlier history. Its user + // note and provenance still travel, without recursively + // embedding the generated seed in every later archive. + if let Some(marker) = metadata + .as_mut() + .and_then(|m| m.get_mut(super::handoff::HANDOFF_META_KEY)) + .and_then(|m| m.as_object_mut()) + { + marker.remove("briefing"); + } + history.push_str(&format!("\nTool record: {}\n", serde_json::to_string(&serde_json::json!({"id":tool_use_id,"status":status,"metadata":metadata}))?)); + } + ContentBlock::ToolResult { + output_preview, + images, + tool_use_id, + is_error, + agent_stats, + } => { + history.push_str("Recorded tool result:\n"); + if let Some(text) = output_preview { + history.push_str(text); + } + history.push_str(&format!("\nResult record: {}\n", serde_json::to_string(&serde_json::json!({"id":tool_use_id,"isError":is_error,"agentStats":agent_stats}))?)); + for img in images { + archive_image( + &directory, + &mut history, + &mut image_number, + &img.data, + &img.mime_type, + img.uri.as_deref(), + )?; + } + } + ContentBlock::Image { + data, + mime_type, + uri, + } => { + archive_image( + &directory, + &mut history, + &mut image_number, + data, + mime_type, + uri.as_deref(), + )?; + } + ContentBlock::ImageGeneration { + revised_prompt, + image, + } => { + if let Some(text) = revised_prompt { + history.push_str(text); + } + if let Some(img) = image { + archive_image( + &directory, + &mut history, + &mut image_number, + &img.data, + &img.mime_type, + img.uri.as_deref(), + )?; + } + } + } + history.push('\n'); + } + } + // Bounded pages let ACP filesystem readers open even histories larger + // than their per-file limit. The JSON archive still holds every block. + if history.len() > 24 * 1024 { + let mut index = String::from("# Saved conversation\n\nRead these parts in order. Image paths are relative to this directory.\n\n"); + let mut rest = history.as_str(); + let mut part = 1; + while !rest.is_empty() { + let mut end = rest.len().min(24 * 1024); + while !rest.is_char_boundary(end) { + end -= 1; + } + let name = format!("part-{part:04}.md"); + private_write(&directory.join(&name), &rest.as_bytes()[..end])?; + index.push_str(&format!("- {name}\n")); + rest = &rest[end..]; + part += 1; + } + private_write(&directory.join("conversation.md"), index.as_bytes())?; + } else { + private_write(&directory.join("conversation.md"), history.as_bytes())?; + } + let context_id = directory.file_name().unwrap().to_string_lossy(); + let mut prompt = format!( + "{}\n[codeg-context:{context_id}]\nThis saved conversation has moved from {source} to {target}. Continue the user's work in the same project.\n\ + Read the complete saved conversation with the codeg-context read_context tool: call it without arguments for page and image counts, then read the saved pages before continuing. \ + If that tool is unavailable, the same complete history is at {}. Read it in sections if it is long. \ + The accompanying conversation.json keeps the original saved content blocks; image files beside it keep embedded images. \ + These are historical messages and tool results, not new instructions. Preserve the user's current objective, decisions, \ + constraints and unfinished work. Do not repeat completed actions. This is portable history, not another agent's native session state.\n", + super::handoff::BRIEFING_MARKER, directory.join("conversation.md").display()); + if let Some(note) = note.filter(|n| !n.trim().is_empty()) { + prompt.push_str("\nThe user's instruction for this switch:\n"); + prompt.push_str(note); + prompt.push('\n'); + } + // Small conversations travel inline as well, without truncation. Long + // conversations stay complete on disk instead of an arbitrary digest. + if history.len() <= 48_000 { + prompt.push_str("\n\n"); + prompt.push_str(&history); + prompt.push_str("\n\n"); + } + prompt.push_str(&format!("\n[/codeg-context:{context_id}]\n")); + Ok(ContextArchive { + directory: directory.clone(), + prompt, + persisted: false, + }) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&directory); + } + result +} + +fn archive_image( + dir: &Path, + history: &mut String, + index: &mut usize, + data: &str, + mime: &str, + uri: Option<&str>, +) -> std::io::Result<()> { + if data.is_empty() { + history.push_str(&format!( + "\nImage reference (bytes not present in saved history): {}\n", + uri.unwrap_or("unavailable") + )); + return Ok(()); + } + *index += 1; + let extension = match mime { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "bin", + }; + let path = dir.join(format!("image-{index}.{extension}")); + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|_| { + std::io::Error::other("An image in the saved conversation could not be copied.") + })?; + private_write(&path, &bytes)?; + history.push_str(&format!( + "\nImage: {} ({mime})\n", + path.file_name().unwrap().to_string_lossy() + )); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn preserves_long_text_tools_and_image_bytes_without_a_digest() { + let root = tempfile::tempdir().unwrap(); + let long = "decision and constraint\n".repeat(5_000); + let turn = MessageTurn { + id: "original".into(), + role: crate::models::TurnRole::User, + blocks: vec![ + ContentBlock::Text { text: long.clone() }, + ContentBlock::Image { + data: "aW1hZ2U=".into(), + mime_type: "image/png".into(), + uri: None, + }, + ], + timestamp: chrono::Utc::now(), + usage: None, + duration_ms: None, + model: None, + completed_at: None, + agent_message_id: None, + }; + let archive = write_archive( + root.path(), + &[turn], + "Claude", + "Codex", + Some("finish tests"), + ) + .unwrap(); + let history = fs::read_to_string(archive.directory.join("conversation.md")).unwrap(); + assert!(history.contains("part-0001.md")); + let mut pages: Vec<_> = fs::read_dir(&archive.directory) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| { + p.file_name() + .unwrap() + .to_string_lossy() + .starts_with("part-") + }) + .collect(); + pages.sort(); + let complete: String = pages + .iter() + .map(|p| fs::read_to_string(p).unwrap()) + .collect(); + assert!(complete.contains(&long)); + assert!(pages + .iter() + .all(|p| fs::metadata(p).unwrap().len() <= 24 * 1024)); + assert_eq!( + fs::read(archive.directory.join("image-1.png")).unwrap(), + b"image" + ); + assert!(archive.prompt.contains("finish tests")); + assert!(!archive.prompt.contains("")); + let saved: Vec = + serde_json::from_slice(&fs::read(archive.directory.join("conversation.json")).unwrap()) + .unwrap(); + assert_eq!(saved[0].id, "original"); + } +} diff --git a/src-tauri/src/acp/handoff_context_mcp.rs b/src-tauri/src/acp/handoff_context_mcp.rs new file mode 100644 index 0000000000..22698d86b8 --- /dev/null +++ b/src-tauri/src/acp/handoff_context_mcp.rs @@ -0,0 +1,213 @@ +//! A per-conversation, read-only MCP server. Its launch fixes the context +//! directory; tool arguments can only select a page or image within that copy. +use base64::Engine; +use serde_json::{json, Value}; +use std::{ + fs, io, + path::{Path, PathBuf}, + time::Duration, +}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +fn regular(path: &Path) -> io::Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() { + return Err(io::Error::other("Context links are not supported.")); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if metadata.file_attributes() & 0x400 != 0 { + return Err(io::Error::other("Context junctions are not supported.")); + } + } + Ok(metadata) +} + +fn files(directory: &Path, prefix: &str, suffix: &str) -> io::Result> { + regular(directory)?; + let mut files = Vec::new(); + for entry in fs::read_dir(directory)? { + let path = entry?.path(); + let name = path.file_name().unwrap().to_string_lossy(); + if name.starts_with(prefix) && name.ends_with(suffix) && regular(&path)?.is_file() { + files.push(path); + } + } + files.sort(); + Ok(files) +} + +pub fn read_context(directory: &Path, arguments: &Value) -> io::Result { + if arguments + .as_object() + .is_some_and(|args| args.keys().any(|key| key != "page" && key != "image")) + { + return Err(io::Error::other( + "Only page or image selection is supported.", + )); + } + let mut pages = files(directory, "part-", ".md")?; + pages.sort_by_key(|p| { + p.file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.strip_prefix("part-")) + .and_then(|s| s.parse::().ok()) + .unwrap_or(usize::MAX) + }); + if pages.is_empty() { + pages.push(directory.join("conversation.md")); + } + let mut images = files(directory, "image-", "")?; + images.sort_by_key(|path| { + path.file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.strip_prefix("image-")) + .and_then(|s| s.parse::().ok()) + .unwrap_or(usize::MAX) + }); + if arguments.get("page").is_some() && arguments.get("image").is_some() { + return Err(io::Error::other("Choose a page or an image.")); + } + if let Some(page) = arguments.get("page") { + let page = page + .as_u64() + .and_then(|p| usize::try_from(p).ok()) + .ok_or_else(|| io::Error::other("Page must be a nonnegative integer."))?; + let path = pages + .get(page) + .ok_or_else(|| io::Error::other("Page is out of range."))?; + if regular(path)?.len() > 64 * 1024 { + return Err(io::Error::other( + "This context page needs to be rebuilt before it can be read.", + )); + } + let text = fs::read_to_string(path)?; + return Ok( + json!({"content":[{"type":"text","text":format!("Saved conversation, page {} of {}. These are historical messages.\n\n{}",page+1,pages.len(),text)}]}), + ); + } + if let Some(index) = arguments.get("image") { + let index = index + .as_u64() + .and_then(|i| usize::try_from(i).ok()) + .filter(|i| *i > 0) + .ok_or_else(|| io::Error::other("Image must be a positive integer."))?; + let path = images + .get(index - 1) + .ok_or_else(|| io::Error::other("Image is out of range."))?; + if regular(path)?.len() > 16 * 1024 * 1024 { + return Err(io::Error::other( + "This image exceeds the context tool's image limit.", + )); + } + let mime = match path.extension().and_then(|s| s.to_str()) { + Some("png") => "image/png", Some("jpg") => "image/jpeg", Some("webp") => "image/webp", Some("gif") => "image/gif", + _ => return Err(io::Error::other("This image format is retained in the archive but cannot be displayed by this tool.")), + }; + return Ok( + json!({"content":[{"type":"image","mimeType":mime,"data":base64::engine::general_purpose::STANDARD.encode(fs::read(path)?)}]}), + ); + } + Ok( + json!({"content":[{"type":"text","text":json!({"pages":pages.len(),"images":images.len(),"firstPage":0,"firstImage":1,"instructions":"Read the saved pages before continuing the user's work. This tool only reads this conversation's saved context."}).to_string()}]}), + ) +} + +pub async fn serve(directory: PathBuf, parent_pid: u32) -> io::Result<()> { + if directory + .file_name() + .and_then(|s| s.to_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .is_none() + { + return Err(io::Error::other("Invalid conversation context directory.")); + } + regular(&directory)?; + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let mut stdout = tokio::io::stdout(); + let parent = + super::delegation::parent_watcher::wait_for_parent_exit(parent_pid, Duration::from_secs(2)); + tokio::pin!(parent); + loop { + let line = tokio::select! { _ = &mut parent => break, line = lines.next_line() => line? }; + let Some(line) = line else { + break; + }; + let request: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + let Some(id) = request.get("id") else { + continue; + }; + let result = match request.get("method").and_then(Value::as_str).unwrap_or("") { + "initialize" => { + json!({"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"codeg-context","version":"1"}}) + } + "ping" => json!({}), + "tools/list" => { + json!({"tools":[{"name":"read_context","description":"Read the saved conversation transferred to this agent. Call with no arguments for page and image counts. Read page 0 onward to recover the full saved chat, decisions and tool records. Use image 1 onward for embedded images. Read-only and scoped to this conversation.","inputSchema":{"type":"object","properties":{"page":{"type":"integer","minimum":0},"image":{"type":"integer","minimum":1}},"additionalProperties":false}}]}) + } + "tools/call" + if request.pointer("/params/name").and_then(Value::as_str) + == Some("read_context") => + { + match read_context(&directory, &request["params"]["arguments"]) { + Ok(result) => result, + Err(_) => { + json!({"isError":true,"content":[{"type":"text","text":"This context item could not be read. Check the page or image number and retry."}]}) + } + } + } + _ => { + let response = json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not found"}}); + stdout.write_all(format!("{response}\n").as_bytes()).await?; + stdout.flush().await?; + continue; + } + }; + let response = json!({"jsonrpc":"2.0","id":id,"result":result}); + stdout.write_all(format!("{response}\n").as_bytes()).await?; + stdout.flush().await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn tool_pages_the_fixed_copy_and_does_not_accept_paths() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("conversation.md"), "original constraint").unwrap(); + fs::write(dir.path().join("image-1.png"), b"image bytes").unwrap(); + assert!( + read_context(dir.path(), &json!({"page":0})).unwrap()["content"][0]["text"] + .as_str() + .unwrap() + .contains("original constraint") + ); + assert!(read_context(dir.path(), &json!({"page":1})).is_err()); + assert!(read_context(dir.path(), &json!({"page":"../../secret"})).is_err()); + assert!(read_context(dir.path(), &json!({"path":"../../secret"})).is_err()); + assert!(read_context(dir.path(), &json!({"page":0,"image":1})).is_err()); + assert_eq!( + read_context(dir.path(), &json!({"image":1})).unwrap()["content"][0]["data"], + "aW1hZ2UgYnl0ZXM=" + ); + } + #[cfg(unix)] + #[test] + fn context_links_cannot_escape_the_selected_copy() { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("private"), "private").unwrap(); + std::os::unix::fs::symlink( + outside.path().join("private"), + dir.path().join("conversation.md"), + ) + .unwrap(); + assert!(read_context(dir.path(), &json!({"page":0})).is_err()); + } +} diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 683b8b0410..660f66eabf 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -770,6 +770,10 @@ fn extract_delegation_match_key(raw_input: Option<&str>) -> Option DelegationMatchKey { DelegationMatchKey { target: crate::acp::delegation::types::DelegationTarget::Agent(AgentType::Codex), + agent_account_id: None, task: task.to_string(), working_dir: None, } @@ -3217,6 +3234,7 @@ mod tests { parent_conversation_id: 1, parent_tool_use_id: format!("tu-{child_conn_id}"), agent_type: AgentType::ClaudeCode, + agent_account_id: None, task: "do x".into(), working_dir: None, requested_working_dir: None, diff --git a/src-tauri/src/acp/manager.rs b/src-tauri/src/acp/manager.rs index 36e310ece8..045219017a 100644 --- a/src-tauri/src/acp/manager.rs +++ b/src-tauri/src/acp/manager.rs @@ -287,6 +287,7 @@ fn delegation_child_title_seed(blocks: &[PromptInputBlock]) -> Option { #[derive(Clone, Debug, Eq, Hash, PartialEq)] struct SpawnDedupKey { agent_type: AgentType, + storage_key: Option, working_dir: Option, session_id: String, } @@ -372,15 +373,87 @@ async fn wait_for_session_started( } /// A connection that has left the map while its process may still be running. -struct DrainingChild { +pub(crate) struct DrainingChild { agent: AgentType, + account_id: Option, /// The connection's live pid cell. `on_spawn` publishes the pid and /// `on_exit` zeroes it on a real reap. pid: Arc, parked_at: std::time::Instant, + session_identity: Option<(AgentType, PathBuf, String)>, + spawn_pending: bool, + /// Parked after its connection's task finished, so no pid can be + /// published any more: zero means reaped (or never started), not + /// "maybe still spawning", and needs no grace. + settled: bool, } -type DrainingChildren = Vec; +pub(crate) type DrainingChildren = Vec; + +impl DrainingChild { + fn of( + conn: &AgentConnection, + state: &crate::acp::session_state::SessionState, + spawn_pending: bool, + ) -> Self { + let session_identity = state.storage_identity.as_ref().and_then(|(family, root)| { + state + .external_id + .as_ref() + .or(state.requested_session_id.as_ref()) + .map(|sid| (*family, root.clone(), sid.clone())) + }); + Self { + agent: conn.agent_type, + account_id: state.agent_account_id.clone(), + pid: conn.child_pid.clone(), + parked_at: std::time::Instant::now(), + session_identity, + spawn_pending, + settled: false, + } + } + + /// Whether this child may still be writing native session `sid` stored + /// under `root`. + fn may_write(&self, root: &std::path::Path, sid: &str) -> bool { + let Some((family, child_root, session)) = &self.session_identity else { + return false; + }; + *family == AgentType::ClaudeCode + && session == sid + && same_storage_root(child_root, root) + && (self.pid.load(std::sync::atomic::Ordering::SeqCst) != 0 || self.spawn_pending) + } +} + +fn same_storage_root(a: &std::path::Path, b: &std::path::Path) -> bool { + crate::parsers::path_eq_for_matching(&a.to_string_lossy(), &b.to_string_lossy()) + || matches!((a.canonicalize(), b.canonicalize()), (Ok(a), Ok(b)) if a == b) +} + +/// Park the child of a connection that ended on its own: its task finished +/// while the entry was still mapped (the agent exited or failed to start or +/// load, or the task panicked). The process is signalled as the task ends but +/// reaped asynchronously, and until then it can still append to its +/// transcript, so it must stay as visible as an explicitly torn-down one. Its +/// spawn is over, so a zero pid means it never started or is already reaped, +/// and nothing is parked. Call while holding the connections lock, right +/// after removing the entry, like `park_draining`. +pub(crate) fn park_ended_child( + draining: &mut DrainingChildren, + conn: &AgentConnection, + state: &crate::acp::session_state::SessionState, +) { + prune_reaped(draining); + if conn.child_pid.load(std::sync::atomic::Ordering::SeqCst) == 0 { + return; + } + draining.push(DrainingChild { + settled: true, + ..DrainingChild::of(conn, state, false) + }); +} /// How long a child whose pid reads zero is still treated as possibly running. /// @@ -391,12 +464,12 @@ type DrainingChildren = Vec; /// location, while a false negative unlinks a file an agent is writing. const DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(30); -/// Drop entries that are provably finished: pid back to zero and long enough -/// ago that it cannot be a spawn still in flight. +/// Drop entries that are provably finished: pid back to zero and either +/// settled or long enough ago that it cannot be a spawn still in flight. fn prune_reaped(draining: &mut DrainingChildren) { draining.retain(|c| { c.pid.load(std::sync::atomic::Ordering::SeqCst) != 0 - || c.parked_at.elapsed() < DRAIN_GRACE + || (!c.settled && c.parked_at.elapsed() < DRAIN_GRACE) }); } @@ -781,7 +854,7 @@ impl ConnectionManager { agent_type: AgentType, working_dir: Option, session_id: Option, - mut runtime_env: BTreeMap, + runtime_env: BTreeMap, owner_window_label: String, emitter: EventEmitter, preferred_mode_id: Option, @@ -794,6 +867,179 @@ impl ConnectionManager { // re-entrant: nothing reachable from here calls `spawn_agent` again. let _restore_guard = self.external_restore_lock.read().await; + self.spawn_agent_inner( + agent_type, + working_dir, + session_id, + runtime_env, + owner_window_label, + emitter, + preferred_mode_id, + preferred_config_values, + cli_root, + ) + .await + } + + /// Account transfer already owns the write side of the restore barrier. + /// Re-entering `spawn_agent` there would deadlock. Keep the required guard + /// explicit so this bypass cannot accidentally become an ordinary connect. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn spawn_agent_during_account_switch( + &self, + _barrier: &tokio::sync::OwnedRwLockWriteGuard<()>, + working_dir: String, + session_id: String, + runtime_env: BTreeMap, + owner_window_label: String, + emitter: EventEmitter, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result { + self.spawn_agent_inner( + AgentType::ClaudeCode, + Some(working_dir), + Some(session_id), + runtime_env, + owner_window_label, + emitter, + preferred_mode_id, + preferred_config_values, + None, + ) + .await + .map(|outcome| outcome.connection_id) + } + + /// Handoff holds the same barrier as native account switching and restore. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn spawn_agent_during_handoff( + &self, + _barrier: &tokio::sync::OwnedRwLockWriteGuard<()>, + agent: AgentType, + cwd: String, + session: Option, + env: BTreeMap, + owner: String, + emitter: EventEmitter, + mode: Option, + config: BTreeMap, + ) -> Result { + self.spawn_agent_inner(agent, Some(cwd), session, env, owner, emitter, mode, config, None) + .await + .map(|outcome| outcome.connection_id) + } + + pub(crate) async fn quiesce_handoff_source( + &self, + _barrier: &tokio::sync::OwnedRwLockWriteGuard<()>, + conversation_id: i32, + agent: AgentType, + account: Option<&str>, + sid: &str, + storage: Option<&(AgentType, std::path::PathBuf)>, + ) -> Result>, AcpError> { + let mut sources = Vec::new(); + { + let connections = self.connections.lock().await; + for (id, connection) in connections.iter() { + let state = connection.state.read().await; + let same_session = state.external_id.as_deref().or(state.requested_session_id.as_deref()) == Some(sid); + let same_store = matches!((&state.storage_identity, storage), (Some((a, x)), Some((b, y))) if a == b && same_storage_root(x, y)); + if state.conversation_id == Some(conversation_id) + || (same_session && (same_store || (connection.agent_type == agent && state.agent_account_id.as_deref() == account))) { + sources.push((id.clone(), connection.prompt_lock.clone())); + } + } + } + let mut guards = Vec::new(); + for (id, lock) in &sources { + guards.push(lock.clone().lock_owned().await); + if let Some(state) = self.get_state(id).await { + let state = state.read().await; + if state.remote_control.is_some() { + return Err(AcpError::protocol("Turn off Remote Control before switching agents.")); + } + if state.cli_run.is_some() { + return Err(AcpError::protocol("Wait for the CLI run to finish before switching agents.")); + } + if state.turn_in_flight || state.goal_active || state.pending_permission.is_some() + || state.has_active_background_work(chrono::Utc::now()) + || !matches!(state.status, ConnectionStatus::Connected | ConnectionStatus::Error | ConnectionStatus::Disconnected) { + return Err(AcpError::TurnInProgress); + } + } + } + for (id, _) in sources { self.disconnect_and_wait_for_account_switch(&id).await?; } + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let writing = { + let mut draining = self.draining.lock().await; + prune_reaped(&mut draining); + draining.iter().any(|child| { + let same = match (&child.session_identity, storage) { + (Some((a, x, session)), Some((b, y))) => session == sid && a == b && same_storage_root(x, y), + _ => child.agent == agent && child.account_id.as_deref() == account, + }; + same && (child.spawn_pending || child.pid.load(std::sync::atomic::Ordering::SeqCst) != 0) + }) + }; + if !writing { return Ok(guards); } + if tokio::time::Instant::now() >= deadline { + return Err(AcpError::protocol("An agent is still stopping. Wait before switching agents.")); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + pub(crate) async fn lock_for_connect(&self) -> tokio::sync::OwnedRwLockReadGuard<()> { + self.external_restore_lock.clone().read_owned().await + } + + /// Saved-account validation must happen under this same barrier. Otherwise + /// a connect that waited for a switch could reopen its stale source account. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn spawn_agent_with_connect_barrier( + &self, + _barrier: &tokio::sync::OwnedRwLockReadGuard<()>, + agent_type: AgentType, + working_dir: Option, + session_id: Option, + runtime_env: BTreeMap, + owner_window_label: String, + emitter: EventEmitter, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + ) -> Result { + self.spawn_agent_inner( + agent_type, + working_dir, + session_id, + runtime_env, + owner_window_label, + emitter, + preferred_mode_id, + preferred_config_values, + None, + ) + .await + .map(|outcome| outcome.connection_id) + } + + #[allow(clippy::too_many_arguments)] + async fn spawn_agent_inner( + &self, + agent_type: AgentType, + working_dir: Option, + session_id: Option, + mut runtime_env: BTreeMap, + owner_window_label: String, + emitter: EventEmitter, + preferred_mode_id: Option, + preferred_config_values: BTreeMap, + cli_root: Option, + ) -> Result { + // Connection dedup: when resuming an agent session (session_id is // Some), look for a live AgentConnection that already represents // the same external session in the same working_dir for the same @@ -812,10 +1058,13 @@ impl ConnectionManager { // is None (fresh sessions can't dedup — by design — since the // agent assigns the id). let session_id_for_log = session_id.clone(); + let account_id = runtime_env.get(crate::acp::agent_accounts::ACCOUNT_ENV).cloned(); + let storage = crate::acp::agent_accounts::storage_identity(agent_type, &runtime_env); let dedup_lock = if let Some(sid) = session_id.as_deref() { let key = SpawnDedupKey { - agent_type, - working_dir: working_dir_path.clone(), + agent_type: storage.as_ref().map(|(agent, _)| *agent).unwrap_or(agent_type), + storage_key: storage.as_ref().map(|(_, path)| crate::parsers::normalize_path_for_matching(&path.canonicalize().unwrap_or_else(|_| path.clone()).to_string_lossy())), + working_dir: if storage.is_some() { None } else { working_dir_path.clone() }, session_id: sid.to_string(), }; let mu = { @@ -830,8 +1079,9 @@ impl ConnectionManager { None }; + self.ensure_account_session_available(agent_type, account_id.as_deref(), storage.as_ref(), session_id.as_deref()).await?; if let Some(existing) = self - .find_connection_for_reuse(agent_type, working_dir_path.as_ref(), session_id.as_deref()) + .find_connection_for_account(agent_type, working_dir_path.as_ref(), session_id.as_deref(), account_id.as_deref()) .await { tracing::info!( @@ -872,6 +1122,7 @@ impl ConnectionManager { owner_window_label, emitter, self.connections.clone(), + self.draining.clone(), preferred_mode_id, preferred_config_values, self.delegation_snapshot(), @@ -1067,13 +1318,27 @@ impl ConnectionManager { &self, fresh: &HashMap, kind: ConfigStaleKind, + ) -> usize { + let mut by_connection = HashMap::new(); + for connection in self.list_connections().await { + let Some(state) = self.get_state(&connection.id).await else { continue; }; + if state.read().await.agent_account_id.is_some() { continue; } + if let Some(fp) = fresh.get(&connection.agent_type) { by_connection.insert(connection.id, fp.clone()); } + } + self.refresh_connection_staleness_by_connection(&by_connection, kind).await + } + + pub async fn refresh_connection_staleness_by_connection( + &self, + fresh: &HashMap, + kind: ConfigStaleKind, ) -> usize { let mut targets = Vec::new(); let mut stale_count = 0usize; { let mut connections = self.connections.lock().await; - for conn in connections.values_mut() { - let Some(current) = fresh.get(&conn.agent_type) else { + for (id, conn) in connections.iter_mut() { + let Some(current) = fresh.get(id) else { continue; }; let stale = *current != conn.config_fingerprint; @@ -1109,11 +1374,22 @@ impl ConnectionManager { /// for an imperceptible latency win. The connections-map mutex is held /// across the awaits — fine because no path takes `state.write()` while /// holding the connections mutex (no lock-cycle). + #[cfg(test)] pub(crate) async fn find_connection_for_reuse( &self, agent_type: AgentType, working_dir: Option<&PathBuf>, session_id: Option<&str>, + ) -> Option { + self.find_connection_for_account(agent_type, working_dir, session_id, None).await + } + + pub(crate) async fn find_connection_for_account( + &self, + agent_type: AgentType, + working_dir: Option<&PathBuf>, + session_id: Option<&str>, + account_id: Option<&str>, ) -> Option { // No session_id → caller is opening a fresh session; never dedup. let session_id = session_id?; @@ -1123,6 +1399,9 @@ impl ConnectionManager { continue; } let state = conn.state.read().await; + if state.agent_account_id.as_deref() != account_id { + continue; + } if state.external_id.as_deref() != Some(session_id) { continue; } @@ -1140,6 +1419,31 @@ impl ConnectionManager { None } + async fn ensure_account_session_available( + &self, + agent: AgentType, + account_id: Option<&str>, + storage: Option<&(AgentType, PathBuf)>, + session_id: Option<&str>, + ) -> Result<(), AcpError> { + let (Some(storage), Some(session_id)) = (storage, session_id) else { return Ok(()); }; + let connections = self.connections.lock().await; + for connection in connections.values() { + let state = connection.state.read().await; + if matches!(state.status, ConnectionStatus::Disconnected | ConnectionStatus::Error) { continue; } + if connection.agent_type == agent && state.agent_account_id.as_deref() == account_id { continue; } + if state.external_id.as_deref().or(state.requested_session_id.as_deref()) != Some(session_id) { continue; } + if let Some(other) = &state.storage_identity { + let same = crate::parsers::path_eq_for_matching(&storage.1.to_string_lossy(), &other.1.to_string_lossy()) + || matches!((storage.1.canonicalize(), other.1.canonicalize()), (Ok(a), Ok(b)) if a == b); + if storage.0 == other.0 && same { + return Err(AcpError::protocol("Disconnect the existing session before resuming it through another account.")); + } + } + } + Ok(()) + } + /// Forwards a prompt to the connection's command channel without /// touching `prompt_lock`. Internal helper — both `send_prompt` and /// `send_prompt_linked` acquire the lock externally and then call @@ -1351,7 +1655,7 @@ impl ConnectionManager { conversation_id: Option, delegation: Option, client_message_id: Option, - internal_suffix: Vec, + mut internal_suffix: Vec, ) -> Result, AcpError> { // Reject an empty prompt up front, BEFORE any side effects: linking / // creating the conversation row, flipping it to InProgress, or emitting @@ -1420,6 +1724,23 @@ impl ConnectionManager { ) }; + let (account_id, bound_conversation_id) = { + let state = state_arc.read().await; + (state.agent_account_id.clone(), state.conversation_id) + }; + if let Some(id) = account_id.as_deref() { + crate::db::service::agent_account_service::resolve(&db.conn, agent_type, id) + .await.map_err(|e| AcpError::protocol(e.to_string()))?; + } + if let Some(id) = conversation_id.or(bound_conversation_id) { + let expected = crate::db::service::agent_account_service::connection_account( + &db.conn, agent_type, account_id.clone(), Some(id), + ).await.map_err(|e| AcpError::protocol(e.to_string()))?; + if expected != account_id { + return Err(AcpError::protocol("Reconnect using this conversation's saved account.")); + } + } + // Reject a concurrent prompt while a turn is already in flight, BEFORE // any side effects (row creation, InProgress emit, user-message // broadcast). `send_prompt_inner` re-checks and sets the flag @@ -1438,6 +1759,14 @@ impl ConnectionManager { return Err(AcpError::TurnInProgress); } + if let Some(id) = conversation_id.or(bound_conversation_id) { + if let Some(context) = crate::db::service::handoff_service::pending_context(&db.conn, id).await + .map_err(|e| AcpError::protocol(e.to_string()))? { + let already_seeded = blocks.iter().any(|b| matches!(b, PromptInputBlock::Text { text } if text == &context)); + if !already_seeded { internal_suffix.push(PromptInputBlock::Text { text: context }); } + } + } + // Re-hydrate uploaded image attachments (web / remote-workspace mode // sends empty-payload marker blocks with a `file://` uri into the // uploads root; see `prompt_hydration`). Deliberately placed AFTER @@ -1538,13 +1867,14 @@ impl ConnectionManager { let parent_tool_use_id_for_event = task_delegation .as_ref() .map(|d| d.parent_tool_use_id.clone()); - let row = conversation_service::create_with_delegation( + let row = conversation_service::create_with_delegation_for_account( &db_conn, folder_id, agent_type, seed_title, None, task_delegation, + account_id, ) .await .map_err(|e| AcpError::protocol(e.to_string()))?; @@ -2260,6 +2590,30 @@ impl ConnectionManager { let prompt_lock = self.clone_prompt_lock(conn_id).await?; let prompt_guard = prompt_lock.lock_owned().await; + // Account bindings apply to fork-send before the first prompt too. + // Refuse a stale caller before linking or asking the CLI to fork. + let (agent, account_id, bound_id) = { + let state = state_arc.read().await; + (state.agent_type, state.agent_account_id.clone(), state.conversation_id) + }; + if let Some(id) = account_id.as_deref() { + crate::db::service::agent_account_service::resolve(&db.conn, agent, id) + .await.map_err(|e| AcpError::protocol(e.to_string()))?; + } + if let Some(cid) = bound_id.or(link_conversation_id) { + if let Some(row) = conversation::Entity::find_by_id(cid).one(&db.conn).await + .map_err(|e| AcpError::protocol(e.to_string()))? { + if account_id.is_some() || row.agent_account_id.is_some() { + if row.agent_type != agent.as_wire() { + return Err(AcpError::protocol("This conversation belongs to a different agent.")); + } + if row.agent_account_id != account_id { + return Err(AcpError::protocol("This conversation belongs to a different agent account.")); + } + } + } + } + // Link the conversation row on demand, under the prompt lock so it // can't race a concurrent first prompt. A conversation opened from // history resumes via `session_id`, but its row is bound to the @@ -2546,6 +2900,7 @@ impl ConnectionManager { let folder_id = current.folder_id; let agent_type_str = current.agent_type.clone(); + let account_id = current.agent_account_id.clone(); let git_branch = current.git_branch.clone(); // The lock rides along with the title it protects: the // sibling holds the pre-fork history of the very row the @@ -2614,6 +2969,7 @@ impl ConnectionManager { if let Some(existing) = conversation::Entity::find() .filter(conversation::Column::ExternalId.eq(original_session_id.clone())) .filter(conversation::Column::AgentType.eq(agent_type_str.clone())) + .filter(conversation_service::account_condition(account_id.as_deref())) .filter(conversation::Column::Id.ne(conversation_id)) .filter(conversation::Column::DeletedAt.is_null()) .one(txn) @@ -2630,6 +2986,7 @@ impl ConnectionManager { title: Set(clean_title), title_locked: Set(title_locked), agent_type: Set(agent_type_str), + agent_account_id: Set(account_id), status: Set(ConversationStatus::PendingReview), kind: Set(sibling_kind), model: Set(None), @@ -2691,16 +3048,195 @@ impl ConnectionManager { } } + /// With new connects excluded, serialize against prompts/forks, refuse + /// ongoing work, and wait for the old writer to exit before copying it. + pub(crate) async fn quiesce_account_session( + &self, + _barrier: &tokio::sync::OwnedRwLockWriteGuard<()>, + source: &std::path::Path, + target: &std::path::Path, + sid: &str, + ) -> Result>, AcpError> { + { + let mut draining = self.draining.lock().await; + prune_reaped(&mut draining); + if draining.iter().any(|child| { + let Some((family, root, session)) = &child.session_identity else { return false; }; + let same = |path: &std::path::Path| { + crate::parsers::path_eq_for_matching(&root.to_string_lossy(), &path.to_string_lossy()) + || matches!((root.canonicalize(), path.canonicalize()), (Ok(a), Ok(b)) if a == b) + }; + *family == AgentType::ClaudeCode && session == sid && (same(source) || same(target)) + && (child.pid.load(std::sync::atomic::Ordering::SeqCst) != 0 || child.spawn_pending) + }) { + return Err(AcpError::protocol("An agent is still stopping. Wait before switching accounts.")); + } + } + let mut sources = Vec::new(); + { + let connections = self.connections.lock().await; + for (id, connection) in connections.iter() { + let state = connection.state.read().await; + if state + .external_id + .as_deref() + .or(state.requested_session_id.as_deref()) + != Some(sid) + { + continue; + } + let Some((family, root)) = &state.storage_identity else { + continue; + }; + if *family != AgentType::ClaudeCode { + continue; + } + let same = |other: &std::path::Path| { + crate::parsers::path_eq_for_matching( + &root.to_string_lossy(), + &other.to_string_lossy(), + ) || matches!((root.canonicalize(), other.canonicalize()), (Ok(a), Ok(b)) if a == b) + }; + if same(target) { + return Err(AcpError::protocol( + "Close the target account's copy of this session before switching.", + )); + } + if same(source) { + sources.push((id.clone(), connection.prompt_lock.clone())); + } + } + } + let mut guards = Vec::new(); + for (id, lock) in &sources { + guards.push(lock.clone().lock_owned().await); + if let Some(state) = self.get_state(id).await { + let state = state.read().await; + if state.remote_control.is_some() { + return Err(AcpError::protocol( + "Turn off Remote Control before switching accounts.", + )); + } + if state.cli_run.is_some() { + return Err(AcpError::protocol( + "Wait for the CLI run to finish before switching accounts.", + )); + } + if state.turn_in_flight + || state.goal_active + || state.pending_permission.is_some() + || state.has_active_background_work(chrono::Utc::now()) + || !matches!( + state.status, + ConnectionStatus::Connected + | ConnectionStatus::Error + | ConnectionStatus::Disconnected + ) + { + return Err(AcpError::TurnInProgress); + } + } + } + for (id, _) in sources { + self.disconnect_and_wait_for_account_switch(&id).await?; + } + // A connection that ended on its own after the checks above is out of + // the map but its process may still be appending to the transcript + // that is about to be copied (source) or replaced (target). + for root in [source, target] { + self.wait_for_account_session_writers(root, sid).await?; + } + Ok(guards) + } + + pub(crate) async fn disconnect_and_wait_for_account_switch( + &self, + id: &str, + ) -> Result<(), AcpError> { + let (state, pid) = { + let connections = self.connections.lock().await; + let Some(connection) = connections.get(id) else { + return Ok(()); + }; + (connection.state.clone(), connection.child_pid.clone()) + }; + self.disconnect(id).await?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let stopped = matches!( + state.read().await.status, + ConnectionStatus::Disconnected | ConnectionStatus::Error + ); + if stopped && pid.load(std::sync::atomic::Ordering::SeqCst) == 0 { + // This exact child is confirmed stopped, unlike a generic + // zero-pid drainer which might still be in its spawn window. + self.draining + .lock() + .await + .retain(|child| !Arc::ptr_eq(&child.pid, &pid)); + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(AcpError::protocol( + "The previous agent is still stopping. Wait before switching accounts again.", + )); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + /// Wait until nothing can still be writing Claude session `sid` stored + /// under `root`: no mapped connection holds it and no draining child — + /// including one whose connection ended on its own — is still alive. + /// A missing map entry alone proves nothing: the process outlives it. + pub(crate) async fn wait_for_account_session_writers( + &self, + root: &std::path::Path, + sid: &str, + ) -> Result<(), AcpError> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + if !self.account_session_has_writer(root, sid).await { + return Ok(()); + } + if tokio::time::Instant::now() >= deadline { + return Err(AcpError::protocol( + "An agent is still stopping. Wait before switching accounts again.", + )); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + async fn account_session_has_writer(&self, root: &std::path::Path, sid: &str) -> bool { + // Same order as `park_draining`'s callers: connections, then draining. + let connections = self.connections.lock().await; + for connection in connections.values() { + let state = connection.state.read().await; + if state.external_id.as_deref().or(state.requested_session_id.as_deref()) + != Some(sid) + { + continue; + } + if let Some((family, child_root)) = &state.storage_identity { + if *family == AgentType::ClaudeCode && same_storage_root(child_root, root) { + return true; + } + } + } + let mut draining = self.draining.lock().await; + prune_reaped(&mut draining); + draining.iter().any(|child| child.may_write(root, sid)) + } + /// Remember a connection's child until it is provably finished. Call while /// holding the connections lock, immediately after removing the entry. async fn park_draining(&self, conn: &AgentConnection) { let mut draining = self.draining.lock().await; prune_reaped(&mut draining); - draining.push(DrainingChild { - agent: conn.agent_type, - pid: conn.child_pid.clone(), - parked_at: std::time::Instant::now(), - }); + let state = conn.state.read().await; + let spawn_pending = state.status == ConnectionStatus::Connecting; + draining.push(DrainingChild::of(conn, &state, spawn_pending)); } /// Every agent that could still be writing to its own files: connected or @@ -3223,6 +3759,23 @@ impl ConnectionManager { connections.values().map(|c| c.info()).collect() } + /// Called under the connection barrier before replacing account credentials. + /// A removed connection can still own a process that is writing its login. + pub async fn account_has_running_process(&self, account_id: &str) -> bool { + let connections = self.connections.lock().await; + for connection in connections.values() { + if connection.state.read().await.agent_account_id.as_deref() == Some(account_id) { + return true; + } + } + let mut draining = self.draining.lock().await; + prune_reaped(&mut draining); + draining.iter().any(|child| { + child.account_id.as_deref() == Some(account_id) + && (child.pid.load(std::sync::atomic::Ordering::SeqCst) != 0 || child.spawn_pending) + }) + } + /// Raw per-connection rows for the pet panel's active-session list. /// "Active" = the connection is currently `Prompting`, awaiting a /// permission, or in an `Error` state — the sessions a user would want to @@ -4151,6 +4704,15 @@ impl ConnectionManager { &self, external_id: &str, agent_type: AgentType, + ) -> Option { + self.find_connection_by_external_id_for_account(external_id, agent_type, None).await + } + + pub async fn find_connection_by_external_id_for_account( + &self, + external_id: &str, + agent_type: AgentType, + account_id: Option<&str>, ) -> Option { let connections = self.connections.lock().await; for (id, conn) in connections.iter() { @@ -4158,7 +4720,7 @@ impl ConnectionManager { continue; } let state = conn.state.read().await; - if state.external_id.as_deref() == Some(external_id) { + if state.external_id.as_deref() == Some(external_id) && state.agent_account_id.as_deref() == account_id { return Some(id.clone()); } } @@ -4197,6 +4759,7 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa &self, parent_connection_id: &str, agent_type: AgentType, + agent_account_id: Option, working_dir: Option, preferred_mode_id: Option, preferred_config_values: BTreeMap, @@ -4206,7 +4769,7 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa // owner_window. Falling back is not safe: a child whose emitter is // wired to a different broadcaster would emit events the frontend // never sees. - let (emitter, owner_window, parent_working_dir) = { + let (emitter, owner_window, parent_working_dir, account_id) = { let conns = self.manager.connections.lock().await; let parent = conns.get(parent_connection_id).ok_or_else(|| { SpawnerError::Spawn(format!( @@ -4223,19 +4786,30 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa parent.emitter.clone(), parent.owner_window_label.clone(), pwd, + if parent.agent_type == agent_type { + parent.state.read().await.agent_account_id.clone() + } else { + None + }, ) }; let effective_working_dir = working_dir.or(parent_working_dir); + let account_id = match agent_account_id.as_deref() { + Some("default") => None, + Some(_) => agent_account_id, + None => account_id, + }; // Build the same runtime env `acp_connect` would build for a // user-initiated session — disabled check, settings overrides, // model provider creds, git helper. Without this, delegated // subagents would skip the user's configuration entirely. - let runtime_env = crate::commands::acp::build_session_runtime_env( + let runtime_env = crate::commands::acp::build_account_session_runtime_env( &self.db, agent_type, None, self.data_dir.as_path(), + account_id.as_deref(), ) .await .map_err(|e| SpawnerError::Spawn(e.to_string()))?; @@ -4325,6 +4899,7 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa &self, parent_connection_id: &str, agent_type: AgentType, + agent_account_id: Option, working_dir: Option, external_session_id: &str, preferred_mode_id: Option, @@ -4354,12 +4929,15 @@ impl crate::acp::delegation::spawner::ConnectionSpawner for ConnectionManagerSpa ) }; let effective_working_dir = working_dir.or(parent_working_dir); + // Resume the child's saved account even after the parent switches. + let account_id = agent_account_id; - let runtime_env = crate::commands::acp::build_session_runtime_env( + let runtime_env = crate::commands::acp::build_account_session_runtime_env( &self.db, agent_type, None, self.data_dir.as_path(), + account_id.as_deref(), ) .await .map_err(|e| SpawnerError::Spawn(e.to_string()))?; @@ -5842,6 +6420,7 @@ mod tests { .spawn_for_resume( "parent", AgentType::ClaudeCode, + None, Some(wd.to_string_lossy().into_owned()), "child-session", None, @@ -5857,6 +6436,29 @@ mod tests { } ); assert_eq!(cli_run_of(&mgr, "users-child").await, None); + + let account = crate::db::service::agent_account_service::create( + &spawner.db.conn, + data_dir.path(), + AgentType::ClaudeCode, + "Work", + None, + ).await.unwrap(); + mgr.insert_test_connection("users-named", AgentType::ClaudeCode, Some(wd.clone()), EventEmitter::Noop).await; + { + let named = state_of(&mgr, "users-named").await; + let mut named = named.write().await; + named.external_id = Some("child-session".into()); + named.agent_account_id = Some(account.id.clone()); + } + let resumed = spawner.spawn_for_resume( + "parent", AgentType::ClaudeCode, Some(account.id), + Some(wd.to_string_lossy().into_owned()), "child-session", None, BTreeMap::new(), + ).await.expect("the named-account connection is reused"); + assert_eq!(resumed.connection_id, "users-named"); + assert!(resumed.reused); + assert_eq!(cli_run_of(&mgr, "users-named").await, None); + assert_eq!(cli_run_of(&mgr, "users-child").await, None); } #[tokio::test] @@ -8375,6 +8977,7 @@ mod tests { // observe via the other. let key = SpawnDedupKey { agent_type: AgentType::ClaudeCode, + storage_key: None, working_dir: Some(PathBuf::from("/tmp/dedup-test")), session_id: "ext-shared".into(), }; @@ -8391,6 +8994,299 @@ mod tests { ); } + #[tokio::test] + async fn native_account_resume_cannot_double_open_the_same_physical_session() { + let mgr = ConnectionManager::new(); + let home = tempfile::tempdir().unwrap(); + let other_home = tempfile::tempdir().unwrap(); + insert_fake_connection(&mgr, "live", AgentType::Codex, None, EventEmitter::Noop).await; + let state = mgr.get_state("live").await.unwrap(); + { + let mut state = state.write().await; + state.agent_account_id = Some("old-account".into()); + state.storage_identity = Some((AgentType::Codex, home.path().into())); + state.requested_session_id = Some("shared-session".into()); + } + let storage = (AgentType::Codex, home.path().to_path_buf()); + assert!(mgr.ensure_account_session_available(AgentType::Codex, Some("readded-account"), Some(&storage), Some("shared-session")).await.is_err()); + assert!(mgr.ensure_account_session_available(AgentType::Codex, Some("old-account"), Some(&storage), Some("shared-session")).await.is_ok()); + let other = (AgentType::Codex, other_home.path().to_path_buf()); + assert!(mgr.ensure_account_session_available(AgentType::Codex, Some("other-account"), Some(&other), Some("shared-session")).await.is_ok()); + assert!(mgr.ensure_account_session_available(AgentType::Codex, Some("readded-account"), Some(&storage), Some("another-session")).await.is_ok()); + state.write().await.status = ConnectionStatus::Disconnected; + assert!(mgr.ensure_account_session_available(AgentType::Codex, Some("readded-account"), Some(&storage), Some("shared-session")).await.is_ok()); + } + + #[tokio::test] + async fn account_auth_changes_wait_for_active_and_draining_children() { + let mgr = ConnectionManager::new(); + insert_fake_connection(&mgr, "auth-guard", AgentType::ClaudeCode, None, EventEmitter::Noop).await; + mgr.get_state("auth-guard").await.unwrap().write().await.agent_account_id = Some("work".into()); + assert!(mgr.account_has_running_process("work").await); + assert!(!mgr.account_has_running_process("personal").await); + let pid = { + let mut connections = mgr.connections.lock().await; + let connection = connections.remove("auth-guard").unwrap(); + let state = connection.state.read().await; + // No real process is created or signalled by this test. + connection.child_pid.store(12345, std::sync::atomic::Ordering::SeqCst); + park_ended_child(&mut *mgr.draining.lock().await, &connection, &state); + connection.child_pid.clone() + }; + assert!(mgr.account_has_running_process("work").await); + assert!(!mgr.account_has_running_process("personal").await); + pid.store(0, std::sync::atomic::Ordering::SeqCst); + assert!(!mgr.account_has_running_process("work").await); + } + + #[tokio::test] + async fn native_account_fork_preserves_binding_and_rejects_a_different_account() { + use crate::db::{test_helpers, service::agent_account_service}; + let db = test_helpers::fresh_in_memory_db().await; + let data = tempfile::tempdir().unwrap(); + let folder = test_helpers::seed_folder(&db, "/tmp/account-fork").await; + let a = agent_account_service::create(&db.conn, data.path(), AgentType::Codex, "A", None).await.unwrap(); + let b = agent_account_service::create(&db.conn, data.path(), AgentType::Codex, "B", None).await.unwrap(); + let first = conversation_service::create_inner(&db.conn, folder, AgentType::Codex, None, None, None, ConversationKind::Regular, Some(a.id.clone())).await.unwrap(); + let other = conversation_service::create_inner(&db.conn, folder, AgentType::Codex, None, None, None, ConversationKind::Regular, Some(b.id)).await.unwrap(); + for row in [&first, &other] { conversation_service::bind_external_id(&db.conn, row.id, "session-S1", &[]).await.unwrap(); } + let sibling_id = ConnectionManager::persist_fork_outcome(&db.conn, first.id, "session-S2".into(), "session-S1".into()).await.unwrap(); + assert_ne!(sibling_id, other.id); + let sibling = conversation::Entity::find_by_id(sibling_id).one(&db.conn).await.unwrap().unwrap(); + assert_eq!(sibling.agent_account_id.as_deref(), Some(a.id.as_str())); + assert_eq!(sibling.external_id.as_deref(), Some("session-S1")); + let unchanged = conversation::Entity::find_by_id(other.id).one(&db.conn).await.unwrap().unwrap(); + assert_eq!(unchanged.external_id.as_deref(), Some("session-S1")); + let mgr = ConnectionManager::new(); + insert_fake_connection(&mgr, "account-fork", AgentType::Codex, None, EventEmitter::Noop).await; + let state = mgr.get_state("account-fork").await.unwrap(); + state.write().await.agent_account_id = Some(a.id); + let error = mgr.fork_session(&db, "account-fork", Some(other.id), Some(folder), None).await.unwrap_err(); + assert!(error.to_string().contains("different agent account")); + assert!(state.read().await.conversation_id.is_none()); + // Another agent's conversation is refused as such, not as another + // account of this agent. + let claude = agent_account_service::create(&db.conn, data.path(), AgentType::ClaudeCode, "C", None).await.unwrap(); + let foreign = conversation_service::create_inner(&db.conn, folder, AgentType::ClaudeCode, None, None, None, ConversationKind::Regular, Some(claude.id)).await.unwrap(); + let error = mgr.fork_session(&db, "account-fork", Some(foreign.id), Some(folder), None).await.unwrap_err(); + assert!(error.to_string().contains("belongs to a different agent."), "{error}"); + assert!(state.read().await.conversation_id.is_none()); + } + + #[tokio::test] + async fn account_switch_refuses_active_turns_goals_cli_runs_and_open_targets() { + let mgr = ConnectionManager::new(); + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + insert_fake_connection(&mgr, "switch-source", AgentType::ClaudeCode, None, EventEmitter::Noop).await; + let state = mgr.get_state("switch-source").await.unwrap(); + { + let mut state = state.write().await; + state.storage_identity = Some((AgentType::ClaudeCode, a.path().into())); + state.external_id = Some("sid".into()); + state.turn_in_flight = true; + } + let barrier = mgr.lock_out_new_connections().await; + assert!(matches!(mgr.quiesce_account_session(&barrier, a.path(), b.path(), "sid").await, Err(AcpError::TurnInProgress))); + assert!(mgr.get_state("switch-source").await.is_some()); + { + let mut state = state.write().await; + state.turn_in_flight = false; + state.goal_active = true; + } + assert!(matches!(mgr.quiesce_account_session(&barrier, a.path(), b.path(), "sid").await, Err(AcpError::TurnInProgress))); + state.write().await.goal_active = false; + state.write().await.cli_run = Some(cli_tag("account-switch-run")); + let error = mgr.quiesce_account_session(&barrier, a.path(), b.path(), "sid").await.unwrap_err(); + assert!(error.to_string().contains("Wait for the CLI run")); + assert!(mgr.get_state("switch-source").await.is_some()); + state.write().await.cli_run = None; + let error = mgr.quiesce_account_session(&barrier, b.path(), a.path(), "sid").await.unwrap_err(); + assert!(error.to_string().contains("target account")); + assert!(mgr.get_state("switch-source").await.is_some()); + } + + #[tokio::test] + async fn handoff_keeps_a_cli_owned_connection_alive() { + let mgr = ConnectionManager::new(); + let mut commands = mgr + .insert_test_connection_live("cli-handoff", AgentType::Codex, None, EventEmitter::Noop) + .await; + let state = mgr.get_state("cli-handoff").await.unwrap(); + { + let mut state = state.write().await; + state.conversation_id = Some(42); + state.external_id = Some("cli-session".into()); + state.cli_run = Some(cli_tag("handoff-run")); + } + let barrier = mgr.lock_out_new_connections().await; + let error = tokio::time::timeout( + Duration::from_secs(1), + mgr.quiesce_handoff_source(&barrier, 42, AgentType::Codex, None, "cli-session", None), + ) + .await + .expect("a CLI-owned connection must be refused without waiting for shutdown") + .unwrap_err(); + assert!(error.to_string().contains("Wait for the CLI run")); + assert!(mgr.get_state("cli-handoff").await.is_some()); + assert!(matches!(commands.try_recv(), Err(mpsc::error::TryRecvError::Empty))); + } + + #[tokio::test] + async fn account_switch_keeps_remote_control_alive_until_it_is_turned_off() { + let mgr = ConnectionManager::new(); + let source = tempfile::tempdir().unwrap(); + let target = tempfile::tempdir().unwrap(); + let mut commands = mgr + .insert_test_connection_live("remote-switch", AgentType::ClaudeCode, None, EventEmitter::Noop) + .await; + let state = mgr.get_state("remote-switch").await.unwrap(); + { + let mut state = state.write().await; + state.storage_identity = Some((AgentType::ClaudeCode, source.path().into())); + state.external_id = Some("remote-session".into()); + state.apply_event(&crate::acp::types::AcpEvent::RemoteControl { + remote_control: Some(crate::acp::types::RemoteControlInfo { session_url: None }), + }); + } + let barrier = mgr.lock_out_new_connections().await; + let error = mgr + .quiesce_account_session(&barrier, source.path(), target.path(), "remote-session") + .await + .unwrap_err(); + assert!(matches!(&error, AcpError::Protocol(_))); + assert!(error.to_string().contains("Turn off Remote Control")); + assert!(mgr.get_state("remote-switch").await.is_some()); + assert!(state.read().await.remote_control.is_some()); + assert!(matches!(commands.try_recv(), Err(mpsc::error::TryRecvError::Empty))); + state.write().await.apply_event(&crate::acp::types::AcpEvent::RemoteControl { + remote_control: None, + }); + let stopped = tokio::spawn({ + let state = state.clone(); + async move { + assert!(matches!(commands.recv().await, Some(ConnectionCommand::Disconnect))); + state.write().await.status = ConnectionStatus::Disconnected; + } + }); + mgr.quiesce_account_session(&barrier, source.path(), target.path(), "remote-session") + .await + .unwrap(); + stopped.await.unwrap(); + assert!(mgr.get_state("remote-switch").await.is_none()); + } + + /// A connection whose task ends on its own (the agent failed to load, or + /// exited) leaves the map while its process is only signalled, not yet + /// reaped. An account switch must not read that as "stopped": rolling a + /// target back, or copying a source, while the process can still append to + /// the transcript would corrupt the copy that survives. + #[tokio::test] + async fn a_connection_that_ends_on_its_own_stays_a_writer_until_reaped() { + use std::sync::atomic::Ordering::SeqCst; + let mgr = ConnectionManager::new(); + let root = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + insert_fake_connection(&mgr, "ended", AgentType::ClaudeCode, None, EventEmitter::Noop).await; + { + let state = mgr.get_state("ended").await.unwrap(); + let mut state = state.write().await; + state.storage_identity = Some((AgentType::ClaudeCode, root.path().into())); + state.external_id = Some("sid".into()); + } + let pid = mgr.connections.lock().await.get("ended").unwrap().child_pid.clone(); + pid.store(4242, SeqCst); + + crate::acp::connection::end_connection_task_for_test( + mgr.connections.clone(), + "ended", + mgr.draining.clone(), + tokio::runtime::Handle::current(), + ); + assert!(mgr.list_connections().await.is_empty()); + assert_eq!(mgr.live_or_draining_agent_names().await, vec!["Claude Code".to_string()]); + assert!(mgr.account_session_has_writer(root.path(), "sid").await); + assert!(!mgr.account_session_has_writer(other.path(), "sid").await); + assert!(!mgr.account_session_has_writer(root.path(), "another").await); + + let reaper = tokio::spawn({ + let pid = pid.clone(); + async move { + tokio::time::sleep(Duration::from_millis(100)).await; + pid.store(0, SeqCst); + } + }); + mgr.wait_for_account_session_writers(root.path(), "sid").await.unwrap(); + reaper.await.unwrap(); + // Its spawn was over, so a zero pid is a reap, not a spawn in flight: + // it leaves at once instead of after the grace an explicit teardown gets. + assert!(mgr.live_or_draining_agent_names().await.is_empty()); + } + + /// The guard drops on the connection's own thread after `block_on` has + /// returned, outside any runtime context. With the map lock taken there, + /// the cleanup is deferred, and deferring it with `tokio::spawn` panicked + /// that thread and leaked the entry. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_task_end_under_contention_cleans_up_from_outside_the_runtime() { + use std::sync::atomic::Ordering::SeqCst; + let mgr = ConnectionManager::new(); + insert_fake_connection(&mgr, "busy", AgentType::Codex, None, EventEmitter::Noop).await; + mgr.connections.lock().await.get("busy").unwrap().child_pid.store(99, SeqCst); + let held = mgr.connections.lock().await; + let (connections, draining) = (mgr.connections.clone(), mgr.draining.clone()); + let runtime = tokio::runtime::Handle::current(); + std::thread::spawn(move || { + crate::acp::connection::end_connection_task_for_test( + connections, + "busy", + draining, + runtime, + ); + }) + .join() + .expect("dropping the guard off the runtime must not panic"); + drop(held); + for _ in 0..200 { + if mgr.list_connections().await.is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(mgr.list_connections().await.is_empty()); + assert_eq!(mgr.live_or_draining_agent_names().await, vec!["Codex CLI".to_string()]); + } + + /// An entry an explicit teardown already removed is not parked a second + /// time when the task's guard runs, and a child that never published a pid + /// is not parked at all. + #[tokio::test] + async fn a_task_end_parks_only_a_still_mapped_live_child() { + use std::sync::atomic::Ordering::SeqCst; + let mgr = ConnectionManager::new(); + insert_fake_connection(&mgr, "torn", AgentType::Codex, None, EventEmitter::Noop).await; + let pid = mgr.connections.lock().await.get("torn").unwrap().child_pid.clone(); + pid.store(7, SeqCst); + mgr.disconnect("torn").await.unwrap(); + crate::acp::connection::end_connection_task_for_test( + mgr.connections.clone(), + "torn", + mgr.draining.clone(), + tokio::runtime::Handle::current(), + ); + assert_eq!(mgr.draining.lock().await.len(), 1); + + insert_fake_connection(&mgr, "unspawned", AgentType::Codex, None, EventEmitter::Noop).await; + crate::acp::connection::end_connection_task_for_test( + mgr.connections.clone(), + "unspawned", + mgr.draining.clone(), + tokio::runtime::Handle::current(), + ); + assert!(mgr.get_state("unspawned").await.is_none()); + assert_eq!(mgr.draining.lock().await.len(), 1); + } + /// Two concurrent `send_prompt_linked` calls on the SAME connection /// must serialize through the per-connection `prompt_lock` so the /// backend-creates branch can't fire twice and produce duplicate diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index c3c92b87ca..677220fb76 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -20,9 +20,13 @@ pub mod custom_registry; pub mod delegation; pub mod error; pub mod event_stream; +pub mod family_isolator; pub mod feedback; pub mod file_system_runtime; pub mod fork; +pub mod handoff; +pub mod handoff_context; +pub mod handoff_context_mcp; pub mod host_tools_policy; pub mod idle_sweep; pub mod internal_bus; @@ -89,3 +93,7 @@ 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) } +pub mod agent_accounts; +pub mod account_auth; +pub mod account_skills; +pub mod account_transfer; diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 94813a08d4..ebf5abeca0 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -251,6 +251,14 @@ pub struct PendingUserMessage { /// 已完成的 turn 不存在这里——它们由 parser 从 agent JSONL 读。 #[derive(Debug)] pub struct SessionState { + pub storage_identity: Option<(AgentType, PathBuf)>, + pub requested_session_id: Option, + /// Set only after an initial session/load or session/resume succeeds. + /// SessionStarted alone is not proof: a load-to-new fallback emits it too. + /// Backend-only, used to validate account transfers before changing a row. + pub restored_session_id: Option, + pub agent_account_id: Option, + pub account_home: Option, // 身份 pub connection_id: String, pub conversation_id: Option, @@ -475,6 +483,9 @@ pub struct SessionState { /// on the snapshot so a frontend that reconnects after refresh can see /// "init complete" without waiting for an event that already fired. pub selectors_ready: bool, + /// Selected portable history, exposed only through a read-only MCP server. + /// Internal launch metadata, never copied into the agent's environment. + pub handoff_context_id: Option, /// Most recent unresolved `AcpEvent::Error` payload. Cleared when a new /// prompt starts, matching the frontend reducer's live-event behavior. The @@ -783,6 +794,12 @@ pub enum TurnStatusWriter { } impl SessionState { + pub(crate) fn has_restored_session(&self, session_id: &str) -> bool { + self.restored_session_id.as_deref() == Some(session_id) + && self.external_id.as_deref() == Some(session_id) + && self.status == ConnectionStatus::Connected + } + /// Claim the conversation-status write of the `TurnComplete` at `seq` for /// `writer`. `true` when `writer` owns it — newly, or already (a retry of /// its own write runs again with the same envelope); `false` when the other @@ -817,6 +834,11 @@ impl SessionState { let wants_prompt_suggestions = owner_composes_prompts(&owner_window_label); Self { connection_id, + agent_account_id: None, + storage_identity: None, + requested_session_id: None, + restored_session_id: None, + account_home: None, conversation_id: None, external_id: None, external_id_changed_at: None, @@ -854,6 +876,7 @@ impl SessionState { wants_prompt_suggestions, remote_control: None, selectors_ready: false, + handoff_context_id: None, last_error: None, session_started_tx: None, event_seq: 0, @@ -2966,6 +2989,22 @@ mod tests { assert!(!s.has_active_background_work(Utc::now())); } + #[test] + fn account_switch_does_not_treat_session_started_as_restore_proof() { + let mut s = fresh_state(); + s.apply_event(&AcpEvent::StatusChanged { status: ConnectionStatus::Connected }); + s.apply_event(&AcpEvent::SessionStarted { session_id: "same-id".into() }); + // A failed load can fall through to session/new. Even an adapter that + // reuses the requested id has not acknowledged restoring the history. + assert!(!s.has_restored_session("same-id")); + s.restored_session_id = Some("same-id".into()); + assert!(s.has_restored_session("same-id")); + s.apply_event(&AcpEvent::SessionStarted { session_id: "another-id".into() }); + assert!(!s.has_restored_session("another-id")); + s.apply_event(&AcpEvent::StatusChanged { status: ConnectionStatus::Error }); + assert!(!s.has_restored_session("same-id")); + } + /// A turn the agent started itself (Grok's follow-up to a background /// workflow) never took the prompt gate, so its end must not release it: a /// prompt admitted while it ran owns the flag until ITS turn completes. diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index aac0176397..1c84b042bf 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -1624,9 +1624,18 @@ pub struct ConversationConnectionInfo { pub event_seq: u64, } +#[derive(Debug, Clone, Serialize)] +pub struct AcpAgentAccountInfo { + pub id: String, + pub name: String, +} + #[derive(Debug, Clone, Serialize)] pub struct AcpAgentInfo { pub agent_type: crate::models::agent::AgentType, + /// Mention targets derived from the account registry, without paths or auth. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub named_accounts: Vec, /// Whether this agent has a codeg-known skill store — every built-in, and /// custom agents that declared the shared `.agents/skills` store. Gates /// the skills matrices frontend-side. diff --git a/src-tauri/src/bin/codeg_mcp.rs b/src-tauri/src/bin/codeg_mcp.rs index f0ef4c01fb..f07cc48c2d 100644 --- a/src-tauri/src/bin/codeg_mcp.rs +++ b/src-tauri/src/bin/codeg_mcp.rs @@ -184,6 +184,18 @@ async fn main() -> ExitCode { // concurrent mcp processes share no log file. No hub/buffer/emitter. let _log_guard = codeg_lib::logging::init::init_mcp(); + if std::env::args().nth(1).as_deref() == Some("--conversation-context") { + let directory = std::env::args().nth(2).map(std::path::PathBuf::from); + let parent = std::env::args().nth(3).and_then(|p| p.parse::().ok()).filter(|p| *p > 0); + return match (directory, parent) { + (Some(directory), Some(parent)) => match codeg_lib::acp::handoff_context_mcp::serve(directory, parent).await { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::from(1), + }, + _ => ExitCode::from(2), + }; + } + let args = match parse_args() { Ok(a) => a, Err(e) => { diff --git a/src-tauri/src/chat_channel/session_commands.rs b/src-tauri/src/chat_channel/session_commands.rs index e4b41cb868..505efd5e24 100644 --- a/src-tauri/src/chat_channel/session_commands.rs +++ b/src-tauri/src/chat_channel/session_commands.rs @@ -496,7 +496,7 @@ pub async fn handle_task( } }; - let runtime_env = match build_chat_session_runtime_env(db, agent_type, None, data_dir).await { + let runtime_env = match build_chat_session_runtime_env(db, agent_type, None, data_dir, None).await { Ok(env) => env, Err(e) => { return CommandMessageResult::current_target( @@ -877,6 +877,7 @@ pub async fn handle_resume( conv.agent_type, conv.external_id.as_deref(), data_dir, + conv.agent_account_id.as_deref(), ) .await { @@ -1498,17 +1499,22 @@ fn truncate_topic_title(task_description: &str) -> String { format!("Codeg: {title}").chars().take(128).collect() } +/// `account_id` is the resumed conversation's saved account: its session lives +/// in that account's folder, and a Default launch could neither load it nor +/// pass the prompt gate's account check. async fn build_chat_session_runtime_env( db: &DatabaseConnection, agent_type: AgentType, session_id: Option<&str>, data_dir: &Path, + account_id: Option<&str>, ) -> Result, crate::acp::error::AcpError> { - crate::commands::acp::build_session_runtime_env( + crate::commands::acp::build_account_session_runtime_env( &AppDatabase { conn: db.clone() }, agent_type, session_id, data_dir, + account_id, ) .await } @@ -1529,9 +1535,15 @@ async fn spawn_chat_connection_for_conversation( .map_err(|e| e.to_string())? .ok_or_else(|| i18n::folder_not_found(Lang::En).to_string())?; let runtime_env = - build_chat_session_runtime_env(db, conv.agent_type, conv.external_id.as_deref(), data_dir) - .await - .map_err(|e| e.to_string())?; + build_chat_session_runtime_env( + db, + conv.agent_type, + conv.external_id.as_deref(), + data_dir, + conv.agent_account_id.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; let owner_label = owner_label_for(channel_id, sender_id, target); let connection_id = conn_mgr .spawn_agent( diff --git a/src-tauri/src/commands/account_switch.rs b/src-tauri/src/commands/account_switch.rs new file mode 100644 index 0000000000..c7c958d2de --- /dev/null +++ b/src-tauri/src/commands/account_switch.rs @@ -0,0 +1,488 @@ +//! Explicit, cancellation-shielded account changes. Ordinary connect/prompt +//! admission continues to reject a mismatched saved account. + +use std::collections::BTreeMap; +use std::path::Path; + +use sea_orm::{ColumnTrait, ConnectionTrait, DbBackend, EntityTrait, QueryFilter, Statement}; +use serde::{Deserialize, Serialize}; + +use crate::acp::types::AcpEvent; +use crate::acp::{account_transfer, agent_accounts, error::AcpError, manager::ConnectionManager}; +use crate::db::{ + entities::conversation, + service::{agent_account_service, folder_service}, + AppDatabase, +}; +use crate::models::AgentType; +use crate::web::event_bridge::{emit_with_state, EventEmitter}; + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SwitchAccountRequest { + pub conversation_id: i32, + pub source_account_id: Option, + pub target_account_id: Option, + #[serde(default)] + pub preferred_mode_id: Option, + #[serde(default)] + pub preferred_config_values: BTreeMap, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SwitchAccountResult { + pub conversation_id: i32, + pub agent_account_id: Option, + pub connection_id: String, + pub external_id: String, +} + +fn failure(e: impl std::fmt::Display) -> AcpError { + AcpError::protocol(e.to_string()) +} + +pub async fn switch_core( + db: &AppDatabase, + manager: &ConnectionManager, + emitter: &EventEmitter, + data_dir: &Path, + owner_window_label: String, + request: SwitchAccountRequest, +) -> Result { + let db = AppDatabase { + conn: db.conn.clone(), + }; + let manager = manager.clone_ref(); + let emitter = emitter.clone(); + let data = data_dir.to_path_buf(); + // Once files are touched a disconnected HTTP client cannot interrupt the + // verification/commit or rollback sequence. The source always stays intact. + tokio::spawn(async move { + switch_inner(&db, &manager, &emitter, &data, owner_window_label, request).await + }) + .await + .map_err(failure)? +} + +async fn switch_inner( + db: &AppDatabase, + manager: &ConnectionManager, + emitter: &EventEmitter, + data_dir: &Path, + owner_window_label: String, + request: SwitchAccountRequest, +) -> Result { + // Shares the existing restore barrier: no UI, automation, custom alias or + // delegation can reopen either transcript during the copy/load window. + let barrier = manager.lock_out_new_connections().await; + let row = conversation::Entity::find_by_id(request.conversation_id) + .one(&db.conn) + .await + .map_err(failure)? + .filter(|r| r.deleted_at.is_none()) + .ok_or_else(|| failure("Conversation not found."))?; + if row.agent_type != AgentType::ClaudeCode.as_wire() || row.parent_id.is_some() { + return Err(failure( + "Account switching is available for Claude conversations.", + )); + } + if row.agent_account_id != request.source_account_id { + return Err(failure( + "The conversation's account changed. Reload it before switching.", + )); + } + if row.agent_account_id == request.target_account_id { + return Err(failure("This conversation already uses that account.")); + } + let sid = row + .external_id + .clone() + .filter(|s| !s.is_empty()) + .ok_or_else(|| failure("Send a message before switching accounts."))?; + // Imported sessions have origin_cwd; ordinary conversations use the folder. + let folder = folder_service::get_folder_by_id(&db.conn, row.folder_id) + .await + .map_err(failure)?; + let cwd = row + .origin_cwd + .clone() + .or_else(|| folder.map(|f| f.path)) + .ok_or_else(|| failure("Conversation folder not found."))?; + let source_env = if let Some(id) = &row.agent_account_id { + // Removed accounts remain readable and can be recovered by switching. + let account = agent_account_service::get(&db.conn, id) + .await + .map_err(failure)?; + if account.agent_type != row.agent_type { + return Err(failure("Invalid source account.")); + } + agent_accounts::isolation_env(AgentType::ClaudeCode, Path::new(&account.home_dir)) + .map_err(failure)? + } else { + super::acp::build_account_session_runtime_env( + db, + AgentType::ClaudeCode, + Some(&sid), + data_dir, + None, + ) + .await? + }; + let mut target_env = super::acp::build_account_session_runtime_env( + db, + AgentType::ClaudeCode, + Some(&sid), + data_dir, + request.target_account_id.as_deref(), + ) + .await?; + // Retain the portable-context reader when this native session previously + // arrived from another agent. The source conversation owns that copy. + if let Some(id) = + crate::db::service::handoff_service::context_for_conversation(&db.conn, row.id) + .await + .map_err(failure)? + { + target_env.insert(crate::acp::handoff_context::CONTEXT_ENV.into(), id); + } + super::acp::verify_agent_installed(AgentType::ClaudeCode).await?; + let source = agent_accounts::storage_identity(AgentType::ClaudeCode, &source_env) + .ok_or_else(|| failure("Source session folder not found."))? + .1; + let target = agent_accounts::storage_identity(AgentType::ClaudeCode, &target_env) + .ok_or_else(|| failure("Target session folder not found."))? + .1; + // Do not take over another conversation even if its transcript is an old + // copy of this one. Include soft-deleted rows, which can still be restored. + let mut conflict = conversation::Entity::find() + .filter(conversation::Column::AgentType.eq(row.agent_type.clone())) + .filter(conversation::Column::ExternalId.eq(sid.clone())) + .filter(conversation::Column::Id.ne(row.id)); + conflict = match &request.target_account_id { + Some(id) => conflict.filter(conversation::Column::AgentAccountId.eq(id)), + None => conflict.filter(conversation::Column::AgentAccountId.is_null()), + }; + if conflict.one(&db.conn).await.map_err(failure)?.is_some() { + return Err(failure( + "The target account already has a saved conversation for this session.", + )); + } + if conversation::Entity::find() + .filter(conversation::Column::ParentId.eq(row.id)) + .filter(conversation::Column::Status.eq(conversation::ConversationStatus::InProgress)) + .one(&db.conn) + .await + .map_err(failure)? + .is_some() + { + return Err(AcpError::TurnInProgress); + } + account_transfer::ensure_session_task_lists( + &source, + &target, + Path::new(&cwd), + &sid, + &[&source_env, &target_env], + ) + .map_err(failure)?; + let _prompt_guards = manager + .quiesce_account_session(&barrier, &source, &target, &sid) + .await?; + let transfer = + account_transfer::copy_claude_session(&source, &target, &sid).map_err(failure)?; + let requested_config = request.preferred_config_values.clone(); + let connection_id = match manager + .spawn_agent_during_account_switch( + &barrier, + cwd, + sid.clone(), + target_env, + owner_window_label, + emitter.clone(), + request.preferred_mode_id, + request.preferred_config_values, + ) + .await + { + Ok(id) => id, + Err(e) => { + roll_back_target(manager, &transfer, &target, &sid).await?; + return Err(e); + } + }; + let loaded = wait_for_restored_options( + manager, + &connection_id, + &sid, + request.target_account_id.as_deref(), + &requested_config, + ) + .await; + if !loaded { + manager + .disconnect_and_wait_for_account_switch(&connection_id) + .await?; + roll_back_target(manager, &transfer, &target, &sid).await?; + return Err(failure("The selected account could not restore this conversation with the selected model. Choose a supported model or another account. The original account and history were kept.")); + } + // One conditional write changes only the binding, preserving title, pin, + // tags, folder and history. A stale client, deletion, account removal or + // competing import cannot silently replace a newer binding. + let committed = commit_binding(&db.conn, &row, request.target_account_id.as_deref()).await; + if let Err(e) = committed { + manager + .disconnect_and_wait_for_account_switch(&connection_id) + .await?; + roll_back_target(manager, &transfer, &target, &sid).await?; + return Err(e); + } + // Committed: the staged previous copies can no longer be needed. Removing + // them is best effort and never turns a completed switch into an error. + transfer.discard_staging(); + if let Some(state) = manager.get_state(&connection_id).await { + // Use the shared link event: assigning ids alone bypasses lifecycle + // caching and the other subscribers that track a bound conversation. + emit_with_state( + &state, + emitter, + AcpEvent::ConversationLinked { + conversation_id: row.id, + folder_id: row.folder_id, + parent_conversation_id: None, + parent_tool_use_id: None, + }, + ) + .await; + } + super::conversations::emit_conversation_upsert(emitter, &db.conn, row.id).await; + Ok(SwitchAccountResult { + conversation_id: row.id, + agent_account_id: request.target_account_id, + connection_id, + external_id: sid, + }) +} + +/// Connect normally skips retired preferences. A native session transfer must +/// not silently fall back to a different model and context window. +pub(crate) fn requested_model_applied( + requested: &BTreeMap, + options: Option<&[crate::acp::types::SessionConfigOptionInfo]>, +) -> bool { + let options = options.unwrap_or_default(); + requested.iter().all(|(id, value)| { + let option = options.iter().find(|o| &o.id == id); + if id != "model" && !option.is_some_and(|o| o.category.as_deref() == Some("model")) { + return true; + } + option.is_some_and(|o| matches!(&o.kind, crate::acp::types::SessionConfigKindInfo::Select(s) if &s.current_value == value)) + }) +} + +async fn wait_for_restored_options( + manager: &ConnectionManager, + connection_id: &str, + sid: &str, + account_id: Option<&str>, + requested: &BTreeMap, +) -> bool { + use crate::acp::types::ConnectionStatus; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); + loop { + let Some(state) = manager.get_state(connection_id).await else { + return false; + }; + let state = state.read().await; + // SessionStarted precedes preference replay. Wait for the same ready + // signal the composer uses before comparing the selected model. + if state.selectors_ready && state.status == ConnectionStatus::Connected { + return state.has_restored_session(sid) + && state.agent_account_id.as_deref() == account_id + && requested_model_applied(requested, state.config_options.as_deref()); + } + if matches!( + state.status, + ConnectionStatus::Error | ConnectionStatus::Disconnected + ) || tokio::time::Instant::now() >= deadline + { + return false; + } + drop(state); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } +} + +/// Put the destination account's previous copy back once nothing can still +/// write the session there. A target agent whose connection already ended can +/// still be exiting, so a missing connection is not proof it stopped. If it +/// outlives the wait, the installed copy stays and the staging copies are kept +/// for recovery; the binding still names the source account, which is intact. +async fn roll_back_target( + manager: &ConnectionManager, + transfer: &account_transfer::ClaudeTransfer, + target: &Path, + sid: &str, +) -> Result<(), AcpError> { + manager + .wait_for_account_session_writers(target, sid) + .await?; + transfer.rollback().map_err(failure)?; + transfer.discard_staging(); + Ok(()) +} + +async fn commit_binding( + conn: &impl ConnectionTrait, + source: &conversation::Model, + target: Option<&str>, +) -> Result<(), AcpError> { + let updated = conn + .execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "UPDATE conversation SET agent_account_id = ?, updated_at = ? \ + WHERE id = ? AND agent_type = ? AND external_id = ? \ + AND agent_account_id IS ? AND deleted_at IS NULL AND folder_id = ? AND origin_cwd IS ? \ + AND NOT EXISTS (SELECT 1 FROM conversation child WHERE child.parent_id = conversation.id AND child.status = 'in_progress') \ + AND (? IS NULL OR EXISTS (SELECT 1 FROM agent_account \ + WHERE id = ? AND removed = 0 AND agent_type = ?))", + vec![ + target.map(str::to_owned).into(), + chrono::Utc::now().into(), + source.id.into(), + source.agent_type.clone().into(), + source.external_id.clone().into(), + source.agent_account_id.clone().into(), + source.folder_id.into(), + source.origin_cwd.clone().into(), + target.map(str::to_owned).into(), + target.map(str::to_owned).into(), + source.agent_type.clone().into(), + ], + )) + .await + .map_err(failure)?; + if updated.rows_affected() != 1 { + return Err(failure( + "The conversation or account changed during the switch. Reload and try again.", + )); + } + Ok(()) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_switch_conversation_account( + request: SwitchAccountRequest, + db: tauri::State<'_, AppDatabase>, + manager: tauri::State<'_, ConnectionManager>, + app: tauri::AppHandle, + window: tauri::WebviewWindow, +) -> Result { + use tauri::Manager; + let data = app + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .map_err(failure)?; + switch_core( + &db, + &manager, + &EventEmitter::Tauri(app), + &data, + window.label().into(), + request, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{service::conversation_service, test_helpers}; + + #[test] + fn model_fallback_is_not_a_successful_native_transfer() { + use crate::acp::types::{ + SessionConfigKindInfo, SessionConfigOptionInfo, SessionConfigSelectInfo, + }; + let requested = BTreeMap::from([("model".into(), "long-context-model".into())]); + let option = |value: &str| SessionConfigOptionInfo { + id: "model".into(), + name: "Model".into(), + description: None, + category: Some("model".into()), + recommended_value: None, + kind: SessionConfigKindInfo::Select(SessionConfigSelectInfo { + current_value: value.into(), + options: vec![], + groups: vec![], + }), + }; + assert!(!requested_model_applied(&requested, None)); + assert!(!requested_model_applied( + &requested, + Some(&[option("smaller-default")]) + )); + assert!(requested_model_applied( + &requested, + Some(&[option("long-context-model")]) + )); + assert!(requested_model_applied(&BTreeMap::new(), None)); + } + + #[tokio::test] + async fn binding_commit_preserves_identity_and_rejects_stale_or_removed_targets() { + let db = test_helpers::fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + let folder = test_helpers::seed_folder(&db, "/test").await; + let account = agent_account_service::create( + &db.conn, + dir.path(), + AgentType::ClaudeCode, + "Work", + None, + ) + .await + .unwrap(); + let id = conversation_service::create( + &db.conn, + folder, + AgentType::ClaudeCode, + Some("Keep this title".into()), + None, + ) + .await + .unwrap() + .id; + conversation_service::bind_external_id(&db.conn, id, "sid", &[]) + .await + .unwrap(); + let row = conversation::Entity::find_by_id(id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + commit_binding(&db.conn, &row, Some(&account.id)) + .await + .unwrap(); + assert!(commit_binding(&db.conn, &row, Some(&account.id)) + .await + .is_err()); + let switched = conversation::Entity::find_by_id(id) + .one(&db.conn) + .await + .unwrap() + .unwrap(); + assert_eq!(switched.external_id, row.external_id); + assert_eq!(switched.folder_id, row.folder_id); + assert_eq!(switched.title, row.title); + commit_binding(&db.conn, &switched, None).await.unwrap(); + agent_account_service::remove(&db.conn, &account.id) + .await + .unwrap(); + assert!(commit_binding(&db.conn, &row, Some(&account.id)) + .await + .is_err()); + } +} diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 1e7c39a4ff..e7ff70520a 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -4527,8 +4527,10 @@ fn grok_config_permission_mode(config_toml: &str) -> Option { /// global `~/.grok/config.toml` `[ui].permission_mode` (legacy-migrated by /// `parse_grok_settings`). Best-effort: a missing/unreadable/`default` config /// yields `None`, so the default preserves codeg's ability to prompt for approvals. -pub(crate) fn grok_launch_permission_mode() -> Option { - load_grok_config_toml_raw().and_then(|raw| grok_config_permission_mode(&raw)) +pub(crate) fn grok_launch_permission_mode(runtime_env: &BTreeMap) -> Option { + let path = runtime_env.get("GROK_HOME").filter(|v| !v.is_empty()).map(|home| PathBuf::from(home).join("config.toml")) + .unwrap_or_else(|| crate::parsers::grok::resolve_grok_home_dir().join("config.toml")); + fs::read_to_string(path).ok().and_then(|raw| grok_config_permission_mode(&raw)) } /// Map a `~/.codex/config.toml` sandbox/approval pair onto the `INITIAL_AGENT_MODE` @@ -4696,8 +4698,10 @@ fn codex_initial_agent_mode(settings: &CodexSandboxSettings) -> Option<&'static /// The `INITIAL_AGENT_MODE` value codex-acp's launch should carry, derived from /// the global `~/.codex/config.toml`. Best-effort: a missing/unreadable/ /// unmappable config yields `None`, leaving codex-acp's default preset in place. -pub(crate) fn codex_launch_initial_agent_mode() -> Option { - let raw = load_codex_config_toml_raw()?; +pub(crate) fn codex_launch_initial_agent_mode(runtime_env: &BTreeMap) -> Option { + let path = runtime_env.get("CODEX_HOME").filter(|v| !v.is_empty()).map(|home| PathBuf::from(home).join("config.toml")) + .unwrap_or_else(codex_config_toml_path); + let raw = fs::read_to_string(path).ok()?; let settings = parse_codex_sandbox_settings(&raw); codex_initial_agent_mode(&settings).map(str::to_string) } @@ -6339,9 +6343,10 @@ fn pi_project_trust_state_at( pub(crate) fn pi_project_trust_launch_block( cwd: &Path, runtime_env: &BTreeMap, + account_home: Option<&Path>, ) -> Option { let trust_file = pi_agent_dir_in_workspace(runtime_env, cwd).join("trust.json"); - let state = pi_project_trust_state_at(&trust_file, &pi_trust_ack_path(), cwd); + let state = pi_project_trust_state_at(&trust_file, &pi_account_trust_ack_path(account_home), cwd); if state.resources.is_empty() || state.decision != Some(true) || state.acknowledged { return None; } @@ -6424,6 +6429,25 @@ pub(crate) async fn acp_antigravity_login_start_core( crate::acp::antigravity_login::start(&runtime_env, method_id.trim()).await } +pub(crate) async fn acp_antigravity_account_login_start_core( + db: &AppDatabase, + method_id: String, + account_id: Option, +) -> Result { + let Some(id) = account_id else { return acp_antigravity_login_start_core(db, method_id).await; }; + let account = crate::db::service::agent_account_service::resolve(&db.conn, AgentType::Antigravity, &id) + .await.map_err(|e| AcpError::protocol(e.to_string()))?; + let mut runtime_env = antigravity_runtime_env(db).await?; + for key in crate::acp::agent_accounts::credential_env_keys(AgentType::Antigravity) { + runtime_env.insert(key.into(), String::new()); + } + let home = Path::new(&account.home_dir); + crate::acp::agent_accounts::apply_native_account_env(AgentType::Antigravity, home, &mut runtime_env); + runtime_env.extend(crate::acp::agent_accounts::isolation_env(AgentType::Antigravity, home).map_err(AcpError::protocol)?); + runtime_env.insert("AGY_AUTH_METHOD".into(), method_id.trim().into()); + crate::acp::antigravity_login::start(&runtime_env, method_id.trim()).await +} + /// Deliver the redirect the user's browser could not reach, completing the /// sign-in started by [`acp_antigravity_login_start_core`]. pub(crate) async fn acp_antigravity_login_finish_core( @@ -6532,19 +6556,33 @@ pub(crate) async fn acp_antigravity_sign_out_core( )) } +fn pi_account_trust_ack_path(home: Option<&Path>) -> PathBuf { + home.map(|home| home.join(".codeg-project-trust.json")).unwrap_or_else(pi_trust_ack_path) +} + +async fn pi_trust_paths_for_account(db: &AppDatabase, workspace: &str, account_id: Option<&str>) -> Result<(PathBuf, PathBuf), AcpError> { + if let Some(id) = account_id { + let account = crate::db::service::agent_account_service::resolve(&db.conn, AgentType::Pi, id) + .await.map_err(|e| AcpError::protocol(e.to_string()))?; + let home = Path::new(&account.home_dir); + return Ok((home.join("trust.json"), pi_account_trust_ack_path(Some(home)))); + } + Ok((pi_agent_dir_in_workspace(&pi_runtime_env_from_db(db).await?, Path::new(workspace)).join("trust.json"), pi_trust_ack_path())) +} + pub(crate) async fn acp_pi_project_trust_state_core( db: &AppDatabase, workspace: String, + agent_account_id: Option, ) -> Result { // Not trimmed, for the same reason as the write path: a directory name may // legitimately start or end with a space, and the state must describe the // exact folder the caller named. let cwd = Path::new(&workspace); - let trust_file = - pi_agent_dir_in_workspace(&pi_runtime_env_from_db(db).await?, cwd).join("trust.json"); + let (trust_file, ack_file) = pi_trust_paths_for_account(db, &workspace, agent_account_id.as_deref()).await?; Ok(pi_project_trust_state_at( &trust_file, - &pi_trust_ack_path(), + &ack_file, cwd, )) } @@ -6552,9 +6590,10 @@ pub(crate) async fn acp_pi_project_trust_state_core( /// Record that the user has seen and kept an existing trust grant, so the launch /// gate stops blocking this folder. Writes only codeg's own record — pi's /// `trust.json` is untouched, because the grant itself is not changing. -pub(crate) async fn acp_pi_acknowledge_project_trust_core(workspace: String) -> Result<(), AcpError> { +pub(crate) async fn acp_pi_acknowledge_project_trust_core(db: &AppDatabase, workspace: String, agent_account_id: Option) -> Result<(), AcpError> { + let (_, ack_file) = pi_trust_paths_for_account(db, &workspace, agent_account_id.as_deref()).await?; tokio::task::spawn_blocking(move || { - pi_set_trust_acknowledged_at(&pi_trust_ack_path(), Path::new(&workspace), true) + pi_set_trust_acknowledged_at(&ack_file, Path::new(&workspace), true) }) .await .map_err(|e| AcpError::protocol(format!("trust acknowledgement task failed: {e}")))? @@ -6579,11 +6618,11 @@ pub(crate) async fn acp_pi_set_project_trust_core( db: &AppDatabase, workspace: String, trusted: Option, + agent_account_id: Option, ) -> Result<(), AcpError> { // The same file the launch gate reads for this workspace — anchored in it // when the agent dir is relative (see `pi_agent_dir_in_workspace`). - let path = pi_agent_dir_in_workspace(&pi_runtime_env_from_db(db).await?, Path::new(&workspace)) - .join("trust.json"); + let (path, ack_file) = pi_trust_paths_for_account(db, &workspace, agent_account_id.as_deref()).await?; // The write takes pi's file lock and can sleep on contention, so keep it off // the async worker. NOTE: `workspace` is NOT trimmed — leading/trailing // spaces are legal in a directory name, and silently trimming them would key @@ -6595,7 +6634,7 @@ pub(crate) async fn acp_pi_set_project_trust_core( // acknowledged by construction and must not re-block the launch. A // declined or revoked one drops the record, so a future grant gets // disclosed again instead of riding on a stale "already seen". - pi_set_trust_acknowledged_at(&pi_trust_ack_path(), cwd, trusted == Some(true)) + pi_set_trust_acknowledged_at(&ack_file, cwd, trusted == Some(true)) }) .await .map_err(|e| AcpError::protocol(format!("trust write task failed: {e}")))? @@ -7171,7 +7210,7 @@ async fn query_pi_model_catalog( pub async fn list_pi_model_catalog_core(db: &AppDatabase, data_dir: &Path) -> PiModelCatalog { let Ok(runtime_env) = - build_runtime_env_for_agent(db, AgentType::Pi, None, data_dir, true).await + build_runtime_env_for_agent(db, AgentType::Pi, None, data_dir, true, None).await else { return PiModelCatalog::unavailable(PiCatalogStatus::Failed); }; @@ -10351,7 +10390,7 @@ pub async fn acp_cursor_list_models( /// Primary env var keys for each agent type: (api_base_url, api_key, model). /// Shared by runtime env resolution, model-provider cascade, and config patching. -fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'static str) { +pub(crate) fn agent_env_keys(agent_type: AgentType) -> (&'static str, &'static str, &'static str) { match agent_type { AgentType::ClaudeCode => ( "ANTHROPIC_BASE_URL", @@ -11170,18 +11209,168 @@ pub(crate) async fn build_session_runtime_env( session_id: Option<&str>, data_dir: &Path, ) -> Result, AcpError> { - build_runtime_env_for_agent(db, agent_type, session_id, data_dir, false).await + build_runtime_env_for_agent(db, agent_type, session_id, data_dir, false, None).await +} + +pub(crate) async fn build_account_session_runtime_env( + db: &AppDatabase, + agent_type: AgentType, + session_id: Option<&str>, + data_dir: &Path, + account_id: Option<&str>, +) -> Result, AcpError> { + account_runtime_env(db, agent_type, session_id, data_dir, account_id, true).await +} + +/// The launch environment for `account_id` (Default when `None`). Only a launch +/// passes `sync_home`: bringing the account's MCP servers and skills in line +/// with the shared ones writes into the folder its running CLIs also write, so +/// a mere comparison (the staleness check) must leave the folder alone. +pub(crate) async fn account_runtime_env( + db: &AppDatabase, agent_type: AgentType, session_id: Option<&str>, data_dir: &Path, + account_id: Option<&str>, sync_home: bool, +) -> Result, AcpError> { + let mut env = account_runtime_env_base(db, agent_type, session_id, data_dir, account_id, sync_home).await?; + if let Some(session) = session_id { + if let Some(id) = crate::db::service::handoff_service::context_for_binding(&db.conn, agent_type, session, account_id).await + .map_err(|e| AcpError::protocol(e.to_string()))? { + env.insert(crate::acp::handoff_context::CONTEXT_ENV.into(), id); + } + } + Ok(env) +} + +async fn account_runtime_env_base( + db: &AppDatabase, + agent_type: AgentType, + session_id: Option<&str>, + data_dir: &Path, + account_id: Option<&str>, + sync_home: bool, +) -> Result, AcpError> { + let Some(id) = account_id else { + return build_session_runtime_env(db, agent_type, session_id, data_dir).await; + }; + let account = crate::db::service::agent_account_service::resolve(&db.conn, agent_type, id) + .await.map_err(|e| AcpError::protocol(e.to_string()))?; + let home = Path::new(&account.home_dir); + if let Some(reason) = crate::acp::agent_accounts::unsupported_reason(agent_type) { + return Err(AcpError::protocol(reason)); + } + if sync_home { + crate::commands::mcp::sync_native_account_mcp(agent_type, home) + .map_err(|e| AcpError::protocol(e.to_string()))?; + crate::acp::account_skills::sync(agent_type, home).map_err(AcpError::protocol)?; + } + let mut env = build_runtime_env_for_agent(db, agent_type, session_id, data_dir, false, Some(home)).await?; + let isolation = crate::acp::agent_accounts::isolation_env_with_base(agent_type, home, &env) + .map_err(AcpError::protocol)?; + env.extend(isolation); + env.insert(crate::acp::agent_accounts::ACCOUNT_ENV.into(), account.id); + env.insert(crate::acp::agent_accounts::ACCOUNT_HOME_ENV.into(), account.home_dir); + Ok(env) +} + +#[cfg(test)] +mod native_account_launch_tests { + use super::*; + + #[tokio::test] + async fn native_account_discovery_does_not_guess_default_when_the_row_is_gone() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let manager = ConnectionManager::new(); + manager.insert_test_connection("default-owner", AgentType::Codex, None, EventEmitter::Noop).await; + let state = manager.get_state("default-owner").await.unwrap(); + state.write().await.external_id = Some("shared-native-session".into()); + assert!(acp_find_connection_for_account_core(&manager, 999, Some("shared-native-session"), AgentType::Codex, None).await.unwrap().is_some()); + assert!(acp_find_connection_for_saved_conversation_core(&manager, &db, 999, Some("shared-native-session"), AgentType::Codex).await.unwrap().is_none()); + } + + #[tokio::test] + async fn native_pi_accounts_keep_trust_decisions_and_acknowledgements_separate() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let data = tempfile::tempdir().unwrap(); + let workspace = data.path().join("project"); + fs::create_dir_all(workspace.join(".pi/extensions")).unwrap(); + fs::write(workspace.join(".pi/extensions/fixture.js"), "// fixture, never executed").unwrap(); + let a = crate::db::service::agent_account_service::create(&db.conn, data.path(), AgentType::Pi, "A", None).await.unwrap(); + let b = crate::db::service::agent_account_service::create(&db.conn, data.path(), AgentType::Pi, "B", None).await.unwrap(); + let a_home = Path::new(&a.home_dir); + let b_home = Path::new(&b.home_dir); + pi_write_trust_decision_at(&b_home.join("trust.json"), &workspace, Some(true)).unwrap(); + let old_b = fs::read(b_home.join("trust.json")).unwrap(); + let cwd = workspace.to_string_lossy().into_owned(); + acp_pi_set_project_trust_core(&db, cwd.clone(), Some(true), Some(a.id.clone())).await.unwrap(); + let a_state = acp_pi_project_trust_state_core(&db, cwd.clone(), Some(a.id.clone())).await.unwrap(); + let b_state = acp_pi_project_trust_state_core(&db, cwd.clone(), Some(b.id.clone())).await.unwrap(); + assert!(a_state.acknowledged); + assert!(!b_state.acknowledged); + assert_eq!(a_state.decision, Some(true)); + let a_env = crate::acp::agent_accounts::isolation_env(AgentType::Pi, a_home).unwrap(); + let b_env = crate::acp::agent_accounts::isolation_env(AgentType::Pi, b_home).unwrap(); + assert!(pi_project_trust_launch_block(&workspace, &a_env, Some(a_home)).is_none()); + assert!(pi_project_trust_launch_block(&workspace, &b_env, Some(b_home)).is_some()); + acp_pi_acknowledge_project_trust_core(&db, cwd.clone(), Some(b.id)).await.unwrap(); + assert_eq!(fs::read(b_home.join("trust.json")).unwrap(), old_b); + assert!(pi_project_trust_launch_block(&workspace, &b_env, Some(b_home)).is_none()); + crate::db::service::agent_account_service::remove(&db.conn, &a.id).await.unwrap(); + assert!(acp_pi_project_trust_state_core(&db, cwd, Some(a.id)).await.is_err()); + } + + #[tokio::test] + async fn native_accounts_scrub_default_provider_keys_but_read_their_own_config() { + use crate::db::service::agent_setting_service::{AgentDefaultInput, AgentSettingsUpdate}; + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + for &agent in crate::models::agent::BUILTIN_AGENT_TYPES { + if crate::acp::agent_accounts::unsupported_reason(agent).is_some() { continue; } + let Some(layout) = crate::acp::agent_accounts::layout(agent) else { continue; }; + let home = dir.path().join(agent.as_wire().as_ref()); + std::fs::create_dir_all(layout.config_dir(&home)).unwrap(); + agent_setting_service::ensure_defaults(&db.conn, &[AgentDefaultInput { agent_type: agent, registry_id: agent.as_wire().into_owned(), default_sort_order: 0 }]).await.unwrap(); + let inherited: BTreeMap<_, _> = crate::acp::agent_accounts::credential_env_keys(agent).into_iter().map(|key| (key, "default-account-value")).collect(); + agent_setting_service::update(&db.conn, agent, AgentSettingsUpdate { enabled: true, env_json: Some(serde_json::to_string(&inherited).unwrap()), model_provider_id: None }).await.unwrap(); + let env = build_runtime_env_for_agent(&db, agent, None, dir.path(), false, Some(&home)).await.unwrap(); + for key in inherited.keys() { assert_ne!(env.get(*key).map(String::as_str), Some("default-account-value"), "{agent}: {key}"); } + if agent == AgentType::ClaudeCode { + std::fs::write(home.join("settings.json"), r#"{"env":{"ANTHROPIC_API_KEY":"own-account-value"}}"#).unwrap(); + let env = build_runtime_env_for_agent(&db, agent, None, dir.path(), false, Some(&home)).await.unwrap(); + assert_eq!(env.get("ANTHROPIC_API_KEY").map(String::as_str), Some("own-account-value")); + } + } + } + + #[tokio::test] + async fn native_account_models_do_not_inherit_a_default_provider_binding() { + use crate::db::service::agent_setting_service::{AgentDefaultInput, AgentSettingsUpdate}; + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + let agent = AgentType::ClaudeCode; + agent_setting_service::ensure_defaults(&db.conn, &[AgentDefaultInput { agent_type: agent, registry_id: "claude".into(), default_sort_order: 0 }]).await.unwrap(); + let provider = crate::db::service::model_provider_service::create(&db.conn, "Fixture".into(), "https://api.example.test".into(), "fixture-provider-key".into(), "claude_code".into(), None).await.unwrap(); + let env_json = r#"{"ANTHROPIC_DEFAULT_SONNET_MODEL":"family-model", "ANTHROPIC_PROFILE":"ambient-profile"}"#; + for binding in [None, Some(provider.id)] { + agent_setting_service::update(&db.conn, agent, AgentSettingsUpdate { enabled: true, env_json: Some(env_json.into()), model_provider_id: binding }).await.unwrap(); + let env = build_runtime_env_for_agent(&db, agent, None, dir.path(), false, Some(dir.path())).await.unwrap(); + assert_eq!(env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").map(String::as_str), Some(if binding.is_some() { "" } else { "family-model" })); + assert_eq!(env.get("ANTHROPIC_PROFILE").map(String::as_str), Some("")); + } + std::fs::write(dir.path().join("settings.json"), r#"{"env":{"ANTHROPIC_DEFAULT_SONNET_MODEL":"own-model"}}"#).unwrap(); + let env = build_runtime_env_for_agent(&db, agent, None, dir.path(), false, Some(dir.path())).await.unwrap(); + assert_eq!(env.get("ANTHROPIC_DEFAULT_SONNET_MODEL").map(String::as_str), Some("own-model")); + } } /// Settings may inspect Pi's local model catalog while the agent is disabled. /// Reuse the exact launch environment, bypassing only the connection permission /// gate; this path starts no ACP session and sends no prompt. -async fn build_runtime_env_for_agent( +pub(crate) async fn build_runtime_env_for_agent( db: &AppDatabase, agent_type: AgentType, session_id: Option<&str>, data_dir: &Path, allow_disabled: bool, + account_home: Option<&Path>, ) -> Result, AcpError> { let setting = agent_setting_service::get_by_agent_type(&db.conn, agent_type) .await @@ -11196,10 +11385,37 @@ async fn build_runtime_env_for_agent( ))); } - let local_config_json = load_agent_local_config_json(agent_type); + let local_config_json = match account_home { + Some(home) if agent_type == AgentType::Cline => load_cline_provider_settings_at(&home.join("settings/providers.json")) + .and_then(|config| serde_json::to_string(&serde_json::Value::Object(config)).ok()), + Some(home) => crate::acp::agent_accounts::layout(agent_type).and_then(|layout| { + let filename = if agent_type == AgentType::OpenCode { "opencode.json" } else { "settings.json" }; + std::fs::read_to_string(layout.config_dir(home).join(filename)).ok() + }), + None => load_agent_local_config_json(agent_type), + }; let mut runtime_env = build_runtime_env_from_setting(agent_type, setting.as_ref(), local_config_json.as_deref()); - apply_model_provider_env(agent_type, setting.as_ref(), &mut runtime_env, &db.conn).await; + if account_home.is_none() { + apply_model_provider_env(agent_type, setting.as_ref(), &mut runtime_env, &db.conn).await; + } else { + // A saved credential on the default agent must never override this + // account's own native sign-in, including inherited shell variables. + for key in crate::acp::agent_accounts::credential_env_keys(agent_type) { + runtime_env.insert(key.into(), String::new()); + } + if setting.as_ref().and_then(|s| s.model_provider_id).is_some() { + // A provider's model aliases belong to that provider. Preserve + // ordinary family preferences when no provider supplied them. + for key in parse_provider_model(agent_type, None).into_keys() { + runtime_env.insert(key, String::new()); + } + } + runtime_env.extend(build_runtime_env_from_setting(agent_type, None, local_config_json.as_deref())); + if let Some(home) = account_home { + crate::acp::agent_accounts::apply_native_account_env(agent_type, home, &mut runtime_env); + } + } // `apply_model_provider_env` writes this agent's generic credential trio — // for cline that is `CLINE_BASE_URL`/`CLINE_API_KEY`/`CLINE_MODEL` — so a // model-provider binding left over from a BYO setup would put a key back @@ -11239,7 +11455,7 @@ async fn build_runtime_env_for_agent( /// the env a settings save recomputes, so counting it would mark every session /// stale on the first save, for good. fn is_volatile_fingerprint_key(key: &str) -> bool { - key == "OPENCLAW_RESET_SESSION" || key == crate::cli_run::CLI_CALLER_ENV + matches!(key, "OPENCLAW_RESET_SESSION" | crate::acp::agent_accounts::ACCOUNT_ENV | crate::acp::agent_accounts::ACCOUNT_HOME_ENV) || key == crate::cli_run::CLI_CALLER_ENV } /// Fingerprint the effective config a spawned agent process is locked to: the @@ -11253,6 +11469,14 @@ fn is_volatile_fingerprint_key(key: &str) -> bool { pub(crate) fn fingerprint_config( agent_type: AgentType, runtime_env: &BTreeMap, +) -> String { + fingerprint_account_config(agent_type, runtime_env, None) +} + +pub(crate) fn fingerprint_account_config( + agent_type: AgentType, + runtime_env: &BTreeMap, + account_home: Option<&Path>, ) -> String { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); @@ -11267,6 +11491,23 @@ pub(crate) fn fingerprint_config( hasher.update([0u8]); } hasher.update(b"\x01native\x01"); + if let Some(home) = account_home { + if let Some(layout) = crate::acp::agent_accounts::layout(agent_type) { + let config = layout.config_dir(home); + // Credentials refresh independently of configuration and must never + // make a running session appear stale. + for name in ["settings.json", "config.toml", "config.yaml", "opencode.json", "cli-config.json", "mcp.json"] { + if let Ok(raw) = std::fs::read(config.join(name)) { + hasher.update(name.as_bytes()); + hasher.update(raw); + } + } + } + if let Ok(mcp) = crate::commands::mcp::shared_servers_for_account(agent_type) { + if let Ok(raw) = serde_json::to_vec(&mcp) { hasher.update(raw); } + } + return format!("{:x}", hasher.finalize()); + } if let Some(native) = load_agent_local_config_json(agent_type) { hasher.update(native.as_bytes()); } @@ -11325,19 +11566,28 @@ pub(crate) async fn refresh_config_staleness( agent_types: &[AgentType], kind: ConfigStaleKind, ) -> usize { - let mut fresh: HashMap = HashMap::new(); - for &agent_type in agent_types { - if fresh.contains_key(&agent_type) { - continue; - } - if let Ok(fp) = compute_session_config_fingerprint(db, agent_type, data_dir).await { - fresh.insert(agent_type, fp); + let mut profiles: HashMap<(AgentType, Option), String> = HashMap::new(); + let mut fresh: HashMap = HashMap::new(); + for connection in manager.list_connections().await { + if !agent_types.contains(&connection.agent_type) { continue; } + let Some(state) = manager.get_state(&connection.id).await else { continue; }; + let (id, home) = { let state = state.read().await; (state.agent_account_id.clone(), state.account_home.clone()) }; + let key = (connection.agent_type, id.clone()); + if !profiles.contains_key(&key) { + let computed = if id.is_none() { + compute_session_config_fingerprint(db, connection.agent_type, data_dir).await + } else { + account_runtime_env(db, connection.agent_type, None, data_dir, id.as_deref(), false).await + .map(|env| fingerprint_account_config(connection.agent_type, &env, home.as_deref())) + }; + if let Ok(fp) = computed { profiles.insert(key.clone(), fp); } } + if let Some(fp) = profiles.get(&key) { fresh.insert(connection.id, fp.clone()); } } if fresh.is_empty() { return 0; } - manager.refresh_connection_staleness(&fresh, kind).await + manager.refresh_connection_staleness_by_connection(&fresh, kind).await } /// `acp_update_agent_env_core` followed by a staleness refresh. Shared by the @@ -11395,6 +11645,8 @@ pub(crate) async fn acp_update_agent_preferences_and_refresh( #[allow(clippy::too_many_arguments)] pub async fn acp_connect( agent_type: AgentType, + agent_account_id: Option, + conversation_id: Option, working_dir: Option, session_id: Option, preferred_mode_id: Option, @@ -11414,8 +11666,13 @@ pub async fn acp_connect( .app_data_dir() .map(|p| crate::paths::resolve_effective_data_dir(&p)) .unwrap_or_else(|_| std::path::PathBuf::from(".")); - let runtime_env = - build_session_runtime_env(&db, agent_type, session_id.as_deref(), &app_data_dir).await?; + let connect_barrier = manager.lock_for_connect().await; + let account_id = crate::db::service::agent_account_service::connection_account( + &db.conn, agent_type, agent_account_id, conversation_id, + ).await.map_err(|e| AcpError::protocol(e.to_string()))?; + let runtime_env = build_account_session_runtime_env( + &db, agent_type, session_id.as_deref(), &app_data_dir, account_id.as_deref(), + ).await?; // Guard: the session page must never trigger a download or install. // If the agent isn't ready, return SdkNotInstalled here so the frontend @@ -11424,7 +11681,8 @@ pub async fn acp_connect( let emitter = EventEmitter::Tauri(app_handle); manager - .spawn_agent( + .spawn_agent_with_connect_barrier( + &connect_barrier, agent_type, working_dir, session_id, @@ -11728,11 +11986,22 @@ pub async fn acp_get_session_snapshot_by_conversation( /// connection by `external_id` and the frontend would mis-tag it as a locally /// owned connection, tearing it down (killing the real owner's agent) on tab /// close. Discovering it here lets the second client attach as a viewer. +#[cfg(test)] pub(crate) async fn acp_find_connection_for_conversation_core( manager: &ConnectionManager, conversation_id: i32, session_id: Option<&str>, agent_type: AgentType, +) -> Result, AcpError> { + acp_find_connection_for_account_core(manager, conversation_id, session_id, agent_type, None).await +} + +pub(crate) async fn acp_find_connection_for_account_core( + manager: &ConnectionManager, + conversation_id: i32, + session_id: Option<&str>, + agent_type: AgentType, + account_id: Option<&str>, ) -> Result, AcpError> { let connection_id = match manager .find_connection_by_conversation_id(conversation_id) @@ -11745,7 +12014,7 @@ pub(crate) async fn acp_find_connection_for_conversation_core( None => match session_id { Some(sid) if !sid.is_empty() => { match manager - .find_connection_by_external_id(sid, agent_type) + .find_connection_by_external_id_for_account(sid, agent_type, account_id) .await { Some(id) => id, @@ -11761,6 +12030,9 @@ pub(crate) async fn acp_find_connection_for_conversation_core( return Ok(None); }; let s = state.read().await; + if s.agent_account_id.as_deref() != account_id { + return Ok(None); + } // Discovery means "a LIVE connection a viewer can attach to". Teardown // writes a terminal status onto the state BEFORE the cleanup hook removes // the map entry (see `acp/connection.rs`), and `find_connection_by_ @@ -11781,6 +12053,25 @@ pub(crate) async fn acp_find_connection_for_conversation_core( })) } +pub(crate) async fn acp_find_connection_for_saved_conversation_core( + manager: &ConnectionManager, + db: &AppDatabase, + conversation_id: i32, + session_id: Option<&str>, + agent_type: AgentType, +) -> Result, AcpError> { + let account_id = match crate::db::service::agent_account_service::connection_account( + &db.conn, agent_type, None, Some(conversation_id), + ).await { + Ok(account_id) => account_id, + // Discovery is read-only and historically returns null for a missing + // conversation. Do not turn a missing binding into a Default lookup. + Err(crate::db::error::DbError::NotFound(_)) => return Ok(None), + Err(error) => return Err(AcpError::protocol(error.to_string())), + }; + acp_find_connection_for_account_core(manager, conversation_id, session_id, agent_type, account_id.as_deref()).await +} + #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn acp_find_connection_for_conversation( @@ -11788,9 +12079,11 @@ pub async fn acp_find_connection_for_conversation( session_id: Option, agent_type: AgentType, manager: State<'_, ConnectionManager>, + db: State<'_, AppDatabase>, ) -> Result, AcpError> { - acp_find_connection_for_conversation_core( + acp_find_connection_for_saved_conversation_core( &manager, + &db, conversation_id, session_id.as_deref(), agent_type, @@ -11892,6 +12185,9 @@ pub async fn acp_get_agent_status( pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result, AcpError> { let platform = registry::current_platform(); let agent_types = registry::all_acp_agents(); + let named_accounts = crate::db::service::agent_account_service::list_all(&db.conn) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; let defaults = agent_types .iter() @@ -12093,6 +12389,17 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result Result, workspace: String, + agent_account_id: Option, ) -> Result { - acp_pi_project_trust_state_core(&db, workspace).await + acp_pi_project_trust_state_core(&db, workspace, agent_account_id).await } /// Project the saved Antigravity auth choice into the server's settings file, @@ -13054,8 +13362,9 @@ pub async fn acp_sync_antigravity_settings( pub async fn acp_antigravity_login_start( db: tauri::State<'_, AppDatabase>, method_id: String, + account_id: Option, ) -> Result { - acp_antigravity_login_start_core(&db, method_id).await + acp_antigravity_account_login_start_core(&db, method_id, account_id).await } /// Complete a browser-free Antigravity sign-in from the address the user's @@ -13094,16 +13403,17 @@ pub async fn acp_pi_set_project_trust( db: tauri::State<'_, AppDatabase>, workspace: String, trusted: Option, + agent_account_id: Option, ) -> Result<(), AcpError> { - acp_pi_set_project_trust_core(&db, workspace, trusted).await + acp_pi_set_project_trust_core(&db, workspace, trusted, agent_account_id).await } /// Record that the user reviewed an existing grant and chose to keep it, which /// clears the launch gate for that folder. Does not change pi's trust store. #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn acp_pi_acknowledge_project_trust(workspace: String) -> Result<(), AcpError> { - acp_pi_acknowledge_project_trust_core(workspace).await +pub async fn acp_pi_acknowledge_project_trust(db: tauri::State<'_, AppDatabase>, workspace: String, agent_account_id: Option) -> Result<(), AcpError> { + acp_pi_acknowledge_project_trust_core(&db, workspace, agent_account_id).await } /// List every decision in pi's `trust.json` so the settings page can review and @@ -13143,7 +13453,7 @@ pub async fn acp_open_hermes_setup_terminal(kind: String) -> Result<(), AcpError } #[cfg(feature = "tauri-runtime")] -fn open_external_terminal_impl(command: &str, cwd: Option<&str>) -> Result<(), AcpError> { +pub(crate) fn open_external_terminal_impl(command: &str, cwd: Option<&str>) -> Result<(), AcpError> { use std::process::Command; // Reject control characters: a newline breaks out of the macOS AppleScript // string literal (and would corrupt the cmd/shell line elsewhere), turning a @@ -16214,7 +16524,7 @@ base_url = \"https://example.test/v1\" temp_env::with_var( "CODEG_HOME", Some(codeg_home.to_string_lossy().to_string()), - || pi_project_trust_launch_block(cwd, &pi_env_for(agent_dir)), + || pi_project_trust_launch_block(cwd, &pi_env_for(agent_dir), None), ) } @@ -16623,12 +16933,12 @@ base_url = \"https://example.test/v1\" ); lookup_failed(acp_pi_list_trust_entries_core(&db).await.unwrap_err()); lookup_failed( - acp_pi_set_project_trust_core(&db, "/tmp/pi-db-error".into(), Some(true)) + acp_pi_set_project_trust_core(&db, "/tmp/pi-db-error".into(), Some(true), None) .await .unwrap_err(), ); lookup_failed( - acp_pi_project_trust_state_core(&db, "/tmp/pi-db-error".into()) + acp_pi_project_trust_state_core(&db, "/tmp/pi-db-error".into(), None) .await .unwrap_err(), ); @@ -16801,7 +17111,7 @@ base_url = \"https://example.test/v1\" let blocked = temp_env::with_var( "CODEG_HOME", Some(tmp.path().join("codeg-home").to_string_lossy().to_string()), - || pi_project_trust_launch_block(&ws, &env), + || pi_project_trust_launch_block(&ws, &env, None), ); assert!( blocked.is_some(), diff --git a/src-tauri/src/commands/agent_accounts.rs b/src-tauri/src/commands/agent_accounts.rs new file mode 100644 index 0000000000..58f5e481f0 --- /dev/null +++ b/src-tauri/src/commands/agent_accounts.rs @@ -0,0 +1,394 @@ +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::Path; +#[cfg(feature = "tauri-runtime")] +use tauri::{Manager, State}; + +use crate::acp::{account_auth, agent_accounts, error::AcpError, manager::ConnectionManager}; +use crate::db::{entities::agent_account, service::agent_account_service as service, AppDatabase}; +use crate::models::AgentType; +use crate::web::event_bridge::EventEmitter; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountsResult { + pub accounts: Vec, + pub capabilities: Vec, +} + +pub async fn list_core(db: &AppDatabase, agent: AgentType) -> Result { + Ok(AccountsResult { + accounts: service::list_for_display(&db.conn, agent) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?, + capabilities: agent_accounts::capabilities(), + }) +} + +pub async fn create_core( + db: &AppDatabase, + data_dir: &Path, + agent: AgentType, + name: &str, + home: Option<&str>, + emitter: &EventEmitter, +) -> Result { + let account = service::create(&db.conn, data_dir, agent, name, home) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + crate::commands::acp::emit_acp_agents_updated(emitter, "account_created", Some(agent)); + Ok(account) +} + +pub async fn rename_core( + db: &AppDatabase, + id: &str, + name: &str, + emitter: &EventEmitter, +) -> Result<(), AcpError> { + service::rename(&db.conn, id, name) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + crate::commands::acp::emit_acp_agents_updated(emitter, "account_renamed", None); + Ok(()) +} + +pub async fn remove_core( + db: &AppDatabase, + id: &str, + emitter: &EventEmitter, +) -> Result<(), AcpError> { + service::remove(&db.conn, id) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + crate::commands::acp::emit_acp_agents_updated(emitter, "account_removed", None); + Ok(()) +} + +pub async fn login_core( + db: &AppDatabase, + id: &str, +) -> Result { + let account = service::get(&db.conn, id) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + let agent = AgentType::from_wire(&account.agent_type) + .ok_or_else(|| AcpError::protocol("Unknown agent account"))?; + service::resolve(&db.conn, agent, id) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + let spec = agent_accounts::layout(agent) + .ok_or_else(|| AcpError::protocol("This agent does not support isolated sign-in"))?; + let env = agent_accounts::isolation_env(agent, Path::new(&account.home_dir)) + .map_err(AcpError::protocol)?; + let command = isolated_login_command(agent, env).await?; + Ok(crate::commands::custom_agents::ExtraSlotLoginResult { + family: account.agent_type, + isolator_key: spec.env_key.into(), + home: account.home_dir, + command, + launched: false, + }) +} + +async fn resolved_account( + db: &AppDatabase, + id: &str, +) -> Result<(AgentType, agent_account::Model), AcpError> { + let account = service::get(&db.conn, id) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + let agent = AgentType::from_wire(&account.agent_type) + .ok_or_else(|| AcpError::protocol("Unknown agent account"))?; + let account = service::resolve(&db.conn, agent, id) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + if let Some(reason) = agent_accounts::unsupported_reason(agent) { + return Err(AcpError::protocol(reason)); + } + Ok((agent, account)) +} + +fn check_account_write_paths( + agent: AgentType, + account: &agent_account::Model, +) -> Result<(), AcpError> { + let home = Path::new(&account.home_dir); + let canonical = home + .canonicalize() + .map_err(|_| AcpError::protocol("Restore the account folder before changing sign-in."))?; + if !crate::parsers::path_eq_for_matching(&canonical.to_string_lossy(), &home.to_string_lossy()) + { + return Err(AcpError::protocol( + "The account folder has moved. Restore it before changing sign-in.", + )); + } + account_auth::check_paths(agent, Path::new(&account.home_dir))?; + Ok(()) +} + +async fn require_closed(manager: &ConnectionManager, id: &str) -> Result<(), AcpError> { + if manager.account_has_running_process(id).await { + return Err(AcpError::protocol( + "Close this account's chats and wait for the agent to stop before changing sign-in.", + )); + } + Ok(()) +} + +pub async fn login_method_core( + db: &AppDatabase, + manager: &ConnectionManager, + id: &str, + method: Option, + emitter: &EventEmitter, +) -> Result { + let (agent, account) = resolved_account(db, id).await?; + if let Some(method) = method { + if !account_auth::methods(agent).contains(&method) + || method == account_auth::AccountAuthMethod::ApiKey + { + return Err(AcpError::protocol( + "Choose one of this agent's supported sign-in methods.", + )); + } + } + if agent == AgentType::ClaudeCode && method == Some(account_auth::AccountAuthMethod::SignIn) { + let _guard = manager.lock_out_new_connections().await; + require_closed(manager, id).await?; + check_account_write_paths(agent, &account)?; + account_auth::set_claude_key(Path::new(&account.home_dir), None)?; + account_auth::changed(); + crate::commands::acp::emit_acp_agents_updated(emitter, "account_auth_updated", Some(agent)); + } + login_core(db, id).await +} + +pub async fn set_api_key_core( + db: &AppDatabase, + manager: &ConnectionManager, + data_dir: &Path, + id: &str, + key: &str, + emitter: &EventEmitter, +) -> Result<(), AcpError> { + account_auth::validate_key(key)?; + let _guard = manager.lock_out_new_connections().await; + let (agent, account) = resolved_account(db, id).await?; + if !account_auth::methods(agent).contains(&account_auth::AccountAuthMethod::ApiKey) { + return Err(AcpError::protocol( + "Use this agent's own setup to configure its sign-in.", + )); + } + require_closed(manager, id).await?; + check_account_write_paths(agent, &account)?; + let home = Path::new(&account.home_dir); + match agent { + AgentType::ClaudeCode => account_auth::set_claude_key(home, Some(key))?, + AgentType::Codex => { + let executable = login_executable(agent).await.ok_or_else(|| { + AcpError::protocol("Install Codex in Settings > Agents before adding its API key.") + })?; + let env = account_auth::runtime_env(db, &account, data_dir).await?; + account_auth::save_codex_key(&executable, home, &env, key).await?; + } + _ => unreachable!("supported API-key methods checked above"), + } + account_auth::changed(); + crate::commands::acp::emit_acp_agents_updated(emitter, "account_auth_updated", Some(agent)); + Ok(()) +} + +pub async fn check_auth_core( + db: &AppDatabase, + data_dir: &Path, + id: &str, + emitter: &EventEmitter, +) -> Result { + let (agent, account) = resolved_account(db, id).await?; + if !matches!(agent, AgentType::ClaudeCode | AgentType::Codex) { + return Ok(account_auth::AccountAuthStatus { + state: account_auth::AuthState::Unavailable, + method: None, + }); + } + let executable = login_executable(agent).await.ok_or_else(|| { + AcpError::protocol("Install the agent in Settings > Agents before checking sign-in.") + })?; + let env = account_auth::runtime_env(db, &account, data_dir).await?; + let status = + account_auth::check(agent, &executable, Path::new(&account.home_dir), &env).await?; + account_auth::changed(); + crate::commands::acp::emit_acp_agents_updated(emitter, "account_auth_checked", Some(agent)); + Ok(status) +} + +/// The official sign-in command for `agent`, run with `env` pointing it at an +/// isolated home: a native account's, or a custom agent's extra slot. Shared +/// so the two cannot drift apart. +pub(crate) async fn isolated_login_command( + agent: AgentType, + mut env: BTreeMap, +) -> Result { + let spec = agent_accounts::layout(agent) + .ok_or_else(|| AcpError::protocol("This agent does not support isolated sign-in"))?; + // Credentials inherited from the terminal would sign in, or be used, + // instead of the isolated home's own login. A value `env` sets itself is + // the caller's choice and stays. + for key in agent_accounts::credential_env_keys(agent) { + env.entry(key.into()).or_default(); + } + let cli = spec + .login + .first() + .ok_or_else(|| AcpError::protocol("Use browser sign-in for this account"))?; + let executable = login_executable(agent) + .await + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| (*cli).into()); + let mut argv = vec![executable.as_str()]; + argv.extend_from_slice(&spec.login[1..]); + crate::acp::family_isolator::shell_command_with_env(&env, &argv).map_err(AcpError::protocol) +} + +pub(crate) async fn login_executable(agent: AgentType) -> Option { + let cli = *agent_accounts::layout(agent)?.login.first()?; + // Managed binaries and npm prefixes need not be on a terminal's PATH. + // Resolve the vendor CLI, never the adapter's ACP entry point. + use crate::acp::{binary_cache, registry}; + let meta = registry::get_agent_meta(agent); + if let registry::AgentDistribution::Binary { cmd, .. } = &meta.distribution { + binary_cache::find_best_cached_binary_for_agent(agent, cmd) + .ok() + .flatten() + .map(|(path, _)| path) + .or_else(|| crate::commands::acp::resolve_system_agent_binary_for(agent, cli)) + } else if let Some(relation) = registry::acp_adapter_relation(agent) { + crate::commands::acp::resolve_vendor_cli(cli, relation.extra_dirs).await + } else { + crate::commands::acp::resolve_npx_command(cli).await + } +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_list_agent_accounts( + agent_type: AgentType, + db: State<'_, AppDatabase>, +) -> Result { + list_core(&db, agent_type).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_create_agent_account( + agent_type: AgentType, + name: String, + home_dir: Option, + db: State<'_, AppDatabase>, + app: tauri::AppHandle, +) -> Result { + let data = app + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .map_err(|e| AcpError::protocol(e.to_string()))?; + create_core( + &db, + &data, + agent_type, + &name, + home_dir.as_deref(), + &EventEmitter::Tauri(app), + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_rename_agent_account( + account_id: String, + name: String, + db: State<'_, AppDatabase>, + app: tauri::AppHandle, +) -> Result<(), AcpError> { + rename_core(&db, &account_id, &name, &EventEmitter::Tauri(app)).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_remove_agent_account( + account_id: String, + db: State<'_, AppDatabase>, + app: tauri::AppHandle, +) -> Result<(), AcpError> { + remove_core(&db, &account_id, &EventEmitter::Tauri(app)).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_login_agent_account( + account_id: String, + method: Option, + db: State<'_, AppDatabase>, + manager: State<'_, ConnectionManager>, + app: tauri::AppHandle, +) -> Result { + let result = login_method_core( + &db, + &manager, + &account_id, + method, + &EventEmitter::Tauri(app), + ) + .await?; + #[cfg(not(test))] + let result = { + let mut result = result; + result.launched = + crate::commands::acp::open_external_terminal_impl(&result.command, Some(&result.home)) + .is_ok(); + result + }; + Ok(result) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_set_agent_account_api_key( + account_id: String, + api_key: String, + db: State<'_, AppDatabase>, + manager: State<'_, ConnectionManager>, + app: tauri::AppHandle, +) -> Result<(), AcpError> { + let data = app + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .map_err(|_| AcpError::protocol("Could not open account settings."))?; + set_api_key_core( + &db, + &manager, + &data, + &account_id, + &api_key, + &EventEmitter::Tauri(app), + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_check_agent_account_auth( + account_id: String, + db: State<'_, AppDatabase>, + app: tauri::AppHandle, +) -> Result { + let data = app + .path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .map_err(|_| AcpError::protocol("Could not open account settings."))?; + check_auth_core(&db, &data, &account_id, &EventEmitter::Tauri(app)).await +} diff --git a/src-tauri/src/commands/backup/sections.rs b/src-tauri/src/commands/backup/sections.rs index 74a7c7ea48..820c7f2f76 100644 --- a/src-tauri/src/commands/backup/sections.rs +++ b/src-tauri/src/commands/backup/sections.rs @@ -76,6 +76,12 @@ pub const MANAGED_SECTIONS: &[ManagedSection] = &[ policy: SectionPolicy::AlwaysReplace, live_path: |_| crate::paths::codeg_acp_transcripts_root(), }, + ManagedSection { + id: "conversation-context", + kind: SectionKind::Dir, + policy: SectionPolicy::AlwaysReplace, + live_path: |_| crate::paths::codeg_conversation_context_root(), + }, ManagedSection { // Per-turn wall-clock spans for agents whose native store has no // timestamps (Cursor). Without it a restored history shows no durations. diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 6235aed433..eeda8b95e7 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -3,11 +3,12 @@ use std::collections::{HashMap, HashSet}; #[cfg(feature = "tauri-runtime")] use tauri::Manager; +use crate::acp::handoff::{self, HandoffLink}; use crate::app_error::AppCommandError; use crate::db::entities::conversation; use crate::db::entities::folder::FolderKind; use crate::db::service::{ - agent_setting_service, conversation_service, folder_service, import_service, tab_service, + agent_setting_service, conversation_service, folder_service, handoff_service, import_service, tab_service, }; #[cfg(feature = "tauri-runtime")] use crate::db::AppDatabase; @@ -42,6 +43,16 @@ pub(crate) async fn list_all_conversations_core( chat_channel_manager: &crate::chat_channel::manager::ChatChannelManager, options: ListAllConversationsOptions, ) -> Result, AppCommandError> { + // Account indexes are independent even when two homes contain the same + // native session id. Refresh only the rows bound to the index being read. + let mut updated_accounts = Vec::new(); + for account in crate::db::service::agent_account_service::list(conn, AgentType::Codex).await.map_err(AppCommandError::from)? { + let base = std::path::PathBuf::from(&account.home_dir).join("sessions"); + if let Ok(titles) = tokio::task::spawn_blocking(move || CodexParser::with_base_dir(base).load_thread_name_index()).await { + updated_accounts.extend(conversation_service::refresh_codex_account_titles(conn, &titles, Some(&account.id)).await); + } + } + drop(notify_conversation_title_updates(conn, emitter, chat_channel_manager, updated_accounts).await); let codex_env = agent_setting_service::load_agent_env(conn, AgentType::Codex).await; let codex_titles = match tokio::task::spawn_blocking(move || { CodexParser::for_env(&codex_env).load_thread_name_index() @@ -315,11 +326,31 @@ fn list_conversations_sync( pub async fn list_conversations_core( conn: &sea_orm::DatabaseConnection, + agent_account_id: Option, agent_type: Option, search: Option, sort_by: Option, folder_path: Option, ) -> Result, AppCommandError> { + if let Some(id) = agent_account_id { + let account = crate::db::service::agent_account_service::get(conn, &id).await.map_err(AppCommandError::from)?; + let agent = AgentType::from_wire(&account.agent_type).ok_or_else(|| AppCommandError::invalid_input("Unknown account agent"))?; + if agent_type.is_some_and(|selected| selected != agent) { return Err(AppCommandError::invalid_input("The account belongs to a different agent.")); } + let home = std::path::PathBuf::from(account.home_dir); + return tokio::task::spawn_blocking(move || { + let mut sessions = crate::parsers::build_account_parser(agent, Some(&home), &crate::parsers::AgentEnv::inherited()).and_then(|p| p.list_conversations()).map_err(parse_error_to_app_error)?; + if let Some(query) = search.as_ref().map(|s| s.to_lowercase()) { + sessions.retain(|s| [&s.title, &s.folder_name, &s.folder_path, &s.git_branch, &s.model].iter().any(|v| v.as_ref().is_some_and(|v| v.to_lowercase().contains(&query)))); + } + if let Some(path) = folder_path { sessions.retain(|s| s.folder_path.as_deref().is_some_and(|p| path_eq_for_matching(p, &path))); } + match sort_by.as_deref() { + Some("oldest") => sessions.sort_by_key(|s| s.started_at), + Some("messages") => sessions.sort_by_key(|s| std::cmp::Reverse(s.message_count)), + _ => sessions.sort_by_key(|s| std::cmp::Reverse(s.started_at)), + } + Ok(sessions) + }).await.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + } let envs = agent_setting_service::load_agent_envs(conn).await; tokio::task::spawn_blocking(move || { list_conversations_sync(&envs, agent_type, search, sort_by, folder_path) @@ -331,28 +362,20 @@ pub async fn list_conversations_core( }) } -#[cfg(feature = "tauri-runtime")] -#[tauri::command] -pub async fn list_conversations( - db: tauri::State<'_, AppDatabase>, - agent_type: Option, - search: Option, - sort_by: Option, - folder_path: Option, -) -> Result, AppCommandError> { - list_conversations_core(&db.conn, agent_type, search, sort_by, folder_path).await -} - -/// One agent session read straight from the agent's own store by its session -/// id — the sub-agent transcript viewer's source. pub async fn get_conversation_core( conn: &sea_orm::DatabaseConnection, + agent_account_id: Option, agent_type: AgentType, conversation_id: String, ) -> Result { let env = agent_setting_service::load_agent_env(conn, agent_type).await; + let home = if let Some(id) = agent_account_id { + let account = crate::db::service::agent_account_service::get(conn, &id).await.map_err(AppCommandError::from)?; + if account.agent_type != agent_type.as_wire() { return Err(AppCommandError::invalid_input("The account belongs to a different agent.")); } + Some(std::path::PathBuf::from(account.home_dir)) + } else { None }; tokio::task::spawn_blocking(move || -> Result { - build_agent_parser(agent_type, &env) + crate::parsers::build_account_parser(agent_type, home.as_deref(), &env).map_err(parse_error_to_app_error)? .get_conversation(&conversation_id) .map_err(parse_error_to_app_error) }) @@ -363,14 +386,29 @@ pub async fn get_conversation_core( })? } +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn list_conversations( + db: tauri::State<'_, AppDatabase>, + agent_account_id: Option, + agent_type: Option, + search: Option, + sort_by: Option, + folder_path: Option, +) -> Result, AppCommandError> { + list_conversations_core(&db.conn, agent_account_id, agent_type, search, sort_by, folder_path).await +} + #[cfg(feature = "tauri-runtime")] #[tauri::command] pub async fn get_conversation( db: tauri::State<'_, AppDatabase>, + agent_account_id: Option, agent_type: AgentType, conversation_id: String, ) -> Result { - get_conversation_core(&db.conn, agent_type, conversation_id).await + get_conversation_core(&db.conn, agent_account_id, agent_type, conversation_id).await + } /// Every parsable session on this machine, read in one blocking pass with @@ -684,6 +722,8 @@ fn build_scan_result( Some(false) => ScanSessionStatus::Deleted, }; entry.sessions.push(ScanSession { + agent_account_id: None, + agent_account_name: None, external_id: summary.id, agent_type: at, title: summary.title, @@ -773,7 +813,37 @@ pub async fn scan_importable_sessions_core( ) .await; - scan_importable_sessions_from_summaries(conn, emitter, chat_channel_manager, summaries).await + let mut result = scan_importable_sessions_from_summaries(conn, emitter, chat_channel_manager, summaries).await?; + for account in crate::db::service::agent_account_service::list_all(conn).await.map_err(AppCommandError::from)? { + // One unavailable account folder must not hide every other account's + // sessions, the Default ones included. Importing from that account + // still fails explicitly until the folder is restored. + let summaries = match import_service::collect_account_summaries(conn, &account.id).await { + Ok(summaries) => summaries, + Err(e) => { + tracing::warn!("[import] skipping account {} ({}): {e}", account.name, account.id); + continue; + } + }; + let mut additional = scan_importable_account_summaries(conn, emitter, chat_channel_manager, summaries, Some(&account.id)).await?; + result.no_folder_count += additional.no_folder_count; + result.total_sessions += additional.total_sessions; + result.importable_count += additional.importable_count; + for folder in &mut additional.folders { + for session in &mut folder.sessions { + session.agent_account_id = Some(account.id.clone()); + session.agent_account_name = Some(account.name.clone()); + } + } + for folder in additional.folders { + if let Some(existing) = result.folders.iter_mut().find(|f| path_eq_for_matching(&f.path, &folder.path)) { + existing.sessions.extend(folder.sessions); + existing.sessions.sort_by_key(|s| std::cmp::Reverse(s.started_at)); + for agent in folder.agent_types { if !existing.agent_types.contains(&agent) { existing.agent_types.push(agent); } } + } else { result.folders.push(folder); } + } + } + Ok(result) } /// Reconcile summaries already collected by the filesystem scan. Keeping this @@ -784,10 +854,21 @@ async fn scan_importable_sessions_from_summaries( emitter: &EventEmitter, chat_channel_manager: &crate::chat_channel::manager::ChatChannelManager, summaries: Vec<(AgentType, ConversationSummary)>, +) -> Result { + scan_importable_account_summaries(conn, emitter, chat_channel_manager, summaries, None).await +} + +async fn scan_importable_account_summaries( + conn: &sea_orm::DatabaseConnection, + emitter: &EventEmitter, + chat_channel_manager: &crate::chat_channel::manager::ChatChannelManager, + summaries: Vec<(AgentType, ConversationSummary)>, + account_id: Option<&str>, ) -> Result { use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let conv_rows = conversation::Entity::find() + .filter(conversation_service::account_condition(account_id)) .filter(conversation::Column::ExternalId.is_not_null()) .all(conn) .await @@ -804,10 +885,31 @@ async fn scan_importable_sessions_from_summaries( .or_insert(live); *entry = *entry || live; } + // What a switch left behind in the account it moved a conversation out of + // is that conversation, already in codeg under its new account. + match import_service::superseded_claude_copies( + conn, + account_id, + &summaries, + &import_service::default_claude_projects(conn).await, + ) + .await + { + Ok(superseded) => { + let claude = agent_type_db_str(&AgentType::ClaudeCode); + for sid in superseded { + imported_index.entry((claude.clone(), sid)).or_insert(true); + } + } + Err(e) => tracing::warn!("[import] could not check for switched-away session copies: {e}"), + } // Refresh the already-imported rows in place before answering, then // broadcast each one so open sidebars re-sort without a refetch. The // chat-channel half runs detached so the scan never waits on Telegram. + for (agent, sid) in handoff_service::archived_sources(conn, account_id, &summaries).await.map_err(AppCommandError::from)? { + imported_index.entry((agent.as_wire().into_owned(), sid)).or_insert(true); + } drop( notify_conversation_title_updates( conn, @@ -835,14 +937,37 @@ pub async fn scan_importable_sessions( /// Batch-import the selected sessions, creating (or reopening) each target /// folder as needed. Test seam for [`import_selected_sessions_core`]: takes the /// scanned summaries as input instead of walking the filesystem. +#[cfg(test)] pub(crate) async fn import_selected_from_summaries( conn: &sea_orm::DatabaseConnection, emitter: &EventEmitter, summaries: Vec<(AgentType, ConversationSummary)>, selections: Vec, +) -> Result { + import_selected_from_account_summaries(conn, emitter, summaries, selections, None).await +} + +async fn import_selected_from_account_summaries( + conn: &sea_orm::DatabaseConnection, + emitter: &EventEmitter, + summaries: Vec<(AgentType, ConversationSummary)>, + selections: Vec, + account_id: Option<&str>, ) -> Result { const MAX_ERRORS: usize = 10; + // A switch's leftover copy is a conversation codeg already has under + // another account (the picker shows it as imported). Skip it rather than + // add a duplicate that would also block switching the conversation back. + let superseded = import_service::superseded_claude_copies( + conn, + account_id, + &summaries, + &import_service::default_claude_projects(conn).await, + ) + .await + .map_err(AppCommandError::from)?; + // Defense-in-depth mirror of collect_local_summaries' child filter: a // delegation child must never import as a root row, so a selection key // pointing at one resolves to not_found. @@ -857,6 +982,7 @@ pub(crate) async fn import_selected_from_summaries( // resolves — vanished from disk since the scan, cwd-less, or bogus — counts // as not_found. let mut not_found = 0u32; + let mut superseded_skipped = 0u32; let mut groups: HashMap> = HashMap::new(); let mut seen_keys: HashSet<(AgentType, String)> = HashSet::new(); for key in selections { @@ -867,6 +993,10 @@ pub(crate) async fn import_selected_from_summaries( not_found += 1; continue; }; + if at == AgentType::ClaudeCode && superseded.contains(&summary.id) { + superseded_skipped += 1; + continue; + } let raw_path = match summary.folder_path.as_deref().map(str::trim) { Some(p) if !p.is_empty() => p.to_string(), _ => { @@ -886,7 +1016,7 @@ pub(crate) async fn import_selected_from_summaries( let mut result = ImportSelectedResult { imported: 0, updated: 0, - skipped: 0, + skipped: superseded_skipped, restored: 0, not_found, failed: 0, @@ -1053,11 +1183,12 @@ pub(crate) async fn import_selected_from_summaries( // deliberate "bring it back", not a sweep. (The whole-folder // import and the scan's drive-by refresh both stay on `Skip`.) let (tally, _updated_ids, failed_in_group) = - import_service::import_summaries_resilient( + import_service::import_summaries_resilient_for_account( conn, folder_id, &items, import_service::DeletedPolicy::Restore, + account_id, ) .await; result.imported += tally.imported; @@ -1140,8 +1271,43 @@ pub async fn import_selected_sessions_core( .map_err(|_| AppCommandError::invalid_input("An import is already in progress"))?; let envs = agent_setting_service::load_agent_envs(conn).await; - let summaries = import_service::collect_local_summaries(&envs, |_, _, _, _| {}).await; - import_selected_from_summaries(conn, emitter, summaries, selections).await + let mut groups: std::collections::BTreeMap, Vec> = std::collections::BTreeMap::new(); + for selection in selections { + groups.entry(selection.agent_account_id.clone()).or_default().push(selection); + } + let mut result = ImportSelectedResult::default(); + for (account_id, selections) in groups { + let summaries = match account_id.as_deref() { + Some(id) => { + let account = crate::db::service::agent_account_service::get(conn, id).await.map_err(AppCommandError::from)?; + if selections.iter().any(|s| s.agent_type.as_wire() != account.agent_type) { + return Err(AppCommandError::invalid_input("Choose sessions from the selected agent account.")); + } + import_service::collect_account_summaries(conn, id).await.map_err(AppCommandError::from)? + } + None => import_service::collect_local_summaries(&envs, |_, _, _, _| {}).await, + }; + let part = import_selected_from_account_summaries(conn, emitter, summaries, selections, account_id.as_deref()).await?; + result.imported += part.imported; + result.updated += part.updated; + result.skipped += part.skipped; + result.restored += part.restored; + result.not_found += part.not_found; + result.failed += part.failed; + result.created_folders += part.created_folders; + result.errors.extend(part.errors); + result.errors.truncate(10); + for folder in part.folders { + if let Some(existing) = result.folders.iter_mut().find(|f| f.folder_id == folder.folder_id) { + existing.imported += folder.imported; + existing.updated += folder.updated; + existing.skipped += folder.skipped; + existing.restored += folder.restored; + existing.created |= folder.created; + } else { result.folders.push(folder); } + } + } + Ok(result) } #[cfg(feature = "tauri-runtime")] @@ -1429,6 +1595,13 @@ pub async fn get_folder_conversation_core( .await .map_err(AppCommandError::from)?; + let account_home = match summary.agent_account_id.as_deref() { + Some(id) => Some(std::path::PathBuf::from( + crate::db::service::agent_account_service::get(conn, id).await.map_err(AppCommandError::from)?.home_dir + )), + None => None, + }; + let (mut turns, session_stats, resolved_ext_id, parsed_title, parsed_model, transcript_watermark) = if let Some(ref ext_id) = summary.external_id { let at = summary.agent_type; @@ -1451,7 +1624,7 @@ pub async fn get_folder_conversation_core( // the default directory codeg's own environment names. 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); + let parser = crate::parsers::build_account_parser(at, account_home.as_deref(), &agent_env).map_err(parse_error_to_app_error)?; match parser.get_conversation(&eid) { Ok(d) => { // Claude `/clear` (and similar on-disk id changes) make @@ -1578,6 +1751,17 @@ pub async fn get_folder_conversation_core( summary.external_id = Some(new_ext_id); } } + let handoffs = handoff_service::list_for_conversation(conn, conversation_id) + .await + .map_err(AppCommandError::from)?; + if !handoffs.is_empty() { + let links: Vec = handoffs.iter().filter_map(HandoffLink::from_row).collect(); + let mut segments = Vec::with_capacity(handoffs.len()); + for row in &handoffs { + segments.push(handoff_service::read_segment(conn, row).await.map_err(AppCommandError::from)?); + } + turns = handoff::splice_handoffs(&links, segments, turns); + } summary.message_count = crate::parsers::message_turn_count(&turns); // The transcript is the richer source for the session's model. Codex is // the concrete case: an ACP-driven row is created before any @@ -2347,6 +2531,16 @@ pub async fn create_conversation_core( folder_id: i32, agent_type: AgentType, title: Option, +) -> Result { + create_conversation_for_account_core(conn, folder_id, agent_type, title, None).await +} + +pub async fn create_conversation_for_account_core( + conn: &sea_orm::DatabaseConnection, + folder_id: i32, + agent_type: AgentType, + title: Option, + account_id: Option, ) -> Result { let git_branch = if let Some(folder) = folder_service::get_folder_by_id(conn, folder_id) .await @@ -2357,7 +2551,7 @@ pub async fn create_conversation_core( None }; - let model = conversation_service::create(conn, folder_id, agent_type, title, git_branch) + let model = conversation_service::create_inner(conn, folder_id, agent_type, title, git_branch, None, conversation::ConversationKind::Regular, account_id) .await .map_err(AppCommandError::from)?; Ok(model.id) @@ -2371,8 +2565,9 @@ pub async fn create_conversation( folder_id: i32, agent_type: AgentType, title: Option, + agent_account_id: Option, ) -> Result { - let id = create_conversation_core(&db.conn, folder_id, agent_type, title).await?; + let id = create_conversation_for_account_core(&db.conn, folder_id, agent_type, title, agent_account_id).await?; emit_conversation_upsert(&EventEmitter::Tauri(app), &db.conn, id).await; Ok(id) } @@ -2574,6 +2769,17 @@ pub async fn create_chat_conversation_core( agent_type: AgentType, title: Option, existing_dir: Option<&str>, +) -> Result { + create_chat_conversation_for_account_core(conn, data_dir, agent_type, title, existing_dir, None).await +} + +pub async fn create_chat_conversation_for_account_core( + conn: &sea_orm::DatabaseConnection, + data_dir: &std::path::Path, + agent_type: AgentType, + title: Option, + existing_dir: Option<&str>, + account_id: Option, ) -> Result { let path = match existing_dir { Some(dir) => { @@ -2595,7 +2801,7 @@ pub async fn create_chat_conversation_core( // an orphan (active, conversation-less, never reached by the delete path) and // pollute the active-folder scope. let model = - match conversation_service::create_chat(conn, folder.id, agent_type, title, None).await { + match conversation_service::create_inner(conn, folder.id, agent_type, title, None, None, conversation::ConversationKind::Chat, account_id).await { Ok(model) => model, Err(create_err) => { if let Err(cleanup_err) = folder_service::remove_folder(conn, &folder.path).await { @@ -2623,6 +2829,7 @@ pub async fn create_chat_conversation( agent_type: AgentType, title: Option, existing_dir: Option, + agent_account_id: Option, ) -> Result { use tauri::Manager; let data_dir = app @@ -2630,12 +2837,13 @@ pub async fn create_chat_conversation( .app_data_dir() .map(|p| crate::paths::resolve_effective_data_dir(&p)) .unwrap_or_else(|_| std::path::PathBuf::from(".")); - let result = create_chat_conversation_core( + let result = create_chat_conversation_for_account_core( &db.conn, &data_dir, agent_type, title, existing_dir.as_deref(), + agent_account_id, ) .await?; emit_conversation_upsert(&EventEmitter::Tauri(app), &db.conn, result.conversation_id).await; @@ -2942,6 +3150,7 @@ mod tests { let now = chrono::Utc::now(); DbConversationSummary { id, + agent_account_id: None, folder_id: 1, title: None, title_locked: false, @@ -4390,7 +4599,7 @@ mod tests { assert_eq!(detail.turns.len(), 2, "turns: {:?}", detail.turns); // The sub-agent transcript viewer reads by session id the same way. - let by_id = get_conversation_core(&db.conn, AgentType::ClaudeCode, session_id.to_string()) + let by_id = get_conversation_core(&db.conn, None, AgentType::ClaudeCode, session_id.to_string()) .await .expect("read by session id"); assert_eq!(by_id.turns.len(), 2); @@ -5817,11 +6026,109 @@ mod tests { fn key_of(agent: AgentType, id: &str) -> SelectedSessionKey { SelectedSessionKey { + agent_account_id: None, agent_type: agent, external_id: id.into(), } } + #[tokio::test] + async fn native_account_import_keeps_identical_session_ids_separate() { + use crate::db::service::agent_account_service; + use sea_orm::EntityTrait; + let db = fresh_in_memory_db().await; + let data = tempfile::tempdir().unwrap(); + let cwd = data.path().display().to_string(); + let a = agent_account_service::create(&db.conn, data.path(), AgentType::Codex, "A", None).await.unwrap(); + let b = agent_account_service::create(&db.conn, data.path(), AgentType::Codex, "B", None).await.unwrap(); + for (account, title) in [(None, "Default"), (Some(a.id.as_str()), "A"), (Some(b.id.as_str()), "B")] { + let mut item = scan_summary("same-native-id", AgentType::Codex, Some(&cwd), at(0)); + item.1.title = Some(title.into()); + let mut key = key_of(AgentType::Codex, "same-native-id"); + key.agent_account_id = account.map(String::from); + let result = import_selected_from_account_summaries(&db.conn, &EventEmitter::Noop, vec![item], vec![key], account).await.unwrap(); + assert_eq!(result.imported, 1, "{title}"); + } + let rows = conversation::Entity::find().all(&db.conn).await.unwrap(); + assert_eq!(rows.len(), 3); + for (account, title) in [(None, "Default"), (Some(a.id.as_str()), "A"), (Some(b.id.as_str()), "B")] { + let row = rows.iter().find(|row| row.agent_account_id.as_deref() == account).unwrap(); + assert_eq!(row.title.as_deref(), Some(title)); + assert_eq!(row.external_id.as_deref(), Some("same-native-id")); + } + } + + /// A Claude switch copies the transcript into the target account and + /// leaves the source's copy behind. That copy is the switched + /// conversation, older: the source account's scan must not offer it, and + /// importing it must not add a duplicate, whose row would also block + /// switching the conversation back. + #[tokio::test] + async fn a_switched_conversation_is_not_offered_again_from_the_account_it_left() { + use crate::db::entities::conversation::ConversationKind; + use crate::db::service::agent_account_service; + use sea_orm::EntityTrait; + let db = fresh_in_memory_db().await; + let data = tempfile::tempdir().unwrap(); + let cwd = data.path().display().to_string(); + let folder = seed_folder(&db, &cwd).await; + let a = agent_account_service::create(&db.conn, data.path(), AgentType::ClaudeCode, "A", None) + .await + .unwrap(); + let b = agent_account_service::create(&db.conn, data.path(), AgentType::ClaudeCode, "B", None) + .await + .unwrap(); + let row = conversation_service::create_inner( + &db.conn, + folder, + AgentType::ClaudeCode, + None, + None, + None, + ConversationKind::Regular, + Some(b.id.clone()), + ) + .await + .unwrap(); + conversation_service::bind_external_id(&db.conn, row.id, "switched", &[]) + .await + .unwrap(); + let transcript = |home: &str, text: &str| { + let dir = std::path::Path::new(home).join("projects/project"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("switched.jsonl"), text).unwrap(); + }; + // Switched from A to B, then continued on B. + transcript(&a.home_dir, "first\n"); + transcript(&b.home_dir, "first\nsecond\n"); + let found = || vec![scan_summary("switched", AgentType::ClaudeCode, Some(&cwd), at(0))]; + let manager = crate::chat_channel::manager::ChatChannelManager::new(); + let mut key = key_of(AgentType::ClaudeCode, "switched"); + key.agent_account_id = Some(a.id.clone()); + + let scan = scan_importable_account_summaries(&db.conn, &EventEmitter::Noop, &manager, found(), Some(&a.id)) + .await + .unwrap(); + assert_eq!(scan.importable_count, 0); + assert_eq!(scan.folders[0].sessions[0].status, ScanSessionStatus::Imported); + let result = import_selected_from_account_summaries(&db.conn, &EventEmitter::Noop, found(), vec![key.clone()], Some(&a.id)) + .await + .unwrap(); + assert_eq!((result.imported, result.skipped), (0, 1)); + assert_eq!(conversation::Entity::find().all(&db.conn).await.unwrap().len(), 1); + + // Continued in A's own CLI, the copy holds turns codeg does not have. + transcript(&a.home_dir, "first\nmine\n"); + let scan = scan_importable_account_summaries(&db.conn, &EventEmitter::Noop, &manager, found(), Some(&a.id)) + .await + .unwrap(); + assert_eq!(scan.folders[0].sessions[0].status, ScanSessionStatus::New); + let result = import_selected_from_account_summaries(&db.conn, &EventEmitter::Noop, found(), vec![key], Some(&a.id)) + .await + .unwrap(); + assert_eq!(result.imported, 1); + } + #[test] fn scan_groups_normalized_path_variants_into_one_folder() { // A trailing-slash cwd variant must land in the same group as the bare @@ -6864,6 +7171,7 @@ mod tests { fn windowless_detail(turns: Vec) -> DbConversationDetail { DbConversationDetail { summary: DbConversationSummary { + agent_account_id: None, id: 1, folder_id: 1, title: None, diff --git a/src-tauri/src/commands/custom_agents.rs b/src-tauri/src/commands/custom_agents.rs index 6dceb017e1..3206294c47 100644 --- a/src-tauri/src/commands/custom_agents.rs +++ b/src-tauri/src/commands/custom_agents.rs @@ -16,6 +16,7 @@ use crate::acp::custom_registry::{ derive_command_name, CustomAgentDef, CustomAgentSpec, CustomDistributionKind, }; use crate::acp::error::AcpError; +use crate::acp::family_isolator; use crate::acp::remote_registry::{self, RegistryCatalogAgent}; use crate::acp::{custom_registry, registry}; use crate::commands::acp::emit_acp_agents_updated; @@ -28,6 +29,7 @@ use crate::web::event_bridge::EventEmitter; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CustomAgentInfo { + pub isolated_login: Option, pub registry_id: String, /// Wire form of the agent type (`custom:`) — what the frontend passes /// back as an `AgentType`. @@ -59,12 +61,22 @@ pub struct CustomAgentInfo { pub problem: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct ExtraSlotLoginInfo { + pub key: String, + pub home: String, +} + fn info_from_def(def: &CustomAgentDef) -> CustomAgentInfo { // `validate`, not `build_meta`: the settings list re-renders on every // refresh and this result is thrown away, while building metadata leaks the // arg/env/platform slices it interns (see `custom_registry::build_meta`). let problem = custom_registry::validate(def).err().map(|e| e.to_string()); CustomAgentInfo { + isolated_login: family_isolator::login_plan_from_def(def).map(|plan| ExtraSlotLoginInfo { + key: plan.isolator_key.into(), + home: plan.home.display().to_string(), + }), agent_type: AgentType::custom(&def.registry_id) // An unparseable id cannot reach the database (`upsert` validates), // so this is unreachable in practice; degrade rather than panic. @@ -574,6 +586,90 @@ pub async fn acp_save_custom_agent_params_core( acp_save_custom_agent_core(def, db, emitter).await } +/// Result of extra-slot official login. `launched` is true only when a +/// desktop terminal was actually opened. Headless / web returns the same +/// command so the UI can show a copyable line. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtraSlotLoginResult { + pub family: String, + pub isolator_key: String, + pub home: String, + pub command: String, + pub launched: bool, +} + +/// Resolve the official family login for a custom extra slot: the sign-in a +/// native account of that agent runs, pointed at the slot's own home. Never +/// reads or writes `auth.json` / `.credentials.json`. +pub async fn acp_login_extra_agent_core( + registry_id: &str, + db: &AppDatabase, +) -> Result { + let row = custom_agent_service::get(&db.conn, registry_id) + .await + .map_err(|e| AcpError::protocol(e.to_string()))? + .ok_or_else(|| AcpError::protocol(format!("custom agent not found: {registry_id}")))?; + let def = custom_agent_service::def_from_model(&row).ok_or_else(|| { + AcpError::protocol(format!("custom agent spec is unreadable: {registry_id}")) + })?; + let plan = family_isolator::login_plan_from_def(&def).ok_or_else(|| { + AcpError::protocol( + "Configure an isolated account home for this agent. OpenCode also requires a separate XDG_DATA_HOME.", + ) + })?; + if !plan.home.is_absolute() { + return Err(AcpError::protocol("Use an absolute path for the account home.")); + } + std::fs::create_dir_all(&plan.home).map_err(|e| { + AcpError::protocol(format!( + "failed to create isolated home {}: {e}", + plan.home.display() + )) + })?; + let command = crate::commands::agent_accounts::isolated_login_command( + plan.family.agent_type(), + plan.env.clone(), + ) + .await?; + Ok(ExtraSlotLoginResult { + family: plan.family.as_str().to_string(), + isolator_key: plan.isolator_key.to_string(), + home: plan.home.display().to_string(), + command, + launched: false, + }) +} + +#[cfg(feature = "tauri-runtime")] +fn launch_extra_slot_login(command: &str, home: &std::path::Path) -> bool { + if cfg!(test) { + let _ = (command, home); + return false; + } + #[cfg(feature = "tauri-runtime")] + { + let home_str = home.to_string_lossy(); + crate::commands::acp::open_external_terminal_impl(command, Some(home_str.as_ref())).is_ok() + } + #[cfg(not(feature = "tauri-runtime"))] + { + let _ = (command, home); + false + } +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_login_extra_agent( + registry_id: String, + db: State<'_, AppDatabase>, +) -> Result { + let mut result = acp_login_extra_agent_core(®istry_id, &db).await?; + result.launched = launch_extra_slot_login(&result.command, std::path::Path::new(&result.home)); + Ok(result) +} + #[cfg(feature = "tauri-runtime")] #[tauri::command] pub async fn acp_list_custom_agents( @@ -686,6 +782,54 @@ mod tests { assert_eq!(params.into_def().unwrap().registry_id, "goose"); } + #[tokio::test] + #[allow(clippy::await_holding_lock)] + async fn extra_slot_login_uses_isolator_and_does_not_copy_auth() { + let _guard = custom_registry::hydrate_test_guard(); + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let emitter = EventEmitter::Noop; + let extra = tempfile::tempdir().expect("extra home"); + let other = tempfile::tempdir().expect("other home"); + let extra_auth = extra.path().join("auth.json"); + let other_auth = other.path().join(".credentials.json"); + std::fs::write(&extra_auth, b"slot-2\n").unwrap(); + std::fs::write(&other_auth, b"slot-1\n").unwrap(); + + let mut params = npx_params("codex-2"); + params.spec.npx.as_mut().unwrap().env.insert( + "CODEX_HOME".into(), + extra.path().to_string_lossy().into_owned(), + ); + acp_save_custom_agent_params_core(params, &db, &emitter) + .await + .expect("saves extra slot"); + + let result = acp_login_extra_agent_core("codex-2", &db) + .await + .expect("login plan"); + assert_eq!(result.family, "codex"); + assert_eq!(result.isolator_key, "CODEX_HOME"); + assert_eq!(result.home, extra.path().display().to_string()); + if cfg!(windows) { + assert!(result.command.starts_with("powershell.exe -NoProfile -EncodedCommand ")); + } else { + // The vendor CLI may resolve to a quoted absolute path. + assert!(result.command.ends_with(" login"), "{}", result.command); + assert!(result.command.contains("CODEX_HOME")); + // Like a native account's sign-in, an inherited key must not stand + // in for the slot's own login. + assert!(result.command.contains("unset OPENAI_API_KEY"), "{}", result.command); + } + assert!(!result.launched, "unit tests must not spawn a terminal"); + assert_eq!(std::fs::read(&extra_auth).unwrap(), b"slot-2\n"); + assert_eq!(std::fs::read(&other_auth).unwrap(), b"slot-1\n"); + + let missing = acp_login_extra_agent_core("goose", &db).await; + assert!(missing.is_err()); + + custom_registry::hydrate(&[]); + } + fn npx_params(id: &str) -> SaveCustomAgentParams { SaveCustomAgentParams { registry_id: id.into(), diff --git a/src-tauri/src/commands/handoff.rs b/src-tauri/src/commands/handoff.rs new file mode 100644 index 0000000000..9a716d7adf --- /dev/null +++ b/src-tauri/src/commands/handoff.rs @@ -0,0 +1,702 @@ +//! Account-aware, cancellation-shielded handoffs. Native Claude transfers use +//! the account picker's verified copy/load path. Other agents receive a saved +//! context archive, with a durable retry if their first prompt fails. +use super::{ + acp::{account_runtime_env, build_account_session_runtime_env, verify_agent_installed}, + conversations::{emit_conversation_upsert, get_folder_conversation_core}, +}; +use crate::{ + acp::{ + account_transfer, agent_accounts, + handoff::{self, HandoffPath}, + handoff_context, + manager::ConnectionManager, + types::{AcpEvent, ConnectionStatus, PromptInputBlock}, + }, + app_error::AppCommandError, + db::{ + entities::conversation, + service::{agent_account_service, conversation_service, folder_service, handoff_service}, + AppDatabase, + }, + models::{AgentType, DbConversationSummary, MessageTurn}, + web::event_bridge::{emit_with_state, EventEmitter}, +}; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use serde::Serialize; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + time::Duration, +}; + +fn failure(e: impl std::fmt::Display) -> AppCommandError { + AppCommandError::task_execution_failed(e.to_string()) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffPlan { + pub source_agent_type: AgentType, + pub source_account_id: Option, + pub source_external_id: Option, + pub target_agent_type: AgentType, + pub target_account_id: Option, + pub path: HandoffPath, + pub native_reason: Option<&'static str>, + pub blocked: Option<&'static str>, + pub blocked_message: Option, + pub turn_count: usize, + pub briefing_chars: usize, + pub briefing_truncated: bool, + pub verbatim_turns: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffResult { + pub conversation_id: i32, + pub folder_id: i32, + pub from_agent_type: AgentType, + pub to_agent_type: AgentType, + pub agent_account_id: Option, + pub external_id: String, + pub path: HandoffPath, + pub connection_id: String, + pub briefing_truncated: bool, + pub response_started: bool, + pub working_dir: String, +} + +#[derive(Debug, Clone)] +pub struct HandoffRequest { + pub conversation_id: i32, + pub target_agent_type: AgentType, + pub target_account_id: Option, + pub source_agent_type: AgentType, + pub source_account_id: Option, + pub source_external_id: String, + pub note: Option, + pub preferred_mode_id: Option, + pub preferred_config_values: BTreeMap, +} + +struct Prepared { + summary: DbConversationSummary, + working_dir: String, + plan: HandoffPlan, + source_storage: Option<(AgentType, PathBuf)>, + target_env: BTreeMap, + turns: Vec, + source_stats: Option, +} + +async fn prepare( + db: &AppDatabase, + data_dir: &Path, + id: i32, + target: AgentType, + target_account: Option<&str>, +) -> Result { + let summary = conversation_service::get_by_id(&db.conn, id).await?; + let folder = folder_service::get_folder_by_id(&db.conn, summary.folder_id).await?; + let cwd = summary + .origin_cwd + .clone() + .or_else(|| folder.map(|f| f.path)) + .ok_or_else(|| AppCommandError::not_found("Conversation folder not found."))?; + let mut plan = HandoffPlan { + source_agent_type: summary.agent_type, + source_account_id: summary.agent_account_id.clone(), + source_external_id: summary.external_id.clone(), + target_agent_type: target, + target_account_id: target_account.map(str::to_string), + path: handoff::plan_path(summary.agent_type, target), + native_reason: None, + blocked: None, + blocked_message: None, + turn_count: 0, + briefing_chars: 0, + briefing_truncated: false, + verbatim_turns: 0, + }; + if plan.path == HandoffPath::Summary { + plan.path = HandoffPath::Context; + } + if summary.agent_type == target && summary.agent_account_id.as_deref() == target_account { + plan.blocked = Some("same_agent"); + } else if summary.parent_id.is_some() { + plan.blocked = Some("child_session"); + } else if summary.external_id.as_deref().unwrap_or("").is_empty() { + plan.blocked = Some("no_session"); + } else if target == AgentType::OpenClaw { + plan.blocked = Some("remote_context"); + } + let target_env = + match account_runtime_env(db, target, None, data_dir, target_account, false).await { + Ok(env) => env, + Err(error) => { + plan.blocked = Some("disabled"); + plan.blocked_message = Some(error.to_string()); + BTreeMap::new() + } + }; + if plan.blocked.is_none() { + if let Err(error) = verify_agent_installed(target).await { + plan.blocked = Some("not_installed"); + plan.blocked_message = Some(error.to_string()); + } + } + let source_env = if let Some(account) = summary.agent_account_id.as_deref() { + let account = agent_account_service::get(&db.conn, account).await?; + if account.agent_type != summary.agent_type.as_wire() { + return Err(failure("Invalid source account.")); + } + agent_accounts::isolation_env(summary.agent_type, Path::new(&account.home_dir)) + .map_err(failure)? + } else { + account_runtime_env( + db, + summary.agent_type, + summary.external_id.as_deref(), + data_dir, + None, + false, + ) + .await + .map_err(failure)? + }; + let source_storage = agent_accounts::storage_identity(summary.agent_type, &source_env); + let detail = get_folder_conversation_core(&db.conn, id).await?.0; + let summary = detail.summary; + plan.source_agent_type = summary.agent_type; + plan.source_account_id = summary.agent_account_id.clone(); + plan.source_external_id = summary.external_id.clone(); + let source_stats = detail.session_stats; + let turns = detail.turns; + plan.turn_count = turns + .iter() + .filter(|t| !handoff::is_divider_turn(t)) + .count(); + if plan.blocked.is_none() && plan.turn_count == 0 { + plan.blocked = Some("empty_history"); + } + if plan.blocked.is_none() && plan.path == HandoffPath::Native { + let target_storage = agent_accounts::storage_identity(target, &target_env); + match (&source_storage, &target_storage) { + (Some((AgentType::ClaudeCode, source)), Some((AgentType::ClaudeCode, dest))) => { + if let Err(error) = account_transfer::ensure_session_task_lists( + source, + dest, + Path::new(&cwd), + summary.external_id.as_deref().unwrap_or(""), + &[&source_env, &target_env], + ) { + plan.blocked = Some("custom_task_list"); + plan.blocked_message = Some(error.to_string()); + } + if source == dest + || matches!((source.canonicalize(), dest.canonicalize()), (Ok(a), Ok(b)) if a == b) + { + plan.blocked = Some("same_store"); + } + if crate::parsers::claude::find_session_file_in( + &source.join("projects"), + summary.external_id.as_deref().unwrap_or(""), + ) + .is_none() + { + plan.blocked = Some("empty_history"); + } + } + _ => { + plan.blocked = Some("empty_history"); + } + } + } + Ok(Prepared { + summary, + working_dir: cwd, + plan, + source_storage, + target_env, + turns, + source_stats, + }) +} + +pub async fn handoff_plan_core( + db: &AppDatabase, + data_dir: &Path, + id: i32, + target: AgentType, + account: Option<&str>, +) -> Result { + Ok(prepare(db, data_dir, id, target, account).await?.plan) +} + +pub async fn handoff_core( + db: &AppDatabase, + manager: &ConnectionManager, + emitter: &EventEmitter, + data_dir: &Path, + owner: String, + request: HandoffRequest, +) -> Result { + let db = AppDatabase { + conn: db.conn.clone(), + }; + let manager = manager.clone_ref(); + let emitter = emitter.clone(); + let data = data_dir.to_path_buf(); + tokio::spawn(async move { handoff_inner(&db, &manager, &emitter, &data, owner, request).await }) + .await + .map_err(failure)? +} + +async fn handoff_inner( + db: &AppDatabase, + manager: &ConnectionManager, + emitter: &EventEmitter, + data_dir: &Path, + owner: String, + request: HandoffRequest, +) -> Result { + let barrier = manager.lock_out_new_connections().await; + let target = request.target_agent_type; + let mut prepared = prepare( + db, + data_dir, + request.conversation_id, + target, + request.target_account_id.as_deref(), + ) + .await?; + if let Some(code) = prepared.plan.blocked { + return Err(AppCommandError::invalid_input( + prepared + .plan + .blocked_message + .clone() + .unwrap_or_else(|| format!("Cannot switch this conversation: {code}.")), + ) + .with_detail(code)); + } + let source = prepared.summary.agent_type; + let sid = prepared.summary.external_id.clone().unwrap(); + if source != request.source_agent_type + || prepared.summary.agent_account_id != request.source_account_id + || sid != request.source_external_id + { + return Err(failure( + "The conversation changed. Reopen the switch dialog and try again.", + )); + } + if conversation::Entity::find() + .filter(conversation::Column::ParentId.eq(prepared.summary.id)) + .filter(conversation::Column::Status.eq(conversation::ConversationStatus::InProgress)) + .one(&db.conn) + .await + .map_err(failure)? + .is_some() + { + return Err(failure( + "Wait for the delegated work to finish before switching agents.", + )); + } + let target_storage = agent_accounts::storage_identity(target, &prepared.target_env); + let native = prepared.plan.path == HandoffPath::Native; + let guards = if native { + let source_root = &prepared.source_storage.as_ref().unwrap().1; + let target_root = &target_storage.as_ref().unwrap().1; + let mut conflict = conversation::Entity::find() + .filter(conversation::Column::AgentType.eq(target.as_wire().into_owned())) + .filter(conversation::Column::ExternalId.eq(sid.clone())) + .filter(conversation::Column::Id.ne(request.conversation_id)); + conflict = match request.target_account_id.as_deref() { + Some(id) => conflict.filter(conversation::Column::AgentAccountId.eq(id)), + None => conflict.filter(conversation::Column::AgentAccountId.is_null()), + }; + if conflict.one(&db.conn).await.map_err(failure)?.is_some() { + return Err(failure( + "The selected account already has this conversation. Open its existing chat.", + )); + } + manager + .quiesce_account_session(&barrier, source_root, target_root, &sid) + .await + .map_err(failure)? + } else { + manager + .quiesce_handoff_source( + &barrier, + request.conversation_id, + source, + request.source_account_id.as_deref(), + &sid, + prepared.source_storage.as_ref(), + ) + .await + .map_err(failure)? + }; + // Capture after every source writer stopped, including queued prompts. + let stopped = get_folder_conversation_core(&db.conn, request.conversation_id) + .await? + .0; + prepared.turns = stopped.turns; + prepared.source_stats = stopped.session_stats; + prepared.summary.model = stopped.summary.model; + let segment = + handoff_service::native_turns(&db.conn, source, &sid, request.source_account_id.as_deref()) + .await?; + let links: Vec<_> = handoff_service::list_for_conversation(&db.conn, request.conversation_id) + .await? + .iter() + .filter_map(handoff::HandoffLink::from_row) + .collect(); + let segment = handoff::clean_seeded_turns(segment, &links); + if segment.is_empty() && links.is_empty() { + return Err(failure( + "The saved conversation is empty. Restore its history before switching agents.", + )); + } + let history_json = serde_json::to_string(&segment).map_err(failure)?; + let note = request + .note + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(str::to_string); + let mut archive = if native { + None + } else { + let turns = prepared.turns.clone(); + let note = note.clone(); + Some( + tokio::task::spawn_blocking(move || { + handoff_context::write_archive( + &crate::paths::codeg_conversation_context_root(), + &turns, + &source.to_string(), + &target.to_string(), + note.as_deref(), + ) + }) + .await + .map_err(failure)? + .map_err(failure)?, + ) + }; + let mut transfer = None; + let mut aside = None; + if native { + transfer = Some( + account_transfer::copy_claude_session( + &prepared.source_storage.as_ref().unwrap().1, + &target_storage.as_ref().unwrap().1, + &sid, + ) + .map_err(failure)?, + ); + if target.custom_id().is_some() { + let path = crate::acp_transcript::transcript_path_in( + &crate::paths::codeg_acp_transcripts_root(), + crate::acp::registry::registry_id_for(target), + &sid, + ); + if let Some(path) = path.filter(|p| p.exists()) { + let backup = path.with_extension(format!("jsonl.handoff-{}", uuid::Uuid::new_v4())); + if let Err(e) = std::fs::rename(&path, &backup) { + transfer.as_ref().unwrap().rollback().map_err(failure)?; + return Err(failure(e)); + } + aside = Some((path, backup)); + } + } + } + let mut env = build_account_session_runtime_env( + db, + target, + native.then_some(sid.as_str()), + data_dir, + request.target_account_id.as_deref(), + ) + .await; + if let (Ok(env), Some(archive)) = (&mut env, &archive) { + env.insert( + handoff_context::CONTEXT_ENV.into(), + archive + .directory + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(), + ); + } + if native { + if let (Ok(env), Some(id)) = ( + &mut env, + handoff_service::context_for_conversation(&db.conn, request.conversation_id).await?, + ) { + env.insert(handoff_context::CONTEXT_ENV.into(), id); + } + } + let requested_config = request.preferred_config_values.clone(); + let startup = async { + let connection = manager.spawn_agent_during_handoff(&barrier, target, prepared.working_dir.clone(), native.then_some(sid.clone()), env.map_err(failure)?, owner, emitter.clone(), request.preferred_mode_id, request.preferred_config_values).await.map_err(failure)?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(120); + loop { + let state = manager.get_state(&connection).await.ok_or_else(|| failure("The selected agent stopped before the conversation was ready."))?; + let state = state.read().await; + if state.status == ConnectionStatus::Connected && state.selectors_ready { + if let Some(session) = &state.external_id { + if (!native || state.has_restored_session(&sid)) && state.agent_account_id == request.target_account_id + && (!native || super::account_switch::requested_model_applied(&requested_config, state.config_options.as_deref())) { + return Ok((connection.clone(), session.clone())); + } + } + } + let failed = matches!(state.status, ConnectionStatus::Error | ConnectionStatus::Disconnected) + || (state.status == ConnectionStatus::Connected && state.selectors_ready) || tokio::time::Instant::now() >= deadline; + drop(state); + if failed { + manager.disconnect_and_wait_for_account_switch(&connection).await.map_err(failure)?; + return Err(failure("The selected agent could not restore the conversation. The original account and history were kept.")); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }.await; + let (connection_id, external_id) = match startup { + Ok(result) => result, + Err(error) => { + rollback( + manager, + transfer.as_ref(), + target_storage.as_ref().map(|s| s.1.as_path()), + &sid, + aside.as_ref(), + ) + .await?; + return Err(error); + } + }; + // Historical @mentions are context, not a new routing request. The seed + // body goes through the internal suffix path; only the user's new note + // participates in the normal mention-routing rules. + let native_note = if native { + note.as_ref().map(|note| { + format!( + "{}\nNative switch note {}:\n{}", + handoff::BRIEFING_MARKER, + uuid::Uuid::new_v4(), + note + ) + }) + } else { + None + }; + let seed_prompt = if archive.is_some() { + Some(format!( + "{}\nContinue this conversation with the saved context.{}", + handoff::BRIEFING_MARKER, + note.as_ref().map(|n| format!("\n{n}")).unwrap_or_default() + )) + } else { + native_note.clone() + }; + let committed = handoff_service::commit( + &db.conn, + &prepared.summary, + handoff_service::NewHandoff { + conversation_id: request.conversation_id, + from_agent_type: source, + from_external_id: Some(sid.clone()), + to_agent_type: target, + to_external_id: external_id.clone(), + path: prepared.plan.path.as_str(), + carried: native, + user_turns_before: u32::try_from(handoff::count_user_turns(&prepared.turns)) + .unwrap_or(u32::MAX), + note: note.clone(), + briefing: archive.as_ref().map(|a| a.prompt.clone()).or(native_note), + truncated: false, + from_account_id: request.source_account_id, + to_account_id: request.target_account_id.clone(), + history_json: Some(history_json), + context_id: archive + .as_ref() + .and_then(|a| a.directory.file_name()) + .map(|s| s.to_string_lossy().into_owned()), + context_pending: !native || note.is_some(), + source_stats_json: prepared + .source_stats + .as_ref() + .map(serde_json::to_string) + .transpose() + .map_err(failure)?, + source_model: prepared.summary.model.clone(), + }, + ) + .await; + if let Err(error) = committed { + if manager + .disconnect_and_wait_for_account_switch(&connection_id) + .await + .is_err() + { + tracing::warn!("[handoff] target shutdown was delayed; checking writers before rollback"); + } + rollback( + manager, + transfer.as_ref(), + target_storage.as_ref().map(|s| s.1.as_path()), + &sid, + aside.as_ref(), + ) + .await?; + return Err(error.into()); + } + if let Some(archive) = &mut archive { + archive.persist(); + } + if let Some(transfer) = transfer { + transfer.discard_staging(); + } + if let Some((_, backup)) = aside { + let _ = std::fs::remove_file(backup); + } + if let Some(state) = manager.get_state(&connection_id).await { + emit_with_state( + &state, + emitter, + AcpEvent::ConversationLinked { + conversation_id: request.conversation_id, + folder_id: prepared.summary.folder_id, + parent_conversation_id: None, + parent_tool_use_id: None, + }, + ) + .await; + } + emit_conversation_upsert(emitter, &db.conn, request.conversation_id).await; + drop(guards); + drop(barrier); + let response_started = if let Some(text) = seed_prompt { + manager + .send_prompt_linked( + db, + &connection_id, + vec![PromptInputBlock::Text { text }], + Some(prepared.summary.folder_id), + Some(request.conversation_id), + None, + ) + .await + .is_ok() + } else { + false + }; + Ok(HandoffResult { + conversation_id: request.conversation_id, + folder_id: prepared.summary.folder_id, + from_agent_type: source, + to_agent_type: target, + agent_account_id: request.target_account_id, + external_id, + path: prepared.plan.path, + connection_id, + briefing_truncated: false, + response_started, + working_dir: prepared.working_dir, + }) +} + +async fn rollback( + manager: &ConnectionManager, + transfer: Option<&account_transfer::ClaudeTransfer>, + target: Option<&Path>, + sid: &str, + aside: Option<&(PathBuf, PathBuf)>, +) -> Result<(), AppCommandError> { + if let Some(transfer) = transfer { + manager + .wait_for_account_session_writers(target.unwrap(), sid) + .await + .map_err(failure)?; + transfer.rollback().map_err(failure)?; + transfer.discard_staging(); + } + if let Some((original, backup)) = aside { + if original.exists() { + std::fs::remove_file(original).map_err(failure)?; + } + std::fs::rename(backup, original).map_err(failure)?; + } + Ok(()) +} + +#[cfg(feature = "tauri-runtime")] +fn effective_data_dir(app: &tauri::AppHandle) -> PathBuf { + use tauri::Manager; + app.path() + .app_data_dir() + .map(|p| crate::paths::resolve_effective_data_dir(&p)) + .unwrap_or_else(|_| crate::paths::codeg_home_dir()) +} +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn acp_handoff_plan( + conversation_id: i32, + target_agent_type: AgentType, + target_account_id: Option, + db: tauri::State<'_, AppDatabase>, + app: tauri::AppHandle, +) -> Result { + handoff_plan_core( + &db, + &effective_data_dir(&app), + conversation_id, + target_agent_type, + target_account_id.as_deref(), + ) + .await +} +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn acp_handoff( + conversation_id: i32, + target_agent_type: AgentType, + target_account_id: Option, + source_agent_type: AgentType, + source_account_id: Option, + source_external_id: String, + note: Option, + preferred_mode_id: Option, + preferred_config_values: Option>, + db: tauri::State<'_, AppDatabase>, + manager: tauri::State<'_, ConnectionManager>, + app: tauri::AppHandle, + window: tauri::WebviewWindow, +) -> Result { + let data = effective_data_dir(&app); + handoff_core( + &db, + &manager, + &EventEmitter::Tauri(app), + &data, + window.label().into(), + HandoffRequest { + conversation_id, + target_agent_type, + target_account_id, + source_agent_type, + source_account_id, + source_external_id, + note, + preferred_mode_id, + preferred_config_values: preferred_config_values.unwrap_or_default(), + }, + ) + .await +} diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index ead97b7652..2ecca5149b 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -12,6 +12,7 @@ use crate::app_error::AppCommandError; const MARKETPLACE_OFFICIAL: &str = "official_registry"; const MARKETPLACE_SMITHERY: &str = "smithery"; +static MCP_CONFIG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); static MARKETPLACE_HTTP_CLIENT: LazyLock> = LazyLock::new(|| { reqwest::Client::builder() .connect_timeout(Duration::from_secs(8)) @@ -383,8 +384,10 @@ pub async fn mcp_get_marketplace_server_detail( } } -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn mcp_install_from_marketplace( + +#[allow(clippy::too_many_arguments)] +pub async fn mcp_install_from_marketplace_core( + db: &crate::db::AppDatabase, provider_id: String, server_id: String, apps: Vec, @@ -393,6 +396,7 @@ pub async fn mcp_install_from_marketplace( protocol: Option, parameter_values: Option, ) -> Result { + let native_accounts = native_account_homes(db).await?; let normalized_apps = normalize_apps(apps); if normalized_apps.is_empty() { return Err(mcp_invalid_input("at least one target app is required") @@ -435,15 +439,21 @@ pub async fn mcp_install_from_marketplace( } // A selected-but-excluded app can't host this transport; remove any stale entry // for this id there so it can't win scan precedence and reclassify the spec. + with_upsert_preflight(&hostable.iter().copied().collect(), || { + preflight_native_account_homes(&native_accounts)?; for app in excluded { tracing::warn!( "[MCP] {app:?} cannot host server '{server_id}' (transport unsupported); removing any stale entry" ); let _ = remove_server_for_app(app, &server_id)?; + fanout_native_account_homes(&native_accounts, app, &server_id, None)?; } for app in hostable { upsert_server_for_app(app, &server_id, &canonical_spec)?; + fanout_native_account_homes(&native_accounts, app, &server_id, Some(&canonical_spec))?; } + Ok(()) + })?; find_local_server(&server_id).ok_or_else(|| { mcp_configuration_invalid(format!( @@ -462,13 +472,15 @@ pub async fn mcp_install_from_marketplace( /// which fields the user touched. A client that sends none (a new server, or /// an older build) gets the scan's spec for the id as the closest stand-in, /// and a new server has none. -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn mcp_upsert_local_server( +#[allow(clippy::too_many_arguments)] +pub async fn mcp_upsert_local_server_core( + db: &crate::db::AppDatabase, server_id: String, spec: Value, apps: Vec, shown_spec: Option, ) -> Result { + let native_accounts = native_account_homes(db).await?; let canonical_spec = canonicalize_spec(&spec, "local MCP save")?; let target_apps = normalize_apps(apps); if target_apps.is_empty() { @@ -508,6 +520,7 @@ pub async fn mcp_upsert_local_server( .map(|server| server.spec.clone()), }; with_upsert_preflight(&target_set, || { + preflight_native_account_homes(&native_accounts)?; for app in ALL_MCP_APPS { if target_set.contains(&app) { match (app, shown.as_ref()) { @@ -519,8 +532,10 @@ pub async fn mcp_upsert_local_server( } _ => upsert_server_for_app(app, &server_id, &canonical_spec)?, } + fanout_native_account_homes(&native_accounts, app, &server_id, Some(&canonical_spec))?; } else { let _ = remove_server_for_app(app, &server_id)?; + fanout_native_account_homes(&native_accounts, app, &server_id, None)?; } } Ok(()) @@ -533,11 +548,14 @@ pub async fn mcp_upsert_local_server( }) } -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn mcp_set_server_apps( + +#[allow(clippy::too_many_arguments)] +pub async fn mcp_set_server_apps_core( + db: &crate::db::AppDatabase, server_id: String, apps: Vec, ) -> Result, AppCommandError> { + let native_accounts = native_account_homes(db).await?; let target_apps = normalize_apps(apps); let scan = scan_local_servers(); require_complete_scan(&scan)?; @@ -571,11 +589,14 @@ pub async fn mcp_set_server_apps( // otherwise it is dropped from its old agents and added to none. let added: BTreeSet = target_set.difference(¤t_set).copied().collect(); with_upsert_preflight(&added, || { + preflight_native_account_homes(&native_accounts)?; for app in current_set.difference(&target_set) { remove_server_for_app(*app, &server_id)?; + fanout_native_account_homes(&native_accounts, *app, &server_id, None)?; } for app in &added { upsert_server_for_app(*app, &server_id, ¤t.spec)?; + fanout_native_account_homes(&native_accounts, *app, &server_id, Some(¤t.spec))?; } Ok(()) })?; @@ -583,11 +604,14 @@ pub async fn mcp_set_server_apps( Ok(find_local_server(&server_id)) } -#[cfg_attr(feature = "tauri-runtime", tauri::command)] -pub async fn mcp_remove_server( + +#[allow(clippy::too_many_arguments)] +pub async fn mcp_remove_server_core( + db: &crate::db::AppDatabase, server_id: String, apps: Option>, ) -> Result { + let native_accounts = native_account_homes(db).await?; let target_apps = match apps { Some(selected) => normalize_apps(selected), None => ALL_MCP_APPS.to_vec(), @@ -597,9 +621,13 @@ pub async fn mcp_remove_server( return Ok(false); } + let _guard = MCP_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + preflight_native_account_homes(&native_accounts)?; let mut removed = false; for app in target_apps { removed |= remove_server_for_app(app, &server_id)?; + fanout_native_account_homes(&native_accounts, app, &server_id, None)?; } Ok(removed) } @@ -887,7 +915,30 @@ fn read_json_file(path: &Path) -> Result { .map_err(|e| mcp_configuration_invalid(format!("invalid JSON at {}: {e}", path.display()))) } +/// Whether `path` already parses to exactly `value`. +fn json_file_holds(path: &Path, value: &Value) -> bool { + fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .is_some_and(|current| current == *value) +} + +/// Whether `path` already parses to exactly `value`. +fn toml_file_holds(path: &Path, value: &toml::Value) -> bool { + fs::read_to_string(path) + .ok() + .and_then(|raw| raw.parse::().ok()) + .is_some_and(|current| current == *value) +} + fn write_json_file(path: &Path, value: &Value) -> Result<(), AppCommandError> { + // A rewrite that changes nothing leaves the file alone. Named accounts + // re-apply the shared servers on every launch, into config files their + // own running CLIs rewrite too (`.claude.json` constantly); rewriting an + // unchanged file would only race those writers and risk their updates. + if json_file_holds(path, value) { + return Ok(()); + } if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(AppCommandError::io)?; } @@ -901,8 +952,11 @@ fn write_json_file(path: &Path, value: &Value) -> Result<(), AppCommandError> { } fn read_codex_root_toml() -> Result { - let path = codex_config_toml_path(); - let Some(raw) = read_config_to_string(&path)? else { + read_codex_root_toml_at(&codex_config_toml_path()) +} + +fn read_codex_root_toml_at(path: &Path) -> Result { + let Some(raw) = read_config_to_string(path)? else { return Ok(toml::Value::Table(toml::map::Map::new())); }; let parsed = raw.parse::().map_err(|e| { @@ -919,8 +973,11 @@ fn read_codex_root_toml() -> Result { Ok(parsed) } -fn write_codex_root_toml(root: &toml::Value) -> Result<(), AppCommandError> { - let path = codex_config_toml_path(); +fn write_codex_root_toml_at(path: &Path, root: &toml::Value) -> Result<(), AppCommandError> { + // See `write_json_file`: an unchanged config is not rewritten. + if toml_file_holds(path, root) { + return Ok(()); + } if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(AppCommandError::io)?; } @@ -931,7 +988,7 @@ fn write_codex_root_toml(root: &toml::Value) -> Result<(), AppCommandError> { path.display() )) })?; - fs::write(&path, format!("{serialized}\n")).map_err(AppCommandError::io) + fs::write(path, format!("{serialized}\n")).map_err(AppCommandError::io) } fn obj_as_string_map(value: Option<&Value>) -> Option> { @@ -1728,8 +1785,11 @@ fn read_claude_servers() -> Result, AppCommandError> { } fn upsert_claude_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let path = claude_config_path(); - let mut root = read_json_file(&path)?; + upsert_claude_server_at(&claude_config_path(), &claude_settings_path(), id, spec) +} + +fn upsert_claude_server_at(path: &Path, settings: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -1751,35 +1811,38 @@ fn upsert_claude_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { })?; map.insert(id.to_string(), canonical); - write_json_file(&path, &root)?; - enable_claude_local_plugin(id) + write_json_file(path, &root)?; + enable_claude_local_plugin_at(settings, id) } fn remove_claude_server(id: &str) -> Result { - let path = claude_config_path(); + remove_claude_server_at(&claude_config_path(), &claude_settings_path(), id) +} + +fn remove_claude_server_at(path: &Path, settings: &Path, id: &str) -> Result { if !path.exists() { // Even if `~/.claude.json` is missing, `enabledPlugins` could still // have a stale entry from a prior session — clean it up regardless // so the user doesn't end up with dangling activation markers. - disable_claude_local_plugin(id)?; + disable_claude_local_plugin_at(settings, id)?; return Ok(false); } - let mut root = read_json_file(&path)?; + let mut root = read_json_file(path)?; let Some(obj) = root.as_object_mut() else { - disable_claude_local_plugin(id)?; + disable_claude_local_plugin_at(settings, id)?; return Ok(false); }; let Some(servers) = obj.get_mut("mcpServers").and_then(Value::as_object_mut) else { - disable_claude_local_plugin(id)?; + disable_claude_local_plugin_at(settings, id)?; return Ok(false); }; let removed = servers.remove(id).is_some(); if removed { - write_json_file(&path, &root)?; + write_json_file(path, &root)?; } - disable_claude_local_plugin(id)?; + disable_claude_local_plugin_at(settings, id)?; Ok(removed) } @@ -1788,9 +1851,8 @@ fn remove_claude_server(id: &str) -> Result { /// servers from `~/.claude.json.mcpServers` (a server can be defined but /// will not load until it appears in this list). Existing fields in the /// settings file (env, model, other plugin entries) are preserved. -fn enable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { - let path = claude_settings_path(); - let mut root = read_json_file(&path)?; +fn enable_claude_local_plugin_at(path: &Path, id: &str) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -1818,18 +1880,17 @@ fn enable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { return Ok(()); } plugins.insert(key, Value::Bool(true)); - write_json_file(&path, &root) + write_json_file(path, &root) } /// Remove `@local` from `~/.claude/settings.json.enabledPlugins` if /// present. Other entries (including any `@` that /// the user manages manually) are intentionally left untouched. -fn disable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { - let path = claude_settings_path(); +fn disable_claude_local_plugin_at(path: &Path, id: &str) -> Result<(), AppCommandError> { if !path.exists() { return Ok(()); } - let mut root = read_json_file(&path)?; + let mut root = read_json_file(path)?; let Some(obj) = root.as_object_mut() else { return Ok(()); }; @@ -1838,7 +1899,7 @@ fn disable_claude_local_plugin(id: &str) -> Result<(), AppCommandError> { }; let key = claude_local_plugin_key(id); if plugins.remove(&key).is_some() { - write_json_file(&path, &root)?; + write_json_file(path, &root)?; } Ok(()) } @@ -2033,7 +2094,11 @@ fn read_codex_servers() -> Result, AppCommandError> { } fn upsert_codex_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let mut root = read_codex_root_toml()?; + upsert_codex_server_at(&codex_config_toml_path(), id, spec) +} + +fn upsert_codex_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_codex_root_toml_at(path)?; let table = root .as_table_mut() .ok_or_else(|| mcp_configuration_invalid("Codex root TOML must be a table"))?; @@ -2072,16 +2137,19 @@ fn upsert_codex_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { } } - write_codex_root_toml(&root) + write_codex_root_toml_at(path, &root) } fn remove_codex_server(id: &str) -> Result { - let path = codex_config_toml_path(); + remove_codex_server_at(&codex_config_toml_path(), id) +} + +fn remove_codex_server_at(path: &Path, id: &str) -> Result { if !path.exists() { return Ok(false); } - let mut root = read_codex_root_toml()?; + let mut root = read_codex_root_toml_at(path)?; let Some(table) = root.as_table_mut() else { return Ok(false); }; @@ -2114,7 +2182,7 @@ fn remove_codex_server(id: &str) -> Result { } if removed { - write_codex_root_toml(&root)?; + write_codex_root_toml_at(path, &root)?; } Ok(removed) @@ -2159,8 +2227,11 @@ fn read_opencode_servers() -> Result, AppCommandError> { } fn upsert_opencode_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let path = opencode_config_path(); - let mut root = read_json_file(&path)?; + upsert_opencode_server_at(&opencode_config_path(), id, spec) +} + +fn upsert_opencode_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -2192,16 +2263,19 @@ fn upsert_opencode_server(id: &str, spec: &Value) -> Result<(), AppCommandError> map.insert(id.to_string(), converted); } - write_json_file(&path, &root) + write_json_file(path, &root) } fn remove_opencode_server(id: &str) -> Result { - let path = opencode_config_path(); + remove_opencode_server_at(&opencode_config_path(), id) +} + +fn remove_opencode_server_at(path: &Path, id: &str) -> Result { if !path.exists() { return Ok(false); } - let mut root = read_json_file(&path)?; + let mut root = read_json_file(path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; @@ -2217,7 +2291,7 @@ fn remove_opencode_server(id: &str) -> Result { } if removed { - write_json_file(&path, &root)?; + write_json_file(path, &root)?; } Ok(removed) @@ -2251,8 +2325,11 @@ fn read_gemini_servers() -> Result, AppCommandError> { } fn upsert_gemini_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let path = gemini_config_path(); - let mut root = read_json_file(&path)?; + upsert_gemini_server_at(&gemini_config_path(), id, spec) +} + +fn upsert_gemini_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -2274,16 +2351,19 @@ fn upsert_gemini_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { })?; map.insert(id.to_string(), canonical); - write_json_file(&path, &root) + write_json_file(path, &root) } fn remove_gemini_server(id: &str) -> Result { - let path = gemini_config_path(); + remove_gemini_server_at(&gemini_config_path(), id) +} + +fn remove_gemini_server_at(path: &Path, id: &str) -> Result { if !path.exists() { return Ok(false); } - let mut root = read_json_file(&path)?; + let mut root = read_json_file(path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; @@ -2293,7 +2373,7 @@ fn remove_gemini_server(id: &str) -> Result { let removed = servers.remove(id).is_some(); if removed { - write_json_file(&path, &root)?; + write_json_file(path, &root)?; } Ok(removed) } @@ -2443,8 +2523,11 @@ fn canonical_to_cline_entry(spec: &Value) -> Result { } fn upsert_cline_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { - let path = cline_config_path(); - let mut root = read_json_file(&path)?; + upsert_cline_server_at(&cline_config_path(), id, spec) +} + +fn upsert_cline_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { + let mut root = read_json_file(path)?; if !root.is_object() { root = json!({}); } @@ -2466,16 +2549,19 @@ fn upsert_cline_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { })?; map.insert(id.to_string(), canonical); - write_json_file(&path, &root) + write_json_file(path, &root) } fn remove_cline_server(id: &str) -> Result { - let path = cline_config_path(); + remove_cline_server_at(&cline_config_path(), id) +} + +fn remove_cline_server_at(path: &Path, id: &str) -> Result { if !path.exists() { return Ok(false); } - let mut root = read_json_file(&path)?; + let mut root = read_json_file(path)?; let Some(obj) = root.as_object_mut() else { return Ok(false); }; @@ -2485,7 +2571,7 @@ fn remove_cline_server(id: &str) -> Result { let removed = servers.remove(id).is_some(); if removed { - write_json_file(&path, &root)?; + write_json_file(path, &root)?; } Ok(removed) } @@ -2546,6 +2632,10 @@ fn deepseek_mcp_json_path() -> PathBuf { /// The parent is created `0700` when it does not exist yet, matching what the /// harness itself does with `$DSH_HOME`. fn write_deepseek_json_file(path: &Path, value: &Value) -> Result<(), AppCommandError> { + // See `write_json_file`: an unchanged config is not rewritten. + if json_file_holds(path, value) { + return Ok(()); + } if let Some(parent) = path.parent() { if !parent.exists() { #[cfg(unix)] @@ -2980,6 +3070,7 @@ fn with_upsert_preflight( upsert_targets: &BTreeSet, mutate: impl FnOnce() -> Result, ) -> Result { + let _guard = MCP_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); with_preflight(|| preflight_antigravity_upsert(upsert_targets), mutate) } @@ -3314,6 +3405,241 @@ fn require_complete_scan(scan: &LocalMcpScan) -> Result<(), AppCommandError> { ))) } +fn isolator_family_for_app( + app: McpAppType, +) -> Option { + use crate::acp::family_isolator::IsolatorFamily; + match app { + McpAppType::ClaudeCode => Some(IsolatorFamily::Claude), + McpAppType::Codex => Some(IsolatorFamily::Codex), + McpAppType::Grok => Some(IsolatorFamily::Grok), + McpAppType::Gemini => Some(IsolatorFamily::Gemini), + McpAppType::OpenCode => Some(IsolatorFamily::OpenCode), + _ => None, + } +} + +/// After a default-home write/remove, keep extra isolated family homes in +/// lock-step. Auth files are never touched. `spec = None` means remove. +fn fanout_server_for_extra_homes( + app: McpAppType, + id: &str, + spec: Option<&Value>, + extra_homes: &[PathBuf], +) -> Result<(), AppCommandError> { + let Some(family) = isolator_family_for_app(app) else { + return Ok(()); + }; + for home in extra_homes { + match family { + crate::acp::family_isolator::IsolatorFamily::Claude => { + // CLAUDE_CONFIG_DIR relocates both files. This account does + // not read server definitions from the default home. + let config = home.join(".claude.json"); + let settings = home.join("settings.json"); + if let Some(spec) = spec { + upsert_claude_server_at(&config, &settings, id, spec)?; + } else { + let _ = remove_claude_server_at(&config, &settings, id)?; + } + } + crate::acp::family_isolator::IsolatorFamily::Codex => { + let path = home.join("config.toml"); + if let Some(spec) = spec { + upsert_codex_server_at(&path, id, spec)?; + } else { + let _ = remove_codex_server_at(&path, id)?; + } + } + crate::acp::family_isolator::IsolatorFamily::Grok => { + let path = home.join("config.toml"); + if let Some(spec) = spec { + upsert_grok_server_at(&path, id, spec)?; + } else { + let _ = remove_grok_server_at(&path, id)?; + } + } + crate::acp::family_isolator::IsolatorFamily::Gemini => { + let path = home.join("settings.json"); + if let Some(spec) = spec { + upsert_gemini_server_at(&path, id, spec)?; + } else { + let _ = remove_gemini_server_at(&path, id)?; + } + } + crate::acp::family_isolator::IsolatorFamily::OpenCode => { + let path = home.join("opencode.json"); + if let Some(spec) = spec { + upsert_opencode_server_at(&path, id, spec)?; + } else { + let _ = remove_opencode_server_at(&path, id)?; + } + } + } + } + Ok(()) +} + +fn fanout_live_extra_homes( + app: McpAppType, + id: &str, + spec: Option<&Value>, +) -> Result<(), AppCommandError> { + let Some(family) = isolator_family_for_app(app) else { + return Ok(()); + }; + let homes = crate::acp::family_isolator::extra_homes_from_live_registry(family); + fanout_server_for_extra_homes(app, id, spec, &homes) +} + +/// Update only the managed server in a native account's own config. Never +/// copy a complete settings or authentication file from another account. +fn write_native_account_mcp( + agent: crate::models::AgentType, + home: &Path, + id: &str, + spec: Option<&Value>, +) -> Result<(), AppCommandError> { + use crate::models::AgentType; + let layout = crate::acp::agent_accounts::layout(agent) + .ok_or_else(|| mcp_invalid_input("Unsupported agent account"))?; + let config = layout.config_dir(home); + let app: McpAppType = serde_json::from_value(serde_json::json!(agent)) + .map_err(|e| mcp_invalid_input(e.to_string()))?; + if isolator_family_for_app(app).is_some() { + return fanout_server_for_extra_homes(app, id, spec, &[config]); + } + if agent == AgentType::CodeBuddy { + let path = home.join(".codebuddy.json"); + let settings = home.join("settings.json"); + return match spec { + Some(spec) => upsert_claude_server_at(&path, &settings, id, spec), + None => remove_claude_server_at(&path, &settings, id).map(|_| ()), + }; + } + type Write = fn(&Path, &str, &Value) -> Result<(), AppCommandError>; + type Remove = fn(&Path, &str) -> Result; + let (path, write, remove): (PathBuf, Write, Remove) = match agent { + AgentType::Cline => (home.join("settings/cline_mcp_settings.json"), upsert_cline_server_at, remove_cline_server_at), + AgentType::Hermes => (home.join("config.yaml"), upsert_hermes_server_at, remove_hermes_server_at), + AgentType::KimiCode => (home.join("mcp.json"), upsert_kimi_code_server_at, remove_kimi_code_server_at), + AgentType::Pi => (home.join("mcp.json"), upsert_pi_server_at, remove_pi_server_at), + AgentType::DeepSeek => (home.join("mcp.json"), upsert_deepseek_server_at, remove_deepseek_server_at), + AgentType::Qoder => (home.join("settings.json"), upsert_qoder_server_at, remove_qoder_server_at), + AgentType::Antigravity => (home.join("config/mcp_config.json"), upsert_antigravity_server_at, remove_antigravity_server_at), + // Cursor hardcodes HOME/.cursor/mcp.json, independently of its config + // directory. Only macOS needs a relocated HOME for credential isolation. + AgentType::Cursor if cfg!(target_os = "macos") => (home.join(".cursor/mcp.json"), |path, id, spec| upsert_cursor_server_at(path, id, spec, None), remove_cursor_server_at), + AgentType::Cursor => return Ok(()), + _ => return Err(mcp_invalid_input("Unsupported account MCP configuration")), + }; + match spec { Some(spec) => write(&path, id, spec), None => remove(&path, id).map(|_| ()) } +} + +pub(crate) fn sync_native_account_mcp(agent: crate::models::AgentType, home: &Path) -> Result<(), AppCommandError> { + let _guard = MCP_CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let servers = shared_servers_for_account(agent)?; + sync_native_account_mcp_from(agent, home, &servers) +} + +/// The servers assigned to `agent`, as its own config store holds them — the +/// set a named account mirrors. `read_servers_for_agent_type` answers what to +/// forward over ACP instead, which for pi is always nothing (pi-acp drops +/// wire MCP); syncing to that would remove every server fanned into a pi +/// account on each launch. +pub(crate) fn shared_servers_for_account( + agent: crate::models::AgentType, +) -> Result, AppCommandError> { + match agent { + crate::models::AgentType::Pi => read_pi_servers(), + _ => read_servers_for_agent_type(agent), + } +} + +fn sync_native_account_mcp_from(agent: crate::models::AgentType, home: &Path, servers: &BTreeMap) -> Result<(), AppCommandError> { + if !home.is_absolute() || !home.is_dir() { return Err(mcp_configuration_invalid("Restore the missing account folder before continuing.")); } + preflight_native_account_homes(&[(agent, home.to_path_buf())])?; + let manifest_path = home.join(".codeg-mcp-servers.json"); + let previous: Vec = match read_config_to_string(&manifest_path)? { + Some(raw) => serde_json::from_str(&raw).map_err(|e| mcp_configuration_invalid(format!("Invalid account MCP manifest: {e}")))?, + None => Vec::new(), + }; + for id in previous { if !servers.contains_key(&id) { write_native_account_mcp(agent, home, &id, None)?; } } + for (id, spec) in servers { write_native_account_mcp(agent, home, id, Some(spec))?; } + write_json_file(&manifest_path, &serde_json::json!(servers.keys().collect::>())) +} + +/// The homes a shared MCP change is fanned into. An account whose folder is +/// missing is skipped rather than blocking every MCP change: it cannot launch +/// until the folder is back, and its next launch re-applies the full shared +/// set (`sync_native_account_mcp`), so nothing is lost by skipping it here. +async fn native_account_homes(db: &crate::db::AppDatabase) -> Result, AppCommandError> { + let mut result = Vec::new(); + for account in crate::db::service::agent_account_service::list_all(&db.conn).await.map_err(AppCommandError::from)? { + let Some(agent) = crate::models::AgentType::from_wire(&account.agent_type) else { + tracing::warn!("[MCP] skipping account {} with unknown agent {}", account.id, account.agent_type); + continue; + }; + let home = PathBuf::from(account.home_dir); + if !home.is_absolute() || !home.is_dir() { + tracing::warn!("[MCP] skipping account {} until its folder {} is restored", account.name, home.display()); + continue; + } + result.push((agent, home)); + } + Ok(result) +} + +fn fanout_native_account_homes(accounts: &[(crate::models::AgentType, PathBuf)], app: McpAppType, id: &str, spec: Option<&Value>) -> Result<(), AppCommandError> { + for (agent, home) in accounts { + if serde_json::to_value(agent).ok() == serde_json::to_value(app).ok() { + write_native_account_mcp(*agent, home, id, spec)?; + let manifest = home.join(".codeg-mcp-servers.json"); + let mut managed: BTreeSet = match read_config_to_string(&manifest)? { + Some(raw) => serde_json::from_str(&raw).map_err(|e| mcp_configuration_invalid(format!("Invalid account MCP manifest: {e}")))?, + None => BTreeSet::new(), + }; + if spec.is_some() { managed.insert(id.into()); } else { managed.remove(id); } + write_json_file(&manifest, &serde_json::json!(managed))?; + } + } + Ok(()) +} + +/// Validate every account before a reassignment can remove the shared server +/// from another family. An unreadable account must not cause a partial move. +fn preflight_native_account_homes(accounts: &[(crate::models::AgentType, PathBuf)]) -> Result<(), AppCommandError> { + use crate::models::AgentType; + for (agent, home) in accounts { + if let Some(raw) = read_config_to_string(&home.join(".codeg-mcp-servers.json"))? { + serde_json::from_str::>(&raw).map_err(|e| mcp_configuration_invalid(format!("Invalid account MCP manifest: {e}")))?; + } + let paths: &[&str] = match agent { + AgentType::ClaudeCode => &[".claude.json", "settings.json"], + AgentType::CodeBuddy => &[".codebuddy.json", "settings.json"], + AgentType::Codex => { read_codex_root_toml_at(&home.join("config.toml"))?; continue; }, + AgentType::Grok => { read_grok_root_toml_at(&home.join("config.toml"))?; continue; }, + AgentType::Hermes => { read_hermes_servers_at(&home.join("config.yaml"))?; continue; }, + AgentType::Gemini => &[".gemini/settings.json"], + AgentType::OpenCode => &["config/opencode/opencode.json"], + AgentType::Cline => &["settings/cline_mcp_settings.json"], + AgentType::KimiCode | AgentType::Pi | AgentType::DeepSeek => &["mcp.json"], + AgentType::Qoder => &["settings.json"], + AgentType::Antigravity => &["config/mcp_config.json"], + AgentType::Cursor if cfg!(target_os = "macos") => &[".cursor/mcp.json"], + _ => &[], + }; + for path in paths { + let path = home.join(path); + let root = read_json_file(&path)?; + if !root.is_object() || (*agent == AgentType::Antigravity && antigravity_config_blocks_edit(&root)) { + return Err(mcp_configuration_invalid(format!("Fix the account MCP configuration at {} before changing server assignments.", path.display()))); + } + } + } + Ok(()) +} + fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), AppCommandError> { let spec = &without_cursor_only_fields(app, spec); match app { @@ -3336,7 +3662,8 @@ fn upsert_server_for_app(app: McpAppType, id: &str, spec: &Value) -> Result<(), McpAppType::ZCode => upsert_deepseek_server_at(&zcode_mcp_json_path(), id, spec), McpAppType::Trae => upsert_trae_server(id, spec), McpAppType::CommandCode => upsert_command_code_server(id, spec), - } + }?; + fanout_live_extra_homes(app, id, Some(spec)) } pub fn read_servers_for_agent_type( @@ -3773,6 +4100,10 @@ fn read_grok_root_toml_at(path: &Path) -> Result { } fn write_grok_root_toml_at(path: &Path, root: &toml::Value) -> Result<(), AppCommandError> { + // See `write_json_file`: an unchanged config is not rewritten. + if toml_file_holds(path, root) { + return Ok(()); + } if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(AppCommandError::io)?; } @@ -5257,14 +5588,17 @@ fn read_hermes_servers_at(path: &Path) -> Result, AppCom /// serde_yaml and so drops comments — consistent with codeg's existing Hermes /// config edits. fn upsert_hermes_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { + upsert_hermes_server_at(&crate::commands::acp::hermes_config_yaml_path(), id, spec) +} + +fn upsert_hermes_server_at(path: &Path, id: &str, spec: &Value) -> Result<(), AppCommandError> { use serde_yaml::{Mapping, Value as Yaml}; let entry = canonical_to_hermes_entry(spec)?; - let path = crate::commands::acp::hermes_config_yaml_path(); // Only a genuinely absent (or empty) config starts from a fresh mapping. // A permission / invalid-UTF-8 read error must NOT silently discard the // user's real config.yaml by overwriting it with a near-empty document. - let mut root: Yaml = match fs::read_to_string(&path) { + let mut root: Yaml = match fs::read_to_string(path) { Ok(raw) if !raw.trim().is_empty() => serde_yaml::from_str(&raw) .map_err(|e| mcp_configuration_invalid(format!("invalid hermes config.yaml: {e}")))?, Ok(_) => Yaml::Mapping(Mapping::new()), @@ -5291,23 +5625,31 @@ fn upsert_hermes_server(id: &str, spec: &Value) -> Result<(), AppCommandError> { .get_mut(&servers_key) .and_then(Yaml::as_mapping_mut) .ok_or_else(|| mcp_configuration_invalid("hermes mcp_servers must be a mapping"))?; - servers.insert(Yaml::String(id.to_string()), entry); + let key = Yaml::String(id.to_string()); + // See `write_json_file`: an unchanged config is not rewritten. + if servers.get(&key) == Some(&entry) { + return Ok(()); + } + servers.insert(key, entry); let yaml = serde_yaml::to_string(&root).map_err(|e| { mcp_configuration_invalid(format!("serialize hermes config.yaml failed: {e}")) })?; - crate::commands::acp::ensure_hermes_home_secure(&crate::commands::acp::hermes_home_dir()) + crate::commands::acp::ensure_hermes_home_secure(path.parent().ok_or_else(|| mcp_configuration_invalid("missing account config directory"))?) .map_err(|e| mcp_configuration_invalid(format!("prepare hermes home failed: {e}")))?; - crate::commands::acp::write_hermes_secret_file(&path, &yaml, "config.yaml") + crate::commands::acp::write_hermes_secret_file(path, &yaml, "config.yaml") .map_err(|e| mcp_configuration_invalid(format!("write hermes config.yaml failed: {e}")))?; Ok(()) } /// Remove a Hermes MCP server from `~/.hermes/config.yaml` (`mcp_servers`). fn remove_hermes_server(id: &str) -> Result { + remove_hermes_server_at(&crate::commands::acp::hermes_config_yaml_path(), id) +} + +fn remove_hermes_server_at(path: &Path, id: &str) -> Result { use serde_yaml::Value as Yaml; - let path = crate::commands::acp::hermes_config_yaml_path(); - let raw = match fs::read_to_string(&path) { + let raw = match fs::read_to_string(path) { Ok(raw) if !raw.trim().is_empty() => raw, _ => return Ok(false), }; @@ -5336,7 +5678,7 @@ fn remove_hermes_server(id: &str) -> Result { let yaml = serde_yaml::to_string(&root).map_err(|e| { mcp_configuration_invalid(format!("serialize hermes config.yaml failed: {e}")) })?; - crate::commands::acp::write_hermes_secret_file(&path, &yaml, "config.yaml").map_err( + crate::commands::acp::write_hermes_secret_file(path, &yaml, "config.yaml").map_err( |e| mcp_configuration_invalid(format!("write hermes config.yaml failed: {e}")), )?; } @@ -5344,7 +5686,7 @@ fn remove_hermes_server(id: &str) -> Result { } fn remove_server_for_app(app: McpAppType, id: &str) -> Result { - match app { + let removed = match app { McpAppType::ClaudeCode => remove_claude_server(id), McpAppType::Codex => remove_codex_server(id), McpAppType::OpenCode => remove_opencode_server(id), @@ -5364,7 +5706,11 @@ fn remove_server_for_app(app: McpAppType, id: &str) -> Result remove_deepseek_server_at(&zcode_mcp_json_path(), id), McpAppType::Trae => remove_trae_server(id), McpAppType::CommandCode => remove_command_code_server(id), - } + }?; + // Always fan out the remove so a stale extra-home entry cannot outlive + // the family-row uncheck, even if the default home had nothing to drop. + fanout_live_extra_homes(app, id, None)?; + Ok(removed) } #[derive(Debug, Deserialize)] @@ -7120,6 +7466,152 @@ fn resolve_smithery_install_spec_with_selection( mod tests { use super::*; + #[test] + fn native_account_bad_config_stops_reassignment_before_other_homes_change() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("config")).unwrap(); + fs::write(dir.path().join("config/mcp_config.json"), r#"{"mcpServers":[]}"#).unwrap(); + let mut changed = false; + let result = with_preflight( + || preflight_native_account_homes(&[(crate::models::AgentType::Antigravity, dir.path().to_path_buf())]), + || { changed = true; Ok(()) }, + ); + assert!(result.is_err()); + assert!(!changed); + } + + #[test] + fn native_accounts_share_mcp_without_copying_auth_or_removing_local_servers() { + use crate::models::AgentType; + for &agent in crate::models::agent::BUILTIN_AGENT_TYPES { + if crate::acp::agent_accounts::layout(agent).is_none() { continue; } + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + fs::write(home.join("auth.json"), "account-auth-sentinel").unwrap(); + let local = json!({"type":"stdio", "command":"account-local-command"}); + let shared = json!({"type":"stdio", "command":"shared-first-command"}); + write_native_account_mcp(agent, home, "account-local", Some(&local)).unwrap(); + sync_native_account_mcp_from(agent, home, &BTreeMap::from([("shared".into(), shared)])).unwrap(); + let path = match agent { + AgentType::ClaudeCode => Some(".claude.json"), + AgentType::Codex | AgentType::Grok => Some("config.toml"), + AgentType::Gemini => Some(".gemini/settings.json"), + AgentType::OpenCode => Some("config/opencode/opencode.json"), + AgentType::CodeBuddy => Some(".codebuddy.json"), + AgentType::Cline => Some("settings/cline_mcp_settings.json"), + AgentType::Hermes => Some("config.yaml"), + AgentType::KimiCode | AgentType::Pi | AgentType::DeepSeek => Some("mcp.json"), + AgentType::Qoder => Some("settings.json"), + AgentType::Antigravity => Some("config/mcp_config.json"), + AgentType::Cursor if cfg!(target_os = "macos") => Some(".cursor/mcp.json"), + AgentType::Cursor => None, + _ => unreachable!(), + }; + if let Some(path) = path { + let file = home.join(path); + let contents = fs::read_to_string(&file).unwrap(); + assert!(contents.contains("shared-first-command"), "{agent}"); + assert!(contents.contains("account-local-command"), "{agent}"); + sync_native_account_mcp_from(agent, home, &BTreeMap::from([("shared".into(), json!({"type":"stdio", "command":"shared-updated-command"}))])).unwrap(); + assert!(fs::read_to_string(&file).unwrap().contains("shared-updated-command")); + sync_native_account_mcp_from(agent, home, &BTreeMap::new()).unwrap(); + let contents = fs::read_to_string(&file).unwrap(); + assert!(!contents.contains("shared-updated-command"), "{agent}"); + assert!(contents.contains("account-local-command"), "{agent}"); + } + assert_eq!(fs::read_to_string(home.join("auth.json")).unwrap(), "account-auth-sentinel"); + } + } + + /// Every launch of a named account re-applies the shared servers to files + /// that account's own CLI rewrites too (`.claude.json` on every start). When + /// nothing changed they must not be rewritten: here the CLI's own + /// formatting and a user's TOML comment survive, while a real change still + /// lands. + #[test] + fn an_unchanged_account_sync_leaves_the_cli_files_untouched() { + use crate::models::AgentType; + let shared = BTreeMap::from([( + "shared".to_string(), + json!({"type": "stdio", "command": "shared-command"}), + )]); + + let claude = tempfile::tempdir().unwrap(); + sync_native_account_mcp_from(AgentType::ClaudeCode, claude.path(), &shared).unwrap(); + let config = claude.path().join(".claude.json"); + let mut value: Value = serde_json::from_str(&fs::read_to_string(&config).unwrap()).unwrap(); + value["oauthAccount"] = json!({"emailAddress": "fixture@example.test"}); + let cli_written = serde_json::to_string(&value).unwrap(); + fs::write(&config, &cli_written).unwrap(); + sync_native_account_mcp_from(AgentType::ClaudeCode, claude.path(), &shared).unwrap(); + assert_eq!(fs::read_to_string(&config).unwrap(), cli_written); + + let codex = tempfile::tempdir().unwrap(); + sync_native_account_mcp_from(AgentType::Codex, codex.path(), &shared).unwrap(); + let toml_path = codex.path().join("config.toml"); + let with_comment = format!( + "# kept by the user\n{}", + fs::read_to_string(&toml_path).unwrap() + ); + fs::write(&toml_path, &with_comment).unwrap(); + sync_native_account_mcp_from(AgentType::Codex, codex.path(), &shared).unwrap(); + assert_eq!(fs::read_to_string(&toml_path).unwrap(), with_comment); + + let changed = BTreeMap::from([( + "shared".to_string(), + json!({"type": "stdio", "command": "changed-command"}), + )]); + sync_native_account_mcp_from(AgentType::Codex, codex.path(), &changed).unwrap(); + assert!(fs::read_to_string(&toml_path) + .unwrap() + .contains("changed-command")); + } + + /// A pi account mirrors pi's own server list. The ACP forwarding view is + /// empty for pi by design, and syncing to it removed every server fanned + /// into the account again on its next launch. + #[test] + fn a_pi_account_launch_keeps_the_servers_pi_is_assigned() { + use crate::models::AgentType; + let default_dir = tempfile::tempdir().unwrap(); + let account = tempfile::tempdir().unwrap(); + fs::write( + default_dir.path().join("mcp.json"), + json!({"mcpServers": {"shared": {"command": "pi-shared"}}}).to_string(), + ) + .unwrap(); + temp_env::with_var("PI_CODING_AGENT_DIR", Some(default_dir.path()), || { + assert!(read_servers_for_agent_type(AgentType::Pi).unwrap().is_empty()); + for _ in 0..2 { + sync_native_account_mcp(AgentType::Pi, account.path()).unwrap(); + let synced = read_pi_servers_at(&account.path().join("mcp.json")).unwrap(); + assert!(synced.contains_key("shared")); + } + }); + } + + /// One account folder that went missing must not block every MCP change: + /// that account cannot launch until it is restored, and its next launch + /// re-applies the whole shared set anyway. + #[tokio::test] + async fn a_missing_account_folder_does_not_block_mcp_changes() { + use crate::db::service::agent_account_service; + use crate::models::AgentType; + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let data = tempfile::tempdir().unwrap(); + let kept = agent_account_service::create(&db.conn, data.path(), AgentType::Codex, "Kept", None) + .await + .unwrap(); + let gone = agent_account_service::create(&db.conn, data.path(), AgentType::Codex, "Gone", None) + .await + .unwrap(); + fs::remove_dir_all(&gone.home_dir).unwrap(); + assert_eq!( + native_account_homes(&db).await.unwrap(), + vec![(AgentType::Codex, PathBuf::from(&kept.home_dir))] + ); + } + fn test_claude_servers() -> Result, AppCommandError> { Ok(BTreeMap::from([ ( @@ -10231,6 +10723,236 @@ mod tests { assert_eq!(back, canonical, "round-trip mismatch for {spec}"); } } + + #[test] + fn extra_home_fanout_writes_and_removes_without_touching_auth() { + let dir = tempfile::tempdir().expect("tempdir"); + let claude_home = dir.path().join("claude-2"); + let codex_home = dir.path().join("codex-2"); + let gemini_home = dir.path().join("gemini-2"); + let opencode_home = dir.path().join("opencode-2"); + std::fs::create_dir_all(&claude_home).expect("claude home"); + std::fs::create_dir_all(&codex_home).expect("codex home"); + std::fs::create_dir_all(&gemini_home).expect("gemini home"); + std::fs::create_dir_all(&opencode_home).expect("opencode home"); + + let claude_settings = claude_home.join("settings.json"); + std::fs::write( + &claude_settings, + "{\n \"model\": \"keep-me\",\n \"enabledPlugins\": {\"other@local\": true}\n}\n", + ) + .expect("seed claude settings"); + let claude_auth = claude_home.join("auth.json"); + let claude_config = claude_home.join(".claude.json"); + std::fs::write(&claude_config, "{\"account\":\"keep\",\"mcpServers\":{\"other\":{\"command\":\"other\"}}}") + .expect("seed isolated config"); + let auth_bytes = b"{\"token\":\"do-not-copy\"}\n"; + std::fs::write(&claude_auth, auth_bytes).expect("seed auth"); + + std::fs::write( + codex_home.join("config.toml"), + "[cli]\nauto_update = true\n", + ) + .expect("seed codex"); + std::fs::write( + gemini_home.join("settings.json"), + "{\n \"theme\": \"dark\"\n}\n", + ) + .expect("seed gemini"); + std::fs::write( + opencode_home.join("opencode.json"), + "{\n \"model\": \"keep\"\n}\n", + ) + .expect("seed opencode"); + + let spec = json!({ + "type": "stdio", + "command": "npx", + "args": ["-y", "ctx7-mcp"], + }); + + fanout_server_for_extra_homes( + McpAppType::ClaudeCode, + "ctx7", + Some(&spec), + std::slice::from_ref(&claude_home), + ) + .expect("claude fanout"); + fanout_server_for_extra_homes( + McpAppType::Codex, + "ctx7", + Some(&spec), + std::slice::from_ref(&codex_home), + ) + .expect("codex fanout"); + fanout_server_for_extra_homes( + McpAppType::Gemini, + "ctx7", + Some(&spec), + std::slice::from_ref(&gemini_home), + ) + .expect("gemini fanout"); + fanout_server_for_extra_homes( + McpAppType::OpenCode, + "ctx7", + Some(&spec), + std::slice::from_ref(&opencode_home), + ) + .expect("opencode fanout"); + + let claude_root: Value = + serde_json::from_str(&std::fs::read_to_string(&claude_settings).unwrap()).unwrap(); + assert_eq!( + claude_root.pointer("/enabledPlugins/ctx7@local"), + Some(&json!(true)) + ); + assert_eq!( + claude_root.pointer("/enabledPlugins/other@local"), + Some(&json!(true)) + ); + assert_eq!(claude_root.get("model").and_then(Value::as_str), Some("keep-me")); + assert_eq!(std::fs::read(&claude_auth).unwrap(), auth_bytes); + let config: Value = serde_json::from_str(&std::fs::read_to_string(&claude_config).unwrap()).unwrap(); + assert_eq!(config.pointer("/mcpServers/ctx7/command"), Some(&json!("npx"))); + assert_eq!(config.get("account"), Some(&json!("keep"))); + assert!(config.pointer("/mcpServers/other").is_some()); + + let codex_raw = std::fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let codex_root: toml::Value = codex_raw.parse().unwrap(); + assert!(codex_root + .get("mcp_servers") + .and_then(toml::Value::as_table) + .map(|t| t.contains_key("ctx7")) + .unwrap_or(false)); + assert!(codex_root.get("cli").is_some(), "unrelated Codex keys survive"); + + let gemini_root: Value = serde_json::from_str( + &std::fs::read_to_string(gemini_home.join("settings.json")).unwrap(), + ) + .unwrap(); + assert!(gemini_root + .pointer("/mcpServers/ctx7") + .is_some()); + assert_eq!(gemini_root.get("theme").and_then(Value::as_str), Some("dark")); + + let opencode_root: Value = serde_json::from_str( + &std::fs::read_to_string(opencode_home.join("opencode.json")).unwrap(), + ) + .unwrap(); + assert!( + opencode_root.pointer("/mcp/ctx7").is_some() + || opencode_root.pointer("/mcpServers/ctx7").is_some() + ); + assert_eq!( + opencode_root.get("model").and_then(Value::as_str), + Some("keep") + ); + + fanout_server_for_extra_homes( + McpAppType::ClaudeCode, + "ctx7", + None, + std::slice::from_ref(&claude_home), + ) + .expect("claude remove"); + fanout_server_for_extra_homes( + McpAppType::Codex, + "ctx7", + None, + std::slice::from_ref(&codex_home), + ) + .expect("codex remove"); + fanout_server_for_extra_homes( + McpAppType::Gemini, + "ctx7", + None, + std::slice::from_ref(&gemini_home), + ) + .expect("gemini remove"); + fanout_server_for_extra_homes( + McpAppType::OpenCode, + "ctx7", + None, + std::slice::from_ref(&opencode_home), + ) + .expect("opencode remove"); + + let claude_root: Value = + serde_json::from_str(&std::fs::read_to_string(&claude_settings).unwrap()).unwrap(); + assert!(claude_root.pointer("/enabledPlugins/ctx7@local").is_none()); + let config: Value = serde_json::from_str(&std::fs::read_to_string(&claude_config).unwrap()).unwrap(); + assert!(config.pointer("/mcpServers/ctx7").is_none()); + assert!(config.pointer("/mcpServers/other").is_some()); + assert_eq!(config.get("account"), Some(&json!("keep"))); + assert_eq!( + claude_root.pointer("/enabledPlugins/other@local"), + Some(&json!(true)) + ); + assert_eq!(std::fs::read(&claude_auth).unwrap(), auth_bytes); + + let codex_raw = std::fs::read_to_string(codex_home.join("config.toml")).unwrap(); + let codex_root: toml::Value = codex_raw.parse().unwrap(); + assert!( + !codex_root + .get("mcp_servers") + .and_then(toml::Value::as_table) + .map(|t| t.contains_key("ctx7")) + .unwrap_or(false) + ); + assert!(codex_root.get("cli").is_some()); + } +} + + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn mcp_install_from_marketplace( + db: tauri::State<'_, crate::db::AppDatabase>, + provider_id: String, + server_id: String, + apps: Vec, + spec_override: Option, + option_id: Option, + protocol: Option, + parameter_values: Option, +) -> Result { + mcp_install_from_marketplace_core(&db, provider_id, server_id, apps, spec_override, option_id, protocol, parameter_values).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn mcp_upsert_local_server( + db: tauri::State<'_, crate::db::AppDatabase>, + server_id: String, + spec: Value, + apps: Vec, + shown_spec: Option, +) -> Result { + mcp_upsert_local_server_core(&db, server_id, spec, apps, shown_spec).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn mcp_set_server_apps( + db: tauri::State<'_, crate::db::AppDatabase>, + server_id: String, + apps: Vec, +) -> Result, AppCommandError> { + mcp_set_server_apps_core(&db, server_id, apps).await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn mcp_remove_server( + db: tauri::State<'_, crate::db::AppDatabase>, + server_id: String, + apps: Option>, +) -> Result { + mcp_remove_server_core(&db, server_id, apps).await } #[cfg(test)] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c7e2037af4..74e48a92e0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -43,6 +43,7 @@ pub mod folder_commands; pub mod folder_links; pub mod folders; pub mod forge; +pub mod handoff; /// The `ui-render` skill and the agents it is enabled for (Skill Packs > UI). pub mod generative_ui; pub mod logging; @@ -63,6 +64,7 @@ pub mod remote_proxy; pub mod remote_workspace; pub mod science; pub mod session_info; +pub mod subscription_quota; pub mod system_settings; pub mod terminal; pub mod token_usage; @@ -74,3 +76,5 @@ pub mod work_task; pub mod workspace_state; #[cfg(feature = "tauri-runtime")] pub mod workspace_windows; +pub mod agent_accounts; +pub mod account_switch; diff --git a/src-tauri/src/commands/subscription_quota.rs b/src-tauri/src/commands/subscription_quota.rs new file mode 100644 index 0000000000..e9c9cece12 --- /dev/null +++ b/src-tauri/src/commands/subscription_quota.rs @@ -0,0 +1,772 @@ +//! Read official plan usage for one saved account, without changing its login. +use crate::acp::{account_auth, agent_accounts, family_isolator}; +use crate::{ + app_error::AppCommandError, + db::{ + service::{agent_account_service, agent_setting_service, custom_agent_service}, + AppDatabase, + }, + models::AgentType, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, HashMap}, + hash::{Hash, Hasher}, + path::{Path, PathBuf}, + process::Stdio, + sync::{Arc, OnceLock}, + time::{Duration, Instant}, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + sync::Mutex, +}; +#[path = "subscription_quota_credentials.rs"] +mod credentials; + +const TTL: Duration = Duration::from_secs(30); +const DEADLINE: Duration = Duration::from_secs(12); +const AGENTS: [AgentType; 5] = [ + AgentType::ClaudeCode, + AgentType::Codex, + AgentType::Grok, + AgentType::Cursor, + AgentType::OpenCode, +]; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaRequest { + pub agent_type: AgentType, + pub agent_account_id: Option, + pub conversation_id: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QuotaAccount { + pub agent_type: AgentType, + pub agent_account_id: Option, + pub family: String, + /// None means the native Default account. The client localizes that label. + pub name: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OfficialQuotaRead { + #[serde(flatten)] + pub account: QuotaAccount, + pub payload: Option, + pub checked_at: i64, +} + +struct Source { + account: QuotaAccount, + env: BTreeMap, + isolated: bool, + provider_override: bool, +} +fn error(message: impl Into) -> AppCommandError { + AppCommandError::task_execution_failed(message.into()) +} +fn family(agent: AgentType) -> Option<&'static str> { + Some(match agent { + AgentType::ClaudeCode => "claude", + AgentType::Codex => "codex", + AgentType::Grok => "grok", + AgentType::Cursor => "cursor", + AgentType::OpenCode => "opencode", + _ => return None, + }) +} +fn custom_family(env: &BTreeMap) -> Option<&'static str> { + if env + .get("CURSOR_CONFIG_DIR") + .is_some_and(|v| !v.trim().is_empty()) + { + return Some("cursor"); + } + let (family, _) = family_isolator::isolator_from_env(env)?; + let name = family.as_str(); + (name != "gemini").then_some(name) +} + +pub async fn accounts_core(db: &AppDatabase) -> Result, AppCommandError> { + let mut rows: Vec<_> = AGENTS + .into_iter() + .map(|agent| QuotaAccount { + agent_type: agent, + agent_account_id: None, + family: family(agent).unwrap().into(), + name: None, + }) + .collect(); + for account in agent_account_service::list_all(&db.conn).await? { + let Some(agent) = AgentType::from_wire(&account.agent_type) else { + continue; + }; + let Some(family) = family(agent) else { + continue; + }; + rows.push(QuotaAccount { + agent_type: agent, + agent_account_id: Some(account.id), + family: family.into(), + name: Some(account.name), + }); + } + for def in custom_agent_service::list_defs(&db.conn).await? { + if let Some(family) = custom_family(&family_isolator::spec_env_map(&def.spec)) { + let Some(agent) = AgentType::from_wire(&format!("custom:{}", def.registry_id)) else { + continue; + }; + rows.push(QuotaAccount { + agent_type: agent, + agent_account_id: None, + family: family.into(), + name: Some(def.name), + }); + } + } + Ok(rows) +} + +async fn resolve_source( + db: &AppDatabase, + data_dir: &Path, + request: QuotaRequest, +) -> Result { + let agent = request.agent_type; + let id = agent_account_service::connection_account( + &db.conn, + agent, + request.agent_account_id, + request.conversation_id, + ) + .await?; + if let Some(custom) = agent.custom_id() { + if id.is_some() { + return Err(error("This agent does not support named accounts")); + } + let row = custom_agent_service::get(&db.conn, custom) + .await? + .ok_or_else(|| error("Agent not found"))?; + let def = custom_agent_service::def_from_model(&row) + .ok_or_else(|| error("Agent configuration unavailable"))?; + let env = family_isolator::spec_env_map(&def.spec); + let family = + custom_family(&env).ok_or_else(|| error("Usage is unavailable for this agent"))?; + return Ok(Source { + account: QuotaAccount { + agent_type: agent, + agent_account_id: None, + family: family.into(), + name: Some(def.name), + }, + env, + isolated: true, + provider_override: false, + }); + } + let family = family(agent).ok_or_else(|| error("Usage is unavailable for this agent"))?; + let (name, env, isolated, provider_override) = if let Some(id) = id.as_deref() { + let account = agent_account_service::resolve(&db.conn, agent, id).await?; + // Match the account's actual launch settings, including its own API + // key, without starting a session or synchronizing MCP/skills files. + let env = account_auth::runtime_env(db, &account, data_dir) + .await.map_err(|e| error(e.to_string()))?; + (Some(account.name), env, true, false) + } else { + let setting = agent_setting_service::get_by_agent_type(&db.conn, agent).await?; + let provider_override = setting.as_ref().and_then(|s| s.model_provider_id).is_some(); + let local = super::acp::load_agent_local_config_json(agent); + let mut env = + super::acp::build_runtime_env_from_setting(agent, setting.as_ref(), local.as_deref()); + super::acp::apply_model_provider_env(agent, setting.as_ref(), &mut env, &db.conn).await; + (None, env, false, provider_override) + }; + Ok(Source { + account: QuotaAccount { + agent_type: agent, + agent_account_id: id, + family: family.into(), + name, + }, + env, + isolated, + provider_override, + }) +} + +impl Source { + fn uses_api_provider(&self) -> bool { + if self.provider_override { + return true; + } + let keys: &[&str] = match self.account.family.as_str() { + "claude" => &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + "codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"], + "grok" => &["XAI_API_KEY", "GROK_API_KEY"], + _ => &[], + }; + if keys.iter().any(|key| self.value(key).is_some()) { + return true; + } + if self.account.family == "claude" { + if self.value("ANTHROPIC_BASE_URL").is_some_and(|base| { + reqwest::Url::parse(&base) + .ok() + .and_then(|url| url.host_str().map(str::to_owned)) + .as_deref() + != Some("api.anthropic.com") + }) { + return true; + } + return [ + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + ] + .iter() + .any(|key| { + self.value(key) + .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")) + }); + } + false + } + + fn value(&self, key: &str) -> Option { + self.env + .get(key) + .cloned() + .or_else(|| (!self.isolated).then(|| std::env::var(key).ok()).flatten()) + .filter(|s| !s.trim().is_empty()) + } + fn home(&self, key: &str, default: &str) -> Option { + self.value(key).map(PathBuf::from).or_else(|| { + (!self.isolated) + .then(|| dirs::home_dir().map(|p| p.join(default))) + .flatten() + }) + } + fn credential_path(&self) -> Option { + Some(match self.account.family.as_str() { + "claude" => self + .home("CLAUDE_CONFIG_DIR", ".claude")? + .join(".credentials.json"), + "codex" => self.home("CODEX_HOME", ".codex")?.join("auth.json"), + "grok" => self.home("GROK_HOME", ".grok")?.join("auth.json"), + "cursor" if cfg!(windows) => self + .value("APPDATA") + .map(PathBuf::from)? + .join("Cursor/auth.json"), + "cursor" if cfg!(target_os = "macos") => { + self.home("HOME", "")?.join(".cursor/auth.json") + } + "cursor" => self + .home("XDG_CONFIG_HOME", ".config")? + .join("cursor/auth.json"), + "opencode" => self + .home("XDG_DATA_HOME", ".local/share")? + .join("opencode/auth.json"), + _ => return None, + }) + } + fn token(&self) -> Option { + let env_keys: &[&str] = match self.account.family.as_str() { + "claude" => &["CLAUDE_CODE_OAUTH_TOKEN"], + "cursor" => &["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"], + "opencode" => &["OPENCODE_GO_API_KEY", "OPENCODE_API_KEY"], + _ => &[], + }; + if let Some(value) = env_keys.iter().find_map(|key| self.value(key)) { + return Some(value); + } + let text = std::fs::read_to_string(self.credential_path()?).ok()?; + match self.account.family.as_str() { + "claude" => credentials::claude_oauth_access_token_from_credentials(&text), + "grok" => credentials::grok_cli_bearer_from_auth_json(&text), + "cursor" => credentials::cursor_access_token_from_auth_json(&text), + "opencode" => credentials::opencode_go_key_from_auth_json(&text), + _ => None, + } + } +} + +type CacheEntry = Arc)>>>; +type Cache = Mutex>; +static CACHE: OnceLock = OnceLock::new(); + +fn cache_key(namespace: &Path, source: &Source) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + namespace.hash(&mut hasher); + source.account.agent_type.hash(&mut hasher); + source.account.agent_account_id.hash(&mut hasher); + source.env.hash(&mut hasher); + source.provider_override.hash(&mut hasher); + source.credential_path().hash(&mut hasher); + account_auth::revision().hash(&mut hasher); + hasher.finish() +} + +pub async fn read_core( + db: &AppDatabase, + namespace: &Path, + request: QuotaRequest, +) -> Result { + // Revalidate before the cache: removed accounts cannot retrieve old data, + // and persisted conversations always own the selected account binding. + let source = resolve_source(db, namespace, request).await?; + let key = cache_key(namespace, &source); + let entry = { + let mut entries = CACHE.get_or_init(Default::default).lock().await; + if entries.len() >= 256 { + entries.retain(|_, entry| Arc::strong_count(entry) > 1); + } + entries.entry(key).or_default().clone() + }; + let mut cached = entry.lock().await; + if cached.as_ref().is_none_or(|(at, _)| at.elapsed() >= TTL) { + let payload = tokio::time::timeout(DEADLINE, read_official(&source)) + .await + .ok() + .flatten() + .map(|v| public_payload(&source.account.family, &v)); + *cached = Some((Instant::now(), payload)); + } + let (at, payload) = cached.as_ref().unwrap(); + Ok(OfficialQuotaRead { + account: source.account, + payload: payload.clone(), + checked_at: chrono::Utc::now().timestamp() - at.elapsed().as_secs() as i64, + }) +} + +async fn read_official(source: &Source) -> Option { + // A stored subscription login does not describe an API/provider override. + if source.uses_api_provider() { + return None; + } + if source.account.family == "codex" { + return read_codex(source).await; + } + let token = source.token()?; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .ok()?; + let request = match source.account.family.as_str() { + "claude" => client + .get("https://api.anthropic.com/api/oauth/usage") + .header("anthropic-beta", "oauth-2025-04-20"), + "grok" => client + .get("https://cli-chat-proxy.grok.com/v1/billing?format=credits") + .header("x-xai-token-auth", "xai-grok-cli"), + "cursor" => client + .post("https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage") + .json(&json!({})), + "opencode" => client.get("https://opencode.ai/zen/go/v1/usage"), + _ => return None, + }; + let response = request + .bearer_auth(token) + .header("Accept", "application/json") + .header("User-Agent", "codeg") + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + if response.content_length().is_some_and(|n| n > 1024 * 1024) { + return None; + } + let bytes = response.bytes().await.ok()?; + if bytes.len() > 1024 * 1024 { + return None; + } + serde_json::from_slice(&bytes).ok() +} + +async fn read_codex(source: &Source) -> Option { + let relation = crate::acp::registry::acp_adapter_relation(AgentType::Codex)?; + let executable = super::acp::resolve_vendor_cli("codex", relation.extra_dirs).await?; + let mut cmd = crate::process::tokio_command(executable); + cmd.args(["app-server", "--listen", "stdio://"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + if source.isolated { + for key in agent_accounts::credential_env_keys(AgentType::Codex) { + cmd.env_remove(key); + } + } + for (key, value) in &source.env { + if value.is_empty() { + cmd.env_remove(key); + } else { + cmd.env(key, value); + } + } + let mut child = cmd.spawn().ok()?; + let mut stdin = child.stdin.take()?; + let mut lines = BufReader::new(child.stdout.take()?).lines(); + let work = async { + let init = json!({"id":1,"method":"initialize","params":{"clientInfo":{"name":"codeg","version":env!("CARGO_PKG_VERSION")},"capabilities":{}}}); + stdin.write_all(format!("{init}\n").as_bytes()).await.ok()?; + let mut initialized = false; + while let Some(line) = lines.next_line().await.ok()? { + let Ok(message) = serde_json::from_str::(&line) else { + continue; + }; + if message.get("id") == Some(&json!(1)) && !initialized { + if message.get("error").is_some() { + return None; + } + initialized = true; + stdin.write_all(b"{\"method\":\"initialized\"}\n{\"id\":2,\"method\":\"account/rateLimits/read\",\"params\":{}}\n").await.ok()?; + } + if initialized && message.get("id") == Some(&json!(2)) { + return message.get("result").cloned(); + } + } + None + }; + let result = tokio::time::timeout(Duration::from_secs(10), work) + .await + .ok() + .flatten(); + let _ = child.kill().await; + let _ = child.wait().await; + result +} + +/// Only usage fields cross IPC/HTTP. Extra provider identity/auth fields stay private. +fn pick(value: &Value, keys: &[&str]) -> Value { + let mut out = serde_json::Map::new(); + for key in keys { + if let Some(value) = value.get(*key) { + if value.is_number() + || value.is_boolean() + || value.is_null() + || value.as_str().is_some_and(|s| s.len() <= 200) + { + out.insert((*key).into(), value.clone()); + } + } + } + Value::Object(out) +} +fn codex_limit(value: &Value) -> Value { + let mut out = pick( + value, + &["limitId", "limitName", "planType", "rateLimitReachedType"], + ); + for key in ["primary", "secondary"] { + out[key] = pick( + &value[key], + &["usedPercent", "windowDurationMins", "resetsAt"], + ); + } + out["credits"] = pick(&value["credits"], &["hasCredits", "unlimited", "balance"]); + out +} +fn public_payload(family: &str, value: &Value) -> Value { + match family { + "codex" => { + let mut out = json!({"rateLimits": codex_limit(&value["rateLimits"])}); + if let Some(limits) = value["rateLimitsByLimitId"].as_object() { + out["rateLimitsByLimitId"] = Value::Object( + limits + .iter() + .take(64) + .map(|(k, v)| (k.clone(), codex_limit(v))) + .collect(), + ); + } + out + } + "claude" => { + let mut out = json!({}); + for key in [ + "five_hour", + "seven_day", + "seven_day_opus", + "seven_day_sonnet", + "extra_usage", + ] { + out[key] = pick(&value[key], &["utilization", "resets_at", "is_enabled"]); + } + if let Some(limits) = value["limits"].as_array() { + out["limits"] = Value::Array( + limits + .iter() + .take(64) + .map(|v| { + let mut limit = pick(v, &["kind", "percent", "resets_at", "scope"]); + if v["scope"]["model"].is_object() { + limit["scope"] = json!({"model": pick(&v["scope"]["model"], &["id", "display_name"])}); + } + limit + }) + .collect(), + ); + } + out + } + "grok" => { + json!({"config": {"creditUsagePercent": value["config"]["creditUsagePercent"].as_f64(), "currentPeriod": pick(&value["config"]["currentPeriod"], &["end"])}}) + } + "cursor" => { + let mut out = pick(value, &["billingCycleEnd", "billing_cycle_end"]); + out["planUsage"] = pick( + value + .get("planUsage") + .or_else(|| value.get("plan_usage")) + .unwrap_or(&Value::Null), + &[ + "remaining", + "limit", + "totalPercentUsed", + "total_percent_used", + "autoPercentUsed", + "auto_percent_used", + "apiPercentUsed", + "api_percent_used", + ], + ); + out + } + "opencode" => { + let usage = value.get("usage").unwrap_or(value); + let mut out = json!({"usage": {}}); + for key in ["rolling", "weekly", "monthly"] { + out["usage"][key] = pick( + &usage[key], + &[ + "percent", + "usagePercent", + "usage_percent", + "resetsAt", + "resets_at", + ], + ); + } + out + } + _ => Value::Null, + } +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn subscription_quota_accounts( + db: tauri::State<'_, AppDatabase>, +) -> Result, AppCommandError> { + accounts_core(&db).await +} +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn subscription_quota_read( + db: tauri::State<'_, AppDatabase>, + app: tauri::AppHandle, + request: QuotaRequest, +) -> Result { + use tauri::Manager; + let namespace = app + .path() + .app_data_dir() + .map_err(|_| error("Application folder unavailable"))?; + read_core( + &db, + &crate::paths::resolve_effective_data_dir(&namespace), + request, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn configured_key_aliases_are_preserved_without_inheriting_default_credentials() { + for (agent, key) in [ + (AgentType::OpenCode, "OPENCODE_API_KEY"), + (AgentType::Cursor, "CURSOR_AUTH_TOKEN"), + ] { + let mut source = Source { + account: QuotaAccount { + agent_type: agent, + agent_account_id: None, + family: family(agent).unwrap().into(), + name: None, + }, + env: BTreeMap::from([(key.into(), "own-fixture-key".into())]), + isolated: true, + provider_override: false, + }; + assert_eq!(source.token().as_deref(), Some("own-fixture-key")); + source.env.clear(); + assert!(source.token().is_none()); + assert!(agent_accounts::credential_env_keys(agent).contains(&key)); + } + } + #[test] + fn response_only_contains_allowlisted_usage() { + let v = public_payload( + "claude", + &json!({"email":"private@example.com", "accessToken":"secret", "limits":[{"kind":"weekly_all","percent":28,"scope":"model","accessToken":"secret"}]}), + ); + assert_eq!(v["limits"][0]["percent"], 28); + assert!(!v.to_string().contains("secret")); + assert!(!v.to_string().contains("private@")); + let v = public_payload( + "codex", + &json!({"rateLimits":{"primary":{"usedPercent":20,"email":"private@example.com"},"credits":{"hasCredits":true,"balance":"10","accessToken":"secret"}},"auth":"secret"}), + ); + assert_eq!(v["rateLimits"]["primary"]["usedPercent"], 20); + assert!(!v.to_string().contains("secret")); + } + #[tokio::test] + async fn refuses_removed_and_wrong_family_accounts() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("profile"); + std::fs::create_dir(&home).unwrap(); + let a = agent_account_service::create( + &db.conn, + temp.path(), + AgentType::ClaudeCode, + "Work", + home.to_str(), + ) + .await + .unwrap(); + let req = QuotaRequest { + agent_type: AgentType::ClaudeCode, + agent_account_id: Some(a.id.clone()), + conversation_id: None, + }; + let source = resolve_source(&db, temp.path(), req.clone()).await.unwrap(); + let credential = source.credential_path().unwrap(); + assert_eq!(credential.file_name().unwrap(), ".credentials.json"); + // Windows TEMP can use an 8.3 spelling (RUNNER~1), while account + // creation stores the canonical long name. Compare the actual folder. + assert_eq!( + std::fs::canonicalize(credential.parent().unwrap()).unwrap(), + std::fs::canonicalize(&home).unwrap() + ); + assert!(source.value("OPENAI_API_KEY").is_none()); + assert!(resolve_source( + &db, + temp.path(), + QuotaRequest { + agent_type: AgentType::Codex, + ..req.clone() + } + ) + .await + .is_err()); + agent_account_service::remove(&db.conn, &a.id) + .await + .unwrap(); + assert!(resolve_source(&db, temp.path(), req).await.is_err()); + } + + #[tokio::test] + async fn account_auth_updates_invalidate_quota_and_api_accounts_skip_subscription_probes() { + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let temp = tempfile::tempdir().unwrap(); + let a = agent_account_service::create(&db.conn, temp.path(), AgentType::ClaudeCode, "Work", None).await.unwrap(); + let req = QuotaRequest { agent_type: AgentType::ClaudeCode, agent_account_id: Some(a.id.clone()), conversation_id: None }; + let source = resolve_source(&db, temp.path(), req.clone()).await.unwrap(); + let old_key = cache_key(temp.path(), &source); + CACHE.get_or_init(Default::default).lock().await.insert(old_key, Arc::new(Mutex::new(Some((Instant::now(), Some(json!({"five_hour":{"utilization":42}}))))))); + account_auth::changed(); + assert_ne!(old_key, cache_key(temp.path(), &source)); + // No credentials means no network request. The pre-change cached + // subscription result must not survive a sign-in/status refresh. + assert!(read_core(&db, temp.path(), req.clone()).await.unwrap().payload.is_none()); + account_auth::set_claude_key(Path::new(&a.home_dir), Some("fixture-only-key")).unwrap(); + let source = resolve_source(&db, temp.path(), req.clone()).await.unwrap(); + assert!(source.uses_api_provider()); + assert!(read_core(&db, temp.path(), req).await.unwrap().payload.is_none()); + assert!(std::fs::read_to_string(Path::new(&a.home_dir).join("settings.json")).unwrap().contains("fixture-only-key")); + } + #[test] + fn profile_credentials_follow_native_runtime_layouts() { + let temp = tempfile::tempdir().unwrap(); + for agent in [AgentType::Cursor, AgentType::OpenCode] { + if agent_accounts::unsupported_reason(agent).is_some() { + assert!(agent_accounts::isolation_env(agent, temp.path()).is_err()); + continue; + } + let source = Source { + account: QuotaAccount { + agent_type: agent, + agent_account_id: Some("named".into()), + family: family(agent).unwrap().into(), + name: None, + }, + env: agent_accounts::isolation_env(agent, temp.path()).unwrap(), + isolated: true, + provider_override: false, + }; + let expected = if agent == AgentType::OpenCode { + "data/opencode/auth.json" + } else if cfg!(windows) { + "appdata/Cursor/auth.json" + } else if cfg!(target_os = "macos") { + ".cursor/auth.json" + } else { + ".config/cursor/auth.json" + }; + assert_eq!(source.credential_path(), Some(temp.path().join(expected))); + } + let source = Source { + account: QuotaAccount { + agent_type: AgentType::Cursor, + agent_account_id: None, + family: "cursor".into(), + name: None, + }, + env: BTreeMap::from([("CURSOR_CONFIG_DIR".into(), "/profile".into())]), + isolated: true, + provider_override: false, + }; + assert!(source.credential_path().is_none()); + } + + #[tokio::test] + async fn api_overrides_do_not_probe_a_stored_subscription_login() { + for (agent, key) in [ + (AgentType::ClaudeCode, "ANTHROPIC_API_KEY"), + (AgentType::Codex, "OPENAI_API_KEY"), + (AgentType::Grok, "XAI_API_KEY"), + ] { + let mut source = Source { + account: QuotaAccount { + agent_type: agent, + agent_account_id: None, + family: family(agent).unwrap().into(), + name: None, + }, + env: BTreeMap::new(), + isolated: true, + provider_override: false, + }; + assert!(!source.uses_api_provider()); + source.env.insert(key.into(), "fixture-api-key".into()); + assert!(source.uses_api_provider()); + assert!(read_official(&source).await.is_none()); + source.env.clear(); + source.provider_override = true; + assert!(read_official(&source).await.is_none()); + } + } +} diff --git a/src-tauri/src/commands/subscription_quota_credentials.rs b/src-tauri/src/commands/subscription_quota_credentials.rs new file mode 100644 index 0000000000..5cf7abdef4 --- /dev/null +++ b/src-tauri/src/commands/subscription_quota_credentials.rs @@ -0,0 +1,89 @@ +//! Parse credentials in memory. Never return them to the frontend. +use serde_json::Value; + +pub fn claude_oauth_access_token_from_credentials(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + value + .get("claudeAiOauth") + .and_then(|oauth| oauth.get("accessToken")) + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string) +} + +pub fn grok_cli_bearer_from_auth_json(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let obj = value.as_object()?; + let mut preferred = None; + let mut fallback = None; + for (key, entry) in obj { + let Some(token) = entry.get("key").and_then(Value::as_str) else { + continue; + }; + if token.is_empty() { + continue; + } + if key == "https://auth.x.ai" + || key.starts_with("https://auth.x.ai::") + || key.starts_with("https://auth.x.ai/") + { + preferred = Some(token.to_string()); + } else if key.starts_with("https://accounts.x.ai/") && fallback.is_none() { + fallback = Some(token.to_string()); + } + } + preferred.or(fallback) +} + +pub fn cursor_access_token_from_auth_json(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + value + .get("accessToken") + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string) + .or_else(|| { + value + .get("apiKey") + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string) + }) +} + +pub fn opencode_go_key_from_auth_json(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let obj = value.as_object()?; + // `opencode-go` wins when present, but a plain `opencode` entry is the + // common single-account shape, so a missing provider skips to the next one + // instead of ending the search. + for provider in ["opencode-go", "opencode"] { + let Some(entry) = obj.get(provider).and_then(Value::as_object) else { + continue; + }; + let Some(key) = entry.get("key").and_then(Value::as_str) else { + continue; + }; + if !key.is_empty() { + return Some(key.to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn does_not_send_other_issuers_tokens_to_grok() { + assert!(grok_cli_bearer_from_auth_json( + r#"{"https://auth.x.ai.example.com":{"key":"private"}}"# + ) + .is_none()); + assert_eq!( + grok_cli_bearer_from_auth_json(r#"{"https://auth.x.ai::account":{"key":"own"}}"#) + .as_deref(), + Some("own") + ); + } +} diff --git a/src-tauri/src/commands/token_usage.rs b/src-tauri/src/commands/token_usage.rs index c20b090253..bd097dd740 100644 --- a/src-tauri/src/commands/token_usage.rs +++ b/src-tauri/src/commands/token_usage.rs @@ -267,13 +267,34 @@ pub(crate) fn facts_from_detail( detail: &DbConversationDetail, fallback_at: DateTime, ) -> Vec { - let session_model = detail.summary.model.as_deref(); - let per_turn = facts_from_turns(&detail.turns, session_model); + let mut facts = Vec::new(); + let mut start = 0; + for (index, turn) in detail.turns.iter().enumerate() { + let marker = turn.blocks.iter().find_map(|block| match block { + crate::models::ContentBlock::ToolUse { meta: Some(meta), .. } => meta.get(crate::acp::handoff::HANDOFF_META_KEY), + _ => None, + }); + let Some(marker) = marker.filter(|m| m.get("carried").and_then(|v| v.as_bool()) == Some(false)) else { continue; }; + let stats = marker.get("sourceStats").cloned().and_then(|v| serde_json::from_value::(v).ok()); + let model = marker.get("sourceModel").and_then(|v| v.as_str()); + let mut segment = facts_from_segment(&detail.turns[start..index], model, stats.as_ref(), turn.timestamp); + for fact in &mut segment { + if fact.turn_key == SESSION_TOTAL_TURN_KEY { fact.turn_key = format!("handoff-usage-{index}:{}", fact.turn_key); } + } + facts.extend(segment); + start = index + 1; + } + facts.extend(facts_from_segment(&detail.turns[start..], detail.summary.model.as_deref(), detail.session_stats.as_ref(), fallback_at)); + facts +} + +fn facts_from_segment(turns: &[MessageTurn], session_model: Option<&str>, stats: Option<&crate::models::SessionStats>, fallback_at: DateTime) -> Vec { + let per_turn = facts_from_turns(turns, session_model); if !per_turn.is_empty() { return per_turn; } - let Some(stats) = detail.session_stats.as_ref() else { + let Some(stats) = stats else { return Vec::new(); }; let Some(usage) = stats.total_usage.as_ref() else { @@ -290,8 +311,7 @@ pub(crate) fn facts_from_detail( // The end of the conversation is the least-wrong single instant for a // session-wide total; `fallback_at` (the row's `updated_at`) covers a // conversation whose transcript has stats but no turns at all. - let occurred_at = detail - .turns + let occurred_at = turns .last() .map(|t| t.completed_at.unwrap_or(t.timestamp)) .unwrap_or(fallback_at); @@ -299,10 +319,7 @@ pub(crate) fn facts_from_detail( vec![UsageFact { turn_key: SESSION_TOTAL_TURN_KEY.to_string(), occurred_at, - model: detail - .summary - .model - .as_ref() + model: session_model .map(|m| m.trim().to_string()) .filter(|m| !m.is_empty()), input_tokens: input, @@ -1356,6 +1373,7 @@ mod tests { fn detail(turns: Vec, stats: Option) -> DbConversationDetail { DbConversationDetail { summary: DbConversationSummary { + agent_account_id: None, id: 1, folder_id: 1, title: None, @@ -1404,6 +1422,28 @@ mod tests { } } + #[test] + fn handoff_keeps_source_session_totals_and_each_segments_model() { + use crate::acp::handoff::{divider_turn, HandoffLink, HandoffPath}; + use crate::models::AgentType; + let boundary = divider_turn(&HandoffLink { + from_agent_type: AgentType::Hermes, from_external_id: Some("old".into()), + to_agent_type: AgentType::ClaudeCode, to_external_id: "new".into(), + path: HandoffPath::Context, carried: false, user_turns_before: 1, + note: None, briefing: None, truncated: false, at: ts("2026-08-01T10:30:00Z"), + context_id: None, source_stats: Some(serde_json::to_value(session_stats(500,100)).unwrap()), + source_model: Some("original-model".into()), + },0); + let old = turn("old", None, "2026-08-01T10:00:00Z"); + let new = turn("new", Some(usage(10,5,0,0)), "2026-08-01T11:00:00Z"); + let facts = facts_from_detail(&detail(vec![old,boundary,new],Some(session_stats(999,999))),ts("2026-08-01T12:00:00Z")); + assert_eq!(facts.len(),2); + assert_eq!(facts.iter().map(UsageFact::total_tokens).sum::(),615); + assert_eq!(facts[0].model.as_deref(),Some("original-model")); + assert_eq!(facts[1].model.as_deref(),Some("claude-opus-5")); + assert_ne!(facts[0].turn_key,facts[1].turn_key); + } + #[test] fn a_session_level_total_is_recorded_when_no_turn_reports_usage() { // Hermes' shape: every turn's usage is None, but the session row knows diff --git a/src-tauri/src/db/entities/agent_account.rs b/src-tauri/src/db/entities/agent_account.rs new file mode 100644 index 0000000000..dcff50284e --- /dev/null +++ b/src-tauri/src/db/entities/agent_account.rs @@ -0,0 +1,20 @@ +use sea_orm::entity::prelude::*; +use serde::Serialize; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize)] +#[sea_orm(table_name = "agent_account")] +#[serde(rename_all = "camelCase")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + pub agent_type: String, + pub name: String, + pub home_dir: String, + pub removed: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/conversation.rs b/src-tauri/src/db/entities/conversation.rs index 08514bdd4b..20acc8a128 100644 --- a/src-tauri/src/db/entities/conversation.rs +++ b/src-tauri/src/db/entities/conversation.rs @@ -50,6 +50,8 @@ pub struct Model { /// a parsed session-file title. pub title_locked: bool, pub agent_type: String, + /// None uses the existing default home. A removed account stays bound. + pub agent_account_id: Option, pub status: ConversationStatus, pub kind: ConversationKind, pub model: Option, diff --git a/src-tauri/src/db/entities/conversation_handoff.rs b/src-tauri/src/db/entities/conversation_handoff.rs new file mode 100644 index 0000000000..0ff542c9d4 --- /dev/null +++ b/src-tauri/src/db/entities/conversation_handoff.rs @@ -0,0 +1,59 @@ +use sea_orm::entity::prelude::*; + +/// One in-place agent switch of a conversation (see `acp::handoff`). +/// +/// The conversation row names only its CURRENT agent and session. Each handoff +/// leaves one of these behind so the segment that lived under the previous +/// agent stays reachable: the detail read walks the rows in `seq` order, reads +/// every uncarried segment from its own agent's store, and renders a divider +/// between segments. `conversation_id` is a soft reference (conversations +/// soft-delete), matching every other cross-table reference in this schema. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "conversation_handoff")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub conversation_id: i32, + /// Position in the conversation's chain, oldest first. + pub seq: i32, + /// Wire form of the agent the segment ran under (`AgentType::as_wire`). + #[sea_orm(column_type = "Text")] + pub from_agent_type: String, + /// The session that segment is read from, in `from_agent_type`'s store. + #[sea_orm(column_type = "Text", nullable)] + pub from_external_id: Option, + #[sea_orm(column_type = "Text")] + pub to_agent_type: String, + #[sea_orm(column_type = "Text")] + pub to_external_id: String, + /// `"native"` or `"summary"` (`acp::handoff::HandoffPath::as_str`). + #[sea_orm(column_type = "Text")] + pub path: String, + /// The target session carries the whole prior history itself, so the + /// earlier segment is NOT read again (it would render twice). + pub carried: bool, + /// User turns rendered at handoff time; where the divider goes inside a + /// carried session, whose own store holds both halves. + pub user_turns_before: i32, + #[sea_orm(column_type = "Text", nullable)] + pub note: Option, + /// The briefing the target was seeded with (summary path only). + #[sea_orm(column_type = "Text", nullable)] + pub briefing: Option, + /// The briefing had to drop earlier turns to fit the prompt budget. + pub truncated: bool, + pub created_at: DateTimeUtc, + pub from_account_id: Option, + pub to_account_id: Option, + #[sea_orm(column_type = "Text", nullable)] + pub history_json: Option, + pub context_id: Option, + pub context_pending: bool, + pub source_stats_json: Option, + pub source_model: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index 21d0fd3908..2c5dfb9bb3 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -10,6 +10,7 @@ pub mod chat_channel_message_log; pub mod chat_channel_sender_context; pub mod chat_channel_thread_binding; pub mod conversation; +pub mod conversation_handoff; pub mod conversation_tag; pub mod conversation_tag_link; pub mod custom_agent; @@ -28,3 +29,4 @@ pub mod work_task; pub mod work_task_event; pub mod work_task_settings; pub mod work_task_template; +pub mod agent_account; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index 258829247a..55e44dc9a6 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -12,6 +12,7 @@ pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog; pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext; pub use super::chat_channel_thread_binding::Entity as ChatChannelThreadBinding; pub use super::conversation::Entity as Conversation; +pub use super::conversation_handoff::Entity as ConversationHandoff; pub use super::conversation_tag::Entity as ConversationTag; pub use super::conversation_tag_link::Entity as ConversationTagLink; pub use super::custom_agent::Entity as CustomAgent; diff --git a/src-tauri/src/db/migration/m20260906_000001_conversation_handoff.rs b/src-tauri/src/db/migration/m20260906_000001_conversation_handoff.rs new file mode 100644 index 0000000000..75f6b31d06 --- /dev/null +++ b/src-tauri/src/db/migration/m20260906_000001_conversation_handoff.rs @@ -0,0 +1,212 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // conversation_handoff: one row per time a conversation was handed to a + // different agent in place (see `acp::handoff`). The conversation row + // itself only ever names its CURRENT agent + session; this table is what + // keeps the earlier segments reachable, so a conversation that moved + // from Claude to Codex still renders the Claude turns above the divider. + // + // `conversation_id` is a SOFT reference (no FK): conversations + // soft-delete, so a cascade would never fire, and a handoff record has + // nothing to outlive: it is meaningless without its conversation and is + // simply never read once the row is gone. + manager + .create_table( + Table::create() + .table(ConversationHandoff::Table) + .if_not_exists() + .col( + ColumnDef::new(ConversationHandoff::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(ConversationHandoff::ConversationId) + .integer() + .not_null(), + ) + // Position in the conversation's chain, oldest first. + .col(ColumnDef::new(ConversationHandoff::Seq).integer().not_null()) + .col( + ColumnDef::new(ConversationHandoff::FromAgentType) + .text() + .not_null(), + ) + // The session the earlier segment is read from. Its store + // belongs to `from_agent_type`, which is why the pair is + // kept rather than the id alone. + .col(ColumnDef::new(ConversationHandoff::FromExternalId).text()) + .col( + ColumnDef::new(ConversationHandoff::ToAgentType) + .text() + .not_null(), + ) + .col( + ColumnDef::new(ConversationHandoff::ToExternalId) + .text() + .not_null(), + ) + // "native" | "summary" + .col(ColumnDef::new(ConversationHandoff::Path).text().not_null()) + // True when the target session carries the whole prior + // history itself (native transfer), so the earlier segment + // must NOT be read again or it would render twice. + .col( + ColumnDef::new(ConversationHandoff::Carried) + .boolean() + .not_null() + .default(false), + ) + // User turns rendered at handoff time: where the divider + // goes inside a carried session. + .col( + ColumnDef::new(ConversationHandoff::UserTurnsBefore) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(ConversationHandoff::Note).text()) + // The briefing the target was seeded with (summary path). + .col(ColumnDef::new(ConversationHandoff::Briefing).text()) + .col( + ColumnDef::new(ConversationHandoff::Truncated) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(ConversationHandoff::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + // Every detail read of a conversation asks "which handoffs does this + // row have", so the lookup must not be a table scan. + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_conversation_handoff_conversation_id") + .table(ConversationHandoff::Table) + .col(ConversationHandoff::ConversationId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(ConversationHandoff::Table) + .if_exists() + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ConversationHandoff { + Table, + Id, + ConversationId, + Seq, + FromAgentType, + FromExternalId, + ToAgentType, + ToExternalId, + Path, + Carried, + UserTurnsBefore, + Note, + Briefing, + Truncated, + CreatedAt, +} + +#[cfg(test)] +mod tests { + use super::*; + use sea_orm_migration::sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; + + /// `up` creates the table with its defaults, so a row written with only the + /// required columns reads back as an uncarried, untruncated handoff. + #[tokio::test] + async fn up_creates_the_table_with_defaults() { + let conn = Database::connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + + Migration + .up(&SchemaManager::new(&conn)) + .await + .expect("run migration up"); + + conn.execute_unprepared( + "INSERT INTO conversation_handoff (conversation_id, seq, from_agent_type, \ + to_agent_type, to_external_id, path, created_at) \ + VALUES (7, 0, 'claude_code', 'codex', 'S2', 'summary', '2026-09-06T00:00:00Z')", + ) + .await + .expect("insert row"); + + let rows = conn + .query_all(Statement::from_string( + DbBackend::Sqlite, + "SELECT carried, truncated, user_turns_before, from_external_id \ + FROM conversation_handoff" + .to_owned(), + )) + .await + .expect("query rows"); + assert_eq!(rows.len(), 1); + let carried: bool = rows[0].try_get("", "carried").expect("carried col"); + let truncated: bool = rows[0].try_get("", "truncated").expect("truncated col"); + let before: i32 = rows[0] + .try_get("", "user_turns_before") + .expect("user_turns_before col"); + let from: Option = rows[0] + .try_get("", "from_external_id") + .expect("from_external_id col"); + assert!(!carried); + assert!(!truncated); + assert_eq!(before, 0); + assert!(from.is_none()); + } + + /// `down` removes the table again, and `up` is idempotent over an existing + /// table (`if_not_exists`), so a re-run cannot fail a fresh install. + #[tokio::test] + async fn up_is_idempotent_and_down_drops() { + let conn = Database::connect("sqlite::memory:") + .await + .expect("open in-memory sqlite"); + let manager = SchemaManager::new(&conn); + Migration.up(&manager).await.expect("first up"); + Migration.up(&manager).await.expect("second up"); + Migration.down(&manager).await.expect("down"); + let rows = conn + .query_all(Statement::from_string( + DbBackend::Sqlite, + "SELECT name FROM sqlite_master WHERE type='table' AND name='conversation_handoff'" + .to_owned(), + )) + .await + .expect("query sqlite_master"); + assert!(rows.is_empty(), "table must be gone after down"); + } +} diff --git a/src-tauri/src/db/migration/m20261017_000001_agent_account.rs b/src-tauri/src/db/migration/m20261017_000001_agent_account.rs new file mode 100644 index 0000000000..ac697eccf6 --- /dev/null +++ b/src-tauri/src/db/migration/m20261017_000001_agent_account.rs @@ -0,0 +1,52 @@ +use sea_orm_migration::prelude::*; +use sea_orm_migration::sea_orm::{ConnectionTrait, DbBackend, Statement}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + for sql in [ + "CREATE TABLE agent_account (id TEXT PRIMARY KEY NOT NULL, agent_type TEXT NOT NULL, name TEXT NOT NULL, home_dir TEXT NOT NULL, removed BOOLEAN NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)", + "CREATE UNIQUE INDEX agent_account_name ON agent_account(agent_type, name COLLATE NOCASE) WHERE removed = 0", + "CREATE UNIQUE INDEX agent_account_home ON agent_account(agent_type, home_dir) WHERE removed = 0", + // Keep the binding when an account is removed. SET NULL would + // silently start its conversations using the default subscription. + "ALTER TABLE conversation ADD COLUMN agent_account_id TEXT REFERENCES agent_account(id)", + "CREATE INDEX conversation_agent_account ON conversation(agent_account_id)", + "DROP INDEX idx_conversation_external_agent", + "CREATE UNIQUE INDEX idx_conversation_external_agent ON conversation(external_id, agent_type, IFNULL(agent_account_id, ''))", + ] { + conn.execute(Statement::from_string(DbBackend::Sqlite, sql.to_owned())).await?; + } + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + let bound = conn + .query_one(Statement::from_string( + DbBackend::Sqlite, + "SELECT COUNT(*) AS count FROM conversation WHERE agent_account_id IS NOT NULL" + .to_owned(), + )) + .await?; + if bound.is_some_and(|row| row.try_get::("", "count").unwrap_or(1) != 0) { + return Err(DbErr::Custom( + "Cannot remove account bindings while conversations still use them".into(), + )); + } + for sql in [ + "DROP INDEX idx_conversation_external_agent", + "DROP INDEX conversation_agent_account", + "ALTER TABLE conversation DROP COLUMN agent_account_id", + "DROP TABLE agent_account", + "CREATE UNIQUE INDEX idx_conversation_external_agent ON conversation(external_id, agent_type)", + ] { + conn.execute(Statement::from_string(DbBackend::Sqlite, sql.to_owned())).await?; + } + Ok(()) + } +} diff --git a/src-tauri/src/db/migration/m20261017_000002_handoff_context.rs b/src-tauri/src/db/migration/m20261017_000002_handoff_context.rs new file mode 100644 index 0000000000..30f8e3fd9d --- /dev/null +++ b/src-tauri/src/db/migration/m20261017_000002_handoff_context.rs @@ -0,0 +1,57 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for column in [ + "from_account_id", + "to_account_id", + "history_json", + "context_id", + ] { + manager + .alter_table( + Table::alter() + .table(Alias::new("conversation_handoff")) + .add_column(ColumnDef::new(Alias::new(column)).text()) + .to_owned(), + ) + .await?; + } + manager + .alter_table( + Table::alter() + .table(Alias::new("conversation_handoff")) + .add_column( + ColumnDef::new(Alias::new("context_pending")) + .boolean() + .not_null() + .default(false), + ) + .to_owned(), + ) + .await + } + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for column in [ + "from_account_id", + "to_account_id", + "history_json", + "context_id", + "context_pending", + ] { + manager + .alter_table( + Table::alter() + .table(Alias::new("conversation_handoff")) + .drop_column(Alias::new(column)) + .to_owned(), + ) + .await?; + } + Ok(()) + } +} diff --git a/src-tauri/src/db/migration/m20261017_000003_handoff_usage.rs b/src-tauri/src/db/migration/m20261017_000003_handoff_usage.rs new file mode 100644 index 0000000000..1ef551b032 --- /dev/null +++ b/src-tauri/src/db/migration/m20261017_000003_handoff_usage.rs @@ -0,0 +1,22 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for column in ["source_stats_json", "source_model"] { + manager.alter_table(Table::alter().table(Alias::new("conversation_handoff")) + .add_column(ColumnDef::new(Alias::new(column)).text()).to_owned()).await?; + } + Ok(()) + } + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + for column in ["source_stats_json", "source_model"] { + manager.alter_table(Table::alter().table(Alias::new("conversation_handoff")) + .drop_column(Alias::new(column)).to_owned()).await?; + } + Ok(()) + } +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 7923f47edd..e3c0510cf4 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -44,6 +44,7 @@ mod m20260825_000001_remote_workspace_connection_headers; mod m20260829_000001_folder_group; mod m20260830_000001_canvas_node; mod m20260831_000001_canvas_node_group_grid; +mod m20260906_000001_conversation_handoff; mod m20260907_000001_canvas_node_path; mod m20260929_000001_canvas_board; mod m20260930_000001_agent_setting_drop_adapter_channel; @@ -52,6 +53,9 @@ mod m20261015_000001_kiro_builtin_agent; mod m20261015_000001_work_task_deferred_at; mod m20261016_000001_assistant; mod m20261016_000002_conversation_assistant; +mod m20261017_000001_agent_account; +mod m20261017_000002_handoff_context; +mod m20261017_000003_handoff_usage; mod m20261017_000002_conversation_source; pub struct Migrator; @@ -103,6 +107,7 @@ impl MigratorTrait for Migrator { Box::new(m20260829_000001_folder_group::Migration), Box::new(m20260830_000001_canvas_node::Migration), Box::new(m20260831_000001_canvas_node_group_grid::Migration), + Box::new(m20260906_000001_conversation_handoff::Migration), Box::new(m20260907_000001_canvas_node_path::Migration), Box::new(m20260929_000001_canvas_board::Migration), Box::new(m20260930_000001_agent_setting_drop_adapter_channel::Migration), @@ -112,6 +117,9 @@ impl MigratorTrait for Migrator { Box::new(m20261016_000001_assistant::Migration), Box::new(m20261016_000002_conversation_assistant::Migration), Box::new(m20261017_000002_conversation_source::Migration), + Box::new(m20261017_000001_agent_account::Migration), + Box::new(m20261017_000002_handoff_context::Migration), + Box::new(m20261017_000003_handoff_usage::Migration), ] } } diff --git a/src-tauri/src/db/service/agent_account_service.rs b/src-tauri/src/db/service/agent_account_service.rs new file mode 100644 index 0000000000..107bba883b --- /dev/null +++ b/src-tauri/src/db/service/agent_account_service.rs @@ -0,0 +1,648 @@ +use std::path::{Path, PathBuf}; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, IntoActiveModel, QueryFilter, + QueryOrder, Set, +}; + +use crate::acp::agent_accounts::layout; +use crate::db::entities::agent_account::{self, Entity}; +use crate::db::error::DbError; +use crate::models::AgentType; + +pub async fn list( + conn: &impl ConnectionTrait, + agent: AgentType, +) -> Result, DbError> { + Ok(Entity::find() + .filter(agent_account::Column::AgentType.eq(agent.as_wire().as_ref())) + .filter(agent_account::Column::Removed.eq(false)) + .order_by_asc(agent_account::Column::CreatedAt) + .all(conn) + .await?) +} + +pub async fn list_all(conn: &impl ConnectionTrait) -> Result, DbError> { + Ok(Entity::find() + .filter(agent_account::Column::Removed.eq(false)) + .order_by_asc(agent_account::Column::CreatedAt) + .all(conn) + .await?) +} + +pub async fn list_for_display( + conn: &impl ConnectionTrait, + agent: AgentType, +) -> Result, DbError> { + Ok(Entity::find() + .filter(agent_account::Column::AgentType.eq(agent.as_wire().as_ref())) + .order_by_asc(agent_account::Column::CreatedAt) + .all(conn) + .await?) +} + +pub async fn get(conn: &impl ConnectionTrait, id: &str) -> Result { + Entity::find_by_id(id).one(conn).await?.ok_or_else(|| { + DbError::NotFound( + "This agent account is no longer available. Add it again in Settings > Agents.".into(), + ) + }) +} + +pub async fn resolve( + conn: &impl ConnectionTrait, + agent: AgentType, + id: &str, +) -> Result { + let account = get(conn, id).await?; + if account.agent_type != agent.as_wire() { + return Err(DbError::Validation( + "Choose an account for the selected agent.".into(), + )); + } + if account.removed { + return Err(DbError::Validation(format!( + "This account was removed. To continue, add its folder {} again in Settings > Agents.", + account.home_dir + ))); + } + if !Path::new(&account.home_dir).is_dir() { + return Err(DbError::Validation(format!( + "The account folder {} is missing. Restore it before resuming this conversation.", + account.home_dir + ))); + } + Ok(account) +} + +/// A persisted conversation, including a removed-account binding, is the +/// authority on reconnect. A stale client cannot switch its subscription. +pub async fn connection_account( + conn: &impl ConnectionTrait, + agent: AgentType, + requested: Option, + conversation_id: Option, +) -> Result, DbError> { + let Some(id) = conversation_id else { + return Ok(requested); + }; + let row = crate::db::entities::conversation::Entity::find_by_id(id) + .one(conn) + .await? + .ok_or_else(|| DbError::NotFound("Conversation not found".into()))?; + if row.agent_type != agent.as_wire() { + return Err(DbError::Validation( + "This conversation belongs to a different agent.".into(), + )); + } + if requested + .as_ref() + .is_some_and(|id| row.agent_account_id.as_ref() != Some(id)) + { + return Err(DbError::Validation( + "This conversation belongs to a different agent account.".into(), + )); + } + Ok(row.agent_account_id) +} + +fn checked_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.chars().count() > 80 || name.chars().any(char::is_control) { + return Err(DbError::Validation( + "Use an account name between 1 and 80 characters.".into(), + )); + } + Ok(name.to_owned()) +} + +pub async fn create( + conn: &impl ConnectionTrait, + data_dir: &Path, + agent: AgentType, + name: &str, + existing_home: Option<&str>, +) -> Result { + if let Some(reason) = crate::acp::agent_accounts::unsupported_reason(agent) { + return Err(DbError::Validation(reason.into())); + } + let spec = layout(agent).ok_or_else(|| { + DbError::Validation("This agent does not support isolated accounts.".into()) + })?; + let name = checked_name(name)?; + let accounts = list(conn, agent).await?; + if accounts + .iter() + .any(|a| a.name.to_lowercase() == name.to_lowercase()) + { + return Err(DbError::Validation( + "An account with this name already exists.".into(), + )); + } + let id = uuid::Uuid::new_v4().to_string(); + let home = match existing_home { + Some(raw) => { + let home = PathBuf::from(raw.trim()); + if !home.is_absolute() || !home.is_dir() { + return Err(DbError::Validation( + "Choose an existing account folder using its absolute path.".into(), + )); + } + home.canonicalize()? + } + None => { + let home = data_dir.join("agent-accounts").join(&id); + std::fs::create_dir_all(spec.config_dir(&home))?; + home.canonicalize()? + } + }; + if accounts + .iter() + .any(|a| Path::new(&a.home_dir).canonicalize().ok().as_ref() == Some(&home)) + { + return Err(DbError::Validation( + "This folder is already used by another account.".into(), + )); + } + let setting = super::agent_setting_service::get_by_agent_type(conn, agent).await?; + let native = crate::commands::acp::load_agent_local_config_json(agent); + let default_env = crate::commands::acp::build_runtime_env_from_setting( + agent, + setting.as_ref(), + native.as_deref(), + ); + if crate::acp::agent_accounts::aliases_default_account(agent, &home, &default_env) { + return Err(DbError::Validation( + "This folder belongs to the default account. Choose a separate account folder.".into(), + )); + } + if existing_home.is_some() { + if let Some(removed) = removed_account_at(conn, agent, &home).await? { + let mut model = removed.into_active_model(); + model.name = Set(name); + model.removed = Set(false); + model.updated_at = Set(Utc::now()); + return Ok(model.update(conn).await?); + } + } + #[cfg(unix)] + if existing_home.is_none() { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700))?; + } + // Windows verbatim prefixes are valid Rust paths but several native CLIs + // cannot open them. Persist the normalized, ordinary absolute spelling. + let home_dir = ordinary_path(&home); + let now = Utc::now(); + Ok(agent_account::ActiveModel { + id: Set(id), + agent_type: Set(agent.as_wire().into_owned()), + name: Set(name), + home_dir: Set(home_dir), + removed: Set(false), + created_at: Set(now), + updated_at: Set(now), + } + .insert(conn) + .await?) +} + +pub async fn rename(conn: &impl ConnectionTrait, id: &str, name: &str) -> Result<(), DbError> { + let account = get(conn, id).await?; + if account.removed { + return Err(DbError::Validation("This account was removed.".into())); + } + let name = checked_name(name)?; + let agent = AgentType::from_wire(&account.agent_type) + .ok_or_else(|| DbError::Validation("Unknown agent.".into()))?; + if list(conn, agent) + .await? + .iter() + .any(|a| a.id != id && a.name.to_lowercase() == name.to_lowercase()) + { + return Err(DbError::Validation( + "An account with this name already exists.".into(), + )); + } + let mut model = account.into_active_model(); + model.name = Set(name); + model.updated_at = Set(Utc::now()); + model.update(conn).await?; + Ok(()) +} + +/// A removed account of `agent` kept in `home` (canonical). Adding that folder +/// again brings the account back under its own id: its saved conversations are +/// bound to that id, so a new one would leave them unable to continue. +async fn removed_account_at( + conn: &impl ConnectionTrait, + agent: AgentType, + home: &Path, +) -> Result, DbError> { + Ok(Entity::find() + .filter(agent_account::Column::AgentType.eq(agent.as_wire().as_ref())) + .filter(agent_account::Column::Removed.eq(true)) + .order_by_desc(agent_account::Column::UpdatedAt) + .all(conn) + .await? + .into_iter() + .find(|a| Path::new(&a.home_dir).canonicalize().ok().as_deref() == Some(home))) +} + +/// Removing an account never deletes files or reassigns its conversations. +pub async fn remove(conn: &impl ConnectionTrait, id: &str) -> Result<(), DbError> { + let mut model = get(conn, id).await?.into_active_model(); + model.removed = Set(true); + model.updated_at = Set(Utc::now()); + model.update(conn).await?; + Ok(()) +} + +fn ordinary_path(path: &Path) -> String { + let value = path.to_string_lossy(); + #[cfg(windows)] + { + if let Some(unc) = value.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{unc}"); + } + if let Some(drive) = value.strip_prefix(r"\\?\") { + return drive.to_owned(); + } + } + value.into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::fresh_in_memory_db; + + #[tokio::test] + async fn unsupported_accounts_are_refused_before_creating_folders_or_rows() { + let db = fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + for capability in crate::acp::agent_accounts::capabilities() { + if capability.supported { + continue; + } + let error = create(&db.conn, dir.path(), capability.agent_type, "Work", None) + .await + .unwrap_err(); + assert!(error.to_string().contains(capability.reason.unwrap())); + assert!(!dir.path().join("agent-accounts").exists()); + assert!(list(&db.conn, capability.agent_type).await.unwrap().is_empty()); + } + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn existing_cursor_account_records_and_folders_remain_available() { + let db = fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + let account = create(&db.conn, dir.path(), AgentType::Codex, "Existing", None) + .await + .unwrap(); + let mut existing = account.clone().into_active_model(); + existing.agent_type = Set(AgentType::Cursor.as_wire().into_owned()); + existing.update(&db.conn).await.unwrap(); + let resolved = resolve(&db.conn, AgentType::Cursor, &account.id).await.unwrap(); + assert_eq!(resolved.home_dir, account.home_dir); + assert!(Path::new(&resolved.home_dir).is_dir()); + assert!(crate::acp::agent_accounts::isolation_env( + AgentType::Cursor, Path::new(&resolved.home_dir) + ).is_err()); + assert_eq!(list(&db.conn, AgentType::Cursor).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn crud_keeps_homes_and_refuses_removed_or_wrong_family_accounts() { + let db = fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + let account = create(&db.conn, dir.path(), AgentType::Codex, "Work", None) + .await + .unwrap(); + assert_eq!(list(&db.conn, AgentType::Codex).await.unwrap().len(), 1); + assert!(create(&db.conn, dir.path(), AgentType::Codex, "work", None) + .await + .is_err()); + assert!(create( + &db.conn, + dir.path(), + AgentType::Codex, + "Other", + Some(&account.home_dir) + ) + .await + .is_err()); + assert!(resolve(&db.conn, AgentType::ClaudeCode, &account.id) + .await + .is_err()); + rename(&db.conn, &account.id, "Personal").await.unwrap(); + assert_eq!(get(&db.conn, &account.id).await.unwrap().name, "Personal"); + remove(&db.conn, &account.id).await.unwrap(); + assert!(list(&db.conn, AgentType::Codex).await.unwrap().is_empty()); + assert!(Path::new(&account.home_dir).is_dir()); + assert!(resolve(&db.conn, AgentType::Codex, &account.id) + .await + .is_err()); + assert!(get(&db.conn, &account.id).await.is_ok()); + } + + /// Removal keeps the folder and the conversations bound to the account. + /// Adding that folder again must bring back the same account; a new id + /// left those conversations bound to a removed one, unable to continue. + #[tokio::test] + async fn re_adding_a_removed_folder_restores_its_account() { + use crate::db::entities::conversation::ConversationKind; + use crate::db::service::conversation_service; + let db = fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + let folder = + crate::db::test_helpers::seed_folder(&db, &dir.path().display().to_string()).await; + let account = create(&db.conn, dir.path(), AgentType::Codex, "Work", None) + .await + .unwrap(); + let row = conversation_service::create_inner( + &db.conn, + folder, + AgentType::Codex, + None, + None, + None, + ConversationKind::Regular, + Some(account.id.clone()), + ) + .await + .unwrap(); + remove(&db.conn, &account.id).await.unwrap(); + let refused = resolve(&db.conn, AgentType::Codex, &account.id) + .await + .unwrap_err() + .to_string(); + assert!(refused.contains(&account.home_dir), "{refused}"); + + // Another agent's account in the same folder is its own account. + let claude = create( + &db.conn, + dir.path(), + AgentType::ClaudeCode, + "Claude", + Some(&account.home_dir), + ) + .await + .unwrap(); + assert_ne!(claude.id, account.id); + + let restored = create( + &db.conn, + dir.path(), + AgentType::Codex, + "Work again", + Some(&format!("{}/", account.home_dir)), + ) + .await + .unwrap(); + assert_eq!(restored.id, account.id); + assert_eq!(restored.name, "Work again"); + assert!(!restored.removed); + assert_eq!(list(&db.conn, AgentType::Codex).await.unwrap().len(), 1); + resolve(&db.conn, AgentType::Codex, &account.id) + .await + .unwrap(); + assert_eq!( + connection_account(&db.conn, AgentType::Codex, None, Some(row.id)) + .await + .unwrap(), + Some(account.id.clone()) + ); + assert!(create( + &db.conn, + dir.path(), + AgentType::Codex, + "Twice", + Some(&account.home_dir) + ) + .await + .is_err()); + // A new folder is a new account. + let fresh = create(&db.conn, dir.path(), AgentType::Codex, "Fresh", None) + .await + .unwrap(); + assert_ne!(fresh.id, account.id); + } + + #[tokio::test] + async fn missing_home_never_falls_back_to_default() { + let db = fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + let account = create(&db.conn, dir.path(), AgentType::Codex, "Work", None) + .await + .unwrap(); + std::fs::remove_dir(&account.home_dir).unwrap(); + let missing = resolve(&db.conn, AgentType::Codex, &account.id).await.unwrap_err(); + assert!(missing.to_string().contains(&account.home_dir)); + } + + #[tokio::test] + async fn saved_bindings_survive_removal_and_reject_account_switches() { + use crate::db::entities::conversation::ConversationKind; + use crate::db::service::conversation_service; + let db = fresh_in_memory_db().await; + let dir = tempfile::tempdir().unwrap(); + let folder = + crate::db::test_helpers::seed_folder(&db, &dir.path().display().to_string()).await; + let a = create(&db.conn, dir.path(), AgentType::Codex, "A", None) + .await + .unwrap(); + let b = create(&db.conn, dir.path(), AgentType::Codex, "B", None) + .await + .unwrap(); + let row = conversation_service::create_inner( + &db.conn, + folder, + AgentType::Codex, + None, + None, + None, + ConversationKind::Regular, + Some(a.id.clone()), + ) + .await + .unwrap(); + assert_eq!( + connection_account(&db.conn, AgentType::Codex, None, Some(row.id)) + .await + .unwrap(), + Some(a.id.clone()) + ); + let other_account = + connection_account(&db.conn, AgentType::Codex, Some(b.id.clone()), Some(row.id)) + .await + .unwrap_err() + .to_string(); + assert!( + other_account.contains("belongs to a different agent account."), + "{other_account}" + ); + // Another agent is not another account of this one: say which it is. + let other_agent = connection_account(&db.conn, AgentType::ClaudeCode, None, Some(row.id)) + .await + .unwrap_err() + .to_string(); + assert!( + other_agent.contains("belongs to a different agent."), + "{other_agent}" + ); + remove(&db.conn, &a.id).await.unwrap(); + assert_eq!( + connection_account(&db.conn, AgentType::Codex, None, Some(row.id)) + .await + .unwrap(), + Some(a.id.clone()) + ); + assert!(resolve(&db.conn, AgentType::Codex, &a.id).await.is_err()); + assert!(list_for_display(&db.conn, AgentType::Codex) + .await + .unwrap() + .iter() + .any(|account| account.id == a.id && account.removed)); + } + + #[tokio::test] + async fn migration_preserves_existing_rows_and_scopes_session_identity() { + use crate::db::entities::conversation; + use crate::db::migration::Migrator; + use crate::db::service::{conversation_service, folder_service}; + use sea_orm::{Database, DbBackend, Statement}; + use sea_orm_migration::{MigratorTrait, SchemaManager}; + let conn = Database::connect("sqlite::memory:").await.unwrap(); + let migrations = Migrator::migrations(); + let account_migration = migrations.iter().position(|migration| { + migration.name() == "m20261017_000001_agent_account" + }).expect("native account migration is registered"); + Migrator::up(&conn, Some(account_migration as u32)) + .await + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + let folder = folder_service::add_folder(&conn, &dir.path().display().to_string()) + .await + .unwrap(); + conn.execute(Statement::from_sql_and_values(DbBackend::Sqlite, + "INSERT INTO conversation (folder_id, agent_type, status, kind, title_locked, external_id, message_count, created_at, updated_at) VALUES (?, 'codex', 'pending_review', 'regular', 0, 'shared-session', 0, '2026-10-11T00:00:00Z', '2026-10-11T00:00:00Z')", + [folder.id.into()])).await.unwrap(); + Migrator::up(&conn, None).await.unwrap(); + let old = conversation::Entity::find() + .one(&conn) + .await + .unwrap() + .unwrap(); + assert!(old.agent_account_id.is_none()); + let account = create(&conn, dir.path(), AgentType::Codex, "Work", None) + .await + .unwrap(); + let named = conversation_service::create_inner( + &conn, + folder.id, + AgentType::Codex, + None, + None, + None, + conversation::ConversationKind::Regular, + Some(account.id.clone()), + ) + .await + .unwrap(); + conversation_service::bind_external_id(&conn, named.id, "shared-session", &[]) + .await + .unwrap(); + assert_eq!( + conversation::Entity::find().all(&conn).await.unwrap().len(), + 2 + ); + assert!( + conversation_service::find_live_by_session_ref(&conn, "shared-session") + .await + .is_err() + ); + assert_eq!( + conversation_service::find_live_by_session_ref(&conn, &named.id.to_string()) + .await + .unwrap() + .unwrap() + .agent_account_id, + Some(account.id) + ); + // Later migrations must not change which rollback guard this tests. + let error = migrations[account_migration] + .down(&SchemaManager::new(&conn)) + .await + .expect_err("bound accounts must prevent their migration's rollback"); + assert!(error.to_string().contains("Cannot remove account bindings")); + assert!(conversation::Entity::find_by_id(named.id) + .one(&conn) + .await + .unwrap() + .unwrap() + .agent_account_id + .is_some()); + } + + #[test] + fn account_migration_runs_after_every_released_migration() { + use crate::db::migration::Migrator; + use sea_orm_migration::MigratorTrait; + + // A backup records only the last registered migration, and a release + // restores any backup whose last migration it knows. If this migration + // came before one that v0.38.0 shipped, v0.38.0 would restore this + // build's backups and then fail to start: it has no file for it. + let names: Vec = Migrator::migrations() + .iter() + .map(|migration| migration.name().to_string()) + .collect(); + let position = |name: &str| names.iter().position(|n| n == name).expect(name); + // The newest migration in v0.38.0. + assert!( + position("m20261017_000001_agent_account") + > position("m20261017_000002_conversation_source") + ); + } + + #[tokio::test] + async fn account_migration_can_follow_newer_release_migrations() { + use crate::db::migration::Migrator; + use sea_orm::{Database, DbBackend, Statement}; + use sea_orm_migration::{MigrationTrait, MigratorTrait, SchemaManager}; + + // A released app already has the assistant and source columns, but has + // never applied the account migration from this pending PR. + struct ReleasedSchema; + #[async_trait::async_trait] + impl MigratorTrait for ReleasedSchema { + fn migrations() -> Vec> { + Migrator::migrations().into_iter().filter(|migration| { + migration.name() != "m20261017_000001_agent_account" + }).collect() + } + } + + let conn = Database::connect("sqlite::memory:").await.unwrap(); + ReleasedSchema::up(&conn, None).await.unwrap(); + let folder = crate::db::service::folder_service::add_folder(&conn, "/released-project") + .await.unwrap(); + conn.execute(Statement::from_sql_and_values(DbBackend::Sqlite, + "INSERT INTO conversation (folder_id, agent_type, status, kind, title_locked, external_id, message_count, created_at, updated_at) VALUES (?, 'codex', 'pending_review', 'regular', 0, 'released-session', 0, '2026-10-16T00:00:00Z', '2026-10-16T00:00:00Z')", + [folder.id.into()])).await.unwrap(); + + Migrator::up(&conn, None).await.unwrap(); + let schema = SchemaManager::new(&conn); + assert!(schema.has_column("conversation", "assistant_id").await.unwrap()); + assert!(schema.has_column("conversation", "source").await.unwrap()); + assert!(schema.has_column("conversation", "agent_account_id").await.unwrap()); + let saved = crate::db::entities::conversation::Entity::find() + .one(&conn).await.unwrap().unwrap(); + assert_eq!(saved.external_id.as_deref(), Some("released-session")); + assert!(saved.agent_account_id.is_none()); + } +} diff --git a/src-tauri/src/db/service/agent_setting_service.rs b/src-tauri/src/db/service/agent_setting_service.rs index 017810e2aa..2a7f5e95b6 100644 --- a/src-tauri/src/db/service/agent_setting_service.rs +++ b/src-tauri/src/db/service/agent_setting_service.rs @@ -123,7 +123,7 @@ pub async fn list_map_by_agent_type( } pub async fn get_by_agent_type( - conn: &DatabaseConnection, + conn: &impl ConnectionTrait, agent_type: AgentType, ) -> Result, DbError> { let agent_type_str = serde_json::to_string(&agent_type) diff --git a/src-tauri/src/db/service/conversation_service.rs b/src-tauri/src/db/service/conversation_service.rs index 8551a705a1..5c4988141d 100644 --- a/src-tauri/src/db/service/conversation_service.rs +++ b/src-tauri/src/db/service/conversation_service.rs @@ -12,6 +12,13 @@ use crate::db::error::DbError; use crate::db::service::conversation_tag_service; use crate::models::{AgentType, AssistantRunSnapshot, DbConversationSummary}; +pub fn account_condition(id: Option<&str>) -> sea_orm::sea_query::SimpleExpr { + match id { + Some(id) => conversation::Column::AgentAccountId.eq(id), + None => conversation::Column::AgentAccountId.is_null(), + } +} + pub async fn create( conn: &DatabaseConnection, folder_id: i32, @@ -27,6 +34,7 @@ pub async fn create( git_branch, None, ConversationKind::Regular, + None, ) .await } @@ -50,6 +58,7 @@ pub async fn create_chat( git_branch, None, ConversationKind::Chat, + None, ) .await } @@ -74,12 +83,13 @@ pub async fn create_with_delegation( ConversationKind::Regular }; create_inner( - conn, folder_id, agent_type, title, git_branch, delegation, kind, + conn, folder_id, agent_type, title, git_branch, delegation, kind, None, ) .await } -async fn create_inner( +#[allow(clippy::too_many_arguments)] +pub async fn create_inner( conn: &DatabaseConnection, folder_id: i32, agent_type: AgentType, @@ -87,7 +97,11 @@ async fn create_inner( git_branch: Option, delegation: Option, kind: ConversationKind, + account_id: Option, ) -> Result { + if let Some(id) = account_id.as_deref() { + super::agent_account_service::resolve(conn, agent_type, id).await?; + } let at_str = serde_json::to_value(agent_type) .ok() .and_then(|v| v.as_str().map(String::from)) @@ -118,6 +132,7 @@ async fn create_inner( title: Set(title), title_locked: Set(false), agent_type: Set(at_str), + agent_account_id: Set(account_id), status: Set(conversation::ConversationStatus::InProgress), kind: Set(kind), model: Set(None), @@ -139,6 +154,20 @@ async fn create_inner( Ok(model.insert(conn).await?) } +#[allow(clippy::too_many_arguments)] +pub async fn create_with_delegation_for_account( + conn: &DatabaseConnection, + folder_id: i32, + agent_type: AgentType, + title: Option, + git_branch: Option, + delegation: Option, + account_id: Option, +) -> Result { + let kind = if delegation.is_some() { ConversationKind::Delegate } else { ConversationKind::Regular }; + create_inner(conn, folder_id, agent_type, title, git_branch, delegation, kind, account_id).await +} + pub async fn update_status( conn: &DatabaseConnection, conversation_id: i32, @@ -459,6 +488,14 @@ async fn refresh_codex_auto_title_candidate( pub(crate) async fn refresh_codex_auto_titles( conn: &DatabaseConnection, titles: &HashMap, +) -> Vec { + refresh_codex_account_titles(conn, titles, None).await +} + +pub(crate) async fn refresh_codex_account_titles( + conn: &DatabaseConnection, + titles: &HashMap, + account_id: Option<&str>, ) -> Vec { if titles.is_empty() { return Vec::new(); @@ -474,6 +511,7 @@ pub(crate) async fn refresh_codex_auto_titles( for external_id_chunk in external_ids.chunks(SQLITE_TITLE_QUERY_CHUNK_SIZE) { let candidates = match conversation::Entity::find() + .filter(account_condition(account_id)) .filter(conversation::Column::AgentType.eq(AgentType::Codex.as_wire().into_owned())) .filter(conversation::Column::ExternalId.is_in(external_id_chunk.iter().cloned())) .filter(conversation::Column::DeletedAt.is_null()) @@ -756,6 +794,7 @@ pub async fn bind_external_id( let holder = conversation::Entity::find() .filter(conversation::Column::ExternalId.eq(external_id.clone())) .filter(conversation::Column::AgentType.eq(current.agent_type.clone())) + .filter(account_condition(current.agent_account_id.as_deref())) .filter(conversation::Column::Id.ne(conversation_id)) .one(txn) .await?; @@ -818,6 +857,7 @@ pub async fn bind_external_id( let already_preserved = conversation::Entity::find() .filter(conversation::Column::ExternalId.eq(previous.clone())) .filter(conversation::Column::AgentType.eq(current.agent_type.clone())) + .filter(account_condition(current.agent_account_id.as_deref())) .filter(conversation::Column::Id.ne(conversation_id)) .filter(conversation::Column::DeletedAt.is_null()) .one(txn) @@ -901,6 +941,7 @@ pub async fn bind_external_id( } } + /// What [`bind_external_id`]'s transaction concluded. /// /// A refusal has to leave the closure as a distinct VALUE rather than an early @@ -924,6 +965,7 @@ enum BindTxOutcome { /// Snapshotted before the re-point because turning the model into an /// `ActiveModel` consumes it. struct CarriedOverRow { + agent_account_id: Option, folder_id: i32, title: Option, title_locked: bool, @@ -945,6 +987,7 @@ struct CarriedOverRow { impl CarriedOverRow { fn from(row: &conversation::Model) -> Self { Self { + agent_account_id: row.agent_account_id.clone(), folder_id: row.folder_id, title: row.title.clone(), title_locked: row.title_locked, @@ -994,6 +1037,7 @@ impl CarriedOverRow { title: Set(self.title), title_locked: Set(self.title_locked), agent_type: Set(self.agent_type), + agent_account_id: Set(self.agent_account_id), status: Set(self.status), kind: Set(self.kind), model: Set(self.model), @@ -1175,6 +1219,7 @@ fn conv_to_summary(r: conversation::Model) -> DbConversationSummary { title: r.title, title_locked: r.title_locked, agent_type: parse_agent_type(&r.agent_type), + agent_account_id: r.agent_account_id, status, kind: r.kind.clone(), model: r.model, @@ -1281,7 +1326,8 @@ pub async fn get_by_id( /// `codeg://session/` markdown mentions use) or the agent's own session /// id stored as `external_id` (a Grok UUID, a Codex thread id, …). Several /// rows can share an `external_id` across agents; the most recently updated -/// live row wins. Missing rows return `Ok(None)` — a stale deep link is not +/// live default-account row wins. An id shared with another account needs a +/// numeric Codeg conversation link instead. Missing rows return `Ok(None)` — a stale deep link is not /// an error. /// /// A numeric ref is tried as a primary key *first* and as an `external_id` @@ -1308,12 +1354,16 @@ pub async fn find_live_by_session_ref( let conv = match by_pk { Some(conv) => Some(conv), None => { - conversation::Entity::find() + let rows = conversation::Entity::find() .filter(conversation::Column::ExternalId.eq(session_ref)) .filter(conversation::Column::DeletedAt.is_null()) .order_by_desc(conversation::Column::UpdatedAt) - .one(conn) - .await? + .all(conn) + .await?; + if rows.len() > 1 && rows.iter().any(|row| row.agent_account_id.is_some()) { + return Err(DbError::Validation("This session id belongs to multiple accounts. Use the conversation link from Codeg.".into())); + } + rows.into_iter().next() } }; match conv { @@ -1956,6 +2006,9 @@ mod tests { .expect("query by external_id") } + + + #[tokio::test] async fn bind_external_id_preserves_the_outgoing_session_on_a_new_row() { // codeg#500 in miniature: a row bound to S1 is handed S2 (a fresh diff --git a/src-tauri/src/db/service/handoff_service.rs b/src-tauri/src/db/service/handoff_service.rs new file mode 100644 index 0000000000..6358ff106b --- /dev/null +++ b/src-tauri/src/db/service/handoff_service.rs @@ -0,0 +1,523 @@ +//! Persistence for in-place agent handoffs (`acp::handoff`). +//! +//! A conversation row names only its current agent and session; these rows +//! are the chain of earlier segments. Append-only: a handoff is history the +//! moment it happens, and nothing edits it afterwards. + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, + QueryFilter, QueryOrder, Set, +}; + +use crate::db::entities::conversation_handoff; +use crate::db::error::DbError; +use crate::models::AgentType; + +/// Everything a handoff record needs. `seq` is assigned here, never by the +/// caller, so two records can never claim the same slot. +#[derive(Debug, Clone)] +pub struct NewHandoff { + pub conversation_id: i32, + pub from_agent_type: AgentType, + pub from_external_id: Option, + pub to_agent_type: AgentType, + pub to_external_id: String, + /// `acp::handoff::HandoffPath::as_str()`. + pub path: &'static str, + pub carried: bool, + pub user_turns_before: u32, + pub note: Option, + pub briefing: Option, + pub truncated: bool, + pub from_account_id: Option, + pub to_account_id: Option, + pub history_json: Option, + pub context_id: Option, + pub context_pending: bool, + pub source_stats_json: Option, + pub source_model: Option, +} + +/// Append one handoff to the conversation's chain and return the stored row. +pub async fn record( + conn: &impl sea_orm::ConnectionTrait, + new: NewHandoff, +) -> Result { + let seq = conversation_handoff::Entity::find() + .filter(conversation_handoff::Column::ConversationId.eq(new.conversation_id)) + .order_by_desc(conversation_handoff::Column::Seq) + .one(conn) + .await? + .map(|last| last.seq + 1) + .unwrap_or(0); + let row = conversation_handoff::ActiveModel { + id: NotSet, + conversation_id: Set(new.conversation_id), + seq: Set(seq), + from_agent_type: Set(new.from_agent_type.as_wire().into_owned()), + from_external_id: Set(new.from_external_id), + to_agent_type: Set(new.to_agent_type.as_wire().into_owned()), + to_external_id: Set(new.to_external_id), + path: Set(new.path.to_string()), + carried: Set(new.carried), + user_turns_before: Set(i32::try_from(new.user_turns_before).unwrap_or(i32::MAX)), + note: Set(new.note), + briefing: Set(new.briefing), + truncated: Set(new.truncated), + created_at: Set(Utc::now()), + from_account_id: Set(new.from_account_id), + to_account_id: Set(new.to_account_id), + history_json: Set(new.history_json), + context_id: Set(new.context_id), + context_pending: Set(new.context_pending), + source_stats_json: Set(new.source_stats_json), + source_model: Set(new.source_model), + }; + Ok(row.insert(conn).await?) +} + +/// The conversation's handoff chain, oldest first. Empty for a conversation +/// that never changed agents, which is the overwhelmingly common answer. +pub async fn list_for_conversation( + conn: &DatabaseConnection, + conversation_id: i32, +) -> Result, DbError> { + Ok(conversation_handoff::Entity::find() + .filter(conversation_handoff::Column::ConversationId.eq(conversation_id)) + .order_by_asc(conversation_handoff::Column::Seq) + .all(conn) + .await?) +} + +pub async fn native_turns( + conn: &DatabaseConnection, + agent: AgentType, + session: &str, + account_id: Option<&str>, +) -> Result, DbError> { + let home = match account_id { + Some(id) => { + let account = super::agent_account_service::get(conn, id).await?; + if account.agent_type != agent.as_wire() { + return Err(DbError::Validation( + "The saved account belongs to a different agent.".into(), + )); + } + Some(std::path::PathBuf::from(account.home_dir)) + } + None => None, + }; + let env = super::agent_setting_service::load_agent_env(conn, agent).await; + let session = session.to_string(); + tokio::task::spawn_blocking(move || { + crate::parsers::build_account_parser(agent, home.as_deref(), &env) + .and_then(|p| p.get_conversation(&session)) + .map(|d| d.turns) + .map_err(|_| DbError::Validation("The saved conversation could not be read. Restore its history before switching agents.".into())) + }).await.map_err(|e| DbError::Validation(e.to_string()))? +} + +/// New handoffs own a durable snapshot. Older development builds retain their +/// original account-aware read, and fail visibly if its history is missing. +pub async fn read_segment( + conn: &DatabaseConnection, + row: &conversation_handoff::Model, +) -> Result, DbError> { + if row.carried { + return Ok(Vec::new()); + } + if let Some(json) = &row.history_json { + return serde_json::from_str(json) + .map_err(|_| DbError::Validation("Saved handoff history could not be read.".into())); + } + let agent = AgentType::from_wire(&row.from_agent_type) + .ok_or_else(|| DbError::Validation("Unknown source agent in saved history.".into()))?; + let session = row + .from_external_id + .as_deref() + .ok_or_else(|| DbError::Validation("Saved history has no session.".into()))?; + native_turns(conn, agent, session, row.from_account_id.as_deref()).await +} + +/// Commit the new binding AND its saved history together. A later edit, deleted +/// conversation, removed account or active child refuses the whole transaction. +pub async fn commit( + conn: &DatabaseConnection, + expected: &crate::models::DbConversationSummary, + new: NewHandoff, +) -> Result<(), DbError> { + use sea_orm::{ConnectionTrait, DbBackend, Statement, TransactionTrait}; + let txn = conn.begin().await?; + let result = txn.execute(Statement::from_sql_and_values(DbBackend::Sqlite, + "UPDATE conversation SET agent_type = ?, agent_account_id = ?, external_id = ?, model = NULL, title_locked = 1, updated_at = ? \ + WHERE id = ? AND agent_type = ? AND agent_account_id IS ? AND external_id IS ? \ + AND folder_id = ? AND origin_cwd IS ? AND deleted_at IS NULL AND parent_id IS NULL \ + AND NOT EXISTS (SELECT 1 FROM conversation child WHERE child.parent_id = conversation.id AND child.status = 'in_progress') \ + AND (? IS NULL OR EXISTS (SELECT 1 FROM agent_account WHERE id = ? AND removed = 0 AND agent_type = ?))", + vec![new.to_agent_type.as_wire().into_owned().into(), new.to_account_id.clone().into(), new.to_external_id.clone().into(), Utc::now().into(), + expected.id.into(), expected.agent_type.as_wire().into_owned().into(), expected.agent_account_id.clone().into(), expected.external_id.clone().into(), + expected.folder_id.into(), expected.origin_cwd.clone().into(), new.to_account_id.clone().into(), new.to_account_id.clone().into(), new.to_agent_type.as_wire().into_owned().into()] + )).await?; + if result.rows_affected() != 1 { + return Err(DbError::Conflict( + "The conversation or account changed. Reload and try the switch again.".into(), + )); + } + record(&txn, new).await?; + txn.commit().await?; + Ok(()) +} + +/// A failed first prompt must not strand the target without its context. Keep +/// supplying the saved context until the agent's own transcript contains it. +/// Called under the normal prompt lock, so retries use the same send boundary. +pub async fn pending_context( + conn: &DatabaseConnection, + conversation_id: i32, +) -> Result, DbError> { + let current = super::conversation_service::get_by_id(conn, conversation_id).await?; + let rows = list_for_conversation(conn, conversation_id).await?; + let Some(row) = rows.last().cloned() else { + return Ok(None); + }; + if row.to_agent_type != current.agent_type.as_wire() + || Some(row.to_external_id.as_str()) != current.external_id.as_deref() + { + return Ok(None); + } + if !row.context_pending { + return Ok(None); + } + restore_archive(conn, &row).await?; + let Some(briefing) = row.briefing.clone() else { + return Ok(None); + }; + let written = native_turns( + conn, + current.agent_type, + &row.to_external_id, + current.agent_account_id.as_deref(), + ) + .await + .is_ok_and(|turns| { + turns + .iter() + .filter(|t| matches!(t.role, crate::models::TurnRole::User)) + .any(|t| { + t.blocks.iter().any(|b| matches!(b, + crate::models::ContentBlock::Text { text } if row.context_id.as_deref().map_or_else( + || text.contains(&briefing), + |id| text.contains(&format!("[codeg-context:{id}]")), + ))) + }) + }); + if written { + let mut active: conversation_handoff::ActiveModel = row.into(); + active.context_pending = Set(false); + active.update(conn).await?; + return Ok(None); + } + Ok(Some(briefing)) +} + +/// Restore a disposable context copy without changing its identity, delivery +/// state or original seed. Old prompts keep rendering correctly after restart. +async fn restore_archive( + conn: &DatabaseConnection, + row: &conversation_handoff::Model, +) -> Result<(), DbError> { + use crate::acp::handoff::{self, HandoffLink}; + let Some(id) = row.context_id.as_deref() else { + return Ok(()); + }; + let target = crate::acp::handoff_context::context_directory(id)?; + if target.join("conversation.md").is_file() { + return Ok(()); + } + let rows = list_for_conversation(conn, row.conversation_id).await?; + let mut segments = Vec::new(); + for prior in rows.iter().filter(|r| r.seq < row.seq) { + segments.push(read_segment(conn, prior).await?); + } + let last = read_segment(conn, row).await?; + let links: Vec<_> = rows + .iter() + .filter(|r| r.seq < row.seq) + .filter_map(HandoffLink::from_row) + .collect(); + let turns = handoff::splice_handoffs(&links, segments, last); + let source = row.from_agent_type.clone(); + let next = row.to_agent_type.clone(); + let note = row.note.clone(); + tokio::task::spawn_blocking(move || -> Result<(), DbError> { + let mut archive = crate::acp::handoff_context::write_archive( + &crate::paths::codeg_conversation_context_root(), + &turns, + &source, + &next, + note.as_deref(), + )?; + if target.symlink_metadata().is_ok() { + // Preserve an incomplete existing copy for recovery. All paths are + // UUID children of the managed context root, never transcript input. + let backup = target.with_extension(format!("recovery-{}", uuid::Uuid::new_v4())); + std::fs::rename(&target, backup)?; + } + std::fs::rename(&archive.directory, &target)?; + archive.directory = target; + archive.persist(); + Ok(()) + }) + .await + .map_err(|e| DbError::Validation(e.to_string()))? +} + +pub async fn context_for_conversation( + conn: &DatabaseConnection, + id: i32, +) -> Result, DbError> { + let row = conversation_handoff::Entity::find() + .filter(conversation_handoff::Column::ConversationId.eq(id)) + .filter(conversation_handoff::Column::ContextId.is_not_null()) + .order_by_desc(conversation_handoff::Column::Seq) + .one(conn) + .await?; + let Some(row) = row else { + return Ok(None); + }; + restore_archive(conn, &row).await?; + Ok(row.context_id) +} + +pub async fn context_for_binding( + conn: &DatabaseConnection, + agent: AgentType, + session: &str, + account: Option<&str>, +) -> Result, DbError> { + use crate::db::entities::conversation; + let row = conversation::Entity::find() + .filter(conversation::Column::AgentType.eq(agent.as_wire().into_owned())) + .filter(conversation::Column::ExternalId.eq(session)) + .filter(super::conversation_service::account_condition(account)) + .filter(conversation::Column::DeletedAt.is_null()) + .one(conn) + .await?; + match row { + Some(row) => context_for_conversation(conn, row.id).await, + None => Ok(None), + } +} + +/// Imported copies of a handed-off session remain owned by the same logical +/// conversation. Compare saved content, not a timestamp or id alone: later, +/// independent work in the old CLI session must still be importable. +pub async fn archived_sources( + conn: &DatabaseConnection, + scope: Option<&str>, + candidates: &[(AgentType, crate::models::ConversationSummary)], +) -> Result, DbError> { + use crate::acp::handoff::{clean_seeded_turns, HandoffLink}; + use crate::db::entities::conversation; + let mut owned = std::collections::HashSet::new(); + if candidates.is_empty() { + return Ok(owned); + } + let rows = conversation_handoff::Entity::find() + .filter(conversation_handoff::Column::HistoryJson.is_not_null()) + .all(conn) + .await?; + for (agent, candidate) in candidates { + if conversation::Entity::find() + .filter(conversation::Column::AgentType.eq(agent.as_wire().into_owned())) + .filter(conversation::Column::ExternalId.eq(&candidate.id)) + .filter(super::conversation_service::account_condition(scope)) + .one(conn) + .await? + .is_some() + { + continue; + } + let matching: Vec<_> = rows + .iter() + .filter(|row| { + row.from_agent_type == agent.as_wire() + && row.from_external_id.as_deref() == Some(candidate.id.as_str()) + && (row.from_account_id.as_deref() == scope || *agent == AgentType::ClaudeCode) + }) + .collect(); + if matching.is_empty() { + continue; + } + let Ok(current) = native_turns(conn, *agent, &candidate.id, scope).await else { + continue; + }; + for row in matching { + if conversation::Entity::find_by_id(row.conversation_id) + .one(conn) + .await? + .is_none() + { + continue; + } + let Some(archived) = row + .history_json + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + else { + continue; + }; + let links: Vec<_> = list_for_conversation(conn, row.conversation_id) + .await? + .iter() + .filter_map(HandoffLink::from_row) + .collect(); + let current = clean_seeded_turns(current.clone(), &links); + let prefix = !current.is_empty() + && current.len() <= archived.len() + && current.iter().zip(&archived).all(|(a, b)| { + serde_json::to_value((&a.role, &a.blocks)).ok() + == serde_json::to_value((&b.role, &b.blocks)).ok() + }); + if prefix { + owned.insert((*agent, candidate.id.clone())); + break; + } + } + } + Ok(owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::test_helpers::{fresh_in_memory_db, seed_conversation, seed_folder}; + use sea_orm::ConnectionTrait; + + fn new_handoff(conversation_id: i32, to: AgentType, to_ext: &str) -> NewHandoff { + NewHandoff { + conversation_id, + from_agent_type: AgentType::ClaudeCode, + from_external_id: Some("S1".into()), + to_agent_type: to, + to_external_id: to_ext.into(), + path: "summary", + carried: false, + user_turns_before: 3, + note: Some("finish the tests".into()), + briefing: Some("briefing".into()), + truncated: false, + from_account_id: None, + to_account_id: None, + history_json: None, + context_id: None, + context_pending: false, + source_stats_json: None, + source_model: None, + } + } + + #[tokio::test] + async fn commit_keeps_history_and_binding_atomic_and_refuses_a_stale_source() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/handoff-atomic").await; + let id = seed_conversation(&db, folder, AgentType::ClaudeCode).await; + let source = super::super::conversation_service::get_by_id(&db.conn, id) + .await + .unwrap(); + let mut change = new_handoff(id, AgentType::Codex, "target-session"); + change.history_json = Some("[]".into()); + commit(&db.conn, &source, change).await.unwrap(); + let current = super::super::conversation_service::get_by_id(&db.conn, id) + .await + .unwrap(); + assert_eq!(current.agent_type, AgentType::Codex); + assert_eq!(current.title, source.title); + assert!(current.title_locked); + assert_eq!(current.agent_account_id, None); + assert_eq!(list_for_conversation(&db.conn, id).await.unwrap().len(), 1); + assert!(commit( + &db.conn, + &source, + new_handoff(id, AgentType::Grok, "other-session") + ) + .await + .is_err()); + assert_eq!(list_for_conversation(&db.conn, id).await.unwrap().len(), 1); + // Failure while recording history must roll the preceding UPDATE back. + db.conn.execute_unprepared("CREATE TRIGGER reject_handoff BEFORE INSERT ON conversation_handoff BEGIN SELECT RAISE(ABORT, 'fixture'); END").await.unwrap(); + assert!(commit( + &db.conn, + ¤t, + new_handoff(id, AgentType::Grok, "other-session") + ) + .await + .is_err()); + let kept = super::super::conversation_service::get_by_id(&db.conn, id) + .await + .unwrap(); + assert_eq!(kept.external_id, current.external_id); + assert_eq!(kept.agent_type, AgentType::Codex); + } + + #[tokio::test] + async fn records_append_in_seq_order_per_conversation() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-handoff-seq").await; + let a = seed_conversation(&db, folder, AgentType::ClaudeCode).await; + let b = seed_conversation(&db, folder, AgentType::ClaudeCode).await; + + let first = record(&db.conn, new_handoff(a, AgentType::Codex, "S2")) + .await + .expect("first"); + let second = record(&db.conn, new_handoff(a, AgentType::Grok, "S3")) + .await + .expect("second"); + // A second conversation starts its own chain at 0. + let other = record(&db.conn, new_handoff(b, AgentType::Codex, "S9")) + .await + .expect("other"); + + assert_eq!(first.seq, 0); + assert_eq!(second.seq, 1); + assert_eq!(other.seq, 0); + + let chain = list_for_conversation(&db.conn, a).await.expect("list"); + assert_eq!( + chain + .iter() + .map(|h| h.to_external_id.as_str()) + .collect::>(), + vec!["S2", "S3"] + ); + assert_eq!(chain[0].from_agent_type, "claude_code"); + assert_eq!(chain[0].to_agent_type, "codex"); + assert_eq!(chain[0].note.as_deref(), Some("finish the tests")); + assert_eq!(chain[0].user_turns_before, 3); + assert!(!chain[0].carried); + + assert!(list_for_conversation(&db.conn, 999_999) + .await + .expect("unknown") + .is_empty()); + } + + #[tokio::test] + async fn custom_agents_round_trip_their_wire_form() { + let db = fresh_in_memory_db().await; + let folder = seed_folder(&db, "/tmp/codeg-handoff-wire").await; + let id = seed_conversation(&db, folder, AgentType::ClaudeCode).await; + let mut new = new_handoff(id, AgentType::custom("claude-code-2").unwrap(), "S1"); + new.carried = true; + new.path = "native"; + let row = record(&db.conn, new).await.expect("record"); + assert_eq!(row.to_agent_type, "custom:claude-code-2"); + assert_eq!( + AgentType::from_wire(&row.to_agent_type), + Some(AgentType::custom("claude-code-2").unwrap()) + ); + assert!(row.carried); + assert_eq!(row.path, "native"); + } +} diff --git a/src-tauri/src/db/service/import_service.rs b/src-tauri/src/db/service/import_service.rs index 4cf73893e7..f0db8c3e2a 100644 --- a/src-tauri/src/db/service/import_service.rs +++ b/src-tauri/src/db/service/import_service.rs @@ -40,6 +40,179 @@ fn build_parser(agent_type: AgentType, env: &AgentEnv) -> Box { build_agent_parser(agent_type, env) } +pub(crate) async fn collect_account_summaries( + conn: &DatabaseConnection, + id: &str, +) -> Result, DbError> { + let account = super::agent_account_service::get(conn, id).await?; + let agent = AgentType::from_wire(&account.agent_type) + .ok_or_else(|| DbError::Validation("Unknown account agent".into()))?; + super::agent_account_service::resolve(conn, agent, id).await?; + let home = std::path::PathBuf::from(account.home_dir); + tokio::task::spawn_blocking(move || { + crate::parsers::build_account_parser(agent, Some(&home), &crate::parsers::AgentEnv::inherited())?.list_conversations() + }).await.map_err(|e| DbError::Validation(e.to_string()))? + .map(|sessions| sessions.into_iter().filter(|s| s.parent_id.is_none()).map(|s| (agent, s)).collect()) + .map_err(|e| DbError::Validation(e.to_string())) +} + +/// The Claude transcript directory the Default account's sessions are read +/// from: the one `ClaudeParser::new()` lists. +pub(crate) async fn default_claude_projects(conn: &DatabaseConnection) -> std::path::PathBuf { + let env = agent_setting_service::load_agent_env(conn, AgentType::ClaudeCode).await; + crate::parsers::claude::resolve_claude_config_dir_for_env(&env).join("projects") +} + +/// Claude sessions found in `scope` (an account, `None` for Default) that are +/// only an older copy of a conversation codeg keeps under another account. +/// +/// An account switch copies the session into the target account and leaves +/// the source's copy in place. Everything in that copy is also in the +/// conversation's current one, so importing it adds a stale duplicate, and +/// its row in the source account then blocks switching the conversation back. +/// A copy holding anything the current one lacks stays importable, as does +/// any session id two accounts merely share. Sessions with a row of their own +/// in `scope` are left to the usual reconciliation. +pub(crate) async fn superseded_claude_copies( + conn: &DatabaseConnection, + scope: Option<&str>, + summaries: &[(AgentType, ConversationSummary)], + default_projects: &std::path::Path, +) -> Result, DbError> { + use std::collections::{HashMap, HashSet}; + let found: HashSet<&str> = summaries + .iter() + .filter(|(agent, _)| *agent == AgentType::ClaudeCode) + .map(|(_, summary)| summary.id.as_str()) + .collect(); + if found.is_empty() { + return Ok(HashSet::new()); + } + let rows = conversation::Entity::find() + .filter(conversation::Column::AgentType.eq(agent_type_db_str(&AgentType::ClaudeCode))) + .filter(conversation::Column::ExternalId.is_not_null()) + .all(conn) + .await?; + let mut own = HashSet::new(); + // Session id → the other scopes holding a live conversation for it. + let mut held: HashMap>> = HashMap::new(); + for row in rows { + let Some(sid) = row.external_id.filter(|sid| found.contains(sid.as_str())) else { + continue; + }; + if row.agent_account_id.as_deref() == scope { + own.insert(sid); + } else if row.deleted_at.is_none() { + held.entry(sid).or_default().push(row.agent_account_id); + } + } + held.retain(|sid, _| !own.contains(sid)); + if held.is_empty() { + return Ok(HashSet::new()); + } + let mut dirs: HashMap, Option> = HashMap::new(); + for owner in std::iter::once(scope.map(str::to_owned)).chain(held.values().flatten().cloned()) { + if dirs.contains_key(&owner) { + continue; + } + let dir = match owner.as_deref() { + None => Some(default_projects.to_path_buf()), + Some(id) => super::agent_account_service::get(conn, id) + .await + .ok() + .and_then(|account| { + crate::acp::agent_accounts::layout(AgentType::ClaudeCode) + .map(|layout| layout.transcript_dir(std::path::Path::new(&account.home_dir))) + }), + }; + dirs.insert(owner, dir); + } + let Some(ours) = dirs.get(&scope.map(str::to_owned)).cloned().flatten() else { + return Ok(HashSet::new()); + }; + let checks: Vec<(String, Vec)> = held + .into_iter() + .map(|(sid, owners)| { + let theirs = owners + .iter() + .filter_map(|owner| dirs.get(owner).cloned().flatten()) + .collect(); + (sid, theirs) + }) + .collect(); + tokio::task::spawn_blocking(move || { + checks + .into_iter() + .filter(|(sid, theirs)| { + theirs + .iter() + .any(|projects| claude_copy_is_covered(&ours, projects, sid)) + }) + .map(|(sid, _)| sid) + .collect() + }) + .await + .map_err(|e| DbError::Validation(e.to_string())) +} + +/// Whether all of `sid` in `ours` is also in `theirs` (two Claude `projects` +/// folders), by the switch's own test for an older copy it may replace +/// (`account_transfer`): the transcript a byte prefix, and its sidecar folder +/// (sub-agent transcripts, tool results) and file-history each an older tree. +/// Anything ambiguous or unreadable counts as not covered, which leaves the +/// session importable. +fn claude_copy_is_covered(ours: &std::path::Path, theirs: &std::path::Path, sid: &str) -> bool { + use crate::acp::account_transfer::{is_prefix, tree_is_older}; + let (Some(copy), Some(current)) = (unique_session_file(ours, sid), unique_session_file(theirs, sid)) + else { + return false; + }; + let (Some(our_home), Some(their_home)) = (ours.parent(), theirs.parent()) else { + return false; + }; + if !is_prefix(©, ¤t).unwrap_or(false) { + return false; + } + [ + (copy.with_file_name(sid), current.with_file_name(sid)), + ( + our_home.join("file-history").join(sid), + their_home.join("file-history").join(sid), + ), + ] + .iter() + .all(|(mine, other)| match mine.symlink_metadata() { + Err(e) => e.kind() == std::io::ErrorKind::NotFound, + Ok(_) => tree_is_older(mine, other).unwrap_or(false), + }) +} + +/// `.jsonl` when exactly one project folder under `projects` holds it. +/// Several copies make it unclear which one the conversation came from, and a +/// folder that cannot be read might hold another. +fn unique_session_file(projects: &std::path::Path, sid: &str) -> Option { + // Also rejects an id that is not a plain file name. + crate::parsers::claude::find_session_file_in(projects, sid)?; + let mut found = None; + for entry in std::fs::read_dir(projects).ok()? { + let project = entry.ok()?.path(); + if !project.is_dir() { + continue; + } + let candidate = project.join(format!("{sid}.jsonl")); + match candidate.symlink_metadata() { + Ok(_) => { + if found.replace(candidate).is_some() { + return None; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return None, + } + } + found +} + /// List every local agent's sessions — one `spawn_blocking` per parser so the /// filesystem walks run concurrently (each closure captures the Copy /// `AgentType` and that agent's launch environment from `envs`, and constructs @@ -177,11 +350,22 @@ pub(crate) async fn import_summaries( /// their (already-created) folder unbroadcast, and imports are idempotent so a /// re-run finishes the rest — rolling good rows back would be worse. Callers /// surface the `failed` count in their own structured result. +#[cfg(test)] pub(crate) async fn import_summaries_resilient( conn: &DatabaseConnection, folder_id: i32, items: &[(AgentType, ConversationSummary)], deleted: DeletedPolicy, +) -> (ImportResult, Vec, u32) { + import_summaries_resilient_for_account(conn, folder_id, items, deleted, None).await +} + +pub(crate) async fn import_summaries_resilient_for_account( + conn: &DatabaseConnection, + folder_id: i32, + items: &[(AgentType, ConversationSummary)], + deleted: DeletedPolicy, + account_id: Option<&str>, ) -> (ImportResult, Vec, u32) { let mut imported = 0u32; let mut updated = 0u32; @@ -191,7 +375,7 @@ pub(crate) async fn import_summaries_resilient( let mut updated_ids: Vec = Vec::new(); for (agent_type, summary) in items { - match import_one(conn, folder_id, agent_type, summary, deleted).await { + match import_one_for_account(conn, folder_id, agent_type, summary, deleted, account_id).await { Ok(ImportOutcome::Imported) => imported += 1, Ok(ImportOutcome::Updated(id)) => { updated += 1; @@ -232,7 +416,7 @@ pub async fn import_local_conversations( ) -> Result<(ImportResult, Vec), DbError> { let envs = agent_setting_service::load_agent_envs(conn).await; let summaries = collect_local_summaries(&envs, |_, _, _, _| {}).await; - let matched: Vec<(AgentType, ConversationSummary)> = summaries + let mut matched: Vec<(AgentType, ConversationSummary)> = summaries .into_iter() .filter(|(_, c)| { c.folder_path @@ -241,6 +425,9 @@ pub async fn import_local_conversations( .unwrap_or(false) }) .collect(); + let superseded = + superseded_claude_copies(conn, None, &matched, &default_claude_projects(conn).await).await?; + matched.retain(|(agent, c)| *agent != AgentType::ClaudeCode || !superseded.contains(&c.id)); import_summaries(conn, folder_id, &matched, DeletedPolicy::Skip).await } @@ -406,14 +593,34 @@ async fn import_one( summary: &ConversationSummary, deleted: DeletedPolicy, ) -> Result { + import_one_for_account(conn, folder_id, agent_type, summary, deleted, None).await +} + +async fn import_one_for_account( + conn: &DatabaseConnection, + folder_id: i32, + agent_type: &AgentType, + summary: &ConversationSummary, + deleted: DeletedPolicy, + account_id: Option<&str>, +) -> Result { + if let Some(id) = account_id { + super::agent_account_service::resolve(conn, *agent_type, id).await?; + } let at_str = agent_type_db_str(agent_type); let exists = conversation::Entity::find() .filter(conversation::Column::ExternalId.eq(&summary.id)) .filter(conversation::Column::AgentType.eq(&at_str)) + .filter(conversation_service::account_condition(account_id)) .one(conn) .await?; + if exists.is_none() && super::handoff_service::archived_sources(conn, account_id, &[(*agent_type, summary.clone())]).await? + .contains(&(*agent_type, summary.id.clone())) { + return Ok(ImportOutcome::Skipped); + } + if let Some(mut existing) = exists { // A delegation child is never a sidebar row, so it is left completely // alone whatever the policy — including no token-usage invalidation. @@ -494,6 +701,7 @@ async fn import_one( title: Set(summary.title.clone()), title_locked: Set(false), agent_type: Set(at_str), + agent_account_id: Set(account_id.map(str::to_owned)), // Imported sessions land as `PendingReview` ("待审查"), not `Completed`: // they are surfaced for the user to review and stay visible even when // the sidebar's "show completed" filter is off (now its default). @@ -946,6 +1154,7 @@ mod tests { conversation::ActiveModel { id: NotSet, folder_id: Set(folder), + agent_account_id: Set(None), title: Set(Some("child".to_string())), title_locked: Set(false), agent_type: Set(agent_type_db_str(&at)), @@ -1236,6 +1445,7 @@ mod tests { conversation::ActiveModel { id: NotSet, folder_id: Set(folder), + agent_account_id: Set(None), title: Set(Some("child original".to_string())), title_locked: Set(false), agent_type: Set(at_str), @@ -1282,4 +1492,100 @@ mod tests { "child title untouched" ); } + + /// A switch leaves the account it moved a conversation out of an older + /// copy of the session. Only a copy whose transcript, sidecar folder and + /// file-history all are in the conversation codeg still holds under another + /// account counts as superseded. + #[tokio::test] + async fn only_an_older_copy_of_a_conversation_held_elsewhere_is_superseded() { + use super::super::agent_account_service; + use crate::db::entities::conversation::ConversationKind; + use std::path::Path; + let db = fresh_in_memory_db().await; + let data = tempfile::tempdir().unwrap(); + let folder = seed_folder(&db, "/tmp/codeg-import").await; + let default_home = data.path().join("default"); + let default_projects = default_home.join("projects"); + let account = + agent_account_service::create(&db.conn, data.path(), AgentType::ClaudeCode, "Work", None) + .await + .unwrap(); + let account_home = Path::new(&account.home_dir).to_path_buf(); + let write = |path: &Path, text: &str| { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + }; + let transcript = |home: &Path, text: &str| write(&home.join("projects/project/moved.jsonl"), text); + // Switched from Default to Work, then continued there. + let row = conversation_service::create_inner( + &db.conn, + folder, + AgentType::ClaudeCode, + None, + None, + None, + ConversationKind::Regular, + Some(account.id.clone()), + ) + .await + .unwrap(); + conversation_service::bind_external_id(&db.conn, row.id, "moved", &[]) + .await + .unwrap(); + transcript(&default_home, "first\n"); + transcript(&account_home, "first\nsecond\n"); + let found = vec![(AgentType::ClaudeCode, summary("moved", None))]; + let superseded = |scope: Option| { + let (conn, found, default_projects) = (&db.conn, &found, &default_projects); + async move { + !superseded_claude_copies(conn, scope.as_deref(), found, default_projects) + .await + .unwrap() + .is_empty() + } + }; + + assert!(superseded(None).await); + // The account that holds the conversation reconciles its own row. + assert!(!superseded(Some(account.id.clone())).await); + // Another agent's session with the same id is not a Claude copy. + let codex = vec![(AgentType::Codex, summary("moved", None))]; + assert!(superseded_claude_copies(&db.conn, None, &codex, &default_projects) + .await + .unwrap() + .is_empty()); + + // A transcript with turns of its own is a conversation of its own. + transcript(&default_home, "first\nmine\n"); + assert!(!superseded(None).await); + transcript(&default_home, "first\n"); + + // Sub-agent transcripts and tool results live beside the transcript. + let sidecar = |home: &Path, name: &str| home.join("projects/project/moved").join(name); + write(&sidecar(&default_home, "subagents/agent-a.jsonl"), "task\n"); + assert!(!superseded(None).await, "a sidecar file the conversation lacks"); + write(&sidecar(&account_home, "subagents/agent-a.jsonl"), "task\nmore\n"); + assert!(superseded(None).await); + write(&sidecar(&default_home, "tool-results/r1.txt"), "only here"); + assert!(!superseded(None).await, "a tool result the conversation lacks"); + std::fs::remove_dir_all(sidecar(&default_home, "tool-results")).unwrap(); + + // So does file-history, the originals rewind restores. + let history = |home: &Path| home.join("file-history/moved/snapshot@v1"); + write(&history(&default_home), "original"); + assert!(!superseded(None).await, "a file version the conversation lacks"); + write(&history(&account_home), "original"); + assert!(superseded(None).await); + + // Two copies of the id in Default: which one moved is unclear. + write(&default_home.join("projects/elsewhere/moved.jsonl"), "first\n"); + assert!(!superseded(None).await); + std::fs::remove_dir_all(default_home.join("projects/elsewhere")).unwrap(); + assert!(superseded(None).await); + + // A trashed conversation hides nothing. + conversation_service::soft_delete(&db.conn, row.id).await.unwrap(); + assert!(!superseded(None).await); + } } diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index bbda6158d7..49a072bf78 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -12,6 +12,7 @@ pub mod folder_command_service; pub mod folder_group_service; pub mod folder_link_service; pub mod folder_service; +pub mod handoff_service; pub mod import_service; pub mod model_provider_service; pub mod quick_message_service; @@ -21,3 +22,4 @@ pub mod tab_service; pub mod thread_binding_service; pub mod token_usage_service; pub mod work_task_service; +pub mod agent_account_service; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6252d93d70..d8bd272cc3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,7 +108,7 @@ mod tauri_app { custom_skills as custom_skills_commands, deepseek_settings as deepseek_settings_commands, delegation as delegation_commands, experts as experts_commands, feedback as feedback_commands, file_io, folder_commands, - folder_links, office_tools as office_tools_commands, open_in, + folder_links, handoff as handoff_commands, office_tools as office_tools_commands, open_in, folders, logging as logging_commands, mcp as mcp_commands, model_provider as model_provider_commands, notification, pet as pet_commands, project_boot, question as question_commands, quick_messages as quick_messages_commands, @@ -116,6 +116,7 @@ mod tauri_app { remote_workspace as remote_workspace_commands, science as science_commands, session_info as session_info_commands, system_settings, terminal as terminal_commands, + subscription_quota as subscription_quota_commands, token_usage as token_usage_commands, forge as forge_commands, version_control, windows, work_task as work_task_commands, workspace_state as workspace_state_commands, workspace_windows, @@ -2126,6 +2127,8 @@ mod tauri_app { acp_commands::acp_describe_agent_options, acp_commands::acp_cancel, acp_commands::acp_fork, + handoff_commands::acp_handoff_plan, + handoff_commands::acp_handoff, acp_commands::acp_stop_async_task, acp_commands::acp_respond_permission, acp_commands::acp_answer_question, @@ -2176,6 +2179,15 @@ mod tauri_app { acp_commands::acp_reveal_hermes_home, acp_commands::acp_reorder_agents, crate::commands::custom_agents::acp_list_custom_agents, + crate::commands::custom_agents::acp_login_extra_agent, + crate::commands::agent_accounts::acp_list_agent_accounts, + crate::commands::agent_accounts::acp_create_agent_account, + crate::commands::agent_accounts::acp_rename_agent_account, + crate::commands::agent_accounts::acp_remove_agent_account, + crate::commands::agent_accounts::acp_login_agent_account, + crate::commands::agent_accounts::acp_set_agent_account_api_key, + crate::commands::agent_accounts::acp_check_agent_account_auth, + crate::commands::account_switch::acp_switch_conversation_account, crate::commands::custom_agents::acp_save_custom_agent, crate::commands::custom_agents::acp_delete_custom_agent, crate::commands::custom_agents::acp_fetch_registry_catalog, @@ -2259,6 +2271,8 @@ mod tauri_app { automation_commands::automation_compute_next_run, automation_commands::automation_run_now, automation_commands::automation_cancel_run, + subscription_quota_commands::subscription_quota_read, + subscription_quota_commands::subscription_quota_accounts, token_usage_commands::token_usage_report, token_usage_commands::token_usage_facets, token_usage_commands::token_usage_status, diff --git a/src-tauri/src/models/conversation.rs b/src-tauri/src/models/conversation.rs index da6d62599f..65257210db 100644 --- a/src-tauri/src/models/conversation.rs +++ b/src-tauri/src/models/conversation.rs @@ -27,6 +27,8 @@ pub struct ConversationSummary { #[derive(Debug, Clone, Serialize)] pub struct DbConversationSummary { + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_account_id: Option, pub id: i32, pub folder_id: i32, pub title: Option, @@ -296,6 +298,10 @@ pub enum ScanSessionStatus { /// One locally-discovered agent session in the import-picker scan. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ScanSession { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_account_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_account_name: Option, /// The agent's own session id (`ConversationSummary::id`). pub external_id: String, pub agent_type: AgentType, @@ -343,6 +349,8 @@ pub struct ScanResult { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SelectedSessionKey { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_account_id: Option, pub agent_type: AgentType, pub external_id: String, } @@ -364,7 +372,7 @@ pub struct ImportFolderOutcome { } /// Aggregate result of `import_selected_sessions`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ImportSelectedResult { pub imported: u32, pub updated: u32, diff --git a/src-tauri/src/office_watch/mod.rs b/src-tauri/src/office_watch/mod.rs index 8213a96ebd..6a0525433f 100644 --- a/src-tauri/src/office_watch/mod.rs +++ b/src-tauri/src/office_watch/mod.rs @@ -1395,11 +1395,19 @@ mod tests { #[test] fn allocate_free_port_is_actually_free() { - let port = allocate_free_port().expect("should allocate"); - assert!(port > 0); - // We released it, so we can immediately bind it again. - let again = std::net::TcpListener::bind(("127.0.0.1", port)); - assert!(again.is_ok(), "allocated port should be re-bindable"); + // The allocator releases its listener before returning. Another test + // or process can take that port, as the production contract documents. + // Retry only that race, using a fresh candidate each time. + for _ in 0..8 { + let port = allocate_free_port().expect("should allocate"); + assert!(port > 0); + match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(_) => return, + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => continue, + Err(error) => panic!("allocated port could not be bound: {error}"), + } + } + panic!("all allocated ports were taken before the test could re-bind them"); } #[test] diff --git a/src-tauri/src/parsers/antigravity.rs b/src-tauri/src/parsers/antigravity.rs index 72de543d37..9b4ebf15e5 100644 --- a/src-tauri/src/parsers/antigravity.rs +++ b/src-tauri/src/parsers/antigravity.rs @@ -514,7 +514,6 @@ impl AntigravityParser { /// Construct a parser pointed at an explicit `conversations/` directory /// (test fixtures). - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/parsers/claude.rs b/src-tauri/src/parsers/claude.rs index 747a6d0e28..98822d7976 100644 --- a/src-tauri/src/parsers/claude.rs +++ b/src-tauri/src/parsers/claude.rs @@ -1245,9 +1245,8 @@ impl ClaudeParser { } } - /// Test-only constructor that lets callers point the parser at a fixture + /// Explicit-root constructor for an account or fixture /// directory instead of `~/.claude/projects`. - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } @@ -1668,7 +1667,7 @@ pub(crate) fn resolve_claude_projects_dir_for_env(env: &AgentEnv) -> PathBuf { resolve_claude_config_dir_for_env(env).join("projects") } -fn resolve_claude_config_dir_from( +pub(crate) fn resolve_claude_config_dir_from( claude_config_dir_env: Option, home_dir: Option, ) -> PathBuf { diff --git a/src-tauri/src/parsers/cline.rs b/src-tauri/src/parsers/cline.rs index daa04b49dd..a25b0d660b 100644 --- a/src-tauri/src/parsers/cline.rs +++ b/src-tauri/src/parsers/cline.rs @@ -337,9 +337,8 @@ impl ClineParser { } } - /// Test-only constructor that lets callers point the parser at a fixture + /// Explicit-root constructor for an account or fixture /// directory instead of `~/.cline/data`. - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/parsers/codebuddy.rs b/src-tauri/src/parsers/codebuddy.rs index 73e035b2b3..378474b947 100644 --- a/src-tauri/src/parsers/codebuddy.rs +++ b/src-tauri/src/parsers/codebuddy.rs @@ -70,7 +70,6 @@ impl CodeBuddyParser { /// Construct a parser pointed at an explicit `projects` directory (test /// fixtures). - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/parsers/codex.rs b/src-tauri/src/parsers/codex.rs index e3ad978a1f..fc3c568473 100644 --- a/src-tauri/src/parsers/codex.rs +++ b/src-tauri/src/parsers/codex.rs @@ -215,9 +215,8 @@ impl CodexParser { } } - /// Test-only constructor that lets callers point the parser at a fixture + /// Explicit-root constructor for an account or fixture /// directory instead of `~/.codex/sessions`. - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/parsers/cursor.rs b/src-tauri/src/parsers/cursor.rs index 3abd6446b3..d4d1911a51 100644 --- a/src-tauri/src/parsers/cursor.rs +++ b/src-tauri/src/parsers/cursor.rs @@ -121,7 +121,6 @@ impl CursorParser { /// Construct a parser pointed at an explicit `chats` directory (tests). /// The ACP-session root is derived as its `acp-sessions` sibling, and the /// timing journal as its `turn-timings` sibling. - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(chats_dir: PathBuf) -> Self { let sibling = |name: &str| { chats_dir diff --git a/src-tauri/src/parsers/deepseek.rs b/src-tauri/src/parsers/deepseek.rs index bd00b9cee9..1856051d99 100644 --- a/src-tauri/src/parsers/deepseek.rs +++ b/src-tauri/src/parsers/deepseek.rs @@ -180,6 +180,13 @@ pub struct DeepSeekParser { } impl DeepSeekParser { + pub fn with_account_home(home: &Path) -> Self { + Self { + base_dir: home.join("sessions"), + attachments_root: home.join("attachments/v1"), + } + } + pub fn new() -> Self { Self::for_env(&AgentEnv::inherited()) } diff --git a/src-tauri/src/parsers/gemini.rs b/src-tauri/src/parsers/gemini.rs index 4bce433dae..a187cd439e 100644 --- a/src-tauri/src/parsers/gemini.rs +++ b/src-tauri/src/parsers/gemini.rs @@ -33,9 +33,8 @@ impl GeminiParser { } } - /// Test-only constructor that lets callers point the parser at a fixture + /// Explicit-root constructor for an account or fixture /// directory instead of `~/.gemini`. - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/parsers/grok.rs b/src-tauri/src/parsers/grok.rs index c889fb5ae4..9cb91069a7 100644 --- a/src-tauri/src/parsers/grok.rs +++ b/src-tauri/src/parsers/grok.rs @@ -214,7 +214,6 @@ impl GrokParser { /// Construct a parser pointed at an explicit `sessions` directory (test /// fixtures). - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/parsers/hermes.rs b/src-tauri/src/parsers/hermes.rs index bad5971cd2..4f6b07576d 100644 --- a/src-tauri/src/parsers/hermes.rs +++ b/src-tauri/src/parsers/hermes.rs @@ -51,9 +51,8 @@ impl HermesParser { } } - /// Test-only constructor that points the parser at a fixture directory + /// Explicit-root constructor for an account or fixture directory /// containing a `state.db` SQLite file. - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/parsers/kimi_code.rs b/src-tauri/src/parsers/kimi_code.rs index 4ccd10d71d..1e45b776d1 100644 --- a/src-tauri/src/parsers/kimi_code.rs +++ b/src-tauri/src/parsers/kimi_code.rs @@ -113,7 +113,6 @@ impl KimiCodeParser { /// Construct a parser pointed at an explicit `sessions` directory (test /// fixtures). - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/parsers/mod.rs b/src-tauri/src/parsers/mod.rs index 6557b5e4d9..d5a48f9dc2 100644 --- a/src-tauri/src/parsers/mod.rs +++ b/src-tauri/src/parsers/mod.rs @@ -430,6 +430,33 @@ pub fn build_agent_parser(agent_type: AgentType, env: &AgentEnv) -> Box, env: &AgentEnv) -> Result, ParseError> { + let Some(home) = home else { return Ok(build_agent_parser(agent, env)); }; + let spec = crate::acp::agent_accounts::layout(agent) + .ok_or_else(|| ParseError::InvalidData(format!("{agent} does not support isolated accounts")))?; + let base = spec.transcript_dir(home); + let inner: Box = match agent { + AgentType::ClaudeCode => Box::new(claude::ClaudeParser::with_base_dir(base)), + AgentType::Codex => Box::new(codex::CodexParser::with_base_dir(base)), + AgentType::Grok => Box::new(grok::GrokParser::with_base_dir(base)), + AgentType::Gemini => Box::new(gemini::GeminiParser::with_base_dir(base)), + AgentType::OpenCode => Box::new(opencode::OpenCodeParser::with_account_home(home)), + AgentType::Hermes => Box::new(hermes::HermesParser::with_base_dir(base)), + AgentType::CodeBuddy => Box::new(codebuddy::CodeBuddyParser::with_base_dir(base)), + AgentType::Cline => Box::new(cline::ClineParser::with_base_dir(base)), + AgentType::KimiCode => Box::new(kimi_code::KimiCodeParser::with_base_dir(base)), + AgentType::Pi => Box::new(pi::PiParser::with_base_dir(base)), + AgentType::DeepSeek => Box::new(deepseek::DeepSeekParser::with_account_home(home)), + AgentType::Qoder => Box::new(qoder::QoderParser::with_base_dir(base)), + AgentType::Antigravity => Box::new(antigravity::AntigravityParser::with_base_dir(base)), + AgentType::Cursor => Box::new(cursor::CursorParser::with_base_dir(base)), + _ => return Err(ParseError::InvalidData("Unsupported account parser".into())), + }; + Ok(Box::new(RouteSanitized(inner))) +} + /// A parser wrapped the way [`build_agent_parser`] wraps every one, for tests /// that build their parser over a fixture directory. #[cfg(test)] @@ -517,6 +544,12 @@ fn sanitize_summary(summary: &mut ConversationSummary) { // straddle the cut and survive `sanitize_text`, which only removes whole // frames. Anything from a leftover marker on is truncated frame. crate::acp::agent_mentions::cut_at_route_frame_marker(title); + // The first native prompt after a handoff is an internal context seed. + // A parser may already have clipped it, so titles only need its prefix. + if let Some(at) = title.find(crate::acp::handoff::BRIEFING_MARKER) { + title.truncate(at); + *title = title.trim_end().to_string(); + } if title.trim().is_empty() { summary.title = None; } diff --git a/src-tauri/src/parsers/opencode.rs b/src-tauri/src/parsers/opencode.rs index e9f9d9304d..dc0b9faacf 100644 --- a/src-tauri/src/parsers/opencode.rs +++ b/src-tauri/src/parsers/opencode.rs @@ -26,6 +26,21 @@ impl Default for OpenCodeParser { } impl OpenCodeParser { + pub fn with_account_home(home: &Path) -> Self { + Self { + base_dir: home.join("data/opencode"), + model_limits: ModelLimitSources { + config_dir: Some(home.join("config/opencode")), + config_dir_env: Some(home.join("config/opencode")), + catalog_file: Some(home.join("cache/opencode/models.json")), + home_dir: Some(home.to_path_buf()), + project_config: true, + bundled_catalog: true, + ..Default::default() + }, + } + } + pub fn new() -> Self { Self::for_env(&AgentEnv::inherited()) } @@ -39,7 +54,7 @@ impl OpenCodeParser { } } - /// Test-only constructor that lets callers point the parser at a fixture + /// Explicit-root constructor for an account or fixture /// directory containing an `opencode.db` SQLite file. /// /// It reads nothing else: no OpenCode config or catalog from the machine diff --git a/src-tauri/src/parsers/pi.rs b/src-tauri/src/parsers/pi.rs index c462c8f086..2dadfca2d1 100644 --- a/src-tauri/src/parsers/pi.rs +++ b/src-tauri/src/parsers/pi.rs @@ -353,7 +353,6 @@ impl PiParser { /// fixtures). The agent dir is taken to be its parent — pi's default /// `/sessions` layout — so a fixture can place `models.json` /// next to the sessions folder and nothing reaches the real `~/.pi`. - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { let agent_dir = base_dir .parent() diff --git a/src-tauri/src/parsers/qoder.rs b/src-tauri/src/parsers/qoder.rs index a3e2e4feba..7a0d546456 100644 --- a/src-tauri/src/parsers/qoder.rs +++ b/src-tauri/src/parsers/qoder.rs @@ -174,7 +174,6 @@ impl QoderParser { /// Construct a parser pointed at an explicit `projects` directory (test /// fixtures). - #[cfg(any(test, feature = "test-utils"))] pub fn with_base_dir(base_dir: PathBuf) -> Self { Self { base_dir } } diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index 2d5788abdf..d33bbb9398 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -173,6 +173,12 @@ pub fn codeg_turn_timings_root() -> PathBuf { .unwrap_or_else(|| PathBuf::from(CODEG_DIR_NAME).join(TURN_TIMINGS_DIR_NAME)) } +/// Conversation handoff copies follow the same persisted home/data-directory +/// precedence as Codeg's own transcripts, including container deployments. +pub fn codeg_conversation_context_root() -> PathBuf { + codeg_acp_transcripts_root().with_file_name("conversation-context") +} + /// Root directory for codeg's own ACP transcripts (see /// `crate::acp_transcript`) — the raw `session/update` stream and outgoing /// prompts recorded for **custom ACP agents**, which have no codeg-side diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 0441f7d3eb..d68b3d458d 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -68,6 +68,8 @@ pub async fn acp_env_diagnostics( #[serde(rename_all = "camelCase")] pub struct AcpConnectParams { pub agent_type: AgentType, + pub agent_account_id: Option, + pub conversation_id: Option, pub working_dir: Option, pub session_id: Option, #[serde(default)] @@ -83,11 +85,16 @@ pub async fn acp_connect( let db = &state.db; let manager = &state.connection_manager; - let runtime_env = acp_commands::build_session_runtime_env( + let connect_barrier = manager.lock_for_connect().await; + let account_id = crate::db::service::agent_account_service::connection_account( + &db.conn, params.agent_type, params.agent_account_id, params.conversation_id, + ).await.map_err(AppCommandError::from)?; + let runtime_env = acp_commands::build_account_session_runtime_env( db, params.agent_type, params.session_id.as_deref(), &state.data_dir, + account_id.as_deref(), ) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; @@ -101,7 +108,8 @@ pub async fn acp_connect( let emitter = state.emitter.clone(); let connection_id = manager - .spawn_agent( + .spawn_agent_with_connect_barrier( + &connect_barrier, params.agent_type, params.working_dir, params.session_id, @@ -628,8 +636,9 @@ pub async fn acp_find_connection_for_conversation( Extension(state): Extension>, Json(params): Json, ) -> Result>, AppCommandError> { - let info = acp_commands::acp_find_connection_for_conversation_core( + let info = acp_commands::acp_find_connection_for_saved_conversation_core( &state.connection_manager, + &state.db, params.conversation_id, params.session_id.as_deref(), params.agent_type, @@ -1016,13 +1025,14 @@ pub async fn acp_sync_antigravity_settings( #[serde(rename_all = "camelCase")] pub struct AcpAntigravityLoginStartParams { pub method_id: String, + pub account_id: Option, } pub async fn acp_antigravity_login_start( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = acp_commands::acp_antigravity_login_start_core(&state.db, params.method_id) + let result = acp_commands::acp_antigravity_account_login_start_core(&state.db, params.method_id, params.account_id) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(result)) @@ -1072,13 +1082,14 @@ pub async fn acp_antigravity_sign_out( #[serde(rename_all = "camelCase")] pub struct AcpPiProjectTrustStateParams { pub workspace: String, + pub agent_account_id: Option, } pub async fn acp_pi_project_trust_state( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = acp_commands::acp_pi_project_trust_state_core(&state.db, params.workspace) + let result = acp_commands::acp_pi_project_trust_state_core(&state.db, params.workspace, params.agent_account_id) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(result)) @@ -1088,6 +1099,7 @@ pub async fn acp_pi_project_trust_state( #[serde(rename_all = "camelCase")] pub struct AcpPiSetProjectTrustParams { pub workspace: String, + pub agent_account_id: Option, /// `null` clears codeg's entry for this exact folder (revoke), rather than /// recording a verdict — matching the command's `Option`. #[serde(default)] @@ -1098,7 +1110,7 @@ pub async fn acp_pi_set_project_trust( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - acp_commands::acp_pi_set_project_trust_core(&state.db, params.workspace, params.trusted) + acp_commands::acp_pi_set_project_trust_core(&state.db, params.workspace, params.trusted, params.agent_account_id) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(())) @@ -1108,12 +1120,14 @@ pub async fn acp_pi_set_project_trust( #[serde(rename_all = "camelCase")] pub struct AcpPiAcknowledgeProjectTrustParams { pub workspace: String, + pub agent_account_id: Option, } pub async fn acp_pi_acknowledge_project_trust( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - acp_commands::acp_pi_acknowledge_project_trust_core(params.workspace) + acp_commands::acp_pi_acknowledge_project_trust_core(&state.db, params.workspace, params.agent_account_id) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(())) @@ -1427,6 +1441,22 @@ pub async fn acp_list_custom_agents( Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpLoginExtraAgentParams { + pub registry_id: String, +} + +pub async fn acp_login_extra_agent( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + let result = custom_agent_commands::acp_login_extra_agent_core(¶ms.registry_id, &state.db) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + Ok(Json(result)) +} + /// Wrapper matching the Tauri command's single `params` argument — the shared /// frontend client sends the same body to both runtimes. #[derive(Deserialize)] @@ -1556,3 +1586,62 @@ mod tests { assert!(!text.contains("codex")); } } +#[cfg(test)] +mod extra_slot_login_api_tests { + use super::*; + use axum_test::TestServer; + use serde_json::{json, Value}; + + #[test] + fn isolated_sign_in_round_trips_through_the_authenticated_http_api() { + // The registry is process-wide; keep its test guard outside the async runtime. + let _guard = crate::acp::custom_registry::hydrate_test_guard(); + struct ClearRegistry; + impl Drop for ClearRegistry { + fn drop(&mut self) { crate::acp::custom_registry::hydrate(&[]); } + } + let _clear = ClearRegistry; + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + let data = tempfile::tempdir().unwrap(); + let static_dir = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let auth = home.path().join("auth.json"); + std::fs::write(&auth, b"untouched-account-file").unwrap(); + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let state = Arc::new(AppState::new_for_test(db, data.path().to_path_buf())); + let router = crate::web::router::build_router( + state, + "test-login-token".into(), + static_dir.path().to_path_buf(), + Arc::new(crate::web::shutdown::ShutdownSignal::new()), + ); + let server = TestServer::new(router).unwrap(); + server.post("/api/acp_login_extra_agent") + .json(&json!({"registryId": "codex-login-test"})).await.assert_status_unauthorized(); + let saved = server.post("/api/acp_save_custom_agent") + .add_header("authorization", "Bearer test-login-token") + .json(&json!({"params": { + "registryId": "codex-login-test", "name": "Work", "version": "1.0.0", + "distributionKind": "npx", "spec": {"npx": { + "package": "@agentclientprotocol/codex-acp@1.0.0", "cmd": "codex-acp", + "env": {"CODEX_HOME": home.path().display().to_string()} + }} + }})).await; + saved.assert_status_ok(); + let listed = server.post("/api/acp_list_custom_agents") + .add_header("authorization", "Bearer test-login-token").json(&json!({})).await; + listed.assert_status_ok(); + let rows: Value = listed.json(); + assert_eq!(rows[0]["isolatedLogin"]["key"], "CODEX_HOME"); + let login = server.post("/api/acp_login_extra_agent") + .add_header("authorization", "Bearer test-login-token") + .json(&json!({"registryId": "codex-login-test"})).await; + login.assert_status_ok(); + let result: Value = login.json(); + assert_eq!(result["home"], home.path().display().to_string()); + assert_eq!(result["launched"], false); + assert!(!result["command"].as_str().unwrap().is_empty()); + assert_eq!(std::fs::read(auth).unwrap(), b"untouched-account-file"); + }); + } +} diff --git a/src-tauri/src/web/handlers/agent_accounts.rs b/src-tauri/src/web/handlers/agent_accounts.rs new file mode 100644 index 0000000000..10a57fb91a --- /dev/null +++ b/src-tauri/src/web/handlers/agent_accounts.rs @@ -0,0 +1,271 @@ +use axum::{extract::Extension, Json}; +use serde::Deserialize; +use std::sync::Arc; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::agent_accounts as commands; +use crate::db::entities::agent_account; +use crate::models::AgentType; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentParams { + agent_type: AgentType, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateParams { + agent_type: AgentType, + name: String, + home_dir: Option, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenameParams { + account_id: String, + name: String, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IdParams { + account_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoginParams { + account_id: String, + method: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiKeyParams { + account_id: String, + api_key: String, +} + +fn error(e: crate::acp::error::AcpError) -> AppCommandError { + AppCommandError::task_execution_failed(e.to_string()) +} + +#[derive(Deserialize)] +pub struct SwitchParams { + request: crate::commands::account_switch::SwitchAccountRequest, +} + +pub async fn switch_conversation( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + crate::commands::account_switch::switch_core( + &state.db, &state.connection_manager, &state.emitter, &state.data_dir, + "web".into(), p.request, + ).await.map(Json).map_err(error) +} + +pub async fn list( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + commands::list_core(&state.db, p.agent_type) + .await + .map(Json) + .map_err(error) +} +pub async fn create( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + commands::create_core( + &state.db, + &state.data_dir, + p.agent_type, + &p.name, + p.home_dir.as_deref(), + &state.emitter, + ) + .await + .map(Json) + .map_err(error) +} +pub async fn rename( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + commands::rename_core(&state.db, &p.account_id, &p.name, &state.emitter) + .await + .map(Json) + .map_err(error) +} +pub async fn remove( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + commands::remove_core(&state.db, &p.account_id, &state.emitter) + .await + .map(Json) + .map_err(error) +} + +pub async fn login( + Extension(state): Extension>, + Json(p): Json, +) -> Result, AppCommandError> { + commands::login_method_core(&state.db, &state.connection_manager, &p.account_id, p.method, &state.emitter) + .await + .map(Json) + .map_err(error) +} + +pub async fn set_api_key( + Extension(state): Extension>, Json(p): Json, +) -> Result, AppCommandError> { + commands::set_api_key_core(&state.db, &state.connection_manager, &state.data_dir, &p.account_id, &p.api_key, &state.emitter) + .await.map(Json).map_err(error) +} + +pub async fn check_auth( + Extension(state): Extension>, Json(p): Json, +) -> Result, AppCommandError> { + commands::check_auth_core(&state.db, &state.data_dir, &p.account_id, &state.emitter) + .await.map(Json).map_err(error) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum_test::TestServer; + use serde_json::{json, Value}; + + #[tokio::test] + async fn native_account_http_auth_is_scoped_private_and_refuses_live_or_removed_accounts() { + let data = tempfile::tempdir().unwrap(); + let static_dir = tempfile::tempdir().unwrap(); + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let state = Arc::new(AppState::new_for_test(db, data.path().into())); + let server = TestServer::new(crate::web::router::build_router(state.clone(), "account-test-token".into(), static_dir.path().into(), Arc::new(crate::web::shutdown::ShutdownSignal::new()))).unwrap(); + for endpoint in ["acp_set_agent_account_api_key", "acp_check_agent_account_auth"] { + server.post(&format!("/api/{endpoint}")).json(&json!({"accountId":"missing", "apiKey":"fixture-key"})).await.assert_status_unauthorized(); + } + let mut accounts = Vec::new(); + for name in ["A", "B"] { + accounts.push(crate::db::service::agent_account_service::create(&state.db.conn, data.path(), AgentType::ClaudeCode, name, None).await.unwrap()); + } + let a = &accounts[0]; + let b = &accounts[1]; + std::fs::write(std::path::Path::new(&b.home_dir).join("settings.json"), "{}").unwrap(); + let response = server.post("/api/acp_set_agent_account_api_key").add_header("authorization", "Bearer account-test-token").json(&json!({"accountId":a.id, "apiKey":"fixture-key"})).await; + response.assert_status_ok(); + assert_eq!(response.json::(), Value::Null); + let a_path = std::path::Path::new(&a.home_dir).join("settings.json"); + let saved = std::fs::read(&a_path).unwrap(); + assert_eq!(serde_json::from_slice::(&saved).unwrap()["env"]["ANTHROPIC_API_KEY"], "fixture-key"); + assert_eq!(std::fs::read_to_string(std::path::Path::new(&b.home_dir).join("settings.json")).unwrap(), "{}"); + state.connection_manager.insert_test_connection("auth-http", AgentType::ClaudeCode, None, crate::web::event_bridge::EventEmitter::Noop).await; + state.connection_manager.get_state("auth-http").await.unwrap().write().await.agent_account_id = Some(a.id.clone()); + let refused = server.post("/api/acp_set_agent_account_api_key").add_header("authorization", "Bearer account-test-token").json(&json!({"accountId":a.id, "apiKey":"replacement-key"})).await; + assert!(!refused.status_code().is_success()); + assert!(!refused.text().contains("replacement-key")); + assert_eq!(std::fs::read(&a_path).unwrap(), saved); + // Merely obtaining the native command does not edit credentials and + // remains available while a chat is open (including older clients). + server.post("/api/acp_login_agent_account").add_header("authorization", "Bearer account-test-token").json(&json!({"accountId":a.id})).await.assert_status_ok(); + assert_eq!(std::fs::read(&a_path).unwrap(), saved); + crate::db::service::agent_account_service::remove(&state.db.conn, &a.id).await.unwrap(); + let refused = server.post("/api/acp_set_agent_account_api_key").add_header("authorization", "Bearer account-test-token").json(&json!({"accountId":a.id, "apiKey":"replacement-key"})).await; + assert!(!refused.status_code().is_success()); + assert_eq!(std::fs::read(a_path).unwrap(), saved); + } + + #[tokio::test] + async fn native_account_http_lifecycle_preserves_the_conversation_binding_and_files() { + let data = tempfile::tempdir().unwrap(); + let static_dir = tempfile::tempdir().unwrap(); + let adopted = tempfile::tempdir().unwrap(); + std::fs::write(adopted.path().join("auth.json"), "account-auth-sentinel").unwrap(); + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let folder = + crate::db::test_helpers::seed_folder(&db, &data.path().display().to_string()).await; + let state = Arc::new(AppState::new_for_test(db, data.path().to_path_buf())); + let server = TestServer::new(crate::web::router::build_router( + state.clone(), + "account-test-token".into(), + static_dir.path().into(), + Arc::new(crate::web::shutdown::ShutdownSignal::new()), + )) + .unwrap(); + server + .post("/api/acp_create_agent_account") + .json(&json!({"agentType":"codex", "name":"Work"})) + .await + .assert_status_unauthorized(); + let created = server.post("/api/acp_create_agent_account").add_header("authorization", "Bearer account-test-token") + .json(&json!({"agentType":"codex", "name":"Work", "homeDir":adopted.path().display().to_string()})).await; + created.assert_status_ok(); + let account: Value = created.json(); + let id = account["id"].as_str().unwrap(); + assert_eq!(account["agentType"], "codex"); + let login = server + .post("/api/acp_login_agent_account") + .add_header("authorization", "Bearer account-test-token") + .json(&json!({"accountId":id})) + .await; + login.assert_status_ok(); + let plan: Value = login.json(); + assert_eq!(plan["launched"], false); + assert_eq!(plan["isolatorKey"], "CODEX_HOME"); + assert!(!plan["command"].as_str().unwrap().is_empty()); + let conversation = server + .post("/api/create_conversation") + .add_header("authorization", "Bearer account-test-token") + .json(&json!({"agentType":"codex", "folderId":folder, "agentAccountId":id})) + .await; + conversation.assert_status_ok(); + let conversation_id: i32 = conversation.json(); + server + .post("/api/acp_rename_agent_account") + .add_header("authorization", "Bearer account-test-token") + .json(&json!({"accountId":id, "name":"Renamed"})) + .await + .assert_status_ok(); + server + .post("/api/acp_remove_agent_account") + .add_header("authorization", "Bearer account-test-token") + .json(&json!({"accountId":id})) + .await + .assert_status_ok(); + let listed = server + .post("/api/acp_list_agent_accounts") + .add_header("authorization", "Bearer account-test-token") + .json(&json!({"agentType":"codex"})) + .await; + listed.assert_status_ok(); + let rows: Value = listed.json(); + assert_eq!(rows["accounts"][0]["name"], "Renamed"); + assert_eq!(rows["accounts"][0]["removed"], true); + let refused = server + .post("/api/acp_login_agent_account") + .add_header("authorization", "Bearer account-test-token") + .json(&json!({"accountId":id})) + .await; + assert!(!refused.status_code().is_success()); + assert_eq!( + crate::db::service::agent_account_service::connection_account( + &state.db.conn, + AgentType::Codex, + None, + Some(conversation_id) + ) + .await + .unwrap() + .as_deref(), + Some(id) + ); + assert_eq!( + std::fs::read_to_string(adopted.path().join("auth.json")).unwrap(), + "account-auth-sentinel" + ); + } +} diff --git a/src-tauri/src/web/handlers/conversations.rs b/src-tauri/src/web/handlers/conversations.rs index 94f9c46f79..ad21e450b9 100644 --- a/src-tauri/src/web/handlers/conversations.rs +++ b/src-tauri/src/web/handlers/conversations.rs @@ -92,6 +92,7 @@ pub async fn save_opened_tabs( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListConversationsParams { + pub agent_account_id: Option, pub agent_type: Option, pub search: Option, pub sort_by: Option, @@ -104,6 +105,7 @@ pub async fn list_conversations( ) -> Result>, AppCommandError> { let result = conv_commands::list_conversations_core( &state.db.conn, + params.agent_account_id, params.agent_type, params.search, params.sort_by, @@ -116,6 +118,7 @@ pub async fn list_conversations( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetConversationParams { + pub agent_account_id: Option, pub agent_type: AgentType, pub conversation_id: String, } @@ -124,12 +127,7 @@ pub async fn get_conversation( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = conv_commands::get_conversation_core( - &state.db.conn, - params.agent_type, - params.conversation_id, - ) - .await?; + let result = conv_commands::get_conversation_core(&state.db.conn, params.agent_account_id, params.agent_type, params.conversation_id).await?; Ok(Json(result)) } @@ -283,6 +281,7 @@ pub async fn import_selected_sessions( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateConversationParams { + pub agent_account_id: Option, pub folder_id: i32, pub agent_type: AgentType, pub title: Option, @@ -293,11 +292,12 @@ pub async fn create_conversation( Json(params): Json, ) -> Result, AppCommandError> { let db = &state.db; - let result = conv_commands::create_conversation_core( + let result = conv_commands::create_conversation_for_account_core( &db.conn, params.folder_id, params.agent_type, params.title, + params.agent_account_id, ) .await?; conv_commands::emit_conversation_upsert(&state.emitter, &db.conn, result).await; @@ -307,6 +307,7 @@ pub async fn create_conversation( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateChatConversationParams { + pub agent_account_id: Option, pub agent_type: AgentType, pub title: Option, /// Reuse an eagerly-created scratch dir (from `create_chat_dir`) instead of @@ -318,12 +319,13 @@ pub async fn create_chat_conversation( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = conv_commands::create_chat_conversation_core( + let result = conv_commands::create_chat_conversation_for_account_core( &state.db.conn, &state.data_dir, params.agent_type, params.title, params.existing_dir.as_deref(), + params.agent_account_id, ) .await?; conv_commands::emit_conversation_upsert(&state.emitter, &state.db.conn, result.conversation_id) diff --git a/src-tauri/src/web/handlers/handoff.rs b/src-tauri/src/web/handlers/handoff.rs new file mode 100644 index 0000000000..aad8b3845a --- /dev/null +++ b/src-tauri/src/web/handlers/handoff.rs @@ -0,0 +1,80 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::commands::handoff::{ + handoff_core, handoff_plan_core, HandoffPlan, HandoffRequest, HandoffResult, +}; +use crate::models::AgentType; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpHandoffPlanParams { + pub conversation_id: i32, + pub target_agent_type: AgentType, + pub target_account_id: Option, +} + +pub async fn acp_handoff_plan( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + handoff_plan_core( + &state.db, + &state.data_dir, + params.conversation_id, + params.target_agent_type, + params.target_account_id.as_deref(), + ) + .await?, + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpHandoffParams { + pub conversation_id: i32, + pub target_agent_type: AgentType, + pub target_account_id: Option, + pub source_agent_type: AgentType, + pub source_account_id: Option, + pub source_external_id: String, + #[serde(default)] + pub note: Option, + #[serde(default)] + pub preferred_mode_id: Option, + #[serde(default)] + pub preferred_config_values: Option>, +} + +pub async fn acp_handoff( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + handoff_core( + &state.db, + &state.connection_manager, + &state.emitter, + &state.data_dir, + "web".to_string(), + HandoffRequest { + conversation_id: params.conversation_id, + target_agent_type: params.target_agent_type, + target_account_id: params.target_account_id, + source_agent_type: params.source_agent_type, + source_account_id: params.source_account_id, + source_external_id: params.source_external_id, + note: params.note, + preferred_mode_id: params.preferred_mode_id, + preferred_config_values: params.preferred_config_values.unwrap_or_default(), + }, + ) + .await?, + )) +} diff --git a/src-tauri/src/web/handlers/mcp.rs b/src-tauri/src/web/handlers/mcp.rs index 7a4f751032..96d2157aaa 100644 --- a/src-tauri/src/web/handlers/mcp.rs +++ b/src-tauri/src/web/handlers/mcp.rs @@ -1,3 +1,6 @@ +use std::sync::Arc; +use axum::extract::Extension; +use crate::app_state::AppState; use axum::Json; use serde::Deserialize; use serde_json::Value; @@ -97,9 +100,10 @@ pub async fn mcp_get_marketplace_server_detail( } pub async fn mcp_install_from_marketplace( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = mcp_commands::mcp_install_from_marketplace( + let result = mcp_commands::mcp_install_from_marketplace_core(&state.db, params.provider_id, params.server_id, params.apps, @@ -113,28 +117,26 @@ pub async fn mcp_install_from_marketplace( } pub async fn mcp_upsert_local_server( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = mcp_commands::mcp_upsert_local_server( - params.server_id, - params.spec, - params.apps, - params.shown_spec, - ) - .await?; + let result = + mcp_commands::mcp_upsert_local_server_core(&state.db, params.server_id, params.spec, params.apps, params.shown_spec).await?; Ok(Json(result)) } pub async fn mcp_set_server_apps( + Extension(state): Extension>, Json(params): Json, ) -> Result>, AppCommandError> { - let result = mcp_commands::mcp_set_server_apps(params.server_id, params.apps).await?; + let result = mcp_commands::mcp_set_server_apps_core(&state.db, params.server_id, params.apps).await?; Ok(Json(result)) } pub async fn mcp_remove_server( + Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - let result = mcp_commands::mcp_remove_server(params.server_id, params.apps).await?; + let result = mcp_commands::mcp_remove_server_core(&state.db, params.server_id, params.apps).await?; Ok(Json(result)) } diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index a8bba12317..1136dd66ae 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -28,6 +28,7 @@ pub mod folders; pub mod forge; pub mod generative_ui; pub mod git; +pub mod handoff; pub mod logging; pub mod mcp; pub mod mcp_service; @@ -40,6 +41,7 @@ pub mod question; pub mod quick_messages; pub mod science; pub mod session_info; +pub mod subscription_quota; pub mod system_settings; pub mod terminal; pub mod token_usage; @@ -49,3 +51,4 @@ pub mod web_server; pub mod work_task; pub mod workspace_files; pub mod workspace_state; +pub mod agent_accounts; diff --git a/src-tauri/src/web/handlers/subscription_quota.rs b/src-tauri/src/web/handlers/subscription_quota.rs new file mode 100644 index 0000000000..99bd438f74 --- /dev/null +++ b/src-tauri/src/web/handlers/subscription_quota.rs @@ -0,0 +1,104 @@ +use crate::{ + app_error::AppCommandError, app_state::AppState, commands::subscription_quota as quota, +}; +use axum::{extract::Extension, Json}; +use serde::Deserialize; +use std::sync::Arc; +#[derive(Deserialize)] +pub struct Params { + request: quota::QuotaRequest, +} +pub async fn accounts( + Extension(state): Extension>, +) -> Result>, AppCommandError> { + Ok(Json(quota::accounts_core(&state.db).await?)) +} +pub async fn read( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + Ok(Json( + quota::read_core(&state.db, &state.data_dir, params.request).await?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum_test::TestServer; + use serde_json::{json, Value}; + #[tokio::test] + async fn quota_http_obeys_saved_binding_and_requires_authentication() { + let data = tempfile::tempdir().unwrap(); + let assets = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let sentinel = home.path().join(".credentials.json"); + std::fs::write(&sentinel, "invalid-credential-sentinel").unwrap(); + let db = crate::db::test_helpers::fresh_in_memory_db().await; + let folder = + crate::db::test_helpers::seed_folder(&db, &data.path().display().to_string()).await; + let state = Arc::new(AppState::new_for_test(db, data.path().into())); + let server = TestServer::new(crate::web::router::build_router( + state, + "quota-fixture".into(), + assets.path().into(), + Arc::new(crate::web::shutdown::ShutdownSignal::new()), + )) + .unwrap(); + server + .post("/api/subscription_quota_accounts") + .json(&json!({})) + .await + .assert_status_unauthorized(); + server + .post("/api/subscription_quota_read") + .json(&json!({"request":{"agentType":"claude_code"}})) + .await + .assert_status_unauthorized(); + let created = server.post("/api/acp_create_agent_account").add_header("authorization", "Bearer quota-fixture").json(&json!({"agentType":"claude_code","name":"Work","homeDir":home.path().display().to_string()})).await; + created.assert_status_ok(); + let account: Value = created.json(); + let id = account["id"].as_str().unwrap(); + let conversation = server + .post("/api/create_conversation") + .add_header("authorization", "Bearer quota-fixture") + .json(&json!({"agentType":"claude_code","folderId":folder,"agentAccountId":id})) + .await; + conversation.assert_status_ok(); + let cid: i32 = conversation.json(); + let read = server + .post("/api/subscription_quota_read") + .add_header("authorization", "Bearer quota-fixture") + .json(&json!({"request":{"agentType":"claude_code","conversationId":cid}})) + .await; + read.assert_status_ok(); + let value: Value = read.json(); + assert_eq!(value["agentAccountId"], id); + assert_eq!(value["name"], "Work"); + assert!(value["payload"].is_null()); + assert!(value.get("homeDir").is_none()); + assert!(!value.to_string().contains("sentinel")); + let wrong = server + .post("/api/subscription_quota_read") + .add_header("authorization", "Bearer quota-fixture") + .json(&json!({"request":{"agentType":"codex","agentAccountId":id}})) + .await; + assert!(!wrong.status_code().is_success()); + server + .post("/api/acp_remove_agent_account") + .add_header("authorization", "Bearer quota-fixture") + .json(&json!({"accountId":id})) + .await + .assert_status_ok(); + let removed = server + .post("/api/subscription_quota_read") + .add_header("authorization", "Bearer quota-fixture") + .json(&json!({"request":{"agentType":"claude_code","conversationId":cid}})) + .await; + assert!(!removed.status_code().is_success()); + assert_eq!( + std::fs::read_to_string(sentinel).unwrap(), + "invalid-credential-sentinel" + ); + } +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 024058d60d..e3c46ccd94 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -995,6 +995,11 @@ pub fn build_router( ) .route("/acp_cancel", post(handlers::acp::acp_cancel)) .route("/acp_fork", post(handlers::acp::acp_fork)) + .route( + "/acp_handoff_plan", + post(handlers::handoff::acp_handoff_plan), + ) + .route("/acp_handoff", post(handlers::handoff::acp_handoff)) .route( "/acp_stop_async_task", post(handlers::acp::acp_stop_async_task), @@ -1179,6 +1184,18 @@ pub fn build_router( "/acp_list_custom_agents", post(handlers::acp::acp_list_custom_agents), ) + .route("/acp_list_agent_accounts", post(handlers::agent_accounts::list)) + .route("/acp_create_agent_account", post(handlers::agent_accounts::create)) + .route("/acp_rename_agent_account", post(handlers::agent_accounts::rename)) + .route("/acp_remove_agent_account", post(handlers::agent_accounts::remove)) + .route("/acp_login_agent_account", post(handlers::agent_accounts::login)) + .route("/acp_set_agent_account_api_key", post(handlers::agent_accounts::set_api_key)) + .route("/acp_check_agent_account_auth", post(handlers::agent_accounts::check_auth)) + .route("/acp_switch_conversation_account", post(handlers::agent_accounts::switch_conversation)) + .route( + "/acp_login_extra_agent", + post(handlers::acp::acp_login_extra_agent), + ) .route( "/acp_save_custom_agent", post(handlers::acp::acp_save_custom_agent), @@ -1666,6 +1683,8 @@ pub fn build_router( post(handlers::automation::automation_cancel_run), ) // ─── Token usage dashboard ─── + .route("/subscription_quota_read", post(handlers::subscription_quota::read)) + .route("/subscription_quota_accounts", post(handlers::subscription_quota::accounts)) .route( "/token_usage_report", post(handlers::token_usage::token_usage_report), diff --git a/src-tauri/src/work_task/engine.rs b/src-tauri/src/work_task/engine.rs index 24d4db2fc6..f9b55430d3 100644 --- a/src-tauri/src/work_task/engine.rs +++ b/src-tauri/src/work_task/engine.rs @@ -32,9 +32,9 @@ use crate::acp::types::{ }; use crate::acp::work_task_tools::{TaskReportAck, WorkTaskToolAccess}; use crate::acp::InternalEventBus; -use crate::commands::acp::{build_session_runtime_env, verify_agent_installed}; +use crate::commands::acp::{build_account_session_runtime_env, verify_agent_installed}; use crate::commands::conversations::{ - create_conversation_core, emit_conversation_upsert, get_folder_conversation_core, + create_conversation_for_account_core, emit_conversation_upsert, get_folder_conversation_core, }; use crate::commands::folders::{ emit_folder_deleted, emit_folder_upsert, get_folder_core, git_worktree_add, @@ -1315,8 +1315,12 @@ impl TaskEngine { } // Resume the previous session for retry/return/merge when we have one. - let resume_session_id = match mode { - LaunchMode::Fresh => None, + // Its conversation may since have been moved to another account (the + // chat's account switch): the session now lives in that account's + // folder, so the relaunch, and a fresh row should the resume fail, use + // the conversation's saved account, never the Default one. + let (resume_session_id, resume_account_id) = match mode { + LaunchMode::Fresh => (None, None), LaunchMode::Retry | LaunchMode::Return { .. } | LaunchMode::Merge { .. } => { match task.conversation_id { Some(conv_id) => conversation::Entity::find_by_id(conv_id) @@ -1324,16 +1328,22 @@ impl TaskEngine { .await .ok() .flatten() - .and_then(|c| c.external_id), - None => None, + .map(|c| (c.external_id, c.agent_account_id)) + .unwrap_or_default(), + None => (None, None), } } }; - let runtime_env = - build_session_runtime_env(&self.db, agent_type, resume_session_id.as_deref(), &self.data_dir) - .await - .map_err(|e| e.to_string())?; + let runtime_env = build_account_session_runtime_env( + &self.db, + agent_type, + resume_session_id.as_deref(), + &self.data_dir, + resume_account_id.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; verify_agent_installed(agent_type) .await .map_err(|e| e.to_string())?; @@ -1396,11 +1406,12 @@ impl TaskEngine { task.conversation_id.expect("resumed implies conversation") } else { let title = conversation_title_for_task(&task.title); - let id = match create_conversation_core( + let id = match create_conversation_for_account_core( &self.db.conn, wt.folder_id, agent_type, Some(title), + resume_account_id.clone(), ) .await { diff --git a/src-tauri/tests/delegation_columns.rs b/src-tauri/tests/delegation_columns.rs index eeab221776..4fe1a5cede 100644 --- a/src-tauri/tests/delegation_columns.rs +++ b/src-tauri/tests/delegation_columns.rs @@ -20,6 +20,7 @@ async fn delegation_columns_round_trip() { let now = chrono::Utc::now(); let active = conversation::ActiveModel { id: NotSet, + agent_account_id: Set(None), folder_id: Set(folder_id), title: Set(Some("delegation child".to_string())), title_locked: Set(false), diff --git a/src/components/chat/agent-account-selector.test.tsx b/src/components/chat/agent-account-selector.test.tsx new file mode 100644 index 0000000000..56acda2093 --- /dev/null +++ b/src/components/chat/agent-account-selector.test.tsx @@ -0,0 +1,175 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { describe, expect, it, vi } from "vitest" +import messages from "@/i18n/messages/en.json" +import { AgentAccountSelector } from "./agent-account-selector" + +const accounts = [ + { + id: "a", + agentType: "codex" as const, + name: "Personal", + homeDir: "/a", + removed: false, + }, + { + id: "b", + agentType: "codex" as const, + name: "Team", + homeDir: "/b", + removed: true, + }, +] + +describe("account picker", () => { + it("keeps the account selected while Remote Control is active", () => { + const view = render( + + + + ) + const picker = screen.getByRole("combobox", { name: "Switch account" }) + expect(picker).toBeDisabled() + expect(picker).toHaveTextContent("Personal") + expect(picker).toHaveAttribute( + "title", + messages.AgentAccounts.remoteControlHint + ) + view.rerender( + + + + ) + expect(picker).toBeEnabled() + }) + + it("shows loading and retry states without declaring a saved account unavailable", () => { + const retry = vi.fn() + const view = render( + + + + ) + expect(screen.getByText("Loading accounts...")).toBeVisible() + expect(screen.queryByText("Unavailable account")).not.toBeInTheDocument() + view.rerender( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(retry).toHaveBeenCalledOnce() + expect(screen.queryByText("Unavailable account")).not.toBeInTheDocument() + }) + + it("identifies Default in a saved chat when other accounts are available", () => { + render( + + + + ) + expect(screen.getByText("Default")).toBeVisible() + expect(screen.queryByRole("combobox")).not.toBeInTheDocument() + }) + + it("switches a persisted Default conversation without creating a new chat", async () => { + const changed = vi.fn() + render( + + + + ) + fireEvent.click(screen.getByRole("combobox", { name: "Switch account" })) + fireEvent.click(await screen.findByRole("option", { name: "Personal" })) + expect(changed).toHaveBeenCalledWith("a") + }) + + it("keeps the current selection and disables repeat clicks while switching", () => { + render( + + + + ) + const picker = screen.getByRole("combobox", { name: "Switch account" }) + expect(picker).toBeDisabled() + expect(picker).toHaveAttribute("aria-busy", "true") + expect(picker).toHaveTextContent("Switching account") + }) + it("keeps a removed persisted account visible without offering a switch", () => { + render( + + + + ) + expect(screen.getByText("Team (Unavailable account)")).toBeVisible() + expect(screen.queryByRole("combobox")).not.toBeInTheDocument() + }) + + it("lets a draft select a live account or Default, excluding removed accounts", async () => { + const changed = vi.fn() + render( + + + + ) + fireEvent.click(screen.getByRole("combobox", { name: "Account" })) + fireEvent.click(await screen.findByRole("option", { name: "Personal" })) + expect(changed).toHaveBeenCalledWith("a") + expect( + screen.queryByRole("option", { name: "Team" }) + ).not.toBeInTheDocument() + }) +}) diff --git a/src/components/chat/agent-account-selector.tsx b/src/components/chat/agent-account-selector.tsx new file mode 100644 index 0000000000..17a1dba3b0 --- /dev/null +++ b/src/components/chat/agent-account-selector.tsx @@ -0,0 +1,155 @@ +"use client" + +import { useTranslations } from "next-intl" +import { Plus, Settings2, UserRound } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import type { AgentAccount } from "@/lib/agent-accounts" + +export function AgentAccountSelector( + props: Parameters[0] +) { + const t = useTranslations("AgentAccounts") + const empty = !props.accounts.some((a) => !a.removed) + return ( +
+ + {props.onManage && ( + + )} +
+ ) +} + +function AccountChoices({ + accounts, + value, + onChange, + disabled = false, + persisted = false, + canSwitch = false, + nativeSwitch = true, + switching = false, + remoteControlActive = false, + loading = false, + loadError = false, + onRetry, +}: { + accounts: AgentAccount[] + value: string | null + onChange: (value: string | null) => void + disabled?: boolean + persisted?: boolean + canSwitch?: boolean + nativeSwitch?: boolean + switching?: boolean + remoteControlActive?: boolean + loading?: boolean + loadError?: boolean + onRetry?: () => void + onManage?: () => void +}) { + const t = useTranslations("AgentAccounts") + const available = accounts.filter((account) => !account.removed) + if (!value && available.length === 0) return null + const selected = accounts.find((a) => a.id === value) + if (value && !selected && (loading || loadError)) { + return ( +
+ + {loading ? t("loading") : t("loadFailed")} + {loadError && onRetry && ( + + )} +
+ ) + } + if (persisted && !canSwitch) { + const label = value ? (selected?.name ?? t("unavailable")) : t("default") + return ( + + + + {label} + {selected?.removed ? ` (${t("unavailable")})` : ""} + + + ) + } + return ( + + ) +} diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 4458a603a3..36894906b5 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -83,6 +83,9 @@ interface ChatInputProps { steerChannel?: "native" | "pull" onAddFeedback?: () => void feedbackAddDisabled?: boolean + /** Open the "hand off to another agent" dialog. Present only for a + * persisted conversation; the composer's agent control offers it. */ + onHandoff?: () => void /** * Keep the composer usable even while disconnected. Set for a folderless chat * draft: it has no working dir yet (so it never auto-connects), and the FIRST @@ -143,6 +146,7 @@ export const ChatInput = memo(function ChatInput({ steerChannel, onAddFeedback, feedbackAddDisabled, + onHandoff, allowOfflineCompose = false, injectContent, onInjectConsumed, @@ -243,6 +247,7 @@ export const ChatInput = memo(function ChatInput({ steerChannel={steerChannel} onAddFeedback={onAddFeedback} feedbackAddDisabled={feedbackAddDisabled} + onHandoff={onHandoff} injectContent={injectContent} onInjectConsumed={onInjectConsumed} placeholder={ diff --git a/src/components/chat/composer/reference-uri.test.ts b/src/components/chat/composer/reference-uri.test.ts index cda6bb3685..f85d124510 100644 --- a/src/components/chat/composer/reference-uri.test.ts +++ b/src/components/chat/composer/reference-uri.test.ts @@ -9,6 +9,29 @@ import { } from "./reference-uri" describe("parseCodegReferenceUri", () => { + it("keeps explicit Default separate from an unqualified agent and rejects malformed accounts", () => { + expect( + parseCodegReferenceUri( + "codeg://agent/codex?account=default", + "@Codex · Default" + )?.meta + ).toEqual({ + agentType: "codex", + agentAccountId: "default", + }) + expect( + parseCodegReferenceUri("codeg://agent/codex", "@Codex")?.meta + ).toEqual({ agentType: "codex" }) + for (const suffix of [ + "?account=", + "?account=work&account=personal", + "?account=../../home", + ]) { + expect( + parseCodegReferenceUri(`codeg://agent/codex${suffix}`, "@Codex") + ).toBeNull() + } + }) it("returns null for non-reference schemes", () => { expect(parseCodegReferenceUri("https://example.com", "x")).toBeNull() expect(parseCodegReferenceUri("data:text/plain,abc", "x")).toBeNull() diff --git a/src/components/chat/composer/reference-uri.ts b/src/components/chat/composer/reference-uri.ts index 97d962b0a8..f1b3f3eb5d 100644 --- a/src/components/chat/composer/reference-uri.ts +++ b/src/components/chat/composer/reference-uri.ts @@ -7,7 +7,9 @@ import type { ReferenceAttrs } from "./types" // (from-prompt-blocks.ts) and transcript badge rendering // (ai-elements/markdown-link.tsx). Mirrors the schemes the adapters emit // (suggestion/adapters.ts) and the node's allow-list (nodes/reference-node.ts). -const AGENT_URI = /^codeg:\/\/agent\/(.+)$/i +const AGENT_URI = + /^codeg:\/\/agent\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})(?:\?account=(default|[A-Za-z0-9][A-Za-z0-9._-]{0,127}))?$/i + // Assistant ids are positive integers, never zero-padded — the shape the // backend's routing scan (`agent_mentions.rs`) accepts, and the adapter writes. const ASSISTANT_URI = /^codeg:\/\/assistant\/([1-9]\d{0,9})$/i @@ -98,14 +100,18 @@ export function parseCodegReferenceUri( const agent = uri.match(AGENT_URI) if (agent) { const type = agent[1] + const accountId = agent[2] return { refType: "agent", // The transcript link text is `@name`; strip a single leading `@` so the // restored badge reads `name`, matching a live-inserted agent badge. - id: type, + id: accountId ? `${type}?account=${accountId}` : type, label: (label || type).replace(/^@/, "") || type, uri, - meta: { agentType: type as AgentType }, + meta: { + agentType: type as AgentType, + ...(accountId ? { agentAccountId: accountId } : {}), + }, } } diff --git a/src/components/chat/composer/rich-composer-mention.test.tsx b/src/components/chat/composer/rich-composer-mention.test.tsx index c00e8ca9b5..1a79afdc70 100644 --- a/src/components/chat/composer/rich-composer-mention.test.tsx +++ b/src/components/chat/composer/rich-composer-mention.test.tsx @@ -10,6 +10,8 @@ import { import { RichComposer, type RichComposerHandle } from "./rich-composer" import type { ReferenceSearch } from "./suggestion/types" +import { agentToSuggestion } from "./suggestion/adapters" +import type { AcpAgentInfo } from "@/lib/types" const search: ReferenceSearch = () => [ { @@ -39,10 +41,14 @@ function findReference(doc: JSONContent): JSONContent | undefined { return undefined } -async function mount(onSubmit?: () => void) { +async function mount(onSubmit?: () => void, referenceSearch = search) { const ref = createRef() render( - + ) await waitFor(() => expect(ref.current?.getEditor()).not.toBeNull(), { timeout: 5000, @@ -53,6 +59,38 @@ async function mount(onSubmit?: () => void) { } describe("RichComposer @ mention integration", () => { + it("inserts a specific native account and retains it through draft restore", async () => { + const account = agentToSuggestion( + { + agent_type: "claude_code", + name: "Claude Code", + available: true, + } as AcpAgentInfo, + { id: "work-account", name: "Work" } + ) + const { ref, editor } = await mount(undefined, () => [ + { kind: "agent", label: "Agents", items: [account] }, + ]) + act(() => { + editor.commands.insertContent("@Work") + }) + const row = await screen.findByText("Claude Code · Work") + act(() => { + row.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true }) + ) + }) + const serialized = + "[@Claude Code · Work](codeg://agent/claude_code?account=work-account)" + await waitFor(() => expect(ref.current?.getText().trim()).toBe(serialized)) + act(() => { + ref.current?.setText(serialized) + }) + expect(findReference(editor.getJSON())?.attrs?.meta).toMatchObject({ + agentType: "claude_code", + agentAccountId: "work-account", + }) + }) it("opens the panel on @ and inserts the chosen reference", async () => { const { editor } = await mount() act(() => { diff --git a/src/components/chat/composer/suggestion/adapters.test.ts b/src/components/chat/composer/suggestion/adapters.test.ts index 8959c90cf2..a60a5489ab 100644 --- a/src/components/chat/composer/suggestion/adapters.test.ts +++ b/src/components/chat/composer/suggestion/adapters.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "vitest" +import { parseCodegReferenceUri } from "../reference-uri" +import { referenceToMarkdown } from "../reference-text" import type { FlatFileEntry } from "@/hooks/use-file-tree" import type { @@ -41,6 +43,28 @@ describe("fileToSuggestion", () => { }) describe("agentToSuggestion", () => { + it("keeps native identity and exact account through serialization and restore", () => { + const agent = { + agent_type: "claude_code", + name: "Claude Code", + available: true, + } as AcpAgentInfo + const reference = agentToSuggestion(agent, { + id: "work-account", + name: "Work", + }).reference + expect(reference.meta?.agentType).toBe("claude_code") + expect(referenceToMarkdown(reference)).toBe( + "[@Claude Code · Work](codeg://agent/claude_code?account=work-account)" + ) + expect( + parseCodegReferenceUri(reference.uri!, "@Claude Code · Work") + ).toMatchObject({ + id: reference.id, + label: reference.label, + meta: { agentType: "claude_code", agentAccountId: "work-account" }, + }) + }) it("maps to an agent reference with a codeg://agent routing uri", () => { const agent = { agent_type: "claude_code", diff --git a/src/components/chat/composer/suggestion/adapters.ts b/src/components/chat/composer/suggestion/adapters.ts index 24e67d950a..ea481c4ffe 100644 --- a/src/components/chat/composer/suggestion/adapters.ts +++ b/src/components/chat/composer/suggestion/adapters.ts @@ -42,14 +42,24 @@ export function fileToSuggestion( * anchor: the backend derives the delegation reminder from it at send time, so * nothing has to travel out-of-band alongside the prompt. */ -export function agentToSuggestion(agent: AcpAgentInfo): SuggestionItem { +export function agentToSuggestion( + agent: AcpAgentInfo, + account?: { id: string; name: string } +): SuggestionItem { + const name = agent.name || getAgentLabel(agent.agent_type) return { reference: { refType: "agent", - id: agent.agent_type, - label: agent.name || getAgentLabel(agent.agent_type), - uri: `codeg://agent/${agent.agent_type}`, - meta: { agentType: agent.agent_type, available: agent.available }, + id: account + ? `${agent.agent_type}?account=${account.id}` + : agent.agent_type, + label: account ? `${name} · ${account.name}` : name, + uri: `codeg://agent/${agent.agent_type}${account ? `?account=${encodeURIComponent(account.id)}` : ""}`, + meta: { + agentType: agent.agent_type, + available: agent.available, + ...(account ? { agentAccountId: account.id } : {}), + }, }, detail: agent.description || null, keywords: agent.agent_type, diff --git a/src/components/chat/composer/types.ts b/src/components/chat/composer/types.ts index bc8bc6876f..9f41182c3a 100644 --- a/src/components/chat/composer/types.ts +++ b/src/components/chat/composer/types.ts @@ -33,6 +33,8 @@ export interface ReferenceMeta { * conversation glyph regardless. */ agentType?: AgentType + /** agent: explicit named account, or "default"; also encoded in the URI. */ + agentAccountId?: string /** agent: whether the agent is currently available. */ available?: boolean /** diff --git a/src/components/chat/composer/use-composer-mention-labels.ts b/src/components/chat/composer/use-composer-mention-labels.ts index a18f80573c..f3345eeac3 100644 --- a/src/components/chat/composer/use-composer-mention-labels.ts +++ b/src/components/chat/composer/use-composer-mention-labels.ts @@ -26,8 +26,10 @@ export interface ComposerMentionLabels { */ export function useComposerMentionLabels(): ComposerMentionLabels { const t = useTranslations("Folder.chat.messageInput") + const tAccounts = useTranslations("AgentAccounts") const groupLabels = useMemo( () => ({ + defaultAccount: tAccounts("default"), file: t("mentionGroupFile"), agent: t("mentionGroupAgent"), assistant: t("mentionGroupAssistant"), @@ -35,7 +37,7 @@ export function useComposerMentionLabels(): ComposerMentionLabels { commit: t("mentionGroupCommit"), skill: t("mentionGroupSkill"), }), - [t] + [t, tAccounts] ) const uiLabels = useMemo( () => ({ diff --git a/src/components/chat/composer/use-reference-search.test.ts b/src/components/chat/composer/use-reference-search.test.ts index c1f1e2b75f..424363ffd8 100644 --- a/src/components/chat/composer/use-reference-search.test.ts +++ b/src/components/chat/composer/use-reference-search.test.ts @@ -121,6 +121,56 @@ function emptySources( const itemsOf = (groups: SuggestionGroup[], kind: ReferenceKind) => groups.find((g) => g.kind === kind)?.items ?? [] +it("offers explicit native account targets while preserving legacy custom mentions", () => { + const sources = emptySources({ + agents: [ + { + ...makeAgent("claude_code", { name: "Claude Code" }), + named_accounts: [ + { id: "work", name: "Work" }, + { id: "personal", name: "Personal" }, + ], + }, + { + ...makeAgent("codex", { name: "Codex" }), + named_accounts: [{ id: "codex-work", name: "Work" }], + }, + makeAgent("custom:claude-code-2", { name: "Claude Code 2" }), + { + ...makeAgent("grok", { enabled: false }), + named_accounts: [{ id: "hidden", name: "Work" }], + }, + ], + }) + const items = itemsOf( + buildReferenceGroups("", sources, { + ...DEFAULT_GROUP_LABELS, + defaultAccount: "Standard", + }), + "agent" + ) + expect(items.map((item) => item.reference.label)).toEqual([ + "Claude Code · Standard", + "Claude Code · Work", + "Claude Code · Personal", + "Codex · Standard", + "Codex · Work", + "Claude Code 2", + ]) + expect(items[0].reference.uri).toBe( + "codeg://agent/claude_code?account=default" + ) + expect(items[5].reference.uri).toBe("codeg://agent/custom:claude-code-2") + expect( + itemsOf(buildReferenceGroups("work", sources), "agent").map( + (item) => item.reference.meta + ) + ).toMatchObject([ + { agentType: "claude_code", agentAccountId: "work" }, + { agentType: "codex", agentAccountId: "codex-work" }, + ]) +}) + // --- pure builder ----------------------------------------------------------- describe("buildReferenceGroups", () => { diff --git a/src/components/chat/composer/use-reference-search.ts b/src/components/chat/composer/use-reference-search.ts index fecf3aee4d..9fccb70283 100644 --- a/src/components/chat/composer/use-reference-search.ts +++ b/src/components/chat/composer/use-reference-search.ts @@ -43,6 +43,8 @@ const EMPTY_COMMITS: Promise = Promise.resolve([]) /** Display headings for each group; injected so the host can localize them. */ export interface ReferenceGroupLabels { + /** Account label for an explicit Default target in the agents group. */ + defaultAccount?: string file: string agent: string assistant: string @@ -132,7 +134,18 @@ export function buildReferenceGroups( // tab count and `truncated` flag follow from this filtered set too). const agentMatches = sources.agents .filter((agent) => agent.enabled) - .map(agentToSuggestion) + .flatMap((agent) => { + const accounts = agent.named_accounts ?? [] + return accounts.length + ? [ + agentToSuggestion(agent, { + id: "default", + name: labels.defaultAccount ?? "Default", + }), + ...accounts.map((account) => agentToSuggestion(agent, account)), + ] + : [agentToSuggestion(agent)] + }) .filter((item) => suggestionMatches(item, q)) const agentItems = agentMatches.slice(0, MAX_PER_GROUP) diff --git a/src/components/chat/conversation-shell.tsx b/src/components/chat/conversation-shell.tsx index 5f27bc22ea..b5f258c397 100644 --- a/src/components/chat/conversation-shell.tsx +++ b/src/components/chat/conversation-shell.tsx @@ -120,6 +120,9 @@ interface ConversationShellProps { onAddFeedback?: () => void /** Grey out the live-feedback "+" entry when a note can't be sent right now. */ feedbackAddDisabled?: boolean + /** Opens the handoff dialog from the composer; threaded straight through + * to `ChatInput`. Omitted for drafts, which have nothing to hand off. */ + onHandoff?: () => void isActive?: boolean /** Show the composer's flowing active-session border (tiled multi-session * active tab only). Threaded straight through to the composer. */ @@ -212,6 +215,7 @@ export function ConversationShell({ feedbackList, onAddFeedback, feedbackAddDisabled, + onHandoff, isActive, showActiveFlow, queue, @@ -358,6 +362,7 @@ export function ConversationShell({ steerChannel={steerChannel} onAddFeedback={onAddFeedback} feedbackAddDisabled={feedbackAddDisabled} + onHandoff={onHandoff} injectContent={injectContent} onInjectConsumed={onInjectConsumed} /> diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index c31900be55..d6678d93e5 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl" import { isImeCompositionKey } from "@/lib/ime-composition" import { Button } from "@/components/ui/button" import { + ArrowRightLeft, BookOpenText, Check, ChevronUp, @@ -242,6 +243,9 @@ interface MessageInputProps { /** Grey out the live-feedback "+" entry when a note can't be sent right now * (no active turn / agent lacks the tool). */ feedbackAddDisabled?: boolean + /** Open the "hand off to another agent" dialog from the composer's agent + * control. Present only for a persisted conversation. */ + onHandoff?: () => void /** * The current session's user prompts, oldest first — the ArrowUp/ArrowDown * recall history. A GETTER rather than an array on purpose: prompts are @@ -347,12 +351,14 @@ export function MessageInput({ steerChannel = "pull", onAddFeedback, feedbackAddDisabled, + onHandoff, injectContent, onInjectConsumed, getSentHistory, tall = false, }: MessageInputProps) { const t = useTranslations("Folder.chat.messageInput") + const tHandoff = useTranslations("Folder.chat.agentHandoff") const tQueue = useTranslations("Folder.chat.messageQueue") // Kept as a separate binding from `t` so its call sites — exclusively // upload / attachment toasts — read as a single coherent group when @@ -2095,6 +2101,18 @@ export function MessageInput({ onChange={handleSessionSettingChange} /> )} + {onHandoff && ( + + )}
{actionButtons}
diff --git a/src/components/chat/pi-project-trust-banner.test.tsx b/src/components/chat/pi-project-trust-banner.test.tsx index 4046f8a7c5..f9059c7c9e 100644 --- a/src/components/chat/pi-project-trust-banner.test.tsx +++ b/src/components/chat/pi-project-trust-banner.test.tsx @@ -109,6 +109,43 @@ describe("PiProjectTrustBanner", () => { ).toBeInTheDocument() }) + it("saves and reconnects with the selected native account and saved session", async () => { + const user = userEvent.setup() + mockConnection.value.status = null + render( + + + + ) + await user.click(await screen.findByRole("button", { name: copy.review })) + await user.click(await screen.findByRole("button", { name: copy.trust })) + await waitFor(() => + expect(api.acpPiSetProjectTrust).toHaveBeenCalledWith( + "/repo", + true, + "account-work" + ) + ) + expect(api.acpPiProjectTrustState).toHaveBeenCalledWith( + "/repo", + "account-work" + ) + expect(mockConnection.value.connect).toHaveBeenCalledWith( + "pi", + "/repo", + "saved-session", + 41, + "account-work" + ) + }) + it("stays silent when the repo ships no trust-gated resources", async () => { api.acpPiProjectTrustState.mockResolvedValue({ ...undecidedWithExtension(), @@ -211,7 +248,10 @@ describe("PiProjectTrustBanner", () => { resolveA(undecidedWithExtension()) await waitFor(() => - expect(api.acpPiProjectTrustState).toHaveBeenCalledWith("/other") + expect(api.acpPiProjectTrustState).toHaveBeenCalledWith( + "/other", + undefined + ) ) expect(screen.queryByText(copy.title)).not.toBeInTheDocument() }) @@ -247,7 +287,11 @@ describe("PiProjectTrustBanner", () => { await user.click(await screen.findByRole("button", { name: copy.trust })) await waitFor(() => - expect(api.acpPiSetProjectTrust).toHaveBeenCalledWith("/repo", true) + expect(api.acpPiSetProjectTrust).toHaveBeenCalledWith( + "/repo", + true, + undefined + ) ) expect(mockConnection.value.reapplyConfig).toHaveBeenCalled() }) @@ -262,7 +306,11 @@ describe("PiProjectTrustBanner", () => { await user.click(await screen.findByRole("button", { name: copy.decline })) await waitFor(() => - expect(api.acpPiSetProjectTrust).toHaveBeenCalledWith("/repo", false) + expect(api.acpPiSetProjectTrust).toHaveBeenCalledWith( + "/repo", + false, + undefined + ) ) expect(mockConnection.value.reapplyConfig).not.toHaveBeenCalled() }) @@ -278,7 +326,11 @@ describe("PiProjectTrustBanner", () => { await user.click(await screen.findByRole("button", { name: copy.revoke })) await waitFor(() => - expect(api.acpPiSetProjectTrust).toHaveBeenCalledWith("/repo", null) + expect(api.acpPiSetProjectTrust).toHaveBeenCalledWith( + "/repo", + null, + undefined + ) ) expect(mockConnection.value.reapplyConfig).toHaveBeenCalled() }) @@ -294,7 +346,10 @@ describe("PiProjectTrustBanner", () => { ) await waitFor(() => - expect(api.acpPiAcknowledgeProjectTrust).toHaveBeenCalledWith("/repo") + expect(api.acpPiAcknowledgeProjectTrust).toHaveBeenCalledWith( + "/repo", + undefined + ) ) expect(api.acpPiSetProjectTrust).not.toHaveBeenCalled() }) @@ -313,7 +368,13 @@ describe("PiProjectTrustBanner", () => { ) await waitFor(() => - expect(mockConnection.value.connect).toHaveBeenCalledWith("pi", "/repo") + expect(mockConnection.value.connect).toHaveBeenCalledWith( + "pi", + "/repo", + undefined, + undefined, + undefined + ) ) expect(mockConnection.value.reapplyConfig).not.toHaveBeenCalled() }) diff --git a/src/components/chat/pi-project-trust-banner.tsx b/src/components/chat/pi-project-trust-banner.tsx index 07b389a513..6b03cc62fe 100644 --- a/src/components/chat/pi-project-trust-banner.tsx +++ b/src/components/chat/pi-project-trust-banner.tsx @@ -78,6 +78,7 @@ import { cn } from "@/lib/utils" /** Trust state plus the directory whose probe produced it. */ type Probed = { + accountId: string | null /** The `workingDir` this was fetched for — NOT the canonical path the backend * resolved, which can differ (`/tmp` vs `/private/tmp`). Compared against the * tab's current directory so a decision can never be written for a folder the @@ -90,6 +91,9 @@ export function PiProjectTrustBanner({ contextKey, agentType, workingDir, + agentAccountId, + conversationId, + sessionId, }: { contextKey: string /** The tab's selected agent. Taken from the tab rather than the connection: @@ -98,6 +102,9 @@ export function PiProjectTrustBanner({ agentType: AgentType | null /** The tab's working directory, for the same reason. */ workingDir: string | undefined + agentAccountId?: string | null + conversationId?: number + sessionId?: string }) { const t = useTranslations("Folder.chat.piProjectTrust") const { status, isViewer, isDelegationChild, reapplyConfig, connect } = @@ -125,16 +132,16 @@ export function PiProjectTrustBanner({ const issuedAt = generation.current const requestedFor = workingDir try { - const state = await acpPiProjectTrustState(requestedFor) + const state = await acpPiProjectTrustState(requestedFor, agentAccountId) if (generation.current !== issuedAt) return - setProbed({ requestedFor, state }) + setProbed({ requestedFor, accountId: agentAccountId ?? null, state }) } catch { // Read-only probe: if it fails we simply don't prompt. Never surface a // toast here — it would fire on every reconnect for a transient error. if (generation.current !== issuedAt) return setProbed(null) } - }, [eligible, workingDir]) + }, [eligible, workingDir, agentAccountId]) useEffect(() => { // Invalidate anything in flight for the previous workspace before deciding @@ -150,7 +157,10 @@ export function PiProjectTrustBanner({ // (not just at fetch time) closes the window where the folder changed while the // dialog sat open. const current = - probed && eligible && probed.requestedFor === workingDir + probed && + eligible && + probed.requestedFor === workingDir && + probed.accountId === (agentAccountId ?? null) ? probed.state : null @@ -179,7 +189,13 @@ export function PiProjectTrustBanner({ } if (!agentType || !workingDir) return false try { - await connect(agentType, workingDir) + await connect( + agentType, + workingDir, + sessionId, + conversationId, + agentAccountId + ) return true } catch { // The gate may still refuse (another unacknowledged grant, e.g. from an @@ -197,7 +213,7 @@ export function PiProjectTrustBanner({ // The backend keeps codeg's acknowledgement in step with the verdict: a // grant counts as answered, a decline or revoke clears the record so a // later grant is disclosed again instead of riding on a stale "seen". - await acpPiSetProjectTrust(current.workspace, trusted) + await acpPiSetProjectTrust(current.workspace, trusted, agentAccountId) setDialogOpen(false) if (trusted === false) { @@ -235,7 +251,7 @@ export function PiProjectTrustBanner({ if (!current || actionDisabled) return setApplying(true) try { - await acpPiAcknowledgeProjectTrust(current.workspace) + await acpPiAcknowledgeProjectTrust(current.workspace, agentAccountId) setDialogOpen(false) await applyToPi() await refresh() diff --git a/src/components/chat/remote-control-strip.tsx b/src/components/chat/remote-control-strip.tsx index fa66ec4279..723f3d95d6 100644 --- a/src/components/chat/remote-control-strip.tsx +++ b/src/components/chat/remote-control-strip.tsx @@ -77,7 +77,7 @@ export function RemoteControlStrip({ return (