Skip to content

Setup, end to end: pick a Bot, connect a model, prove it answers - #451

Open
davidmckayv wants to merge 47 commits into
mainfrom
s5-mastra-bot-kind
Open

Setup, end to end: pick a Bot, connect a model, prove it answers#451
davidmckayv wants to merge 47 commits into
mainfrom
s5-mastra-bot-kind

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

Setup, end to end: pick a Bot, connect a model, and prove it answers before the window hands over.

Twelve harnesses in the picker, each dialled through the package its own maintainers publish. Four ways to connect a model: a ChatGPT plan, a Claude plan, a key, or any OpenAI-compatible endpoint. Credentials live in the machine's own store rather than in .env. The install ends with a question the Bot has to answer, because a setup that reports success and leaves somebody with a Bot that cannot reply has proved nothing.

What this needed along the way

Every one of these was found by driving the window rather than by reading the code, and each is its own commit with the failure written down.

Nothing installed the engine. Setup ended at "Install Podman Desktop or Docker Desktop first" on any machine that had neither, which is every machine this is for. It installs Podman and Compose now, each pinned to the digest it was tested against and refused if it does not match, and it recovers from a Podman removed by deleting its folder. Podman ships no Compose, so both halves are needed or a fresh install still cannot raise the stack.

Image references were unqualified, so every engine resolved them against Docker Hub and reported a permissions error for a repository that had never been pushed. Nothing is built from a version now: references come from the release's own manifest, and an image a release does not include is named as that. That was this bug's third form, so the guard now checks for a registry.

The compatible-endpoint row demanded a key while its own summary names Ollama and vLLM, which have none. Both bundled Bots then exited at startup without one, which was the other end of the same feature. A base URL is a model now, and a placeholder is sent to endpoints that read no key so this works against Bot images already published.

The app host process died on its first API call. serve ran vite preview through bun --bun, and Vite's proxy calls socket.destroySoon(), which bun does not implement. It served its page, exited, and the window went on saying OpenBot was running with nothing on the port. The app has its own small server now: no Vite at runtime, no Node, websocket upgrades forwarded, paths confined to the directory.

Stop did not stop OpenBot on Windows. The containers went down, success was reported, and the server, the worker and both halves of the app kept serving. The handles a window holds die with the window, and the Windows arm of the fallback returned 0. Host pids are recorded beside the logs now and ended with their children.

Four Keychain dialogs per screen mount. macOS authorizes each read of a stored password separately unless the build's signature is one the item already trusts, and a development build's never is. The store is asked once per name per run.

Also: both plan sign-ins set the engine up rather than refusing; a conversation whose thread this deployment cannot reach says so instead of drawing a blank window; a failure carries a sentence for the person and the real output behind a disclosure, everywhere.

Known, and not in this branch

The twelve Bot images and the two sign-in images are not in any release, so the picker cannot be satisfied until one carries them. Driven on Windows, that is reported as "This version of OpenBot does not include the Bot you picked", and the route through it is the bring-your-own-address row.

The sign-in takes the token's default organization with no way to choose one, so somebody in several can land in the wrong one and be told they have no projects.

A channel whose thread was minted under a different project still dead-ends on the write path: creating it conflicts and fetching it 404s. The window already says a fresh history starts; the remap that would deliver it is not built yet.

Verified

Driven point-and-click on macOS and on Windows Server 2022, on a box stripped back to no container engine at all. On Windows: the engine installed itself, five containers came up healthy, migrations applied, the Bot answered "17 × 23 = 391", OpenBot itself loaded, a message sent from the app rendered, and clicking a conversation in the rail opened it. 187 Rust tests, and the netstat parser's test run natively on Windows because reading five columns as four is what made the first attempt at Stop report success while leaving everything running.

The picker needs something to pick. These are the first eight rows: CrewAI,
Agno, LlamaIndex, Pydantic AI, LangGraph, AWS Strands, Google ADK and Langroid,
each an image that speaks AG-UI and each built on the integration somebody else
already keeps working. Six of those packages come from the AG-UI repository and
two are published by the framework's own authors. Not one line of protocol is
ours, which is the rule.

Every one answered the same question over a real AG-UI stream before it was
committed. Not one of the eight worked from its documentation alone:

`uvicorn --host ::` binds IPv6 only, with no v4-mapped addresses, so the first
container refused 127.0.0.1 and would have failed its Compose healthcheck for
reasons nothing on screen would explain. Agno moved its interface to
`agno.os.interfaces.agui` and ships neither FastAPI nor python-multipart with
the extra that needs them. LlamaIndex's litellm wrapper sends `tool_choice` with
no tools, which OpenAI rejects outright, so it takes the provider-specific class
instead. Pydantic AI has no `to_ag_ui()`; the entry point is
`AGUIAdapter.dispatch_request`. LangGraph refuses to run without a checkpointer
because the integration resumes threads by id. Strands and Langroid both take a
`name` their READMEs omit. Langroid rejects `openai/gpt-4o-mini` as an invalid
model id and wants the bare name.

The catalogue is data rather than wizard code, so a ninth is a row and an image.
It deliberately excludes OpenBot's own `built-in` agent type, which is a system
prompt and not a harness, and everything the AG-UI table still marks In Progress.
…erved

AG2, Microsoft Agent Framework and Claude Agent SDK, on the same terms as the
other eight: an image, the integration its own maintainers publish, and nothing
of the protocol written here. AG2 and Microsoft each answered a real AG-UI run
before being committed. AG2 mounts through `build_asgi` rather than the helper
its README names, and Microsoft's client takes the model positionally and builds
an agent with `as_agent`, not `create_agent`.

Claude Agent SDK is the row where a plan stands in for a key, so it carries the
precedence trap the build doc warned about: Anthropic resolves
`ANTHROPIC_API_KEY` ahead of `CLAUDE_CODE_OAUTH_TOKEN`, and a container given
both bills the key while the subscription somebody deliberately chose goes
unused. It refuses to start with both set, and refuses to start with neither.
That guard is tested; the harness answering is not, because it needs a
subscription token or an Anthropic key and this machine has neither.

Mastra comes off the list. Not a judgement on Mastra: there is no way to serve
it over AG-UI today without breaking the rule this list exists for.
`@mastra/agui` is Mastra's own server-side helper and peer-depends on
`@mastra/core >=0.10.7 <0.12.0`, last published July 2025, against a core now at
1.64. `@ag-ui/mastra` from the AG-UI repository is a client abstraction with no
HTTP handler on it. The two remaining options were a year-old core or an HTTP
layer of ours. A test pins its absence so that putting it back is deliberate.
`ClaudeAgentAdapter` takes a `name` its README omits, which is the eleventh
harness in a row whose documentation was not enough to start it.

With that fixed it answered a real AG-UI run on `CLAUDE_CODE_OAUTH_TOKEN` alone,
with no `ANTHROPIC_API_KEY` set anywhere. That is the whole point of this row:
somebody who pays for a Claude plan gets a working Bot without going to find an
API key, and the precedence guard beside it makes sure the plan is what actually
gets used.
My first answer was wrong. I said Mastra could not be served because
`@mastra/agui` is a year behind its own core and `@ag-ui/mastra` exposes no
handler. The second half of that was a mistake: `registerCopilotKit` is a
subpath export, `@ag-ui/mastra/copilotkit`, and I only listed the root.

