Skip to content

Desktop shell: the window, the engine it acquires, and the stack it raises - #392

Merged
davidmckayv merged 30 commits into
mainfrom
desktop-shell
Sep 6, 2026
Merged

Desktop shell: the window, the engine it acquires, and the stack it raises#392
davidmckayv merged 30 commits into
mainfrom
desktop-shell

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

S3 and S4 together, as agreed: the shell, and the engine acquisition that makes it useful on a machine with nothing installed.

What it is

A Tauri v2 app in desktop/. Rust core, React window, one screen with four states rather than a wizard with more screens than states: something is in the way, nothing is set up yet, it is working, it is running.

It owns the five things the build doc says it owns and nothing else: the container engine, .env, the Compose project, the three host bun processes, and the window itself.

The parts that carry what S1 and S2 cost us

The socket differs three ways and only one needs help. A Podman machine already supplies /var/run/docker.sock inside its VM on macOS and Windows, so Compose's mount is right as written. Rootless Podman on Linux has either no such path or a symlink to the rootful socket that is not running, so only Linux gets ENGINE_SOCKET. Asserted, because getting it wrong looks like a network fault.

No --provider on macOS. applehv has been the default on Apple silicon since Podman 6.1. The pin was written against 5.7, where libkrun was the default.

.env refuses three inheritances from start.sh that are fine for a developer and not for a product: the dev defaults for COMPUTER_TOKEN, SUPERVISOR_TOKEN and WORKER_SHARED_SECRET, which are published in this repository; the example KEY_ENCRYPTION_KEY, which only throws under NODE_ENV=production and would otherwise encrypt the credential vault with a key printed here; and a missing COMPUTER_SUPERVISOR_URL, which silently gives every Bot one shared browser. That last one cost an hour on Windows before the symptom made sense.

Windows setup is a state machine because it crosses a restart — and because the halves cannot share a context. wsl --install needs elevation; podman machine init cannot run elevated at all, because WSL refuses to run as LocalSystem (WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED). So: decide, elevate once, restart, finish as the person. The step is written to disk before the restart and read back after.

The four blockers each carry their own instruction, and the two that are not ours to fix say whose they are. Podman's own errors are translated: every string matched was produced by a real failure during S2, because exit status 0xffffffff teaches nobody anything.

Deliberate choices worth reviewing

  • compose up --no-build. A desktop install has no toolchain; failing loudly on a missing image beats quietly compiling Chromium.
  • migrate is run to completion, not raised as a service, or Compose reports a service that will not stay up.
  • Stopping does not kill the Bot's computer. It belongs to the supervisor rather than Compose, its files and browser profile are volumes, and killing it would sign somebody out of everything their Bot had logged into.
  • The machine is named openbot, not podman-machine-default, so an existing machine with somebody's own work in it is never adopted.

Checks

21 Rust tests, cargo clippy -D warnings clean, cargo fmt --check clean, frontend typechecks and builds. New Desktop workflow builds the app on macOS, Windows and Linux.

Not in this PR

Signing and notarization (S7, needs the certificates), the harness picker and credential flow (S5), telemetry (S6). The icon is a placeholder: Tauri will not build without one and a missing file is a confusing thing to hit in CI.

Not yet verified end to end. It compiles and its logic is tested, but "double-click to a Bot answering" has not been run on a clean machine. That is the next thing I do, on this Mac first.

The core of S3 and S4, without a window around it yet, so the parts that carry
what the platforms taught us are testable on their own.

engine.rs encodes what S1 and S2 found. The socket differs three ways and only
one of them needs help: a Podman machine already supplies /var/run/docker.sock
inside its VM on macOS and Windows, while rootless Podman on Linux has either no
such path or a symlink to the rootful socket that is not running, so only Linux
gets ENGINE_SOCKET. No --provider is passed on macOS because applehv is the
default from Podman 6.1. Detection distinguishes an engine that is installed but
not answering from none at all, because those send a person to different places.

