Desktop shell: the window, the engine it acquires, and the stack it raises - #392
Open
davidmckayv wants to merge 27 commits into
Open
Desktop shell: the window, the engine it acquires, and the stack it raises#392davidmckayv wants to merge 27 commits into
davidmckayv wants to merge 27 commits into
Conversation
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.
davidmckayv
requested review from
MikeRyanDev,
guidovizoso and
tylerslaton
as code owners
September 5, 2026 20:29
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.
davidmckayv
force-pushed
the
desktop-shell
branch
from
September 6, 2026 00:53
0372101 to
e296f0f
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 hostbunprocesses, 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.sockinside 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 getsENGINE_SOCKET. Asserted, because getting it wrong looks like a network fault.No
--provideron macOS.applehvhas been the default on Apple silicon since Podman 6.1. The pin was written against 5.7, where libkrun was the default..envrefuses three inheritances fromstart.shthat are fine for a developer and not for a product: the dev defaults forCOMPUTER_TOKEN,SUPERVISOR_TOKENandWORKER_SHARED_SECRET, which are published in this repository; the exampleKEY_ENCRYPTION_KEY, which only throws underNODE_ENV=productionand would otherwise encrypt the credential vault with a key printed here; and a missingCOMPUTER_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 --installneeds elevation;podman machine initcannot 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 0xffffffffteaches 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.migrateis run to completion, not raised as a service, or Compose reports a service that will not stay up.openbot, notpodman-machine-default, so an existing machine with somebody's own work in it is never adopted.Checks
21 Rust tests,
cargo clippy -D warningsclean,cargo fmt --checkclean, frontend typechecks and builds. NewDesktopworkflow 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.