Built properly, it works. Mastra serves the route from its own server, the image
answers, and the token guard refuses an unauthenticated call. What it serves is
the CopilotKit Runtime protocol rather than AG-UI: it wants a `method` field and
refuses a `RunAgentInput`. `{"method":"info"}` answers with runtime 1.70.1 in SSE
mode and lists the agent, so the endpoint is healthy and simply speaks something
else.

A Bot in OpenBot is an AG-UI URL, so Mastra still cannot be a row. The image
stays because it is what establishes that, and because closing the gap is a
small change at one of two ends: OpenBot accepting a CopilotKit Runtime endpoint
as a second kind of Bot, which is defensible since that runtime is ours, or
Mastra publishing a plain AG-UI route. Writing the AG-UI layer here is the one
option ruled out.
A Mastra server speaks Mastra's client protocol, not AG-UI, and the harness
was papering over that with registerCopilotKit from @ag-ui/mastra/copilotkit.
That serves the CopilotKit Runtime protocol rather than AG-UI: a run reached
it and came back complaining about a missing method field, so the Bot was
dead on arrival.

The translation belongs on this side. remoteTransport now dials a Mastra
endpoint with getRemoteAgents, the bridge Mastra and AG-UI maintain between
them, and hands back an AbstractAgent like the HTTP path does. Construction
moves out of remoteAgentWithStandingRole because building a Mastra transport
is asynchronous and that function is not, but the better reason is that it
leaves one wrapper: the standing role, the holdings message, the offered
tools and the signed assertion apply to both kinds, and no transport has its
own copy to drift from. The harness loses its route and its dependency on
the bridge, and is a plain Mastra server.

RegisteredRemoteAgent is a union of two single-literal variants rather than
one type with a union discriminant, because TypeScript will not eliminate a
member whose discriminant is itself a union: written the other way, the
built-in path below silently stops being narrowed to a built-in Bot.

pickFromRoster is pure and tested separately. A name that was asked for is
never replaced by the only agent present, because that turns a typo into a
Bot that runs and answers as somebody else.
It was out because the only thing a harness could mount served the CopilotKit
Runtime protocol rather than AG-UI, so the row would have been a Bot that
could not answer. The bridge is on OpenBot's side now, so the row is real.

The test is inverted rather than deleted: if Mastra leaves the catalogue
again it means remoteTransport regressed, and that should fail here instead
of the picker quietly getting shorter.
The setup form asked for an OpenAI key in a password field, which is the
developer-shaped main path the audience rule exists to prevent. The model
question is now a screen with two first-class providers and one row for
everything else, and a plan is the default wherever a plan exists.

Two providers and an OpenAI-compatible endpoint is the shape, not a
shortlist: every other vendor speaks that wire format, and the endpoint row
is the only one that asks for a URL. A test pins that, because a second row
asking for one means the main path grew a step nobody non-technical can
finish.

Marks come from @lobehub/icons-static-svg, MIT, vendored rather than fetched
because the window draws before it has a network. Nine of the twelve
harnesses have one. Agno, AG2 and Langroid have none in any maintained set,
so those rows show the name: nothing is invented, since a monogram we drew
reads as the vendor's own. Every row shows its name whether or not it has a
mark, so an unmarked row is not a lesser one, and a person who does not
recognise a logo can still read the row.

They are inlined as data URIs and drawn with img, so no markup is ever
injected and what is in the file never becomes a security question. The
tiles are real radios inside labels rather than buttons wearing
role="radio", which gets arrow-key navigation and the whole tile as the hit
area for free.
The credential was one field holding an OpenAI key, and the screen asked for
it in a password box. Two problems in one: it was the developer-shaped main
path, and it could not express what the other providers need.

ModelCredential is a choice rather than a bag of optional strings, because
the combination that must never exist is the point. ANTHROPIC_API_KEY takes
precedence over a plan's OAuth token in the Claude Agent SDK, so a stack
carrying both bills somebody who just signed in to a plan they already pay
for. Two fields cannot say "never both"; a choice can, and a test pins it.

The plan writes ANTHROPIC_API_KEY as empty rather than omitting it, because
the .env writer preserves lines it does not own and a key left from an
earlier attempt would win by the same route.

start_stack takes the screen's whole answer and works out which keys that
implies, rather than being handed keys. The conversion can fail and says so:
a plan with no token would otherwise raise a stack whose Bot cannot answer,
which reads as a broken product rather than an unfinished sign-in.

The window now asks which Bot, then which model, then installs, holding both
answers so Back does not lose them.
The plan is the default path on the model screen, and it needed a credential
nobody types. `claude setup-token` does the OAuth, but it is a terminal
program: given plain pipes it writes nothing at all and waits. Measured, not
assumed. The same command produced zero bytes on a pipe and four kilobytes
under a pty.

It also needs Anthropic's CLI, which a non-technical person is never going to
be told to install. They don't have to: the Claude Agent SDK ships a
self-contained binary inside the Python package, so the harness image OpenBot
already pulls carries a working CLI. The sign-in runs there, in a throwaway
container, and the machine needs no Node, no npm and no CLI of its own.

Running in a container is also why the code is pasted rather than
redirected. The CLI's callback server is unreachable from a browser outside
the container, so it falls back to printing a code, which Anthropic documents
for exactly this case.

The URL is read from the OSC-8 hyperlink and not from the text beside it.
The CLI prints it twice: once as the link target, intact, and once as display
text with the terminal's line breaks spliced into the query string. The
visible copy opens and then fails on a mangled state, which is a bug that
looks like Anthropic's rather than ours.

Engine addressing goes through Address::parts so a Podman machine named
everywhere else is named here too. Both parsers are pure and tested, the
must-not case included: an API key is never taken for a plan token, since it
outranks one and would bill somebody who just signed in.

OpenAI offers a key only for now, and its summary says so. Its plan login is
proved and belongs here as the default, but it is not wired to a command, and
a button that cannot finish is worse than one that is absent.
They were built by the release for nothing: not listed, so nothing pushed
them, and a desktop install would have had to build each one from source with
a toolchain it does not have.

The list and the Dockerfiles in the tree now agree, which is what the CI
check asserts. Verified on amd64 as well as arm64 for the Claude Agent SDK
harness, the one that matters most: it carries a self-contained CLI binary
that differs per architecture, and the sign-in flow runs it.

That binary is 216 MB, so this image is large by the standards of the others.
Worth knowing before it is pulled on a first run.
A ChatGPT plan is not the compatible shape, which is what the previous
mapping assumed. A plan token is a bearer for chatgpt.com/backend-api/codex,
and langchain-openai pins that address and refuses a caller-supplied one on
purpose, so a token cannot be aimed at somebody else's server and handed
over. Writing it as OPENAI_BASE_URL plus a key would have been us hand-rolling
the thing that guard exists to prevent, and the Codex path shapes its
requests differently, so it would not have worked.

So it is its own credential. It clears OPENAI_API_KEY and OPENAI_BASE_URL for
the same reason the Claude plan clears its key: whichever is left from an
earlier attempt is preferred by every client in the stack, and the person who
just signed in to a plan is billed per request instead.

OpenAI offers a plan again, and first. It was reduced to a key on the
incorrect reading that the login was unwired; OpenAI supports subscription
OAuth in other people's tools, `codex login` exists for it, and of the two
named providers it is the better supported.