env.rs writes the .env, and refuses three inheritances from start.sh that are
fine for a developer and not for a product: the published dev defaults for
COMPUTER_TOKEN, SUPERVISOR_TOKEN and WORKER_SHARED_SECRET, the example
KEY_ENCRYPTION_KEY that only throws under NODE_ENV=production and would
otherwise encrypt the credential vault with a key printed in this repository,
and a missing COMPUTER_SUPERVISOR_URL, which silently gives every Bot the same
browser. Settings the shell does not own survive a rewrite.

stack.rs raises the Compose services with --no-build, runs migrate to
completion rather than as a service, and starts the three host processes with
their output on disk, since a window has no console to inherit.

Twelve tests, including that two installs never share a key and that a rewrite
replaces its settings rather than appending them twice.
Acquisition is two steps that look like one. Installing Podman puts a binary on
PATH; on macOS and Windows nothing answers until a machine is created and
started. The machine is named openbot rather than reusing
podman-machine-default, because somebody may already have one with their own
work in it.

Podman's own failures are turned into instructions, and every string matched was
produced by a real failure during S2. "exit status 0xffffffff" teaches nobody
anything; "Windows needs restarting before a machine can be created" is the
same fact and can be acted on. An error we do not recognise is passed through
rather than swallowed.

Windows setup is a state machine because it crosses a restart, and because the
two halves cannot share a context: wsl --install needs elevation, and podman
machine init cannot run elevated at all, since WSL refuses to run as
LocalSystem. So it is decide, elevate once, restart, then finish as the person.
The step is written to disk before the restart and read back after, so the app
returns to the screen it left. Unreadable state starts over rather than refusing
to run.

The four blockers each carry their own instruction, and the two that are not
ours to fix say whose they are: firmware virtualization is a trip to the BIOS,
and a standard account needs an administrator. The two that are ours promise the
restart they will cost rather than springing it.

Twenty-one tests.
One screen with four states rather than a wizard with more screens than states.
Something is in the way, nothing is set up yet, it is working, it is running.

The blocker state takes the whole screen, because nothing else on it can be
done until the machine allows it, and two of the four blockers are not ours to
clear.

Progress is emitted step by step rather than returned once. Creating an engine
machine and pulling a gigabyte of Chromium take minutes, and a window with
nothing moving in it reads as a hang.

stop_stack stops what this started and deliberately not the Bot's computer: it
belongs to the supervisor rather than to Compose, its files and browser profile
are volumes, and killing it would sign somebody out of everything their Bot had
logged into.

which_bun looks past PATH, because an installer that just put bun there has not
had its PATH reloaded in this process.
The shell is the one thing here that cannot be proved by running it on Linux: it
is a macOS app, a Windows app and a Linux app built from one tree, and the ways
they differ are exactly the ways it fails.

Two jobs. core is fast and holds the assertions about what the shell writes into
.env and which socket it names, which are plain Rust with no window and no
engine behind them. app builds the real artifact on each platform.

Not `tauri bundle`, which signs and notarizes. That is S7 and needs
certificates this workflow deliberately does not hold.
Found by clicking the button rather than by reading the code. Six defects, and
the last one is the one that matters.

The installer does not carry the deployment; it fetches one, at a tag, and
records the version so a restart is not a download. The source alone will not
run, so its dependencies are installed too: without that the server stops at a
package it cannot resolve and the app at a missing vite, and neither mentions
node_modules.

SERVER_INTERNAL_URL is written, because the worker refuses to start without it
and start.sh sets it at run time, so a .env copied from a developer never has
it.

compose run does not take --no-build. Passing it failed on the flag rather than
on anything to do with migrations.

The blocking download runs on a blocking thread. Inside the async command it
panicked the worker rather than returning an error, and the window survived
that, which is worse: the only symptom was a step that never ended.

And the one worth the whole exercise: the shell said "OpenBot is running"
while all three processes it started had already died. Spawning is not
starting. It now watches the children and the port, and reports whichever fails
first with the tail of the log that explains it.

Supervision follows what desktop apps that manage local services actually do:
restart with backoff, a cap so a broken process is reported rather than retried
forever, a window in which restarts count so an old fault does not condemn a new
one, and children stopped when the window closes. That last one is a standing
Tauri issue: a sidecar that outlives the app keeps port 3001, and the next
launch fails with nothing on screen to say why.

