Skip to content

Connect OpenBot Desktop to an OpenBot that runs somewhere else - #1702

Draft
davidmckayv wants to merge 11 commits into
mainfrom
desktop/connect-to-an-openbot
Draft

davidmckayv wants to merge 11 commits into
mainfrom
desktop/connect-to-an-openbot

Conversation

@davidmckayv

@davidmckayv davidmckayv commented Nov 15, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

OpenBot Desktop could only open the OpenBot it installs on the same computer. A person whose company runs OpenBot had no way to use the Desktop app with it.

  • A choice of where OpenBot runs, at set-up and later in Settings, Computer: on this computer, exactly as before, or connected to an OpenBot at an address the person types (one address, such as https://openbot.example.com).
  • Connected, nothing runs on this computer. No engine, no downloads, no CopilotKit account, no model key. The person signs in to that OpenBot in their own browser: OAuth 2.1 authorization code with PKCE (S256), as the public client openbot-desktop-oauth, with a loopback redirect on 127.0.0.1 and a port chosen per sign-in.
  • The sign-in stays in native code. The refresh token is kept in the operating system's credential store (Keychain, Windows Credential Manager) through the keyring crate, never in a file, and the log redaction covers the token shapes. The access token is held in memory, refreshed before it expires (refresh tokens rotate, so refresh is single-flight) and revoked at sign-out.
  • The window loads OpenBot through a relay in the app. The page cannot hold a token, so the app runs an HTTP relay bound to 127.0.0.1 on a port chosen per run. It forwards requests and WebSocket upgrades to the one typed address and nothing else, adds the sign-in, and removes Origin, Cookie and forwarding headers. It answers only the window: any other Host, a foreign Origin, or a request without the per-run secret cookie the app gives the window is refused and nothing is forwarded. The screens come from the OpenBot it is connected to, so they match its release.
  • The relay can name the organization per request. The app takes the organization the person is working in from the page (set_organization), remembers it, and adds it as the OpenBot-Organization header. The page starts calling it once the organization switcher is on main; until then no header is sent and the person works in their own organization.
  • Plain sentences for every failure: not an address, unreachable, not an OpenBot, an OpenBot for one person on its own computer, a release that cannot serve the app, sign-in not finished, sign-in refused, sign-in ended, and the credential store refusing.
  • Settings while connected shows the address, Sign out, and "Use OpenBot on this computer instead", in place of the local services, their memory and the install folder. The app's version, its updates and its log folder stay. Every Sign out in the app goes through the Desktop app there. With OpenBot on this computer, Settings gains one row to connect to an OpenBot instead.
  • Switching back loses nothing. Connected mode never reads or writes the local install.

What is not available while connected, and what happens instead, is listed in docs/desktop-install.md: starting, stopping, restarting and updating OpenBot's services; the local Intelligence and its sign-in; model keys kept on this computer; local folders for Bots; the Recorder's native host finding a local OpenBot. None of it is removed from OpenBot on this computer.

A desktop app from before this change, a browser, and OpenBot on this computer behave as they did.

Where it runs

  • New state that outlives a request? On the person's computer only: the choice (connection.json in the app's config folder, no secret) and the refresh token in the system's credential store. Nothing on the server.
  • What happens on the second replica? Nothing different. Every request the relay forwards carries its own sign-in and names its organization, so any replica answers it.
  • Anything serialised? Token refresh, inside the app: one refresh at a time, and the new refresh token is stored before the old one is dropped.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? Two loopback listeners on the person's computer, 127.0.0.1 only: the relay for the life of a connected session, and the sign-in callback for the length of a sign-in. Nothing on the server.

Boundary and audit

  • Every acting call still goes through the gateway: the relay forwards requests and changes none.
  • New refusals and new failures each write a row: none on the server. The app's own refusals are in its log, with tokens redacted.
  • Nothing new is trusted from the client that the server can resolve itself: the organization header is checked against the person's membership by the server on every request.

Changelog

  • A line in CHANGELOG.md under Unreleased.

Proof

How each test was seen failing: the set-up and Settings tests were written first and seen red. The Rust tests for the relay, sign-in, address check, wiring and log redaction were written with the code and then each seen failing by taking the behaviour out (a guard removed, a header left in), and passing again with it back. The tests of the saved choice, the credential store and the menus were not broken on purpose.

Rust (cargo test --locked --lib --bins, cargo fmt --check, cargo clippy --all-targets -- -D warnings, on macOS):

  • The relay, against a stand-in OpenBot on a real socket: it binds 127.0.0.1 and two relays get different ports; another Host, a foreign Origin, a missing or wrong secret cookie, an absolute request target and CONNECT are refused and nothing reaches the OpenBot; the OpenBot sees the sign-in and the organization header and no Origin, Cookie or forwarding header; Set-Cookie does not come back; a streamed answer arrives in pieces; a WebSocket echoes both ways; an expired sign-in is refreshed once and the request retried; a refused refresh ends the session.
  • Sign-in, against a stand-in that checks the verifier, burns a code on a wrong one, rotates refresh tokens and refuses a replayed one: a whole sign-in; a wrong state; consent denied and Cancel; rotation with the new token stored; two refreshes at once making one request; sign-out revoking and then forgetting.
  • The address check: every failure kind from a real local server or a closed port.
  • Wiring with Tauri's mock runtime: which pages may call which commands; connected mode never touching the local install folder; the status answer and the refusals while connected.
  • The credential store through the crate's own in-memory store, and nothing token-shaped in the config folder or the log.