Also fixes a bug that cost a live sign-in. The CLI positions each word with a
cursor-column escape rather than a space, so stripping escapes leaves
"Pastecodehereifprompted" and a match on the phrase as written never fired:
the code was handed over and the flow sat in the wrong wait until it timed
out, with the prompt on screen throughout. The comparison now ignores
whitespace, and the test uses the shape the CLI actually writes rather than
one with spaces in it, which is what passed while the bug shipped.
`remote_mastra` went into the types and the runtime without going into the
database. The enum was still built_in and remote_ag_ui, so a Mastra Bot
compiled, passed its tests, and could not be written: the row was rejected at
the one point nothing covered. Migration 0028 adds the value.

Duplicating one kept the same shape of mistake. `runForDuplicate` wrote every
endpoint-carrying source as remote_ag_ui, so a copy of a Mastra Bot would
hold the right address and be unable to say anything to it: that endpoint has
no AG-UI route, so the Bot appears, accepts a grant and answers nothing. The
copy is now dialled the way the original was, and carries the agent it named,
because a Mastra endpoint is a roster and a copy that forgets the name gets a
different agent or none.
Bots come from the tenant package, so a kind the package cannot express is a
kind a deployment cannot start with. `agents.yaml` took built-in and
remote-ag-ui only, which is the third place `remote_mastra` was missing after
the types and the database.

`remote_agent_id` comes with it, because a Mastra endpoint is a roster: a
seeded Bot that cannot name which agent it means is as specific as one added
by hand, and no more. It is Mastra's field alone, so an AG-UI Bot does not
pick it up and store something that reads back meaning nothing.

The refusal names all three kinds rather than the two it used to.
The sign-in never worked and reported that the code was refused, when the
code had never been read. Two bugs, both in how the prompt was answered.

Enter on a terminal is a carriage return, and a TUI reading a pty in raw mode
takes that rather than a line feed. And the return has to arrive on its own,
after a pause: the CLI turns on bracketed paste and a code arrives as one
burst, so a 32-character code with the return in the same write submitted
fine while a 92-character one did not. It sat in the prompt, masked, until the
wait expired.

Found by dumping the transcript, which is what the sign-in now writes when
OPENBOT_SIGNIN_TRANSCRIPT names a file. Off by default and never in a build
somebody installs, because the transcript can hold the token: the case worth
looking at is a token printed in a shape the scan missed, which is exactly
the case where the file is a live credential.

A refused code is also its own answer now. The CLI prints `OAuth error: …`
and offers to retry, so there is nothing further to wait for, and waiting out
the timeout to then say something vague is how the first bug stayed hidden.

Verified at the real code length before asking anybody to sign in again: a
92-character code now comes back refused rather than silently ignored.
Every one of the twelve was wrong. The catalogue derived image names from the
row's id and the release derives them from the directory holding the
Dockerfile, so the picker named `openbot-harness-crewai` against a published
`openbot-agent-crewai`, and named it wrong twice for the four rows whose id
is not their folder. Nothing caught it: a wrong image name is correct Rust,
and it fails at the pull, on somebody's first run, with nothing on screen to
say why.

The directory is given per row now rather than derived, and a test reads
.github/published-images.json — the same file CI checks against the tree — so
the picker, the tests and the release agree or it fails here.

Each row also carries the port its image listens on, which is fixed by that
image's Dockerfile and differs per harness. The service that runs the picked
harness has to be told, and the endpoint its Bot is registered at is built
from it. Two more tests: an image without a port is a row that cannot be run,
and two harnesses sharing a port would be one service that cannot serve both.
Picking a harness did nothing: the picker was cosmetic, and nothing in the
product turned a choice into a Bot. There is no create endpoint to call for
this — Bots come from the tenant package — so the choice becomes settings and
seeding does the registration. Nothing new had to be built to make a Bot
appear.

agents.yaml carries two gated rows, one per way of dialling, and at most one
ever exists: the loader drops a Bot whose endpoint interpolates to nothing,
and the shell writes exactly one of the two addresses. Two rows rather than
one because the kind cannot be interpolated, and a Mastra server has no AG-UI
route to talk to. A test holds that both are never written, since both would
register the same harness twice, once as a kind that cannot reach it.

One compose service runs whichever was picked, with its image and port from
.env, because a picked harness is a choice and not twelve branches in that
file. Behind a profile: unset, the image is a request to pull the empty
string, which fails the whole `up` rather than the one service nobody asked
for.

Addressed on loopback rather than by service name, because the server is a
host process here and reaches agent-bot the same way.

The window sends the id alone. The image, the port and the kind are facts
about the harness, and sending them would be a second list to keep in step
with the catalogue.
…t found

Driven end to end on macOS against a real v0.0.8 deployment: deployment
fetched, .env composed, containers raised including the harness service,
migrations applied, host processes started, both loopbacks answering, the Bot
registered in the database, and the Bot answering a real question over AG-UI.
Once with an OpenAI key on CrewAI, once with a Claude plan on the Claude
Agent SDK and no API key present at all.

Four things that only showed up by running it.

The package cannot carry a literal remote-mastra row. The loader refuses an
unknown agent.type by refusing the whole file, so a v0.0.8 deployment failed
to start at all — picked or not. The kind is interpolated now, which also
collapses two gated rows into one: an older server sees remote-ag-ui and
loads normally unless somebody actually picks Mastra.

Ports were only pre-checked for the two host processes, and that check runs
after the containers. Any collision on a container port surfaced as
"Bind for 0.0.0.0:4202 failed: port is already allocated" straight from the
daemon. Every published port is checked before anything is raised, and names
what uses it.

Switching provider left the last one's key behind. A run that signed in to a
Claude plan still carried the OPENAI_API_KEY from the run before it, and the
harness was handed both; whichever a client reads first decides what somebody
is billed for. Answering the model screen now clears the keys that answer
does not imply. Answering nothing still touches nothing, so a key set by hand
is kept.

Harness resolution is out of start_stack and tested, because its refusals are
real states: an id from a window left open across a downgrade, and the row
that installs nothing because the person brings their own address.
…ssion

Three problems, all of them the same problem: this window is the product's
handshake and it read like a form.

There was no welcome screen, so the first thing anybody saw was a question.
There is one now, and it says what OpenBot is in two lines.

The Bot screen asked a non-technical person to choose between twelve agent
frameworks, which is a decision they cannot inform, at the point where people
leave. David's call: use the default, say so, and put the list behind a
disclosure that names the only person it is for. The framework is never named
on the main path, because the name means nothing to the reader and inviting
them to weigh it is the whole mistake. A developer opens it and picks; nobody
else learns the word.

The look is measured from the interface this sits beside rather than guessed:
a warm off-white ground, near-black text, warm-tinted hairlines at 4-8%
rather than cool gray, 12px rows and one 16px focal card. That warmth is most
of why the reference reads calm instead of clinical. xAI's own face is not
shipped; Inter and the system stack are what a window with no network can
rely on.

Light is pinned here and nowhere else. This screen exists for five minutes
before it becomes OpenBot, and the app it hands over to keeps its own light
and dark. Following the system instead would mean half of all installers see
a variant nobody composed against a reference.

Also a step indicator, one authored entrance rather than scattered motion,
and no coloured side border on the error card.
`localhost` does not resolve the same way on every operating system or
runtime: Node takes it to `::1` and bun to `127.0.0.1`. So a name reaches a
different interface depending on what started the process, and a service that
is listening looks like one that is not. `stack.rs` already probes both
numerically for exactly this reason; the rest of the tree did not.

Swept and fixed everywhere something dials or advertises an address: the
setup window's fallback URL, the supervisor's default host for a Bot's
computer, the tool endpoint the LangGraph Bot calls back on, and the lines
every service prints on startup.