35 tests.
Four more, all found by watching the processes rather than the exit codes.

The server refuses to start with no identity provider, rather than serve a
deployment where every visitor is an administrator. That is the right refusal on
a server and the wrong question on a laptop, where there is nobody else, so the
shell answers it: OPENBOT_SINGLE_USER=true.

The fetched tree was missing tsconfig.base.json, which app, server and worker
all extend. vite died inside parseExtends, naming the parser and not the file.

And the one that would have made every future run untrustworthy: another
deployment was listening on 3001, so the readiness check was satisfied by a
server this shell had never started. Everything looked green and none of it was
ours. The ports are now checked before anything is spawned, because afterwards
the two are indistinguishable from outside.

The worker starts and sweeps now, which was the SERVER_INTERNAL_URL fix
working.

39 tests.
The last of what a bare machine needs, found by clicking Start and watching what
died.

A model key, because both Bots the deployment ships refuse to start without one
and say so: this Bot cannot answer without a model. Choosing between providers
is its own screen later; this is the one key without which nothing answers at
all. Left out entirely when blank, so Compose does not pass an empty string and
turn the Bot's clear refusal into a confusing one.

AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS, because the private-address floor stops a
hosted deployment reaching into its own network, and on a laptop the supervisor,
the computers and the Bots are all on loopback by design. Without it the server
refuses to call its own supervisor.

TENANT_PACKAGE_DIR so the deployment runs the package it came with rather than a
fallback, and INITIAL_ADMIN_EMAILS so the routes that ask what an actor may do
have an actor to answer about.

A service that exits after compose up is reported rather than passed over.
compose up succeeds once it has asked for everything; both Bots exit
immediately without a model key, and the window would otherwise show a healthy
stack while nothing could answer a question.

Verified by running it, not by reading it: from a machine with nothing at
~/OpenBot, one click fetches the deployment, installs it, writes the settings,
raises five containers, migrates, starts the three host processes, and waits for
the API. The smoke journey then passes 5 of 5 against what it built, including a
Bot driving its own computer through the gateway.

43 tests.
Found by driving the parts I had assumed rather than exercised.

The window told somebody to open the app or use the menu bar and offered
neither. There is an Open button now, and a tray with the two things anybody
wants from a status icon: get to it, or stop it.

Open names localhost rather than an address, against the rule the rest of the
file follows, because the app's dev server binds [::1] and not 127.0.0.1 and
naming either one guesses wrong half the time. TRUSTED_ORIGINS now covers all
three spellings for the same reason: the origin a browser presents and the one
somebody typed can differ, and both are this machine.

A window opened a second time offered to set up a stack that was already
running, and the port check then reported OpenBot as a foreign process holding
its own port. It asks now, of the deployment rather than of a file: a stamp says
one was installed, only an answer on the port says one is running.

Stop works from a window that did not start anything, by finding the processes
under the deployment's directory. Their command lines are identical and only
the working directory says which deployment they belong to, which is also how
this session's own orphans hid from a pkill twice.

Driven, not assumed: a warm start reaches an answering API in six seconds
without re-fetching; Stop takes the whole stack down; and closing the window
leaves no orphaned host processes, which is the failure Tauri has a standing
issue about.
A window that set things up and then sent somebody to a browser tab is a
launcher, and nobody double-clicks OpenBot to get a launcher. Once the stack is
up the window navigates to the running app, so the window is the application.
The tray keeps the controls that would otherwise have nowhere to live, and the
setup screen comes back when there is something to set up again.
Two failures found by starting the stack on a machine with no Docker and a
Podman machine that somebody else had made the default.

The first: podman sends every command to whichever machine is marked default,
and a stopped machine of somebody else's produced "Cannot connect to Podman"
from a machine of ours that was running. The engine is now carried as an address
and every command names its connection. A machine that is already running is
also preferred over booting a second one beside it.

