diff --git a/prompts/en/worker.md.j2 b/prompts/en/worker.md.j2 index d0b92d4f7..111f0b18f 100644 --- a/prompts/en/worker.md.j2 +++ b/prompts/en/worker.md.j2 @@ -100,7 +100,11 @@ Automate a headless Chrome browser. Use this for web scraping, testing web inter 3. `snapshot` — Get the page's accessibility tree with element refs (e1, e2, e3...) 4. `act` — Interact with elements by ref: `click`, `type`, `press_key`, `hover`, `scroll_into_view`, `focus` 5. `screenshot` — Capture the page or a specific element +{%- if browser_persist_session %} +6. `close` — Detach from the browser when done (tabs and session are preserved for the next worker) +{%- else %} 6. `close` — Shut down the browser when done +{%- endif %} **Multi-tab support:** Use `open` to create new tabs, `tabs` to list them, `focus` to switch between them, `close_tab` to close one. diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index 216557a9d..cfd682aec 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -352,6 +352,7 @@ pub async fn spawn_worker_from_state( None => Vec::new(), }; + let browser_config = (**rc.browser_config.load()).clone(); let worker_system_prompt = prompt_engine .render_worker_prompt( &rc.instance_dir.display().to_string(), @@ -361,10 +362,10 @@ pub async fn spawn_worker_from_state( sandbox_read_allowlist, sandbox_write_allowlist, &tool_secret_names, + browser_config.persist_session, ) .map_err(|e| AgentError::Other(anyhow::anyhow!("{e}")))?; let skills = rc.skills.load(); - let browser_config = (**rc.browser_config.load()).clone(); let brave_search_key = (**rc.brave_search_key.load()).clone(); // Append skills listing to worker system prompt. Suggested skills are @@ -887,6 +888,7 @@ pub async fn resume_idle_worker_into_state( Some(store) => store.tool_secret_names(), None => Vec::new(), }; + let browser_config = (**rc.browser_config.load()).clone(); let system_prompt = prompt_engine .render_worker_prompt( &rc.instance_dir.display().to_string(), @@ -896,9 +898,9 @@ pub async fn resume_idle_worker_into_state( sandbox_read_allowlist, sandbox_write_allowlist, &tool_secret_names, + browser_config.persist_session, ) .map_err(|error| format!("failed to render worker prompt: {error}"))?; - let browser_config = (**rc.browser_config.load()).clone(); let brave_search_key = (**rc.brave_search_key.load()).clone(); let (worker, input_tx) = Worker::resume_interactive( diff --git a/src/agent/cortex.rs b/src/agent/cortex.rs index 8534f3f33..6d7342361 100644 --- a/src/agent/cortex.rs +++ b/src/agent/cortex.rs @@ -2398,6 +2398,7 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho None => Vec::new(), }; + let browser_config = (**deps.runtime_config.browser_config.load()).clone(); let worker_system_prompt = prompt_engine .render_worker_prompt( &deps.runtime_config.instance_dir.display().to_string(), @@ -2407,6 +2408,7 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho sandbox_read_allowlist, sandbox_write_allowlist, &tool_secret_names, + browser_config.persist_session, ) .map_err(|error| anyhow::anyhow!("failed to render worker prompt: {error}"))?; @@ -2440,7 +2442,6 @@ async fn pickup_one_ready_task(deps: &AgentDeps, logger: &CortexLogger) -> anyho tracing::warn!(%error, path = %logs_dir.display(), "failed to create logs directory"); } - let browser_config = (**deps.runtime_config.browser_config.load()).clone(); let brave_search_key = (**deps.runtime_config.brave_search_key.load()).clone(); let worker = Worker::new( None, diff --git a/src/config/load.rs b/src/config/load.rs index f78657ecb..d919396ed 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -126,6 +126,21 @@ fn parse_close_policy(value: Option<&str>) -> Option { } } +/// Resolve the effective close policy. When `persist_session` is enabled and no +/// explicit `close_policy` was provided, default to `Detach` so browser tabs and +/// cookies survive across workers. +fn resolve_close_policy( + explicit: Option<&str>, + persist_session: bool, + fallback: ClosePolicy, +) -> ClosePolicy { + parse_close_policy(explicit).unwrap_or(if persist_session { + ClosePolicy::Detach + } else { + fallback + }) +} + impl CortexConfig { fn resolve(overrides: TomlCortexConfig, defaults: CortexConfig) -> CortexConfig { CortexConfig { @@ -1423,8 +1438,11 @@ impl Config { .map(PathBuf::from) .or_else(|| base.screenshot_dir.clone()), persist_session: b.persist_session.unwrap_or(base.persist_session), - close_policy: parse_close_policy(b.close_policy.as_deref()) - .unwrap_or(base.close_policy), + close_policy: resolve_close_policy( + b.close_policy.as_deref(), + b.persist_session.unwrap_or(base.persist_session), + base.close_policy, + ), chrome_cache_dir: chrome_cache_dir.clone(), } }) @@ -1600,8 +1618,12 @@ impl Config { persist_session: b .persist_session .unwrap_or(defaults.browser.persist_session), - close_policy: parse_close_policy(b.close_policy.as_deref()) - .unwrap_or(defaults.browser.close_policy), + close_policy: resolve_close_policy( + b.close_policy.as_deref(), + b.persist_session + .unwrap_or(defaults.browser.persist_session), + defaults.browser.close_policy, + ), chrome_cache_dir: defaults.browser.chrome_cache_dir.clone(), }), channel: a.channel.and_then(|channel_config| { diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index 80640271a..c43bf7fd1 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -253,6 +253,7 @@ impl PromptEngine { sandbox_read_allowlist: Vec, sandbox_write_allowlist: Vec, tool_secret_names: &[String], + browser_persist_session: bool, ) -> Result { self.render( "worker", @@ -264,6 +265,7 @@ impl PromptEngine { sandbox_read_allowlist => sandbox_read_allowlist, sandbox_write_allowlist => sandbox_write_allowlist, tool_secret_names => tool_secret_names, + browser_persist_session => browser_persist_session, }, ) } diff --git a/src/tools/browser.rs b/src/tools/browser.rs index 60fffd9a9..90186b262 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -161,9 +161,14 @@ pub struct BrowserState { element_refs: HashMap, /// Counter for generating element refs. next_ref: usize, - /// Per-launch temp directory for Chrome's user data. Cleaned up on drop to - /// prevent stale singleton locks from blocking subsequent launches. + /// Chrome's user data directory. For ephemeral sessions this is a random + /// temp dir cleaned up on drop. For persistent sessions this is a stable + /// path under the instance dir that survives agent restarts. user_data_dir: Option, + /// When true, `user_data_dir` is a stable path that should NOT be deleted + /// on drop — it holds cookies, localStorage, and login sessions that must + /// survive across agent restarts. + persistent_profile: bool, } impl BrowserState { @@ -176,6 +181,7 @@ impl BrowserState { element_refs: HashMap::new(), next_ref: 0, user_data_dir: None, + persistent_profile: false, } } } @@ -184,6 +190,13 @@ impl Drop for BrowserState { fn drop(&mut self) { // Browser and handler task are dropped automatically — // chromiumoxide's Child has kill_on_drop(true). + + // Persistent profiles store cookies, localStorage, and login sessions + // that must survive across agent restarts — never delete them. + if self.persistent_profile { + return; + } + if let Some(dir) = self.user_data_dir.take() { // Offload sync fs cleanup to a blocking thread so we don't stall // the tokio worker that's dropping this state. @@ -199,7 +212,12 @@ impl Drop for BrowserState { }); } else { // Dropped outside a tokio runtime (unlikely) — clean up inline. - let _ = std::fs::remove_dir_all(&dir); + if let Err(error) = std::fs::remove_dir_all(&dir) { + eprintln!( + "failed to clean up browser user data dir {}: {error}", + dir.display() + ); + } } } } @@ -212,6 +230,7 @@ impl std::fmt::Debug for BrowserState { .field("pages", &self.pages.len()) .field("active_target", &self.active_target) .field("element_refs", &self.element_refs.len()) + .field("persistent_profile", &self.persistent_profile) .finish() } } @@ -554,10 +573,16 @@ impl BrowserTool { // 4. Auto-download via BrowserFetcher (cached in chrome_cache_dir) let executable = resolve_chrome_executable(&self.config).await?; - // Use a unique temp dir per launch to avoid singleton lock collisions - // when multiple workers launch browsers or a previous session crashed. - let user_data_dir = - std::env::temp_dir().join(format!("spacebot-browser-{}", uuid::Uuid::new_v4())); + // Persistent sessions use a stable profile dir under chrome_cache_dir so + // cookies, localStorage, and login sessions survive across agent restarts. + // Ephemeral sessions use a random temp dir to avoid singleton lock collisions. + let (user_data_dir, persistent_profile) = if self.config.persist_session { + (self.config.chrome_cache_dir.join("profile"), true) + } else { + let dir = + std::env::temp_dir().join(format!("spacebot-browser-{}", uuid::Uuid::new_v4())); + (dir, false) + }; let mut builder = ChromeConfig::builder() .no_sandbox() @@ -594,12 +619,21 @@ impl BrowserTool { // the browser we just created and return success. drop(browser); handler_task.abort(); - // Clean up the temp user data dir asynchronously so we don't block - // while holding the shared mutex. - let dir = user_data_dir; - tokio::spawn(async move { - let _ = tokio::fs::remove_dir_all(&dir).await; - }); + // Clean up the temp user data dir — but only for ephemeral sessions. + // Persistent profiles use a shared stable path that the winner is + // actively using. + if !persistent_profile { + let dir = user_data_dir; + tokio::spawn(async move { + if let Err(error) = tokio::fs::remove_dir_all(&dir).await { + tracing::debug!( + path = %dir.display(), + %error, + "failed to clean up browser user data dir (concurrent launch race)" + ); + } + }); + } if self.config.persist_session { return self.reconnect_existing_tabs(&mut state).await; @@ -610,6 +644,7 @@ impl BrowserTool { state.browser = Some(browser); state._handler_task = Some(handler_task); state.user_data_dir = Some(user_data_dir); + state.persistent_profile = persistent_profile; tracing::info!("browser launched"); Ok(BrowserOutput::success("Browser launched successfully")) @@ -1196,16 +1231,17 @@ impl BrowserTool { ClosePolicy::CloseBrowser => { // Take everything out of state under the lock, then do the // actual teardown outside it. - let (browser, handler_task, user_data_dir) = { + let (browser, handler_task, user_data_dir, persistent_profile) = { let mut state = self.state.lock().await; let browser = state.browser.take(); let handler_task = state._handler_task.take(); let user_data_dir = state.user_data_dir.take(); + let persistent_profile = state.persistent_profile; state.pages.clear(); state.active_target = None; state.element_refs.clear(); state.next_ref = 0; - (browser, handler_task, user_data_dir) + (browser, handler_task, user_data_dir, persistent_profile) }; if let Some(task) = handler_task { @@ -1220,8 +1256,10 @@ impl BrowserTool { return Err(BrowserError::new(message)); } - // Clean up the per-launch user data dir to free disk space. - if let Some(dir) = user_data_dir { + // Clean up the user data dir — but only for ephemeral sessions. + // Persistent profiles hold cookies and login sessions that must + // survive browser restarts. + if !persistent_profile && let Some(dir) = user_data_dir { tokio::spawn(async move { if let Err(error) = tokio::fs::remove_dir_all(&dir).await { tracing::debug!( diff --git a/tests/context_dump.rs b/tests/context_dump.rs index 64e5b21fb..290ddf20e 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -356,6 +356,7 @@ async fn dump_worker_context() { let prompt_engine = rc.prompts.load(); let instance_dir = rc.instance_dir.to_string_lossy(); let workspace_dir = rc.workspace_dir.to_string_lossy(); + let browser_config = (**rc.browser_config.load()).clone(); let worker_prompt = prompt_engine .render_worker_prompt( &instance_dir, @@ -365,13 +366,11 @@ async fn dump_worker_context() { Vec::new(), Vec::new(), &[], + browser_config.persist_session, ) .expect("failed to render worker prompt"); print_section("WORKER SYSTEM PROMPT", &worker_prompt); print_stats("System prompt", &worker_prompt); - - // Build the actual worker tool server - let browser_config = (**rc.browser_config.load()).clone(); let brave_search_key = (**rc.brave_search_key.load()).clone(); let worker_id = uuid::Uuid::new_v4(); @@ -530,6 +529,7 @@ async fn dump_all_contexts() { println!("--- TOTAL BRANCH: ~{} tokens ---", branch_total / 4); // ── Worker ── + let browser_config = (**rc.browser_config.load()).clone(); let worker_prompt = prompt_engine .render_worker_prompt( &instance_dir, @@ -539,9 +539,9 @@ async fn dump_all_contexts() { Vec::new(), Vec::new(), &[], + browser_config.persist_session, ) .expect("failed to render worker prompt"); - let browser_config = (**rc.browser_config.load()).clone(); let brave_search_key = (**rc.brave_search_key.load()).clone(); let worker_tool_server = spacebot::tools::create_worker_tool_server( deps.agent_id.clone(),