Set-up screens and Settings (bun test, typecheck, lint, format): choosing connect, each failure kind and what it offers, cancel, launch signed in and with an ended sign-in, going back to this computer, the choice reached from Settings; Settings, Computer connected, local, with an earlier desktop app, and in a browser; Sign out through the Desktop app when connected and the web sign-out otherwise.

Looked at in headless Chromium and headless WebKit, light and dark, at the window's default size and a small one (set-up), and at 1440 and 700 wide (Settings): the first screen before and after, the connect screen in each state, and Settings, Computer in each mode.

Not proven yet, and needed before this leaves draft: the app itself on a Mac and on Windows from an Internal build, connected to a real OpenBot (the Keychain and Credential Manager, the browser hand-off, the tray, a conversation with a real answer, switching back to OpenBot on this computer with its data intact).

…ewhere

Connected, nothing of OpenBot runs on this computer, so Settings, Computer shows where the app is connected, Sign out, and Use OpenBot on this computer instead, in place of the local services, memory and install folder. The app's version, updates and log folder stay. The page holds no sign-in there, so every Sign out asks the desktop app, and a sign-in that stopped working returns to the desktop app's setup. A desktop app from before it could connect is read as OpenBot on this computer, and a browser is unchanged.
The first screen's OpenBot for Business choice can now be picked. It asks
for the address of the OpenBot a company runs in place of what this
computer needs and Install, then signs in through the browser. Nothing is
installed or started on this computer for it. The booking link moves to
that screen, for a company that has no OpenBot yet. OpenBot Cloud is still
shown as coming soon, and Self-hosted is unchanged.

Setup asks the shell where OpenBot runs before anything else. A saved
connection opens that OpenBot, or shows the first screen with the address
filled in and the reason the sign-in ended, and none of the checks,
downloads or starts of an install on this computer run. A shell that does
not know the question is an install on this computer, as before.

What goes wrong is said in the shell's own sentence. The screen reads only
its kind to choose what to offer: the field to fix, Try again, Sign in
again, or opening that OpenBot in the browser. Use OpenBot on this computer
instead goes back, and switches a saved connection first.
…s computer

A person who already runs OpenBot here had no way back to the choice of where it runs. Settings, Computer now has a Connection row that, after a confirm, stops OpenBot here and opens setup at that choice. It is drawn only by a desktop app that can connect.
… this computer

A connection that didn't work is not a failed install. Every connection
problem that is not about the address itself now shows the shell's
sentence in the calm notice, with no failure heading and no
troubleshooting logs, and the way on is the screen's button: Try again,
Sign in again, or Open in browser beside Connect. The connect screen no
longer scrolls at the window's default size. The failure card is as it was
for setup on this computer.

Setup also asks the shell whether the person chose, in Settings, to pick
where OpenBot runs. When they did, the first screen shows whichever way
OpenBot runs today. On a computer that already runs OpenBot, nothing
starts while the choice shows, and Continue goes on as any relaunch does.
OpenBot Desktop could only run OpenBot on the computer it is installed on. This adds what it
takes to show an OpenBot that runs somewhere else instead, as a library module of its own
(desktop/src-tauri/src/connected) that nothing calls yet.

choice: which of the two the person chose, in connection.json in the app's configuration folder,
with the address and the organisation picked there. No file means on this computer.

address: a typed address kept as scheme and host (https, or http for 127.0.0.1 and [::1]; no
scheme is taken as https), and two public questions asked of it: what it offers and how an app
signs in to it (RFC 8414). Each way that can go wrong is one kind with its own sentence: not an
address, unreachable, not an OpenBot, one person's OpenBot on its own computer, a release from
before apps could sign in. A redirect to another host is not followed, and metadata that names
another host is refused.

sign_in: OAuth 2.1 authorization code with PKCE (S256) as the public client
openbot-desktop-oauth, with a listener on 127.0.0.1 at a port the system picks and a redirect of
exactly /callback. The verifier, the state and the callback's first line use what
provider_oauth already has (its random and its challenge are now shared, unchanged). The wait
ends on the code, a refusal, Cancel or ten minutes. After the exchange the OpenBot is asked
/api/me with the new token; a 401 there means it cannot serve the app. Refresh tokens rotate, so
one refresh runs at a time and the new token is stored before the old one is let go. Sign-out
revokes both tokens (RFC 7009) and forgets the sign-in.