The second: docker-compose.yml defaults each service to a local build name, so a
desktop install with nothing built asked a registry for openbot-supervisor:latest
and was told access was denied, which reads as a login problem and is not one.
The release's container-images.json is fetched with the tree and every service is
pinned to the digest that was tested. A manifest missing an image is refused
rather than quietly completed from those defaults.
Five things that only a running application shows.

No capability was declared, so Tauri's ACL denied every listen() and the whole
progress list has never once appeared: a cold install pulls five images behind a
button that says Working and nothing else. The capability is declared, the long
phases announce themselves before they run rather than after, and a step that
reports twice updates its row instead of adding one.

Closing the window destroyed it, and the tray's Open then pointed at a window
that no longer existed, so the only way back to a running stack was to quit and
start again. Close hides, which is what a tray application is for.

A second launch started a second stack against the same ports; single-instance
hands the focus to the window that is already there.

Readiness watched the API and nothing else, so the shell reported a running
stack while the app answered nothing. It now waits for both, and asks each on
127.0.0.1 and ::1, because a dev server binds whichever loopback its runtime
resolved localhost to. Vite is asked to bind both from now on.

Quitting left five containers and every Bot's computer running with no window
to stop them from. Quit stops the stack, the tray offers stopping without
quitting, and the Bots' computers are stopped by the supervisor's own label, so
a kind cluster that shares the openbot- prefix is not touched.
Stopping asked lsof about every process on the machine one at a time. Several
hundred forks, minutes of nothing, and a menu item that looked like a hang. One
pass over all of them takes 0.19 seconds.

Under that was a filter written without its label= prefix, which the engine
answers with "invalid filter" and which nobody saw, because the failure was
assigned to _ and thrown away. Stopping now reports what happened either way,
and the window returns to the setup screen rather than staying on an application
that is no longer running.
The restart policy was written, tested and never called: supervise.rs was a
module nothing imported, so a server that died at three in the morning stayed
dead until somebody noticed the window. The children are now held by name and
watched, a death is answered with a restart after the policy's backoff, and a
process that will not start is reported rather than spun.

CI could not have caught any of it. The desktop workflow pinned a
dtolnay/rust-toolchain commit that does not exist, so all four jobs failed in
three seconds at Set up job, before Rust was ever installed.

The rest is Tauri's generated schemas, which were committed and are now
regenerated on every build with a capability added. They are build output that
happens to be JSON; git and the formatter both ignore them now.
The hash was real and the comment beside it named a version it does not belong
to, which is what zizmor's ref-version-mismatch is for: a pin nobody can check
by reading is a pin nobody checks.
tauri-build generates a Windows resource file and needs an .ico to do it; the
tree had one PNG, so every Windows build failed after four minutes of
compiling with 'icons/icon.ico not found'. The full set is generated from the
same 512px source, and the bundle names the formats each platform actually
wants. The Android and iOS icons the generator also writes are removed: there
are no mobile targets to want them.
Stopping and starting again inside the two seconds the watcher sleeps left the
previous one alive beside the new one. Both hold the same children, so both
would answer the same death and start the same process twice: one server, and
one orphan holding port 3001 that nothing on screen accounts for.

Each run carries a number. A watcher whose number is no longer current returns,
and it asks again after its backoff, because a stop or another start may have
happened while it was waiting. Driven: stop and start three seconds apart,
then one kill, and there is one death, one restart and one server.
The first Windows gate read VirtualizationFirmwareEnabled, which Windows
reports as False once a hypervisor has claimed the extensions. That is exactly
the state of a machine where WSL2 already works, so the check sent everybody
running Hyper-V to a screen naming a firmware setting they cannot change and do
not need to. Measured on Windows Server 2022: firmware False, hypervisor True.

Both are asked now and either answer is enough. The rule is a function rather
than a line inside a shell-out, so the test that pins it runs on every platform
instead of the one nobody runs the tests on.
CI ran cargo build --release. tauri-build emits cargo:rustc-cfg=dev for
anything the Tauri CLI did not build, so that binary still points at the dev
server: run it and the window says "Could not connect to localhost: Connection
refused". It proved the Rust compiles and nothing about the thing people
install. Found by running the release binary on a Linux machine.