TRUSTED_ORIGINS was the same bug pointing the other way. It defaulted to
`localhost` alone, which refused a browser pointed at `127.0.0.1:3010` — the
address the rest of the deployment hands out. It lists all three spellings
now, because an allowlist has to match what a browser sends rather than what
we would prefer it typed.

Three uses stay, each a string somebody else compares rather than an address
we reach: that allowlist, the SSRF denylist that must keep matching the name
or be bypassed by it, and OpenAI's registered OAuth redirect. Each says which
it is, so the next sweep does not "fix" it.

Also adds the ChatGPT plan sign-in, and it needed both of those facts. The
vendor's login refuses a non-loopback callback by design, and a published
Docker port cannot reach a loopback listener inside a container, so the
login keeps its bind and a relay forwards a published port into it. The
callback host is left at the library's default because OpenAI compares the
redirect URI as a string and has `localhost` registered: passing the numeric
literal fails the authorize request with `unknown_error` before a login page
is drawn. Measured twice before the cause was obvious, and pinned by a test.
The plan was offered on the model screen and had no command behind it. It has
one now, and it is not the Claude flow with the names changed.

Anthropic's CLI wants a code typed at a prompt, which is why that half needs
a terminal and shows a field. This login finishes itself when the browser
redirect reaches its callback, so nothing is typed, no pty is needed, and the
screen shows a wait instead. A code box for a flow that never produces one is
how somebody decides the product is broken.

Two facts it took running to find. The vendor's login refuses a non-loopback
callback host on purpose, and a published Docker port cannot reach a loopback
listener inside a container, so the login keeps its bind and a relay forwards
a published port into it. And the callback host is left at the library's
default: OpenAI compares the redirect URI as a string and has `localhost`
registered, so passing the numeric literal — the same address — fails the
authorize request with `unknown_error` before a login page is drawn.

Both loopbacks are published, because a browser resolving that registered
name may pick either family and which one is not ours to decide.

Proven as far as it can be without entering somebody's password: the relay
carries the callback, and OpenAI accepts the authorize request and serves its
login page. The step after that is a credential, so it stays with the person.
Driving the window found three things no test had.

The picked harness answered 401 to everything. It was registered,
addressable and routed to, but the deployment's token was attached by
matching one endpoint exactly, so only the Bot in the box got it. The picked
harness is the same kind of thing: a container this deployment started, on a
port it chose, holding the token it generated. Both endpoints get it now.

And the comparison is slash-insensitive, because `URL` adds a trailing slash
and a stored address need not have one. `new URL("http://127.0.0.1:4206")`
stringifies with a slash while the row says none, so an exact match failed
silently. The endpoint with a path matched only because a path suppresses the
slash, which is why one endpoint worked and the second could not.

Extracting the harness resolution had dropped the image's version tag, so the
name reached `.env` bare. An engine reads that as `:latest`, which no release
publishes, and `compose up` failed with a registry error about a repository
that does exist — at the last step, after everything else had succeeded. A
test now asserts every resolved image carries its tag.

Failures are two-register everywhere. A person gets a sentence about their
situation; whoever is debugging gets the output verbatim, behind a
disclosure. One string could not serve both, which is how "pull access denied
for openbot-agent-langgraph-agui, repository does not exist or may require
'docker login'" became the headline on a setup screen. The sentence is chosen
from what the engine actually said, and anything unrecognised stays general
rather than guessing about somebody's machine.
The last screen asked for an "Intelligence project key", and the only way to
produce one was `npx copilotkit login` followed by `copilotkit project
select`. Two commands, a terminal and a package manager, for somebody whose
whole relationship with this product is a window their IT department sent
them. The audience rule calls that a defect, and it was the largest one left.

The flow is the CLI's own, done here instead: a loopback callback on
127.0.0.1 and an ephemeral port, the browser sent to the hosted sign-in page,
the token that comes back exchanged for a session, the session exchanged for
a product credential, and the person's projects listed with it.

Read out of the published CLI rather than invented. The endpoints, the
parameter names and the order belong to whoever changes them, and guessing at
somebody else's auth is how this breaks quietly six months from now.

The state is checked before the token is used for anything. Anything on this
machine can reach a loopback port, so without that a page in any tab could
finish a sign-in nobody asked for.

The project list is read tolerantly and that is deliberate: it is somebody
else's API, the response has been a bare array and a wrapped object at
different times, and a setup screen showing nothing because a wrapper changed
is worse than one showing a list.
Managed CopilotKit is the main path and now signs in from the window: the
browser goes to the hosted page, the token comes back on a loopback callback,
and OpenBot provisions a key for the project the person picks. They never see
a terminal.

Somebody running their own Intelligence has a key this sign-in knows nothing
about, so the field stays and moves down beside the addresses it belongs
with. Sign in on the main path, paste on the developer one, which is the same
shape the model screen already has.

The third case is a company running Intelligence on its own network with
OpenBot on everybody's laptop, and it is written down rather than built
because it is two things rather than one. Google, Microsoft and Okta already
sign people in to OpenBot itself; Intelligence is reached with a key
belonging to the deployment rather than to a person. So the employee's
sign-in and the deployment's key are separate problems, and the install
should end up asking an employee for neither. That needs settings arriving
from an administrator rather than from the person, which does not exist yet,
and the desktop install currently runs as a dev actor and never signs anybody
in at all.
…han a token

A plan token is a bearer for chatgpt.com/backend-api/codex, and langchain-openai
pins that address on purpose, so it cannot be reached by pointing OPENAI_BASE_URL
at it with the token as a key. The harness now picks the Codex chat model when a
plan is present, so the default Bot can actually answer on a subscription.

Carrying the access token alone was wrong: it expires within the hour and nothing
can renew it, which would give a Bot that works in the morning and fails after
lunch with an auth error nobody could account for. The sign-in now hands back the
vendor's whole store, the store is written beside the .env as an owner-only file,
and compose bind-mounts it read-write so the renewals the provider makes outlast
the container. The .env gets a path, never the credential.

The file is written even when no plan was chosen, because a bind mount with no
source does not fail, it silently creates a directory in its place.
A machine that ran the version before this one has a ChatGPT plan token sitting in its .env that nothing reads any more. The writer preserves lines it does not own, so it would stay there indefinitely. Clearing it costs one line and is the same reasoning the other model keys are emptied for.
Every step before this proves that something started, which is not the same as
proving the choices work. A refused key, a lapsed plan or a model the account
cannot use all give a stack that comes up clean and a Bot that cannot answer,
and handing over at that point means somebody finds out later, inside the
product, with no idea which of their answers caused it. So the wizard now ends
on a question with one checkable answer, and the handover waits for it.

This screen also owns the worst message in the product. Measured against a
deliberately invalid key: the stream opens, says RUN_STARTED, says STEP_STARTED
and then simply stops, with no error event at all, because the framework caught
its own exception and logged it. The whole 401 lives in the container's log and
nowhere else. So a run that produces no text is a failure here rather than an
empty answer, the sentence shown is OpenBot's own and names the choice to
change, and the harness's log is fetched to fill the developer half, since
otherwise there would be no developer half to show.

Two live tests are kept and ignored by default. The fixtures are transcriptions
of a real stream, and a vendor changing the events they emit should break
something.
The .env is a settings file, and a settings file is something somebody opens,
reads out to support or pastes into a chat. A model key, a plan token and the
tokens these services prove themselves to each other with are not settings.

