Skip to content

Commit 621a416

Browse files
committed
desktop: run the person's browser as the one host process beside the containers
Main's OpenBot Browser (#858) runs agent-computer on the host with Bun, to open the person's own Chrome or Edge. The server and worker are containers now, so the browser is the only host process left: - Start prepares Bun and the packages the first time it has a browser to run, as the Intelligence sign-in helper does, after the server answers. It spawns the browser under the startup lock, records it so a later window can end it, and holds it. - The server container reaches it at host.docker.internal (OPENBOT_BROWSER_CONTAINER_URL, which docker-compose.yml prefers), since the computer listens on the host's loopback. - It is restarted with backoff when it dies. Giving up says why, with the tail of its log, and leaves OpenBot running. - Stop closes the held browser before the recorded processes are ended, so Chrome closes its windows and profile first.
1 parent c06ab28 commit 621a416

5 files changed

Lines changed: 250 additions & 21 deletions

File tree

‎desktop/src-tauri/src/env.rs‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -835,10 +835,13 @@ pub fn compose(
835835
* keeps a line it does not own, so Start purges the value an earlier run wrote.
836836
*/
837837
if let Some(port) = ports.browser {
838+
let address = format!("http://127.0.0.1:{port}");
839+
// The server is a container, and this is the host's loopback. See `container_address`.
838840
env.insert(
839-
crate::local_browser::BROWSER_URL_ENV.into(),
840-
format!("http://127.0.0.1:{port}"),
841+
BROWSER_CONTAINER_URL_ENV.into(),
842+
container_address(&address),
841843
);
844+
env.insert(crate::local_browser::BROWSER_URL_ENV.into(), address);
842845
}
843846

844847
// The worker refuses to start without this, by design: it is a fact about where this process
@@ -1046,14 +1049,19 @@ pub const MINTED: [&str; 6] = [
10461049
/// earlier run wrote outlives the answer that implied it: `BOT_MODEL` once asked OpenAI for a model
10471050
/// an earlier endpoint had named, and `OPENBOT_BROWSER_URL` would send the person to a browser that
10481051
/// no longer runs.
1049-
pub const REMOVED_WHEN_UNSET: [&str; 5] = [
1052+
pub const REMOVED_WHEN_UNSET: [&str; 6] = [
10501053
"BOT_PROVIDER",
10511054
"BOT_MODEL",
10521055
"AGENT_BOT_MODEL",
10531056
crate::local_browser::BROWSER_URL_ENV,
1057+
BROWSER_CONTAINER_URL_ENV,
10541058
crate::local_browser::BROWSER_ABSENT_ENV,
10551059
];
10561060

1061+
/// [`crate::local_browser::BROWSER_URL_ENV`] as the server's container reaches it, which
1062+
/// `docker-compose.yml` prefers for the `server` service.
1063+
pub const BROWSER_CONTAINER_URL_ENV: &str = "OPENBOT_BROWSER_CONTAINER_URL";
1064+
10571065
/// Tell the server why this machine runs no browser of the person's own (`BROWSER_ABSENT_ENV`), so
10581066
/// the app can say it to them in a sentence of their own rather than leave it in Desktop's log.
10591067
/// Nothing where a browser runs, and [`REMOVED_WHEN_UNSET`] then takes out what an earlier Start

‎desktop/src-tauri/src/main.rs‎

Lines changed: 215 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use openbot_desktop_lib::{
1919
install, local_browser, local_eval, local_eval_state, preparation,
2020
problem::{LeftoverReset, Problem},
2121
provider, pull_metrics, quiet, recorder_external, recorder_host, recorder_install, stack,
22-
telemetry, tray, windows as win,
22+
supervise, telemetry, tray, windows as win,
2323
};
2424

2525
const QUIT_CLEANUP_NOTICE_FILE: &str = ".openbot-quit-cleanup-notice";
@@ -37,6 +37,9 @@ use tauri::{Emitter, Manager};
3737
struct Shell {
3838
/// Session-only local folder authority; shutdown retires it before stopping the API server.
3939
host_access: Mutex<Option<host_access::HostAccess>>,
40+
/// The person's browser, the one host process a Start runs (see `local_browser`). Held so Stop
41+
/// can let Chrome close its windows and profile before anything is forced.
42+
browser: Mutex<Option<std::process::Child>>,
4043
/// Native Linux engine API owned by this desktop session, kept through Stop/Start.
4144
#[cfg(target_os = "linux")]
4245
podman_api: Mutex<engine::local_api::Service>,
@@ -1462,7 +1465,10 @@ fn reset_leftover_database_with(
14621465
}
14631466
let attempt = StartAttempt::begin(shell)?;
14641467
let _startup = attempt.lock_current()?;
1465-
if shell.containers.lock().unwrap().is_some() || shell.root.lock().unwrap().is_some() {
1468+
if shell.containers.lock().unwrap().is_some()
1469+
|| shell.root.lock().unwrap().is_some()
1470+
|| shell.browser.lock().unwrap().is_some()
1471+
{
14661472
return Err(Problem::plain("OpenBot still owns running services. Choose Stop OpenBot before resetting leftover data."));
14671473
}
14681474
if shell
@@ -1733,7 +1739,7 @@ async fn start_stack_inner<R: tauri::Runtime>(
17331739
let host_token =
17341740
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>());
17351741

1736-
let (found, secrets, ports) = {
1742+
let (found, secrets, ports, local_browser) = {
17371743
let _startup = attempt.lock_current()?;
17381744

17391745
// Belt and braces: a fetch that reported success and left something out is still not a
@@ -2006,7 +2012,7 @@ async fn start_stack_inner<R: tauri::Runtime>(
20062012
report(&app, "services", false, detail);
20072013
})?;
20082014

2009-
(found, secrets, ports)
2015+
(found, secrets, ports, local_browser)
20102016
};
20112017

20122018
/*
@@ -2055,8 +2061,50 @@ async fn start_stack_inner<R: tauri::Runtime>(
20552061
report(&app, "answering", false, problem.said.clone());
20562062
return Err(problem);
20572063
}
2064+
/*
2065+
* The person's own browser, the one host process left (see `local_browser`).
2066+
*
2067+
* After the server answers: the server reaches it only when the person opens their browser, so
2068+
* it is not on the way to the window answering. Bun and the computer's packages are prepared the
2069+
* first time a Start has a browser to run, and reused after that, as the Intelligence sign-in
2070+
* helper prepares them. Installing OpenBot no longer does.
2071+
*/
2072+
let browser_bun = match local_browser.as_ref() {
2073+
Some(_) => {
2074+
report_running(&app, "browser", "Preparing your browser.", None);
2075+
let prepare_root = root.clone();
2076+
let prepared = tauri::async_runtime::spawn_blocking(move || {
2077+
preparation::install_dependencies_if_needed(&prepare_root, || {
2078+
install::ensure_bun(&prepare_root, which_bun())
2079+
})
2080+
})
2081+
.await
2082+
.unwrap_or_else(|error| {
2083+
Err(Problem::with(
2084+
"OpenBot could not prepare your browser. Try Start again.",
2085+
error.to_string(),
2086+
))
2087+
});
2088+
attempt.require_current()?;
2089+
Some(
2090+
prepared
2091+
.inspect_err(|problem| report(&app, "browser", false, problem.said.clone()))?,
2092+
)
2093+
}
2094+
None => None,
2095+
};
20582096
// Stop must not finish between accepting readiness and reporting a successful Start.
20592097
let _startup = attempt.lock_current()?;
2098+
if let (Some(browser), Some(bun)) = (local_browser.as_ref(), browser_bun.as_ref()) {
2099+
start_local_browser(&shell, &root, bun, &secrets, &ports, browser)
2100+
.inspect_err(|problem| report(&app, "browser", false, problem.said.clone()))?;
2101+
report(
2102+
&app,
2103+
"browser",
2104+
true,
2105+
format!("{} is ready.", browser.product.name()),
2106+
);
2107+
}
20602108
*shell.root.lock().unwrap() = Some(root.clone());
20612109
clear_recovery_required(&shell, &root);
20622110
let generation = shell.generation.load(std::sync::atomic::Ordering::SeqCst);
@@ -2088,6 +2136,9 @@ async fn start_stack_inner<R: tauri::Runtime>(
20882136
)
20892137
})?;
20902138
preparation::save_selected_root(&config, &root)?;
2139+
if let (Some(browser), Some(bun)) = (local_browser, browser_bun) {
2140+
supervise_local_browser(app.clone(), root.clone(), bun, secrets, browser, generation);
2141+
}
20912142
watch_connections(app.clone(), root, host_token, generation, ports.server);
20922143

20932144
report(&app, "answering", true, "OpenBot is ready.");
@@ -2561,7 +2612,12 @@ where
25612612
.unwrap()
25622613
.clone()
25632614
.unwrap_or_else(|| root.to_path_buf());
2564-
let result = cleanup(&selected);
2615+
// Closed while it is still held, before `cleanup` ends whatever was recorded.
2616+
let result = match (close_held_browser(shell, &selected), cleanup(&selected)) {
2617+
(Ok(()), result) => result,
2618+
(Err(problem), Ok(_)) => Err(problem),
2619+
(Err(_), Err(problem)) => Err(problem),
2620+
};
25652621
let result = match (host_access_result, result) {
25662622
(Ok(()), result) => result,
25672623
(Err(error), Ok(_)) => Err(Problem::with(
@@ -5583,6 +5639,160 @@ fn publish_connection_failure<R: tauri::Runtime>(
55835639
Ok(true)
55845640
}
55855641

5642+
/// Start the person's browser and hand it to this window and to the record a later window ends it
5643+
/// from. Called under the startup lock, so a Stop cannot finish between the spawn and the handoff.
5644+
fn start_local_browser(
5645+
shell: &Shell,
5646+
root: &Path,
5647+
bun: &Path,
5648+
secrets: &stack::Secrets,
5649+
ports: &openbot_env::Ports,
5650+
browser: &local_browser::LocalBrowser,
5651+
) -> Result<(), Problem> {
5652+
let port = ports.browser.ok_or_else(|| {
5653+
Problem::plain("OpenBot did not choose a port for your browser. Try Start again.")
5654+
})?;
5655+
if let Some(problem) = stack::port_already_taken(&[("browser", port)]) {
5656+
return Err(problem.into());
5657+
}
5658+
let child = spawn_local_browser(root, bun, secrets, browser)?;
5659+
hold_local_browser(shell, root, child)
5660+
}
5661+
5662+
fn spawn_local_browser(
5663+
root: &Path,
5664+
bun: &Path,
5665+
secrets: &stack::Secrets,
5666+
browser: &local_browser::LocalBrowser,
5667+
) -> Result<std::process::Child, Problem> {
5668+
stack::spawn_host_process(
5669+
stack::local_browser_process(),
5670+
root,
5671+
&root.join(".logs"),
5672+
bun,
5673+
secrets,
5674+
Some(browser),
5675+
)
5676+
.map_err(|error| {
5677+
Problem::with(
5678+
"OpenBot could not start your browser. Try Start again.",
5679+
error.to_string(),
5680+
)
5681+
})
5682+
}
5683+
5684+
/// Record it, then hold it. One that cannot be recorded is ended, rather than left running where no
5685+
/// later window could find it.
5686+
fn hold_local_browser(
5687+
shell: &Shell,
5688+
root: &Path,
5689+
child: std::process::Child,
5690+
) -> Result<(), Problem> {
5691+
if let Err(problem) = stack::record_host_processes(root, &[(stack::LOCAL_BROWSER, child.id())])
5692+
{
5693+
let mut held = [(stack::LOCAL_BROWSER, child)];
5694+
if stack::stop_host_children(root, &mut held).is_err() {
5695+
let [(_, mut child)] = held;
5696+
let _ = child.kill();
5697+
let _ = child.wait();
5698+
}
5699+
return Err(problem);
5700+
}
5701+
*shell.browser.lock().unwrap() = Some(child);
5702+
Ok(())
5703+
}
5704+
5705+
/// The browser this window holds, closed so Chrome closes its windows and profile before anything
5706+
/// is forced (see `stack::stop_host_children`). Kept on failure, so another Stop can try again.
5707+
fn close_held_browser(shell: &Shell, root: &Path) -> Result<(), Problem> {
5708+
let mut slot = shell.browser.lock().unwrap();
5709+
let Some(child) = slot.take() else {
5710+
return Ok(());
5711+
};
5712+
let mut held = [(stack::LOCAL_BROWSER, child)];
5713+
match stack::stop_host_children(root, &mut held) {
5714+
Ok(_) => Ok(()),
5715+
Err(problem) => {
5716+
let [(_, child)] = held;
5717+
*slot = Some(child);
5718+
Err(problem)
5719+
}
5720+
}
5721+
}
5722+
5723+
/**
5724+
The person's browser, started again when it dies, as the host-process supervisor once did for all
5725+
four. It ends with the run it was started for, like [`watch_connections`].
5726+
5727+
Giving up is said, with the tail of its log, and OpenBot is left running: it runs without the
5728+
browser, and sending the window back to setup over it would stop everything else too.
5729+
*/
5730+
fn supervise_local_browser<R: tauri::Runtime>(
5731+
app: tauri::AppHandle<R>,
5732+
root: PathBuf,
5733+
bun: PathBuf,
5734+
secrets: stack::Secrets,
5735+
browser: local_browser::LocalBrowser,
5736+
generation: u64,
5737+
) -> std::thread::JoinHandle<()> {
5738+
std::thread::spawn(move || {
5739+
let mut watch = supervise::Watch::new(stack::LOCAL_BROWSER);
5740+
let current = |shell: &Shell| {
5741+
shell.generation.load(std::sync::atomic::Ordering::SeqCst) == generation
5742+
&& shell.root.lock().unwrap().as_deref() == Some(root.as_path())
5743+
};
5744+
loop {
5745+
std::thread::sleep(std::time::Duration::from_secs(2));
5746+
let shell = app.state::<Shell>();
5747+
if !current(&shell) {
5748+
return;
5749+
}
5750+
let exited = match shell.browser.lock().unwrap().as_mut() {
5751+
// Stop took it.
5752+
None => return,
5753+
Some(child) => matches!(child.try_wait(), Ok(Some(_))),
5754+
};
5755+
if !exited {
5756+
continue;
5757+
}
5758+
if !watch.should_restart(std::time::Instant::now()) {
5759+
*shell.browser.lock().unwrap() = None;
5760+
report(
5761+
&app,
5762+
stack::LOCAL_BROWSER,
5763+
false,
5764+
format!(
5765+
"{}\n{}",
5766+
watch.gave_up(),
5767+
stack::tail_of(&root.join(".logs"), stack::LOCAL_BROWSER)
5768+
),
5769+
);
5770+
return;
5771+
}
5772+
report(
5773+
&app,
5774+
stack::LOCAL_BROWSER,
5775+
false,
5776+
"Your browser stopped. Starting it again.",
5777+
);
5778+
std::thread::sleep(supervise::backoff(watch.restarts - 1));
5779+
5780+
// Asked again under the lock: a Stop, or another Start, may have happened meanwhile,
5781+
// and starting a browser into either is how an orphan is made.
5782+
let _startup = shell.startup.lock().unwrap();
5783+
if !current(&shell) {
5784+
return;
5785+
}
5786+
match spawn_local_browser(&root, &bun, &secrets, &browser)
5787+
.and_then(|child| hold_local_browser(&shell, &root, child))
5788+
{
5789+
Ok(()) => report(&app, stack::LOCAL_BROWSER, true, "started again"),
5790+
Err(problem) => report(&app, stack::LOCAL_BROWSER, false, problem.said),
5791+
}
5792+
}
5793+
})
5794+
}
5795+
55865796
/**
55875797
Watch a running OpenBot for a connection it reports refused, and send the window back to setup to
55885798
refresh it.

‎desktop/src-tauri/src/stack.rs‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
1-
//! Raising the stack: the Compose services, then the three processes that are not containers.
1+
//! Raising the stack: the Compose services, and the one process that is not a container.
22
//!
3-
//! `docker-compose.yml` has no `app`, `server` or `worker` service, and the root `Dockerfile` leaves
4-
//! out the supervisor because it needs a socket no serverless platform grants. So the shape is not a
5-
//! choice: containers for postgres, the supervisor, `agent-computer`, the Bots and a one-shot
6-
//! `migrate`, and three host processes for the rest. `scripts/start.sh` does exactly this for a
7-
//! developer. This does it for somebody who double-clicked.
3+
//! Compose runs all of OpenBot: postgres, the supervisor, `agent-computer`, the Bots, a one-shot
4+
//! `migrate`, and the published image's `server` and `worker` under the `openbot` profile. Docker's
5+
//! restart policy keeps them up.
86
//!
9-
//! A fourth host process runs where this machine can run it: the person's own browser, the local
10-
//! Chrome computer `scripts/start-local-chrome-computer.ts` starts for a developer. See
11-
//! `local_browser` for when, and why it is not a container.
7+
//! One host process runs where this machine can run it: the person's own browser, the local Chrome
8+
//! computer `scripts/start-local-chrome-computer.ts` starts for a developer. It opens a window on
9+
//! their desktop, so it cannot be a container; see `local_browser`. The shell is its restart
10+
//! policy.
1211
//!
13-
//! The shell also becomes the restart policy those three do not have. `worker/src/index.ts` names
14-
//! the gap itself: "this process has no restart policy watching it; it is somebody's laptop, left
15-
//! running".
12+
//! [`HOST_PROCESSES`] still names the server, the app and the worker, which earlier versions ran on
13+
//! the host, so a Start or a Stop can find and end what one of those left running.
1614
1715
use std::path::{Path, PathBuf};
1816
use std::process::{Command, Stdio};
@@ -158,6 +156,14 @@ pub const HOST_PROCESSES: [HostProcess; 4] = [
158156
/// The host process that runs the person's own browser.
159157
pub const LOCAL_BROWSER: &str = "browser";
160158

159+
/// The person's browser, the one host process a Start still runs.
160+
pub fn local_browser_process() -> &'static HostProcess {
161+
HOST_PROCESSES
162+
.iter()
163+
.find(|process| process.name == LOCAL_BROWSER)
164+
.expect("HOST_PROCESSES names the person's browser")
165+
}
166+
161167
/// The host processes one start runs, in order: the server, the app and the worker always, and the
162168
/// person's browser where this machine runs one.
163169
pub fn host_processes(local_browser: bool) -> impl Iterator<Item = &'static HostProcess> {
@@ -1717,7 +1723,7 @@ fn configure_local_browser(
17171723

17181724
/// The last build error can precede Bun's wrapper stack, version, and exit message.
17191725
/// Bound both the file read and displayed lines while preserving that useful context.
1720-
fn tail_of(logs: &Path, name: &str) -> String {
1726+
pub fn tail_of(logs: &Path, name: &str) -> String {
17211727
use std::io::{Read, Seek, SeekFrom};
17221728
const MAX_BYTES: u64 = 8 * 1024;
17231729
let read = || -> std::io::Result<Vec<u8>> {

‎desktop/src/SetupProgress.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ function label(step: string): string {
187187
return "Dependencies";
188188
case "recorder":
189189
return "Recorder";
190+
case "browser":
191+
return "Your browser";
190192
case "images":
191193
return "Local software";
192194
case "installation":

‎docker-compose.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ services:
137137
# browser is on the host. So it gets the deployment's own address, the one a host server would
138138
# have used, whatever this container uses itself.
139139
INTELLIGENCE_BROWSER_GATEWAY_WS_URL: ${INTELLIGENCE_GATEWAY_WS_URL:-}
140+
# The person's own browser, which OpenBot Desktop runs on the host where this machine can run
141+
# one, listening on the host's loopback. Its container address, when Desktop gives one.
142+
OPENBOT_BROWSER_URL: ${OPENBOT_BROWSER_CONTAINER_URL:-${OPENBOT_BROWSER_URL:-}}
140143
# The deployment's secrets, from the environment Compose runs in and then from `.env`, which
141144
# Compose reads for these `${...}` and the host server reads for itself. OpenBot Desktop keeps
142145
# them out of that file and hands them to Compose in its environment, so they reach this

0 commit comments

Comments
 (0)