Run OpenBot on Kubernetes: Bots and all, proven on EKS - #235
Open
davidmckayv wants to merge 6 commits into
Open
Conversation
davidmckayv
requested review from
MikeRyanDev,
guidovizoso and
tylerslaton
as code owners
August 24, 2026 19:56
One chart for EKS, GKE, AKS and somebody's own cluster, with nothing but values between them. No cloud branching in any template: every place the clouds differ is a value whose default is what a plain self-hosted cluster does. Identity is one annotations map, because that is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret by default and an ExternalSecret against any backend when asked. Two replicas by default, because horizontal is the point and one hides every bug that is not. A bad install is refused at helm install naming the value to change, rather than found in a crash loop. Three things only a real install could find: drizzle-kit cannot migrate in the shipped image. It reads a TypeScript config, which needs the esbuild that bun install --production leaves out, so it printed one line, exited 1 and said nothing. EMBEDDED_POSTGRES=on was starting containers whose database was never migrated. The migrator inside drizzle-orm is a runtime dependency already and keeps the same journal. sessionOf answered from a map in the process that started the computer, which is right until there are two of them. The replica taking a snapshot is usually not the one handling the click, and an unknown session skips the generation check rather than failing it, so the check that stops a ref from a replaced computer resolving against a live one was silently absent on the shape it was written for. It now asks by listing, never by ensuring, so asking cannot start a computer that had stopped. A browser in an API pod cannot be replicated, so the image's computer gets the same switch its database has.
davidmckayv
force-pushed
the
feat/helm-chart-kubernetes
branch
from
August 24, 2026 19:57
846df9e to
dc8e406
Compare
The chart had no computer, so no Bot could do anything on a cluster. It has one now, and a Bot has driven a real browser on real EKS with the decision in the audit trail. computers.mode picks the shape. shared runs one browser for every Bot and needs nothing installed. sandbox gives each Bot its own as a Sandbox from kubernetes-sigs/agent-sandbox, which is built for exactly this: an isolated stateful singleton with a stable identity and persistent storage, where suspending is a field that keeps the volumes, so a computer comes back with its logins rather than signed out. What decides a computer is idle is the audit trail, not the browser. Asking the browser wakes it, so every computer anything asked about would come back up and the bill would never fall. The work is claimed and leased out of Postgres with for update skip locked. Three features need that one mechanism, so it is written once with all three in view: the culler here, routines, and a hop from one Bot to another. A CronJob runs the sweep rather than a timer in the API, because a timer fires in every replica and suspending a browser somebody just started using is not something to do five times. Also: a fresh EKS cluster very often has no default StorageClass. eksctl creates gp2, unmarked and on the in-tree provisioner current Kubernetes no longer has, so a volume asking for the default never binds and nothing says why. Found on a real 1.34 cluster and written down where somebody configuring one will read it.
computers.mode: sandbox creates Sandbox objects, which exist only once the agent-sandbox controller is installed. Without it the install succeeds, every pod is healthy, and the deployment looks finished right up until the first Bot asks for a browser and the API server answers 404. That is the worst moment to learn it. The check reads the cluster rather than a value somebody has to remember to set, and the message carries the one command that fixes it. Proven both ways: refused on a cluster with no CRD, installs on the EKS cluster that has one. Also from driving it on real EKS: lost+found was listed as a Bot, because an EBS volume is ext4 and arrives with that directory, which a bind mount never does. The allow-list that stops a hostile id becoming a path answers the other half of the question too. The migration Job named a ServiceAccount that does not exist yet, since a pre-install hook runs before the chart's own resources. It talks to a database and never to the cluster, so it needs no account at all. The API pod gets a cluster token only in sandbox mode, the pods roll when the computer template changes, the Sandbox asks for a Service so it has an address that survives a resume, and the cluster CA is actually used when talking to the API server.
A resumed browser counts snapshot generations from one again, so a ref the model still holds from before the suspend matches a row nothing has overwritten, and the boundary decides about an element on a page that no longer exists. The first answer used the node and the pod address. Resuming a real computer on EKS disproved it: a suspended sandbox is very often rescheduled onto the same node and handed the same address back, and both were identical across the cycle, so the check would have said same run for the exact case it exists to catch. The Ready condition's transition time moves whenever a computer starts serving again, needs no permission beyond the sandbox already read, and is precisely the question. Driven on EKS: a ref taken before a suspend is refused after the resume, naming why, and a fresh ref from a new snapshot clicks through.
…e database The NetworkPolicy allowed DNS and the bundled database. Nothing let the API reach a Bot's computer, which it does for every browser action, and nothing let it reach a managed database, whose address this chart cannot know. On a cluster that enforces policy both are outages that read as something else: the API looks broken rather than fenced. The computers and the API server are allowed now, and turning the policy on with an external database and no rule for it is refused with the shape of the rule to add. None of this showed up by installing it, because EKS runs its CNI with --enable-network-policy=false and the policy is inert there. That is worth knowing on its own, so it is written down: a policy that installs, looks right, and does nothing is worse than one that is off. Also driven on EKS: reset takes the volumes with it and the Bot gets a clean profile afterwards, and the HPA reads real metrics.
Every turn in which a Bot used a tool vanished from the transcript on
reload. The sentence the Bot wrote stayed, the browsing that produced it
did not, the inline screen went with it, and the footer said some
messages could not be read.
The history store writes a tool call as {id, name, args}; AG-UI describes
{id, type: function, function: {name, arguments}}. The reader validated
against the second and treated the first as damage from an interrupted
run. It is not damage, it is how every tool call is stored, so a guard
written against one bad turn was deleting all the real ones.
Found by driving a real conversation on the EKS deployment rather than by
reading: two browsing turns, both counted unreadable, both well formed in
the store's own dialect.
Both spellings now read as the same thing. A mixed or unrecognised array
is still refused rather than half-translated, because a reader that
rewrites what it does not recognise is worse than one that refuses it.
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.
Closes #216. Bots run on Kubernetes, proven on a real EKS cluster.
Proven, not argued
A real EKS cluster (1.34, two nodes, us-east-2), image built for amd64 and pushed to ECR:
Real EBS volumes bound on
gp3, replicas spread across both nodes. Then:A Bot opened a real page from inside AWS, a screenshot came back as a real PNG, and the audit trail recorded the decision:
And the horizontal claim, on EKS: a channel created on replica A read straight back off replica B, same Intelligence thread id. Nothing held in one process.
The same install runs on a three-node kind cluster, so a clone can get here without a cloud account.
One chart, four targets
No cloud branching in any template.
grep -i 'aws\|eks\|gke\|aks\|azure'acrosstemplates/returns one comment and zero logic. Every place the clouds genuinely differ is a value whose default is what a plain self-hosted cluster does.serviceAccount.annotationsmap, because IRSA, Workload Identity and AKS workload identity are all annotations on a ServiceAccount.runtimeClassNameunset by default, because GKE has managed gVisor, AKS offers Kata, and on EKS it is bring your own node configuration.ci/holds a values file per target.self-hosted-values.yamlturns nothing on, which is the test: if it ever needs to grow, a default is wrong.A computer for each Bot
computers.modepicks the shape.sharedruns one browser for every Bot and needs nothing installed;sandboxgives each Bot its own as aSandboxfromkubernetes-sigs/agent-sandbox, which is built for exactly this workload;externalpoints at one somebody else runs.Suspending is
operatingMode: Suspended, which terminates the pod and keeps the volumes, so a computer comes back with its logins rather than signed out of everything.What decides a computer is idle is the audit trail, not the browser. Asking the browser would wake it, so every computer anything asked about would come back up and the bill would never fall. That is the invisible way to lose scale-to-zero, and #216 names it.
Durable work, claimed by whichever replica gets there first
work_itemsplusselect ... for update skip lockedand a lease. No coordinator, no leader election, and a replica added is throughput added rather than contention added. Written once with all three of its users in view, as #193 asks: the idle culler here, scheduled routines, and a hop from one Bot to another.A CronJob runs the sweep, because a timer in the API fires in every replica, and suspending a browser somebody just started using is not the audit-retention sweep. The decision is re-checked at the moment of acting, since somebody may have come back in between.
Nine integration tests against real PostgreSQL, including ten replicas racing for ten items and taking each exactly once. That test fails without
skip locked— I removed the clause to check.Five things only a real install could find
drizzle-kit migratecannot run in the shipped image. It reads a TypeScript config, needing the esbuildbun install --productioncorrectly leaves out. It prints one line, exits 1, and says nothing. SoEMBEDDED_POSTGRES=onhas been starting containers whose database was never migrated, and the first symptom is the API reporting thatusersdoes not exist.server/scripts/migrate.tsuses the migrator insidedrizzle-orm, already a runtime dependency, and keeps the same journal.sessionOfstopped checking as soon as there were two replicas. It answered from a map in whichever process started the computer. The replica that took a snapshot is usually not the one handling the click, and an unknown session means "no opinion", so the check that stops a ref from a replaced computer resolving against a live one was silently absent on exactly the shape it was written for. It now asks by listing, never ensuring, so asking cannot wake a computer that had stopped. This is the item #216 lists under "Not decided" as worth a test rather than an assumption.A browser cannot live in a replicated API pod. The image's computer gets the same
EMBEDDED_COMPUTERswitch its database already has, defaulting on so the one-container image is unchanged. Asking for it with more than one replica is refused at install.Migration hook ordering deadlocks both ways with a bundled database.
pre-installruns before the subchart exists, so waiting cannot help;post-installwith--waitnever runs, because the Deployment cannot become ready against an empty schema. On a first install with the bundled database it is an ordinary Job, created in the same pass and waiting for the database itself. Every other case is aprehook, because the database already exists.A fresh EKS cluster very often has no default StorageClass.
eksctlcreatesgp2, unmarked and on the in-treekubernetes.io/aws-ebsprovisioner that current Kubernetes no longer has. A volume asking for the default never binds, the computer sitsPending, and nothing says why. Verified on the real cluster and written down where somebody configuring one will read it.Refused at install, not in a crash loop
No database or two of them; nobody who could sign in; nobody who would be an administrator; a
KEY_ENCRYPTION_KEYof the wrong shape; a bundled database with no password that could be supplied again on upgrade; a computer with no token;externalwith no address; both routers; both secret sources; Okta without an issuer; a browser inside more than one replica. Each names the value to change, and I drove every one.Where it runs
work_items, which is the point: durable, claimed, leased, and the alternative is state in a process.sessionOfis the one place the old answer was wrong, and the culler is built so the answer stays right.Boundary and audit
sandboxesalone. Smaller blast radius than the Docker supervisor, which holds a socket that is root-equivalent on its host.Known and deliberate
The bundled database runs as the superuser, because migration 0000 creates the
vectorextension and 0010 drops it, and only a superuser or the owner can do either. That is whatdocker-compose.ymlalready does, and safe for the same reason. A managed database is not this, which the values file says where somebody configuring RDS will read it.Full gate green: 1468 pass, 0 fail.
helm lintclean on all four targets.