They now go to the credential store each platform actually has: the login
Keychain on macOS through security, DPAPI on Windows through ProtectedData
encrypting to the signed-in user, and an owner-only file on Linux, which is
said out loud rather than dressed up, because no desktop Linux install can be
assumed to run a Secret Service daemon and refusing to save a credential
because gnome-keyring is missing would fail more people than it protects.

The value never goes on a command line on any of them. ps is readable by every
process the person runs, so the Keychain and DPAPI paths both write over stdin.

From the store the credentials travel to the containers and the host processes
as environment. Compose resolves an interpolation from its own environment
before it reads the .env, so a secret reaches exactly the services that declare
it and is written down nowhere. Verified against a real deployment: a .env with
no token, the token in the environment, and the container holding it.

The writer also purges what it moved. Without that, every machine that ran an
earlier version would keep its old plaintext copy exactly where it was and the
change would have bought nothing for anybody who already had OpenBot.
Three buttons asked for `secondary`, which no rule matches, so Stop OpenBot has
been rendering identically to Show OpenBot and the new last screen offered two
equally weighted actions. The stylesheet's quiet button is what they meant.

Found by looking at the screen rather than at the markup, which is the only way
a missing class shows up: nothing errors, the button just draws as the primary.
…ity command

The `security` command takes its password through a prompt whose buffer is 128
bytes, and anything longer is cut off with no error and an exit status of zero.
Probed a length at a time: 128 stores 128, 129 stores 128, 200 stores 128.

An OpenAI project key is 164 characters. Every one of them was being saved
truncated and read back truncated on the next run, while the run that saved it
worked fine, because the value it used came straight from the window. The next
launch would have been the broken one, with a key nobody had changed.

No flag raises that buffer, and the only ways past the prompt put the credential
on a command line where ps can read it. The framework has neither limit, and the
Windows and Linux paths never had one, so this is one platform's dependency
rather than a cross-platform crate and twenty transitive packages.

Round-tripped against the real Keychain at 128, 129, 164, 256 and 512.
KEY_ENCRYPTION_KEY is what every secret the server stores goes through, and
encrypt-sso-config.ts names the symptom itself: a changed key leaves stored
configuration unreadable and sign-in broken until it is registered again.

The shell minted a new one on every press of Start, so a button labelled Start
silently orphaned everything the previous run had encrypted. The rest of the
generated secrets point the same way for a smaller reason: a Bot's computer is a
container that outlives a restart holding the old COMPUTER_TOKEN, so rotating
buys nothing and can only strand it.

Two installs still do not share a key. A machine with nothing stored generates,
which is what a first run is. Verified across a real Stop and Start: the token
the second run handed its containers is the one the first run minted.
Two halves of one dead end, both found by pressing the buttons.

Stop left agent-harness running. Compose only acts on a profiled service when
the profile is named, so the one container the person actually chose stayed up
on their laptop after they had stopped the app, still holding its port.

Then the next Start refused: "something is already listening on port 4206, which
OpenBot uses for the Bot you picked" — about a container OpenBot itself had
started, which the person never saw and could not find. There was no way forward
from that screen. A port this deployment already publishes is not a stranger on
the port, and compose up reuses what is there, so the check now skips our own and
keeps its teeth for somebody else's. Host processes are reclaimed for the same
reason: a start that got as far as spawning the server and then stopped left it
holding 3001, and they are identified by working directory, so anything stopped
belongs to this deployment and no other.

Verified in the window: Stop leaves nothing running, and a Start with our own
container on the port goes straight through.
Four things, all found by signing in through the window rather than testing the
pieces.

The login program is rendered by substituting placeholders, and renaming the
marker to OPENBOT_CHATGPT_STORE made the marker contain the STORE placeholder.
Rendering rewrote the program's own print line into a syntax error, so the
container died before printing anything and the window said "the sign-in never
offered a link to open" — a sentence with no relation to its cause. The
placeholders are underscored now and the test reads the rendered program rather
than the template, which is where it broke.

That failure also had no technical half, so there was nothing to diagnose it
with. It carries the container's output now, with the store line stripped.

The window rendered a failure with String(error), which for a two-fold problem
prints [object Object]. There is one failure component now and every screen uses
it.

And the last screen swallowed its own failure entirely: it recorded that
something went wrong and threw the problem away, believing the screen around it
would show the sentence. Nothing did. A plan that could not answer produced a
"Change the model" button and no words at all — the exact silence that screen
exists to replace.

Proven in the window: consent in the browser, the store written owner-only with
its refresh token, mounted into the harness, and the Bot answering 17 x 23 = 391
through the Codex model with no OpenAI key in the container, the .env or the
Keychain.
…end it

Two defects that only the Anthropic path could show, both on the last screens a
person sees.

macOS routes the clipboard shortcuts through the menu bar, and this window had
no Edit menu, so it had no Paste. Typing into the code field worked and pasting
did nothing — on the one screen whose own instruction is "paste the code it
shows you". Everybody signing in to a Claude plan would have reached that field,
pressed the shortcut they have used all their life, and had nothing happen.

Then, with the code in: a plan is not a key, and only one Bot speaks each
vendor's subscription. Signing in to Claude and keeping the default Bot gave a
stack that came up clean and a Bot whose log read "Missing credentials. Please
pass an `api_key`". The person had answered both screens correctly and had no
way to know which answer to change. The plan now re-points the Bot, and the
model screen says which Bot that will be while there is still a screen to say it
on. Nobody is asked to know that a subscription constrains the framework.

Proven in the window on both plans: ChatGPT answers 17 x 23 = 391 through Codex
with no OpenAI key present, and Claude answers 391 on the Claude Agent SDK with
no Anthropic key present. The refusal of a stale code and the failure of a Bot
that cannot answer both render with a plain sentence and the container's own log
behind a disclosure.
… find

Signing in from the window had never been run end to end. It failed four times
in a row, each time silently or with a message that named nothing, and each fix
was only findable because the failure started carrying what actually came back.

The session is called `cliToken`, not `token`, so the very first exchange failed
with "error decoding response body" and no way to tell which field or which
endpoint. Failures here now carry the response, and that answered it in seconds.

Project ids are numbers. Requiring a string dropped every project, and the
screen said "That account has no projects yet" to somebody with ten of them. An
empty list and an unreadable one are told apart now, because one of them is a
lie a person cannot argue with.

The keys endpoint declares `project_id: z.number()` with no coercion, so the
string "7" came back HTTP 400 VALIDATION_ERROR on the last step of the flow. Read
off `api-keys-routes.ts` rather than guessed.

And the project tiles rendered as blank white rectangles: `button` sets a white
colour, `.tile` overrode the background to white and not the colour, and the
provider rows escaped it only because they are labels. Nothing errored. The
screen asked somebody to choose between six empty boxes.

A shown body is masked, because the one that diagnosed the first bug also
carried a live session token, and the shape is what a developer needs from it.

Proven in the window: sign in, choose an organisation, ten projects listed by
name, one picked, a key created, "Connected to CopilotKit". No key typed.
The compatible row is the only one that names a model, and switching away from
it kept the name. Answering with an OpenAI key after using a local endpoint left
BOT_MODEL=local-model, so the Bot asked OpenAI for a model only that person's own
server has, and the last screen said "That account cannot use the model that was
chosen" about a model this run never chose.

Exactly the failure the key-clearing exists for, with one key missed. It is
removed rather than emptied so the compose default applies, and taken out of the
file as well, because the writer keeps lines it does not own and that is what let
it survive.

Found on a full pass through the window, in the first path.
…ndow