It now runs tauri build, bundling included, because the bundle is the product
and because that is where the failures live: the Windows resource step wants an
.ico and macOS wants an .icns, and a compile misses both.
Podman ships no compose implementation. It looks for an external provider on
PATH and, finding none, answers with seven errors naming docker-compose, which
is a baffling thing to read on a machine where Docker was deliberately not
installed. Docker Desktop puts a provider on PATH, which is why this went
unnoticed until the stack was started on a Linux machine that had only Podman:
every step passed, and then Start failed on the containers.

The engine is now asked whether it can run Compose at all, and the answer names
the package. Asked on both paths, because the health gate only runs when an
engine had to be installed, and a machine that already has Podman skips it and
arrives at Compose, which is the machine this was found on.

Compose v2 rather than podman-compose: v2 is what the stack was tested against
and what reads the healthchecks and depends_on conditions in the compose file.
Verified on Ubuntu 24.04, where docker-compose-v2 installs to a plugin path that
is not on PATH and Podman finds it anyway.
…scripts

A postinstall script is arbitrary code from somebody else's package, and an
installer that runs it on a person's machine while they watch a progress bar is
doing something they did not ask for.

They are also not all portable. @scarf/scarf shells out to node, which a machine
that has bun has no reason to have, so the install failed with
"node: command not found" after the containers were already up and the
migrations already applied. Found on a Linux machine with bun and no node.
node_modules/.bin/vite begins #!/usr/bin/env node, so on a machine with bun and
no Node the app process exits 127 immediately. The supervisor did its job:
started it again five times, gave up, and said "app stopped 6 times in 5
minutes". But the stack it was supervising could never have worked, and nothing
before that point had said Node was needed.

The dev script now hands Vite to bun rather than to its shebang. Verified on a
Linux machine with no Node at all: Vite 7.3.6 starts and serves. Two forms that
look equivalent do not work, so this one is written out in full:
still resolves the bin and hits the shebang, and  is not an
exported path.

A screen that says OpenBot is running while telling you which process gave up is
arguing with itself, so a failure now outranks running in the heading.
…server

The first window of a fresh install opened on CopilotKit's developer inspector,
full width, covering OpenBot completely: a sidebar of Playground, Threads,
AG-UI Events and Frontend Tools, and a What's New panel. Not a skin, the actual
developer tool.

The cause is that the shell started the app with the package's "dev" script. A
Vite dev server sets NODE_ENV to development, and the SDK reads that to decide
whether to draw the inspector. Everything else that follows from it is just as
wrong for an installed application: hot reloading, source maps and development
warnings shipped to somebody who installed a product.

It now builds once and serves the build. Vite's "preview" is a separate config
key with its own defaults, so the /api proxy is shared with the dev server
rather than repeated, and without that every call the app made would have been
answered with the app's own HTML.

Seen on a Linux machine with a browser profile that had never run it. macOS had
the same inspector all along, sitting collapsed in the corner, because that
profile had dismissed it long ago.
…it five times

The app is installed once and the deployment is fetched at a tag, so the two
move apart. An app that calls a script the pinned deployment does not have gets
the worst possible symptom: every step passes, the app process exits on "Script
not found", the supervisor restarts it five times, and the sentence a person is
finally shown names a process rather than the mismatch.

v0.0.7 is exactly that deployment. Its app has a dev script and nothing to serve
a build with, so the shell cannot run it, and DEPLOYMENT_VERSION still points
there. A release carrying the deployment-side fixes has to be cut, and the
version bumped with it, before an install works. Until then this says so in one
sentence instead of failing six times.
The call that turns the window into the running application had its error
thrown away. When it failed, the window stayed on the setup screen with every
step ticked and nothing to say why, which reads as the start having failed when
the stack is up and answering.

