Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3637ee9
Fan MCP family saves into extra isolated homes
Adam-Dalloul Aug 15, 2026
4d6b65c
fix(mcp): sync isolated Claude server definitions and Gemini home
Adam-Dalloul Oct 11, 2026
de4a5ce
Add official Sign in for extra isolated family slots
Adam-Dalloul Aug 15, 2026
7a0103b
fix(accounts): isolate extra-account sign-in across desktop and web
Adam-Dalloul Oct 11, 2026
bf24a4d
fix(accounts): exclude the default home from isolated sign-in
Adam-Dalloul Oct 11, 2026
0473787
test: fix cross-platform sign-in lint checks
Adam-Dalloul Oct 11, 2026
0af7341
Merge v0.36.0 into isolated MCP account sync
Adam-Dalloul Oct 16, 2026
c25d87c
Merge refreshed MCP account sync into isolated sign-in
Adam-Dalloul Oct 16, 2026
55a5706
fix(mcp): retain environments for system agent distributions
Adam-Dalloul Oct 16, 2026
90cbc78
Merge branch 'refresh/oct16-470' into refresh/oct16-471
Adam-Dalloul Oct 16, 2026
7ea07a6
Merge upstream agent and composer updates into PR 470
Adam-Dalloul Oct 17, 2026
40a1047
Merge upstream agent and composer updates into PR 471
Adam-Dalloul Oct 17, 2026
b96cc46
Merge current upstream compatibility into mcp accounts
Adam-Dalloul Oct 27, 2026
1c9eb25
Merge current upstream compatibility into signin accounts
Adam-Dalloul Oct 27, 2026
d0f0d36
Merge current upstream compatibility into mcp accounts
Adam-Dalloul Oct 27, 2026
766dd77
Merge current upstream compatibility into signin accounts
Adam-Dalloul Oct 27, 2026
114d5c3
Merge upstream OpenCode 2 and agent pin updates into mcp accounts
Adam-Dalloul Oct 29, 2026
fbfc08b
fix(mcp): find OpenCode 2's default directory where OpenCode does
Adam-Dalloul Oct 29, 2026
93078bc
Merge upstream OpenCode 2 and agent pin updates into signin accounts
Adam-Dalloul Oct 29, 2026
a87847b
fix(accounts): sign an extra OpenCode slot in to the database its ses…
Adam-Dalloul Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
472 changes: 472 additions & 0 deletions src-tauri/src/acp/family_isolator.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src-tauri/src/acp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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;
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/commands/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13497,7 +13497,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
Expand Down
136 changes: 136 additions & 0 deletions src-tauri/src/commands/custom_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ExtraSlotLoginInfo>,
pub registry_id: String,
/// Wire form of the agent type (`custom:<id>`) — what the frontend passes
/// back as an `AgentType`.
Expand Down Expand Up @@ -59,12 +61,22 @@ pub struct CustomAgentInfo {
pub problem: Option<String>,
}

#[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.
Expand Down Expand Up @@ -574,6 +586,86 @@ 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. Never reads or
/// writes `auth.json` / `.credentials.json`.
pub async fn acp_login_extra_agent_core(
registry_id: &str,
db: &AppDatabase,
) -> Result<ExtraSlotLoginResult, AcpError> {
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 = family_isolator::shell_export_and_login(&plan)
.map_err(AcpError::protocol)?;
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<ExtraSlotLoginResult, AcpError> {
let mut result = acp_login_extra_agent_core(&registry_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(
Expand Down Expand Up @@ -686,6 +778,50 @@ 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 {
assert!(result.command.contains("codex login"));
assert!(result.command.contains("CODEX_HOME"));
}
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(),
Expand Down
Loading
Loading