Clicking a conversation in the rail drew the coworker's name and then nothing at
all. The rail comes from OpenBot's own database, so a channel is listed whatever
the history store says; the messages live in the Intelligence project, and
pointing a deployment at a different project leaves the platform answering
THREAD_NOT_FOUND.

That 404 is deliberately read as "no history" and must stay that way: a thread id
is minted before the thread exists, so a brand-new conversation 404s as its
normal opening move. Widening it would tell somebody their conversation was gone
and invite them to start it over.

The two cases are told apart by a fact the app already stores. lastMessageAt is
set only once something has been said, so a conversation with none is genuinely
new and silence is correct, while one that has been spoken in and comes back
empty has a history this deployment cannot reach. That one now says so, in the
notice slot beside the existing explanations for a deleted coworker and for
turns that could not be parsed.

The channel DTO carries lastMessageAt for it, which the shape tests pin, plus a
new test that the date leaves as a string and leaves at all.
…et one

Setup ended at "Install Podman Desktop or Docker Desktop first" on any machine
that had neither, which is every machine this app is for. The step existed in
the enum with nothing behind it and the screens had been reworded to stop
promising it. So the whole install stopped at a download page.

It installs one now, and the second half is the half that gets forgotten: Podman
ships no Compose implementation, so a machine with a freshly installed Podman
still cannot raise the stack and answers with seven errors naming docker-compose.
Both are fetched, each pinned to the digest of the release it was tested against
and refused if it does not match, because these are files this app then executes.
Only what is missing is added: an engine somebody already has is theirs, and a
Compose that already answers is left alone.

Windows installs unattended. macOS and Linux each raise one authorization prompt,
which is the platform's own and is not something to route around: the package
writes to /opt/podman, and on Linux Podman is a set of binaries wired to the
distribution's paths rather than one file to download.

Two things were needed to make the result usable in the session that installed
it. The MSI extends the USER's PATH, and this process was started with the old
one, so podman could not be run for the rest of the run: every engine command
now names a resolved path, found on PATH first and in the installers' own
locations second. And the Compose provider is put in front of the child's PATH
rather than written into containers.conf, which belongs to whoever else may have
configured it.

Measured on Windows Server 2022, which is also where the recovery came from: a
Podman removed by deleting its folder leaves the registration behind, so /i
becomes a repair with no source and stops with 1603. That case uninstalls and
installs cleanly instead of reporting a failure somebody cannot act on.

Image references now come from the release's manifest wherever the shell runs a
container itself, not only where Compose does. This is the same bug a third time:
first the names were built from the ids and matched nothing published, then the
version stopped being appended so an engine read the bare name as :latest, and
now openbot-agent-langgraph-agui:v0.0.8 was resolved to docker.io/library/... and
the person was told access was denied, which reads as a credentials problem for a
repository that was never pushed. No reference is built here at all any more, and
an image this release does not include is named as that.

Both plan sign-ins set the engine up rather than refusing. They run in a
container, and "No container engine is answering, so the sign-in cannot run" named
an obstacle and no way past it, on a screen whose whole purpose is to put one
there. One function does it for Start and for both of them.

A setup step that stops now carries both registers. podman machine init failing
is exactly the case the two-part failure was written for, and it was the last
place still putting an engine's own words in front of somebody as the headline.
The compatible row refused to continue without an API key, and its own summary
names Ollama and vLLM. Neither has one. So the two examples the screen offers by
name were the two it would not accept, and the way out was to invent a key and
hope the endpoint ignored it.

An address and a model name are what that row needs. The Rust side already
treated the key as optional and writes OPENAI_API_KEY only when it is given, so
the refusal lived entirely in the screen. The field says what it is now rather
than leaving somebody to find out by being stuck.

A failure also belongs to the row that produced it. A refused OpenAI sign-in
stayed on screen after switching to the endpoint row, underneath the address just
typed, where it read as a complaint about that address.

Found by driving the screen on Windows.
Making the compatible-endpoint row accept a blank key fixed one end of that
feature and exposed the other. Both bundled Bots refuse to start without
OPENAI_API_KEY, so somebody who filled in an address for an Ollama or a vLLM got
two dead containers complaining about a key their own server does not have. The
row's summary names Ollama and vLLM by name; they were the two cases it would not
serve.

A base URL is a model, and its key belongs to it. Set, it means any endpoint
speaking that API, which is what the variable's own comment has always said, so
the startup check now asks for a key only when nothing else was named. Plain
OpenAI still refuses without one, which is the case the check was written for,
and the other two providers have no base URL to be named by so neither changes.
The SDK insists on a string even when the endpoint ignores it, so a named
endpoint with no key is handed a placeholder rather than a client that cannot be
constructed.

The decision is a module in each Bot rather than a condition at module scope,
because index.ts serves as it loads and a test cannot import it without binding a
port. Same reason model-options.ts exists.

Also the model name now reaches the bundled Bot. docker-compose.yml reads
AGENT_BOT_MODEL for agent-bot, not BOT_MODEL, so that a model chosen for the
framework Bot cannot silently take its tools away: that Bot writes
/v1/chat/completions by hand and gpt-5.6-* rejects function tools there. The
reasoning is about OpenAI's own catalogue and does not survive a custom endpoint,
where the pin asked the person's own server for a gpt-5.5 it has never heard of.
The name they typed is written to both, and cleared from both when they answer
with something that names no model.
The Bots no longer demand a key when a base URL names the endpoint, but a Bot
image published before they learned that still does, and a deployment pulls the
image the release pinned. So the keyless half of the compatible row would have
stayed broken until the next release, on every machine.

The OpenAI SDK every Bot is built on refuses to construct a client without a
string, which is the whole reason a blank key kills them. Ollama, vLLM, LM Studio
and llama.cpp all ignore the value, so a placeholder is sent instead of nothing
and the endpoint that does not read it is none the wiser.

Never treated as a credential: it is written in plain sight rather than put in the
machine's store, because it is not one. A key somebody actually typed is used
unchanged.
"Show OpenBot" did nothing on a machine where the stack was up. The window said
OpenBot is running, the button was there, and clicking it had no effect at all.

Two faults, one behind the other.

The app host process was dead. It is started through the package's `serve`
script, which ran `vite preview` through `bun --bun` so that a machine with bun
and no Node could start it: `node_modules/.bin/vite` begins with a Node shebang.
But Vite's proxy calls `socket.destroySoon()` when an upstream response ends, and
bun's sockets do not implement it, so the process died with a TypeError on the
FIRST call the app made. It served its page, exited, and nothing was listening on
3010 from then on. The shell went on reporting a stack that was up, because the
containers were.

So the app is served by a small server of its own now. It serves a directory and
forwards one prefix, which is all an install needs; a development server was
never the right thing to be running in an installed application, as the shell's
own comment about this process already said. No Node, no Vite at runtime, and the
websocket upgrade the live screen needs is forwarded rather than answered with
HTML. A miss under /assets is still a 404 rather than the page, because handing a
script tag some HTML fails in the console instead of the network panel. Paths are
normalised and confined to the directory: the deployment's .env sits two levels
above it.

And the button now shows what it was told. `show_openbot` already answered with
"OpenBot is not answering on port 3010 yet, so there is nothing to show", and the
click handler dropped it with `.catch(() => undefined)`. A true sentence was
available and the window threw it away, which is why this looked like a dead
button rather than a dead process. The Ask screen's copy of the same call always
showed it.
Both plan sign-ins keep the URL they were given and put it on screen, with a
comment saying why: an open that silently does nothing, or a machine with no
registered browser, leaves somebody watching a spinner with no idea where they
are meant to go. The CopilotKit sign-in discarded it, so that case had no way out
at all.