It reports now, and the navigation logs what it was asked to do and what came
back, because this is the one step whose failure is invisible from the outside:
the window simply does not change.
The only way to try an installer was to build one on the machine you were trying
it on, which is not what anybody installs. The bundles are uploaded now, so the
artifact that gets tested is the artifact that ships.
The screen read "Windows Subsystem for Linux is not installed. OpenBot can
install it. Windows will need to restart once." Nothing in this application
installs anything. There is no button under that sentence and no code behind
one: wsl --install needs elevation and a restart, and the resumable state
machine that would carry somebody across that reboot is designed and not built.

So a person on a machine without WSL was told to wait for something that was
never going to happen. The screens now name the command to run, and a test
refuses any instruction that offers what this application does not do.

Seen by installing the shipped installer on Windows Server 2022 with WSL
genuinely disabled and looking at the window.
The setup screen said "No container engine is answering yet. OpenBot will
install Podman and create its machine." It creates the machine. It does not
install Podman: Step::InstallEngine is a variant of an enum that no function
fills and nothing calls, so on a machine without an engine the promise is
followed by "could not run podman", which is the program that was supposed to
have been installed.

Same class as the WSL screen: a sentence offering to do something no code does.
The screens now say what to install. The enum variant stays, with a note that
it is unbuilt, so the sequence reads the same when it is.
…ding the window on a dead page

Three things found by running the shell on Windows for the first time, none of
which any test could have seen.

Every shell-out opened a console window. `podman`, `wsl.exe`, `powershell` and
`bun` are console applications, and Windows gives one a console whenever the
parent is a GUI process, before a byte is written, so piping the output does not
help. The visible half was Stop: `compose down` takes several seconds, so its
black window had time to be seen, in the foreground, over OpenBot. The rest
flash, once per poll, for the whole of a first run. Every call now goes through
one wrapper that sets CREATE_NO_WINDOW, which on Unix is the identity.

Right-clicking the packaged window offered Back, Refresh, Save as and Print.
Back walks the window out of OpenBot with nothing to walk it home, and Save as
writes the page out as `Webpage, complete`. macOS never showed this because
Tauri suppresses it there in release builds; Windows has no such setting and
Tauri has no option for it, so the page is asked to refuse instead.

Stop left the window on OpenBot's own page with every server behind it killed.
`show_setup` navigated to `tauri://localhost`, which is the address on macOS and
Linux; Windows serves the bundle from `http://tauri.localhost`, so the
navigation could not resolve and the error was discarded. It is read from the
window at startup now, so no platform is spelled into the code and the
`use_https_scheme` option cannot break it either.
…ave, and advice they already took

Four things found by installing on a clean Ubuntu and a clean Windows Server, in
the window rather than in a log.

Podman writes an underlined notice to stderr before every compose command it
runs, successful or not, so the failure card led with a literal `[4m>>>>` and a
sentence about `docker-compose` that had nothing to do with the failure. The
real error was three lines down. Every place that turns a command's stderr into
something a person reads now goes through one helper that strips terminal
escapes and drops that one known prefix. Only that prefix, and only from the
front: guessing which of somebody else's lines matter is how real errors get
thrown away.

The setup screen told a machine with Podman 6.1.1 on its PATH to go and install
Podman. The engine check had already worked out the difference and said so,
"podman is installed but not answering" against "no container engine found",
and the screen ignored it in favour of a fixed sentence. It uses the answer it
was given now.

`wsl --install` on Windows Server 2022 enables both features and stops there,
leaving no kernel, and the feature state reads Enabled for exactly that. So the
blocker screen passed and the next thing anybody saw was podman quoting a
`wsl.exe` command line and `exit status 0xffffffff`. There is a blocker for it
now that names `wsl --update`. It fires only when neither `wsl --version` nor
the kernel file says a kernel exists, because the older builds where
`--version` is unrecognised and WSL2 works perfectly are real and must not be
turned away.

Podman ships no Compose provider on Windows either, which the existing gate
caught correctly and then explained in Linux's words, naming apt packages to
somebody who has no apt.
@davidmckayv
davidmckayv merged commit c27c1d0 into main Sep 6, 2026
19 checks passed
@davidmckayv
davidmckayv deleted the desktop-shell branch September 6, 2026 19:32
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