rocm-cli: The UI Showed Local, the Wire Went to Azure
What shipping rocm-cli taught us after the launch post: a credential-routing bug in the chat tab, the trust chain behind the installer, and the pipeline that grew out of the repo.
Two months ago we wrote about shipping rocm-cli: the launcher menu, the five-tab dashboard, the honesty-over-polish rule. This is the post about what happened after. It covers a bug I keep coming back to, the parts of the release machinery that nobody writes launch posts about, and the way the repo's own process ended up spawning a separate project.
rocm-cli is AMD's tech-preview single binary for local AI on AMD GPUs. It is public at github.com/ROCm/rocm-cli, MIT licensed. Michael built the TUI, the dashboard, and the chat agent inside it. I was the second pair of hands on most of that. Everything below is from the public repository.
The bug
On July 9, the v0.1.0-rc.2 tag landed with a commit message that is one of the clearest bug descriptions I have read: the UI showed local; the wire went to Azure.
The chat tab lets you point the agent at a local model server or a cloud provider. The flow is: you accept a local endpoint, the status line says local, and you start typing. Underneath, the live agent object had been built against the previous provider and was never rebuilt when the endpoint changed. So the display said one thing while requests, carrying a cloud API key, went to a cloud gateway and came back 401.
That is a credential-routing bug wearing the costume of a status-line bug. It closed in one PR of eight commits. The first commit fixed the routing:
- Rebuild the agent on accept, as a one-shot edge in the event loop rather than on every render, and strip
api_keyandauth_headerfor any loopback base URL so a startup-configured local endpoint cannot leak a cloud credential either.
The other seven came out of review. A reviewer on the PR walked the seams around the fix and found more of the same shape:
- The loopback guard parsed
host:portwithrsplit_once(':'), which split inside a bracketed IPv6 loopback likehttp://[::1]/v1, so the local check missed and the key stayed attached. The fix parses the bracketed authority explicitly. - Operational notices like "switched to local" had been pushed into the transcript as agent turns, so on every later submit the model saw them as things it had said. A new
ChatRole::Systemkeeps them out of the model's context. - If the rebuild itself failed, the tab had already flipped to "Local" while the live agent still pointed at the old backend, the same screen-versus-wire lie through a different door. The edge now carries the previous provider and reverts to it on failure.
- Silently discarding a credential for a loopback URL now logs a warning, so someone running an authenticating local proxy gets a clue instead of a bare 401.
I want to be specific about why this one stays with me. Every layer was individually reasonable. The renderer rendered the state. The guard guarded common URLs. The transcript appended messages. The failure lived in the seams, and it took a first fix plus a reviewer willing to ask "where else does this happen" to close all of them. The honesty rule in the dashboard, em dashes for missing telemetry and a persistent SIMULATED DATA banner for replays, exists so the screen cannot lie about telemetry. This bug was the reminder that the screen can still lie about routing, and that no amount of UI discipline substitutes for checking where the bytes go.
The parts under the launch post
Three pieces of the repo that make the one-line install trustworthy, none of which are visible from the launcher.
The trust chain. Every release artifact ships with a .sha256 sidecar that is always verified, and a detached RSA/SHA-256 .sig that release and nightly CI require. On Windows, the installer verifies the signature with the .NET RSA.VerifyData API so there is no OpenSSL dependency to bootstrap first. On Linux, install.sh verifies with sha256sum or shasum and drops the binary into ~/.local/bin. The public key is in the repo under docs/keys/. None of this is novel. All of it is the difference between a curl-pipe-sh you should run and one you should not.
One file for every version pin. runtime-deps.toml is the single place a runtime version, Lemonade's for example, is spelled. A build script turns it into Rust constants. The header comment says it in one line: editing a version here is the whole bump. I have watched enough version drift across a workspace to appreciate a repo that makes the mistake structurally hard.
No fallback, ever. Engine adapters are discovered as executables named rocm-engine-<name>. If the requested adapter is missing, or cannot satisfy the requested device policy, the command fails. There is no automatic CPU fallback for GPU-required paths, and the docs say so in three separate places. For a tool whose entire reason to exist is running on the GPU, silently succeeding on the CPU would be the worst possible outcome, because it would look like success.
The retirement
On July 1, a single PR routed rocm bootstrap setup through the new dashboard onboarding and deleted the legacy tui.rs. That module was roughly forty-five thousand lines.
The launch post listed "retire your old code the day the new code lands" as a lesson. This is the commit it was learned from. The old TUI had been left alive through the redesign, which meant two code paths, two sets of behaviors to keep consistent, and a growing chance that a fix landed in the wrong one. Deleting it was not brave. Not deleting it earlier was the mistake.
The numbers, and Michael's share
Facts from the public history as of late August: 197 commits, about 152,000 lines of Rust across fifteen workspace members, 2,185 unit test functions, and 111 Gherkin scenarios in 17 feature files that run black-box against the real rocm and rocmd binaries on self-hosted GPU and WSL2 runners. Fifteen built-in dashboard themes, of which the launch post showed five.
Michael's commits are 39 of the 197, about a fifth, concentrated in the TUI, dashboard, and chat surface: the June 17 merge that folded a separate telemetry dashboard into a unified rocm binary, the two-week chat-agent build in late June (daemon autostart, read-only tools, the in-chat approval modal, natural-language planning, provider switching), the June 30 information-architecture redesign that produced the current five tabs, the July 7 self-contained launcher, and the rc.2 bug series above. The other four-fifths belong to other contributors on the team; the launch post credited eight, and the lines that make the binary install ROCm, manage runtimes, and sign releases are mostly theirs.
I am stating the proportion because I think it matters for this site's credibility. Michael built the front door and the room behind it. He did not build the plumbing, and neither of us should write as though he did.
The spin-off
One more thing the repo produced that is not in the repo.
While shipping rocm-cli, Michael built a ticket pipeline around it: label an issue, let a coding agent work it in a worktree, gate the result, review it, merge it. On September 2 that loop was lifted out as a standalone, repo-agnostic tool. The first commit message says exactly that. It is now the factory, and it maintains GPUFlo and itself.
I find that satisfying in a way I did not expect. The most durable output of a project is sometimes the process that shipping it forced you to build.
What I would watch
Windows currently gets the CLI and Lemonade but not the dashboard or vLLM; WSL2 gets everything. Whether the dashboard reaches native Windows is the gap I would watch first. After that, the engine-adapter contract: two adapters today, a discovery mechanism that could hold more.
And if you use the chat tab against a local endpoint, do the thing that found the bug. Watch the wire once.
The views expressed here are my own and do not represent AMD.