Found while driving setup on a machine whose browser is not the one in front of
the person.
Stop took the containers down, reported success, and left OpenBot serving.
Measured on Windows Server 2022: after it, the server still answered on 3001,
the worker was still up, and both halves of the app still answered 200 on 3010.
Only the five containers had gone.

The handles this window holds cover only what this window started, and they are
gone the moment it restarts, so a window stopping a stack an earlier one started
holds nothing. That is the case `stop_processes_under` exists for, and its
Windows arm returned 0 with a comment saying the host processes end with the
session. They do not.

They are found by the ports the deployment publishes now, which the shell already
owns and already checks for clashes, and each is ended with its children:
`bun run serve` starts the real server as a grandchild, so ending the process
holding the port would leave that one behind. Only the app's and the server's
ports, because the containers are Compose's to stop and killing whatever holds a
published container port reaches into the engine's own plumbing.

Also, an unreadable app manifest is no longer reported as an old deployment. A
byte-order mark in front of package.json made serde_json refuse it, and the
refusal was rendered as "the deployment is older than this version of OpenBot",
which sends somebody looking for a newer installer over three bytes. Windows
tooling writes that mark freely: Set-Content -Encoding UTF8 does. It is skipped,
and a manifest that genuinely will not parse says so.
The port sweep freed 3001 and 3010 and left the worker running. It listens on
nothing, so a sweep cannot see it, and its command line is identical to the
server's: both are `bun --env-file=../.env src/index.ts`, differing only by
working directory, which Windows will not tell you cheaply.

So the pids are written beside the logs when the processes start, and Stop reads
them. That is also the honest fix for the case the sweep was standing in for: the
handles a window holds die with the window, and everything else about a running
stack survives it, so a restarted window Stopping a stack an earlier one started
had nothing to work with. Now it has. The sweep stays as a second pass for a
stack whose pid file is gone.

The parse is its own function with a test on real netstat output, because reading
five columns as four is what made the first attempt report success while leaving
everything running: the foreign address was taken for the state and the state for
the pid, so nothing ever matched.
Four Keychain dialogs, every time the setup screen mounted, each needing a click
before the window would go on. Navigating between setup and OpenBot asked four
more times.

macOS authorizes every individual read of a stored password unless the
application is signed with an identity the item's ACL already trusts. A
development build is re-signed on every compile, so its ACL never matches and
every read is a prompt; the wizard reads four secrets to arrive filled in, and it
reads them on mount.

The store is now asked once per name per process and the answer is held in
memory. Absence is cached too, or a machine with no stored credential is asked on
every mount for something that was never there. Writes go through the cache and
forgetting clears it, so the two cannot disagree.

This does not remove the prompts on a first run, and nothing in this process can:
the decision belongs to the operating system and to the signature. A signed and
notarised build is granted once and never asked again, which is the real fix and
belongs to the release.
Three conflicts, each a place where both sides had solved the same problem.

Minting the deployment's secrets. Both branches stopped a restart re-minting
KEY_ENCRYPTION_KEY and orphaning everything encrypted under the old one; main's
is the better half and is kept, including its judgement that a value copied out
of .env.example is present and must still be replaced. This branch's part stays
too, because it moved those secrets into the machine's credential store, so what
a previous start minted is read from there rather than from the file main reads.
One list now, MINTED, where there were two.

The audit initiator main threads into a stalled remote Bot's report. This branch
had extracted that agent's construction into `remoteTransport` so a Mastra Bot
could be built the same way, so the initiator follows it there rather than being
dropped along with the inline construction it was written against.

Migration 0028. Both branches took the number; main's audit_initiator is already
applied elsewhere and does not move, so the Mastra enum value is regenerated as
0029 against main's snapshot rather than renumbered by hand.

One of main's compose assertions counted two Bots reading OPENAI_BASE_URL. There
are three: the harness somebody picks in setup is dialled the same way, and
leaving it out would point the Bot they actually chose at OpenAI while the two
shipped ones went to their gateway.

Verified on the merged tree: 203 Rust tests, 2595 of the repo's, typecheck and
lint clean. The one remaining failure, a payload shape in
plugin-store.integration.test.ts, fails the same way on a clean checkout of main.
CI builds on Linux with `-D warnings`, and there `SERVICE` is dead: only macOS
has a service name to file a password under. Windows keys its DPAPI blobs by
filename and the Linux fallback is a file in the config directory, so both
ignored it. macOS never noticed, because there it is used three times.
@davidmckayv

Copy link
Copy Markdown
Contributor Author

I recommend changes before merging. This review covers commit 5ea56ee5386eed5d6cf6e9a6859b8d4ab36a87c2; fixes are now in progress. I rechecked all 19 findings against published OpenBot code, primary upstream documentation/source, and local reproductions. Two priorities were reduced after validation: the Mastra serialization and CrewAI findings establish lost instructions/context, without establishing an authorization failure. The resulting priorities are 10 P1 and 9 P2 findings.

The findings below separate observed results from conditional consequences. Python requirements are unpinned; version-dependent results refer to the exact versions tested, not every possible future image. The missing release images already disclosed in the PR are not counted as another finding.

1. [P1] Persist migrated secrets before purging .env. Changed code

During migration, env::write removes secrets before vault::remember_all succeeds. If persistence fails before saving the existing KEY_ENCRYPTION_KEY, its only durable copy can disappear; a subsequent launch generates a replacement that cannot decrypt existing credentials. Complete and verify persistence before purging the source. The vault's error propagation, migration writer test, and failure-order tracing support this; no real credential-store denial was induced.

2. [P1] Verify ownership before terminating Windows listeners. Changed code

The recovery sweep treats listeners on ports 3001/3010 as OpenBot and runs taskkill /PID … /T /F, including during startup before conflict detection. Another application's process tree can therefore be killed when permissions allow it. The actual parser accepts an unrelated synthetic PID; no process was terminated. Check deployment ownership and process identity first. Microsoft's netstat and taskkill documentation confirms that the PID identifies the listener and /T /F forcibly terminates its tree; it supplies no ownership check.

3. [P1] Download deployment metadata before resolving the selected harness. Changed code

A clean API-key setup calls harness::picked, which reads container-images.json, before deployment_ready downloads the deployment. The manifest reader immediately propagates the missing-file error, so retries cannot reach the download. Plan sign-in can mask this by prefetching. Reorder acquisition before resolution. This source-traced ordering bug is separate from the disclosed missing image entries in the current release.

4. [P1] Normalize an unused OpenAI base URL before constructing Python clients. Changed code

An ordinary OpenAI-key choice leaves Compose injecting OPENAI_BASE_URL="". With the HEAD LangGraph entrypoint and openai 3.8.0 / langchain-openai 1.6.0, the client keeps an empty URL and cannot send a model request. An offline probe reproduced this; unsetting the variable restored the default API URL. Compose interpolation and the versioned OpenAI constructor support the behavior. Omit the variable or normalize empty values to the intended default.

5. [P1] Carry the Anthropic API-key choice through provider/model configuration. Changed code

With the default LangGraph selection, Anthropic → API key emits ANTHROPIC_API_KEY but does not select BOT_PROVIDER=anthropic or a compatible model. The harness supplies an OpenAI prefix to the LangChain 1.4.0 model factory, and Compose supplies an OpenAI model. An offline _model() call with only a synthetic Anthropic key reproduces the missing-OpenAI-credentials error. Carry provider/model configuration through this path or restrict unsupported combinations.

6. [P1] Mount the ChatGPT token-store directory so refresh can replace the file. Changed code

The single-file bind mount makes chatgpt-auth.json a mount point. The actual langchain-openai 1.6.0 refresh writer uses a temporary file plus atomic replacement: a synthetic file-mount probe fails with EBUSY; a directory-mount control succeeds and updates the host store. This matches the Linux rename restriction on mount points. Refresh cannot complete normal persistence. Loss after refresh-token rotation is conditional; this test did not exercise live OAuth, and a temporary file may remain recoverable inside the container.

7. [P1] Register the actual Agno and LlamaIndex run routes. Changed code

Generated endpoints use the server root, while Agno's adapter exposes /agui and LlamaIndex AG-UI 0.5.0's published router exposes /run. With Agno 3.0.6 and LlamaIndex AG-UI 0.5.0, HEAD ASGI probes return 405/404 at /, healthy /health, and validation errors at the real run paths. Both desktop verification and application transport target the wrong URL. Include the run path in the generated endpoint or mount the adapter at the advertised root.

8. [P1] Apply managed and vault authentication to Mastra rows. Changed code

Both credential branches only accept remote_ag_ui; the newly supported remote_mastra receives neither its vault header nor x-openbot-agent-token. The actual loader returns no headers and performs zero vault reads for a synthetic Mastra row. The packaged Mastra guard exempts only health, so /api/agents is rejected without the token. Apply authentication to both remote kinds. Source tracing also shows eager discovery can reject the request's entire agent map when the failing Mastra bot is visible.

9. [P1] Preserve OpenBot middleware when Mastra agents are cloned. Changed code

CopilotKit clones the registered agent for HTTP runs. @ag-ui/mastra 1.1.2 constructs a fresh agent and copies headers without copying the middleware attached here. Invoking the installed runtime's clone path reproduces one middleware becoming zero, with standing-role messages, granted tools, and signed context absent from transport input. Preserve the wrapper through cloning. The bridge 1.1.2 source map (mastra.ts:629–634) and runtime 1.70.1 artifact (handlers/shared/agent-utils.mjs) byte-match installed files. This is separate from the serialization issue below; fixing either alone leaves the other.

10. [P2] Translate standing-role and OpenBot context into Mastra's wire format. Changed code

Even the original, un-cloned wrapped agent loses system messages and OpenBot-specific forwardedProps during conversion. A real client serialization probe contains the user message and clientTools, but no standing-role/holdings messages or openbotRun, openbotBotId, and openbotDeploymentTools. The published bridge 1.1.2 source (utils.ts:138–222, mastra.ts:2819–2842) corroborates the filtering; the tested native client was 1.43.0. Add an explicit translation for the required context. The probe establishes payload loss; tool schemas do survive serialization.

11. [P2] Preserve the leading standing-role message in CrewAI. Changed code

The AG-UI CrewAI 0.3.0 adapter (endpoint.py, crewai_prepare_inputs) strips the leading system message before OpenBotFlow.answer reads state.messages. OpenBot puts the configured Bot identity/role there. With CrewAI 1.15.20, the real HEAD FastAPI route and a provider-completion recorder return 200 but send only the user message to the provider. Preserve the instruction across the adapter boundary. This establishes lost configured instructions while ordinary conversation can still run.

12. [P1] Use Mastra's transport for the mandatory first question. Changed code

ask_the_bot ignores harness kind/agent ID and sends a generic AG-UI request to the selected root URL. The packaged Mastra agent uses its native /api/agents/{id}/stream transport through the bridge, consistent with Mastra's Agents API and the 1.43.0 client above. A healthy Mastra selection cannot pass this probe and reach normal handover. Reuse the transport or translate both requests and events; changing only the URL is insufficient. No live model run was used.

13. [P2] Scope saved and pending plan credentials to their provider. Changed code

After ChatGPT sign-in, switching the row to Claude retains the shared token. The UI immediately reports sign-in complete and submits the ChatGPT store as a Claude credential without performing Claude sign-in. Reset or scope both the token and pending completion when the provider changes. This transition reproduces with actual React 19.2.8 rendering and synthetic IPC; React's state-preservation rules explain why changing the row does not reset shared component state. The reverse transition also reproduces in the earlier mocked-hook probe.

14. [P2] Make “Change the model” reach the picker after Ask fails. Changed code

The action changes step while running stays true, but the picker requires !running. The actual React transition renders Show/Stop controls instead of the picker and hides the Ask error. Stop/reconfigure the stack or make the correction screen reachable in that state. React's conditional-rendering documentation corroborates the branching semantics; the component and reproduction establish the defect here.

15. [P2] Provide retry/refresh when Intelligence returns no projects. Changed code

An empty projects array enters the project-list branch and removes sign-in. The screen asks the user to create a project and sign in again, but offers neither sign-in nor refresh, while Start stays disabled. An actual React render with an empty project response reproduces the missing recovery control. Expose a retry or refresh action in this state.

16. [P2] Restore persisted plan sessions on a fresh setup mount. Changed code

already_configured omits the saved Claude plan token and ChatGPT store from readback. After Quit/relaunch into setup, a fresh App starts without a model choice and the plan picker requires a new sign-in despite stored credentials. Restore the saved credential kind/session. Stopping within the same mounted App can retain the choice and is an exception; the fresh-mount reproduction establishes this narrower trigger.

17. [P2] Collect and use an endpoint for “An agent you already run.” Changed code

The row promises an agent address and AG-UI check, but no screen collects that address. With an API-key/compatible-endpoint choice, picked(byo-url) returns None, so setup falls back to the bundled Bot and tests it instead. The endpoint model-provider field is not an agent URL. Carry a distinct agent endpoint through registration and verification. Plan choices instead override the harness, so they are outside this specific fallback trigger.

18. [P2] Forward session headers when proxying WebSockets. Changed code

The replacement app server creates the upstream socket using only a URL, dropping cookies/authorization. A synthetic upstream on Bun 1.3.14 receives the headers for HTTP but none for WebSocket. Separate execution of the actual session guard accepts the synthetic cookie and returns 401 without it; the connection between these boundaries is source-traced. Bun 1.3.14 supports client headers. This affects authenticated configurations using app/serve.ts: a configured identity provider disables single-user bypass even with OPENBOT_SINGLE_USER=true. Default unauthenticated desktop setup and direct API-server asset serving are outside this scope.

19. [P2] Distinguish failed history retrieval from a changed project. Changed code

The new notice infers a project change from a used channel with no restored messages. The actual history reader also produces empty messages for HTTP 500, network failures, and unreadable history. When the join also supplies no messages, an outage can therefore receive that diagnosis. Fetch's response/error semantics corroborate the distinction, while the actual reader probes establish that it is discarded here. Preserve the retrieval result or use neutral unavailable-history wording. This is a false diagnostic; no evidence shows that the notice itself deletes history or resets the thread.

Validation: 203 Rust library tests passed (4 live credential/store tests ignored), and 268 focused Bun tests passed during the original review. The web-validation pass additionally rechecked dependency artifacts and reran targeted synthetic reproductions, including real React rendering. Existing CI jobs passed at this commit; image/component-Dockerfile jobs were skipped. No full installer journey, real credential-store denial, real Windows termination, or live OAuth/model call was performed for this review.

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