credentials: the refresh token in the system's credential store through keyring 4.2 (Keychain,
Credential Manager, the Secret Service), under the app's identifier and the address. The access
token stays in memory. Nothing token-shaped is written to a file or a log, and the log's
redaction now also covers OpenBot's own token prefixes and the relay's cookie.

relay: an HTTP/1 server on 127.0.0.1 only, at a port the system picks, that passes each request
and WebSocket upgrade on to the one typed address with the access token added. It answers only
its own Host, only requests that carry the secret made for that run (given to the window as an
HttpOnly, SameSite=Strict cookie at an entry address that is never forwarded), and no request
from another site. Absolute request targets and CONNECT are refused and redirects are handed
back, rewritten onto the relay when they point at the typed address. Origin, Cookie, Referer,
Forwarded and X-Forwarded-* are dropped on the way out and Set-Cookie on the way back. Bodies
stream both ways. The organisation the person picked is named in a header on every request, set
by the shell and never taken from the page. A 401 to a request with no body leads to one refresh
and the request again; a refused refresh says the session ended, once, and the relay stops.

The tests run against a stand-in OpenBot that answers the same addresses the same way: the
metadata document, a form-encoded token endpoint with the client's id in the body, the verifier
really checked, a code that burns on a wrong verifier, rotating refresh tokens where a replayed
one answers invalid_grant, revocation, and a redirect that must be http://127.0.0.1:<port>/callback.
The credential store in tests is keyring's own in-memory one.

New crates: keyring and keyring-core; tokio, hyper, hyper-util, http-body-util, bytes and
futures-util, which reqwest and Tauri already build; tokio-tungstenite for the tests' WebSocket.
The setup window and OpenBot's own page can now ask the shell to connect to an OpenBot at an
address, and the shell behaves accordingly while it is connected. On this computer, nothing
changes.

For the setup window: connection, check_openbot_address, connect_to_openbot, cancel_connect,
show_connected, sign_out_of_openbot, use_this_computer and take_connection_notice. A failure
carries a kind beside its sentence so the window can choose what to offer. Connecting checks the
address, signs in through the person's browser, stops an OpenBot running on this computer the way
Stop does, starts the relay and points the window at it; the choice is kept only once that has
worked. When a sign-in ends while OpenBot shows, the relay stops, setup comes back and the
sentence waits for it.

For OpenBot's page, through the openbot-desktop plugin: connection, sign_out, change_connection
and set_organization, each with a permission of its own granted to the relay's origin as the
others are. change_connection also works on this computer: it stops OpenBot as Stop does and
asks setup to open at the choice. While connected, status answers the app's version, build and
log folder without asking an engine, and restart, stop, intelligence, ai_connection and
change_ai_connection are refused.

The relay's origin is trusted the way a local OpenBot's is, so its page is OpenBot's own page to
the window: the overlay title bar, the theme, notifications, dictation, zoom, and links to
anywhere else (the typed address included) opening in the browser.

While the choice is to be connected, nothing of an installation on this computer is started,
read or written: the engine is not woken, nothing is downloaded or made ready, Start, Show and
the upgrade are refused, the installation's folder is not looked for at launch, and Quit only
stops the relay. The tray and the menu carry Show Window, Open Log Folder, Sign Out and Quit.

The setup window's connect_openbot step is one the shell's telemetry knows; its events carry the
step's name and never the address.

docs/desktop-install.md says what the choice is, how sign-in and the relay work, and what is not
there while connected.
…runtime after sign-in

The boundary check found the new addresses Desktop's native side writes by hand: the deployment question, OAuth's discovery document and the check after sign-in. Each is now listed with its rule. The check after sign-in asks the runtime's own address, which is what the app's screens need from an app's sign-in and an address that stays, in place of /api/me. The test stand-in's file name now ends in _tests.rs so the scan reads it as a test.
The relay added the access token and the organisation header to every request it passed on. The
same host name can serve things that are not OpenBot's: on a company's cluster a socket at a path
outside OpenBot's own goes straight to another service, which checks a join token of its own.
The page reaches that socket through the relay too, so the relay was handing the person's OpenBot
sign-in to a service that has no business seeing it.

The token and the organisation header are now added only to requests and WebSocket upgrades
whose path is /api, starts with /api/, or starts with /.well-known/. Every other path (the app's
pages and files, /client/websocket and anything else) is passed on with neither. An
Authorization or organisation header the page sent is still dropped on every path, and the rest
of the relay's rules (its own Host, the window's cookie, no other site, the stripped headers, the
one address) are the same for every path.

A request that carried no sign-in asks for no token, so it waits on no refresh, and a 401 to one
is somebody else's refusal: it is handed back as it is and uses no refresh token.

The stand-in OpenBot serves a socket at /client/websocket that takes no OpenBot token, a file at
/assets/app.js, and a path that answers 401 for reasons of its own.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant