diff --git a/.env.example b/.env.example
index bfd61b36..706b402a 100644
--- a/.env.example
+++ b/.env.example
@@ -7,7 +7,17 @@ KEY_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
# one to leave alone until somebody has decided otherwise: the trail is append-only and nothing else
# can remove a row, so this is the only way it ever shrinks.
# AUDIT_RETENTION_DAYS=365
+# Two names for one number, and they have to agree.
+#
+# The server reads PORT (server/src/index.ts). scripts/start.sh reads SERVER_PORT, because it also
+# has to know where the app should proxy and which port to report free -- and docs/configuration.md
+# documents SERVER_PORT as the setting. Only PORT shipped here, so moving the server by editing this
+# line left the script still looking at 3001: it found whatever else was there, accepted the first
+# 200 as proof, and failed several stages later parsing that stranger's HTML as JSON.
+#
+# Change both, or neither.
PORT=3001
+SERVER_PORT=3001
TENANT_PACKAGE_DIR=../examples/fintech
# What this deployment calls itself, when more than one shares an Intelligence project. A copy of a
# deployment made for development uses the same project key, and threads are listed per Bot with
@@ -230,8 +240,13 @@ COMPUTER_TOKEN=
#
# This is attribution, not anonymity, and it is not a boundary by itself: it gives a security team a
# per-Bot address for network rules alongside AGENT_COMPUTER_POLICY.
-# EGRESS_PROXY_DEFAULT=http://user:password@proxy.internal:8080
-# EGRESS_PROXY_SALES_BOT=http://sales.proxy.internal:8080
+#
+# These go in `egress.env` beside this file, NOT here. The names are per-Bot, so Compose cannot
+# list them the way it lists every variable below, and it hands a container only what it is told to.
+# In `.env` they reach no process and the browser goes out directly with nothing saying so.
+#
+# EGRESS_PROXY_DEFAULT=http://user:password@proxy.internal:8080
+# EGRESS_PROXY_SALES_BOT=http://sales.proxy.internal:8080
# The managed coworker AG-UI endpoint. Optional: use an HTTP(S) URL, and set MANAGED_AGENT_TOKEN
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a5f0ec41..7fa0f51f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,6 +73,90 @@ jobs:
- run: bun run typecheck
working-directory: ${{ matrix.package }}
+ chart:
+ name: chart (${{ matrix.target }})
+ runs-on: ubuntu-latest
+ strategy:
+ # One red target must not hide whether another is red too.
+ fail-fast: false
+ matrix:
+ target: [self-hosted, eks, eks-sandbox, gke, aks]
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
+ with:
+ version: v3.19.0
+ # For the coherence check below, which is a Bun script like everything else here.
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: 1.3.14
+ # Nothing rendered this chart until now, which is how four values files that produce a server
+ # unable to start were shipped and stayed shipped. Rendering is the cheap half; the refusals in
+ # validation.yaml are the half that catches a missing value before a cluster does.
+ - run: helm dependency build charts/openbot
+ # Structure only. `helm lint` reports a template `fail` as an INFO line and exits 0 even under
+ # `--strict`, which was driven and confirmed, so it cannot gate the refusals below. Rendering
+ # does: `helm template` exits non-zero on one.
+ - run: helm lint charts/openbot --values charts/openbot/ci/${{ matrix.target }}-values.yaml
+ # A key encryption key is a real 32 bytes rather than a placeholder, because the chart checks
+ # its shape. Generated here so no example key is ever a literal in this repository.
+ - name: Render
+ run: |
+ helm template ci charts/openbot \
+ --values charts/openbot/ci/${{ matrix.target }}-values.yaml \
+ --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \
+ --api-versions agents.x-k8s.io/v1beta1/Sandbox \
+ --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \
+ > rendered.yaml
+ # Rendering proves the templates run. This proves the result is coherent, which is a different
+ # question: every secret key a container demands has to be one the chart actually writes.
+ # Getting that wrong is invisible until a pod starts, and every shipped target had it wrong.
+ - run: bun scripts/check-rendered-chart.ts rendered.yaml
+ # And that the refusals are load-bearing rather than decorative. A chart full of `fail`
+ # messages nothing ever triggers is a chart that has never been shown to refuse anything, and
+ # every one of these describes a state that shipped in a values file at some point.
+ - name: Refusals fire
+ run: |
+ set -uo pipefail
+ refuses() {
+ local why="$1"; shift
+ if helm template ci charts/openbot \
+ --values charts/openbot/ci/${{ matrix.target }}-values.yaml \
+ --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \
+ --api-versions agents.x-k8s.io/v1beta1/Sandbox \
+ --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \
+ "$@" >/dev/null 2>&1; then
+ echo "::error::The chart rendered $why, which it is supposed to refuse."
+ return 1
+ fi
+ echo "refused: $why"
+ }
+ # The public example key, which the server will not start with. Only where this chart
+ # holds the secret: with a store, the value is not readable at template time, so the
+ # refusal is deliberately not armed and asserting it here would be asserting a bug.
+ if ! grep -qE '^ *enabled: true' <(sed -n '/^externalSecrets:/,/^[a-z]/p' charts/openbot/ci/${{ matrix.target }}-values.yaml); then
+ refuses "the public example encryption key" \
+ --set-string secrets.keyEncryptionKey="$(head -c 32 /dev/zero | base64)"
+ else
+ echo "skipped: the example-key refusal is not armed when the secret comes from a store"
+ fi
+ # A Bot endpoint with nothing on the request that says who is calling. Armed whether the
+ # secret is this chart's or a store's, because the key list is readable either way.
+ refuses "a managed agent URL with no token" \
+ --set-string config.managedAgent.url=http://agent.default:8000/ag-ui
+ # A browser inside every replica of a replicated API.
+ refuses "an embedded browser across several replicas" \
+ --set server.embeddedComputer=true --set server.replicaCount=2
+ # A Bot's egress proxy on a port the computer's own network policy does not allow. The
+ # variables reach the computer through extraEnv, so nothing else notices that the policy
+ # then refuses to let it be reached.
+ refuses "an egress proxy the network policy blocks" \
+ --set networkPolicy.enabled=true \
+ --set computers.extraEnv[0].name=EGRESS_PROXY_DEFAULT \
+ --set-string computers.extraEnv[0].value=http://proxy.internal:3128
+
test:
name: tests
runs-on: ubuntu-latest
@@ -261,7 +345,7 @@ jobs:
name: verify
runs-on: ubuntu-latest
if: always()
- needs: [static, deployables, test, build, migrations, image]
+ needs: [static, deployables, chart, test, build, migrations, image]
steps:
- name: Require every check
env:
diff --git a/.gitignore b/.gitignore
index f5d48e06..bfc1237c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ docs/plans/
.env
.env.*
!.env.example
+# Per-Bot egress proxies. Carries credentials in the URL, like .env does.
+egress.env
node_modules/
**/dist/
app/src/lib/generated/application-config.ts
@@ -21,3 +23,8 @@ app/src/lib/generated/application-config.ts
# TanStack Router scratch output
app/.tanstack/
+
+# Helm subchart tarballs, fetched by `helm dependency build`. The lock beside them is NOT ignored:
+# it is what makes that fetch reproducible, and ignoring it meant every build resolved the dependency
+# afresh, so CI and a customer install could take different subchart versions with no diff to show it.
+charts/*/charts/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbee9e84..43ed68cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,360 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
## Unreleased
+### A channel a Bot has spoken in unseen shows a dot
+
+The sidebar marks a channel when a Bot has said something since you last had it open: a dot beside
+the preview, the name a touch heavier. Opening the channel clears it, your own messages never set
+it, and the channel you are looking at never shows it. The marker is yours alone — per member, on
+the membership row like the pin — so one person reading does not clear anybody else's dot.
+
+The deployment gains one nullable column, via migration `0019`.
+
+### The API can reach Intelligence and sign-in when a NetworkPolicy is on
+
+`networkPolicy.enabled` wrote a rule for the API server that named DNS, the database and the Bots'
+computers, and nothing on 443. On a cluster that enforces policy the server could therefore reach
+neither CopilotKit Intelligence, nor an identity provider, nor a Bot: nobody could sign in and no
+conversation ran. Two of the five shipped `ci/` targets turn the policy on, and on GKE enforcement is
+the default and cannot be switched off.
+
+Nothing said so. The pod passed every probe and stayed Ready, because `/health` answers from a
+literal, so the first evidence was a timeout to a hostname that read as the internet being down.
+
+The API now reaches HTTP and HTTPS everywhere outside the cluster's private ranges, in every
+`computers.mode` rather than only `sandbox`, cut by the same exception list the computers' own policy
+uses. It still cannot address another pod, a node, or a cloud metadata endpoint.
+
+`mode: sandbox` had been working only because a rule meant for the Kubernetes API server carried no
+destination and so permitted everything. That rule now covers the API server alone, and
+`networkPolicy.kubernetesApiCidr` narrows it to your cluster's service range; left empty it stays as
+it was, because a chart cannot know that range.
+### Taking the wheel stops the Bot's shell, not just its clicks
+
+While a person held the wheel the Bot was refused on the page, and not in the shell. `/exec` and a
+workspace write went through, so a Bot could keep running commands and rewriting its `/workspace`
+underneath somebody who had taken the browser at a login wall. The guard existed and covered
+navigation and the four page actions; the shell arrived later and was never wired to it.
+
+Every acting path now asks the same question in one place, so the property the documentation states
+is the property the computer has. Reading is deliberately not acting: `/files/read` and
+`/files/list` still answer while a person drives, because a Bot that has just been stopped still has
+to be able to say what it was doing.
+
+Nothing to configure. A Bot that acts during a takeover gets the refusal it already got for a click,
+and the trail records the attempt and the failure the same way.
+### A computer that was suspended once suspends again
+
+Scale-to-zero worked once per Bot. A computer suspended, resumed, used and then left alone again was
+offered for suspension on every sweep after that and never suspended, and stayed awake until the next
+day. Nothing reported it, because a sweep that offers work and suspends nothing looks exactly like a
+fleet that is busy.
+
+The queue keys a suspension on the Bot id and keeps the finished row so that a late offer of the same
+key collides with it rather than running the work twice. Both are right. What was wrong is that the
+finished row was kept for a day, which is the window the other half of the sweep needs: a suspension
+that keeps failing is held back that long before anything tries it again. One number could not be
+both, so there are now two, and a finished suspension is kept for the idle window instead. That is
+the same clock the offer runs on, so a Bot cannot come back round as idle until its row has gone.
+
+Nothing to configure, and the sweep already runs on a schedule. A deployment where each Bot has its
+own computer stops paying for browsers that were used once.
+### A Bot's egress proxy is reachable on Kubernetes, or the install is refused
+
+The chart named no egress variable anywhere, so a Helm deployment read the per-Bot proxy settings
+nowhere and every Bot went out directly. They were always settable through `computers.extraEnv`,
+which reaches the computer in both the shared and the sandbox arrangement, but nothing in the chart
+or its README said so, and a setting whose whole purpose is to give a security team a per-Bot
+address is not one to leave undocumented.
+
+The other half is that setting it was not enough. A computer is allowed 80 and 443 to public
+addresses and nothing else, which is almost no proxies: they sit on a private address, or on 3128 or
+8080. So a proxy the network policy provably blocks is now refused at `helm install`, naming
+`networkPolicy.computerExtraEgress`, rather than found later as a Bot that fails on every page.
+Nothing changes for a deployment that sets no proxy, or one that already opened a path to it.
+
+### A finished turn shows the page it opened, not the one open now
+
+Reopening a conversation made every past turn fetch the screen as it is now, so an answer about
+Hacker News from an hour ago sat under a picture of whatever the Bot had open since.
+
+A page is now photographed where it is opened. The server takes the frame the moment a navigation
+succeeds and keeps it in `computer_page_frame` under the computer and the address, which is the one
+moment the screen is certainly showing the page that was asked for. Reopening the conversation shows
+that frame rather than the live screen, and a turn with nothing kept names the page instead of
+drawing the wrong one.
+
+The surface used to capture it itself once the turn went quiet, and that is a race it cannot win: a
+reopened turn and one that has just finished are indistinguishable from inside the component, the
+same computer is driven by other conversations in between, and a resumed computer starts blank. It
+filed pictures of pages the turn never opened, or none at all. It only reads now.
+
+**Redeploy the computers with the server.** A screenshot only says which page it is of on an
+`agent-computer` built after that field was added, and this is what decides whether a frame is kept.
+Where each Bot has a computer of its own there is nobody to race with, so an old computer's picture is
+accepted and the feature works through a rollout. On ONE SHARED COMPUTER it cannot be: another Bot's
+navigation lands between the navigation and the picture, and a frame that cannot be told apart from
+theirs is refused. So a shared-computer deployment that updates the server and not the computer keeps
+no frames until it does, and says so in the server log each time rather than leaving somebody to
+wonder.
+
+Two things followed from making a past turn a record. Its placeholder is decided by the turn being
+over rather than by whether a live frame happens to be in hand, because a tile that was live a moment
+ago keeps its last screenshot and used to fall through to "Waiting for the assistant's screen…" and
+wait there for ever. And opening one full size shows that same kept frame, with no live stream and no
+wheel: zooming a past turn used to mount the socket and offer Take control, so the one gesture for
+looking closer at what a turn did replaced it with whatever the Bot has open now.
+
+### A conversation keeps the browsing that produced its answers
+
+Every turn in which a Bot used a tool was disappearing 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,
+treated the first as damage from an interrupted run, and dropped it. It is not damage: it is how
+every tool call is stored, so what looked like a guard against one bad turn was deleting all of the
+real ones. Observed on a live thread where every browsing turn was counted unreadable and every one
+of them was well formed in the store's own dialect.
+
+The two spellings are now read as the same thing. The check stays for turns that really are
+malformed, and 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.
+
+
+### Run this on Kubernetes
+
+A Helm chart under `charts/openbot`, Bots and all, and the fixes that installing it for real turned
+up. Proven on a real EKS cluster: five workloads, replicas across two nodes, EBS volumes bound, and a
+Bot opening a real page from inside AWS with the decision in the audit trail.
+
+One chart, four targets: EKS, GKE, AKS and somebody's own cluster, with nothing but values between
+them. There is no cloud branching in any template. Every place the clouds genuinely differ is a
+value whose default is what a plain self-hosted cluster does: the cluster's own default StorageClass,
+no RuntimeClass, a plain Kubernetes Secret, an Ingress. Identity is one `serviceAccount.annotations`
+map, which 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, so Secrets Manager, Secret Manager
+and Key Vault are a values block rather than three code paths. Gateway API is supported beside
+Ingress rather than instead of it. `charts/openbot/ci` holds a values file per target.
+
+Two replicas by default, because horizontal is the point and one replica hides every bug that is
+not. A bad install is refused at `helm install`, naming the value to change, rather than discovered
+in a crash loop: no database or two of them, nobody who could sign in, nobody who would be an
+administrator, a key of the wrong shape, both routers enabled, or a browser asked for inside more
+than one replica.
+
+**A Bot's computer is not in an API pod.** The image runs one beside the API so that a single
+container works on its own, and `EMBEDDED_COMPUTER=off` turns it off. A replica must not carry a
+browser: it is a few hundred megabytes holding one Bot's logins, so scaling the API would scale
+those with it.
+
+**Migrations no longer need a development tool.** `bun x drizzle-kit migrate` cannot run in the
+shipped image at all. The CLI reads a TypeScript config, which needs the esbuild that
+`bun install --production` correctly leaves out, so it printed "Reading config file", exited 1 and
+said nothing else. `EMBEDDED_POSTGRES=on` was therefore starting a container whose database was
+never migrated, and the first symptom was the API reporting that `users` does not exist.
+`server/scripts/migrate.ts` uses the migrator inside `drizzle-orm`, which is a runtime dependency
+already, and keeps the same journal, so a database migrated by either tool is migrated.
+
+**A computer for each Bot, suspended when idle.** `computers.mode: sandbox` gives every Bot its own
+browser as a `Sandbox` from `kubernetes-sigs/agent-sandbox`, which is built for this workload: an
+isolated, stateful, singleton pod with a stable identity and persistent storage. Suspending is one
+field, and it keeps the volumes, so a computer comes back with its logins rather than signed out of
+everything. `shared` stays the default and needs nothing installed in the cluster.
+
+**The NetworkPolicy would have fenced the API off from its own work.** Its egress named DNS and the
+bundled database and nothing else, so on a cluster that enforces policy the API could not have
+reached a Bot's computer or, with a managed database, the database. Both are allowed now, and turning
+the policy on with an external database and no rule for it is refused rather than shipped. Worth
+knowing either way: EKS runs its CNI with `--enable-network-policy=false`, so a policy there installs,
+looks right, and does nothing at all.
+
+**A cluster with no controller is refused at install.** `computers.mode: sandbox` needs the
+agent-sandbox CRD, and without it the install succeeds, every pod is healthy, and the deployment
+looks finished until the first Bot asks for a browser. The chart reads the cluster and refuses,
+naming the one command that fixes it.
+
+**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.
+
+**Durable work, claimed by whichever replica gets there first.** `work_items` plus
+`select ... for update skip locked` and a lease: no coordinator, no leader election, and a replica
+added is throughput added. The idle-computer culler is its first user; scheduled routines and
+hand-offs between Bots are the other two, which is why it is written once rather than three times
+slightly differently. 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 something to do five times.
+
+**Which run of a computer this is, across a suspend.** A resumed browser counts snapshot
+generations from one again, so a ref the model still holds from before the suspend would match a row
+nothing has overwritten and the boundary would decide about an element on a page that no longer
+exists. The first answer here used the node and the pod address, and resuming a real computer
+disproved it: a suspended sandbox is very often rescheduled onto the same node and handed the same
+address back, so both were identical across a suspend and resume and the check would have said "same
+run" for the exact case it exists to catch. It reads the `Ready` condition's transition time instead,
+which moves every time a computer starts serving again.
+
+**Which run of a computer this is, on more than one replica.** `sessionOf` answered from a map in
+the process that started the computer, which is right until there are two: the replica that took a
+snapshot is usually not the one handling the click, and the second had nothing to answer with. An
+unknown session means "no opinion" and skips the generation check, so on exactly the deployment
+shape it was written for, the check that stops a ref from a replaced computer resolving against a
+live one was silently absent. It now asks the supervisor when it does not know, by listing rather
+than by ensuring, so asking never starts a computer that had stopped.
+### A routing trail says why a message was not routed, not only that it was not
+
+Every untagged message writes a `channel.routed` row, and that row carried `fallback: true` for two
+completely different situations: the router answering honestly that no specialist was a confident
+match, which is the feature working, and the router not answering at all, which is an endpoint that is
+down. Both read identically, so a deployment whose router had stopped working looked like one whose
+messages were simply hard to route.
+
+That is not hypothetical. The intent router spent an unknown period 404'ing on every deployment that
+set `OPENAI_BASE_URL`, because a `/v1` was appended to a URL that already had one. It was fixed in
+0.0.3, whose own note says untagged messages "silently stopped being routed and nothing said why".
+
+The row now carries `undecided`, naming the cause: `unreachable`, `unparsed`, `off-roster`,
+`unconfident`, or `one-candidate` — and `null` when the router did decide. Named values rather than a
+sentence, because the useful question is how often, and a count needs something to group by.
+
+Two smaller corrections came with it. A message routed to the only coworker that can reach the system
+it names kept that as its reason and threw the cause away, so a router that had been down for a week
+produced rows reading like reach-based routing working as intended; the cause now survives that path.
+And an answer containing no JSON at all — a model replying in prose — was recorded as the router
+naming a coworker off the roster, which sends whoever reads it to look at their roster rather than at
+the model. It is now reported as unparsed, which is what it is.
+
+Nothing changes about where a message goes. Every routing decision is the same decision it was.
+
+### Notion joins the connector catalogue
+
+Notion is now a governed MCP connector, reached through Notion's own hosted server on the
+catalogue's default transport, as the person asking — the same grant, policy and audit machinery
+Google Drive already runs through. Unlike Drive, it ships both read and write tools from the start;
+the writing ones are named in the catalogue, and an advertised tool absent from that list classifies
+as a read — so reconciling the write-tool names against what Notion's hosted server actually calls
+them, on the first Refresh tools, is required, not cosmetic. A tool the server never advertised at
+all still classifies as a write, same as any other connector.
+
+There is no client to register: this deployment introduces itself to Notion on first connect. That
+shortens setup but does not finish it — unlike Drive, whose tool list is this codebase's own code,
+Notion's tool list is an answer from Notion's hosted server, so a deployment has recorded none of it
+until Refresh tools has run at least once; and, like every other connector, a Bot gets nothing until
+its tools are granted to it. Setup is enable at `/admin/plugins/notion`, connect an account at
+`/settings/connected-accounts`, refresh tools, then grant — a bulk **Grant tools…** dialog on
+`/admin/plugins/notion` grants a batch of tools to a batch of Bots in one pass, one grant and one
+audit row per Bot per tool. No migration.
+
+### Refresh tokens rotate in place, and replicas take turns spending them
+
+A vendor that rotates refresh tokens invalidates the one it just handed out, so two replicas racing
+to use a stale token would have the loser refused, or worse: a rotating vendor's reuse detection can
+read that as a stolen token and revoke the whole connection. Every plugin call that mints an access
+token now locks the credential's vault row for the length of the exchange, so a second replica waits
+rather than races, and the rotated token is written back in the same transaction that held the lock.
+Nothing to configure; a connection just stops going stale under concurrent traffic.
+
+### An MCP token is spent only by its own server, and only at the address it was given
+
+Pointing a server at a credential is the one place this deployment takes a reference to a stored
+secret rather than the secret itself. Everywhere else, the value was typed into the same request that
+stores it: a Bot's key is minted from what an administrator pasted and the id it gets is nobody's to
+choose. So this is the one field where which secret and which address could be made to disagree, and
+the add settles the disagreement by spending the credential: the tool refresh runs before the call
+returns and sends what it decrypts to the URL from that same request.
+
+Two ways they could disagree, and both are now refused. A server could be pointed at any `mcp`
+credential in the vault, including one minted for a different vendor, so a token given to one server
+was deliverable to another. And re-adding a server with a different URL rewrote the address while
+keeping the credential, so the same token could be sent somewhere else entirely with no
+cross-server trick at all: the token really did belong to that server, and only the address moved.
+
+The second is why the first was not enough on its own. A credential now has to belong to the server
+it is attached to, and a server that already holds one cannot be re-added at a different address.
+Correcting a title or retrying an interrupted add sends the same URL and is unaffected. A server
+holding no credential can still be re-addressed, because there is nothing to misdirect. Moving a
+server that does hold one means removing it and adding it again with the token the new address is
+meant to have, which is the honest description of what has happened anyway.
+
+This matters more than "an administrator could misconfigure something". A stored credential cannot
+be read back by anybody, by design: the credentials screen answers that a credential exists and
+never what it is. These two shapes were the way around that, so a deployment where somebody has
+used them should treat the credentials involved as disclosed and rotate them.
+
+A token also stops outliving the server it was minted for. Re-adding a server without naming a
+credential used to clear the pointer while leaving the credential live, and removing a server retires
+its token by reading it off that pointer, so a cleared one meant the token survived its server and
+could be attached to a freshly created one at any address, where there was no longer a stored address
+to compare against. Three ordinary acts in a row and the binding above stopped meaning anything. The
+pointer now survives a re-add that names none, removal therefore finds and retires it, and a retired
+credential is refused rather than quietly attached to fail on its next call.
+
+Curated servers keep working as they did. Their URL comes from the catalogue rather than the
+request, and a per-instance hostname is matched against the vendor's own anchored pattern before
+anything is stored, so re-adding one cannot point it at an address of the caller's choosing.
+### A configured egress proxy reaches the browser that uses it
+
+`EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_` were documented as the way to give a Bot a stable
+outbound address, and Compose passed neither to anything. `docker-compose.yml` named no egress
+variable and had no `env_file`, so the shared computer resolved every Bot to no proxy and went out
+directly, and under the supervisor the same emptiness meant there was nothing to forward into the
+computers it creates.
+
+The failure was silent, which for a setting whose purpose is to give a security team a per-Bot
+address for network rules is the worst of the available failures. The stack started, the browser
+left by the host, and the Computers screen reported "Leaves directly" because it was reading the
+same empty environment.
+
+They now live in `egress.env`, which both the computer and the supervisor are given. A file rather
+than more `environment:` entries because `EGRESS_PROXY_` is derived from a Bot's id and there
+is no fixed set of names to list; a file of its own rather than `.env` because that one holds the
+deployment's secrets and the container running a browser and a Bot's shell is deliberately not
+given them. It is optional, so a deployment with no proxy is unchanged, and gitignored, because a
+proxy URL can carry a password.
+
+**Move these two out of `.env` and into `egress.env`.** In `.env` they reach no process.
+### Screens without a conversation stop polling for a Bot that does not exist
+
+Every surface asks which components its Bot holds, and asks again every few seconds so a revoked
+grant leaves an open conversation quickly. The Bot it asked about was whichever one the surface
+declared — and on a screen with no conversation at all, that was the placeholder id the routing
+holder falls back to, which no package registers and the server answers 404 for. An admin page left
+open polled a guaranteed miss every five seconds, indefinitely.
+
+Nothing looked wrong. The screen rendered, because an absent grant list and an empty one draw the
+same. The cost was the noise: a request log where the same 404 repeats forever is one where the 404
+that matters is invisible.
+
+The grant queries now wait for a surface to declare a real Bot, and simply do not run while the
+placeholder holds. Conversation surfaces — the Bot page, channels — declare one and are unchanged.
+### A rule can be tested against history before it is saved
+
+A boundary was written blind: an administrator typed a CEL rule, saved it, and learned what it
+actually matches from the refusals it produced in production. The trail already records every judged
+computer action with the same facts the gateway judged it on, so the question "what would this rule
+have done" had an answer nobody could ask.
+
+The Boundaries page now has **Test first** beside **Add rule**. The candidate — the current policy
+plus the drafted rule — is replayed over recent recorded actions, and the reply names each one it
+would have decided differently and the rule that would have decided it. Nothing is saved and nothing
+is decided; no audit row is written, because no action was permitted or refused.
+
+Replay, not simulation: the context is rebuilt from the audit row exactly as the gateway built it at
+decision time, through the same helpers, so a rule behaves here as it will behave live. The scan is
+bounded and biased to recency, and the reply says how many rows it covered.
+
+### A browser refusal names the element again
+
+Every browser context carries a neutral all-empty `mcp` object, so a rule naming `mcp.effect`
+evaluates to false instead of throwing. The refusal copy keyed on that object being present rather
+than on its contents, so every live browser refusal took the tool-call branch and read
+": on is blocked" — two empty strings where the element and the page belonged. The tests passed,
+because their contexts omitted the field the gateway always attaches.
+
+The branch now keys on the server and tool being named, which a real tool call always has. A refused
+click reads "“Submit order” on shop.example is blocked by the rule ..." again, which is what the Bot
+relays to the person asking.
+
### Knowledge searches instead of guessing
A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the
@@ -59,6 +413,60 @@ this port has to reach it another way**, which is what publishing it on every in
This does not reach back in time. A deployment that has been running with the two on one network
should assume a Bot could have read or written the database, and look at the trail with that in
mind.
+### A credential in an MCP server address is refused in the query and the fragment too
+
+Refusing `https://user:token@vendor.example/mcp` closed the userinfo spelling of a credential in the
+address and left the two obvious ones open. `?token=`, `?api_key=` and their neighbours were still
+accepted, and the address is stored and named in the trail exactly as given: audit redaction keys on
+the field name, `url` is not a sensitive one, so the secret was written to `mcp_servers` and to an
+append-only audit row in clear text. That is the same disclosure the userinfo rule exists to prevent,
+one character away.
+
+A parameter whose name reads as a credential is now refused, in the query string and in the fragment,
+and the refusal points at the token field without repeating what was typed. The name is read rather
+than matched against a list, so `?auth_token=`, `?x-api-key=` and `?X-Amz-Signature=` are refused
+alongside `?token=`: a rule that only catches the spellings somebody thought of reads as a guard
+while behaving like a gap. The test is on the parameter name rather than on the presence of a query,
+because vendors route and version with parameters and a floor that refused every one of them would
+be one an operator works around instead of with. `https://mcp.example.com/mcp?workspace=acme&version=2`
+is unaffected, and so is an ordinary fragment. A credential written into the *path* is still
+accepted: it is indistinguishable from a route, and at least one hosted provider addresses servers
+that way. **A deployment where somebody has put a credential in an address should treat it as
+disclosed and rotate it**, for the same reason as before: the audit row cannot be deleted.
+
+`metadata.goog` is refused too. It is Google's own short name for the metadata server, published
+beside `metadata.google.internal`, and it carries a dot and none of the suffixes this check lists, so
+it read as an ordinary vendor name. The long spelling was only ever refused incidentally, by the
+`.internal` rule. Both are now named, so the address this check was written for is refused on purpose
+rather than by luck.
+### A curated MCP server is pointed at its own kind of credential too
+
+Adding a server by URL was made to check which credential it is being pointed at. Adding one from the
+catalogue, the other half of the same screen, took the same field from the same request and stored it
+unread, so a credential of any kind could be attached to a curated server and spent by the refresh
+that runs before the add returns.
+
+Worth being plain about the reach, because it is narrower than the path beside it. The column is a
+foreign key, so an id naming nothing was already refused by the database, and the one entry in the
+catalogue is reached with each person's own Google account, whose OAuth client is registered through
+its own call and sent to an address pinned in code. Nothing could be delivered to an address a caller
+chose. What was reachable was a credential of the wrong kind being accepted and spent on behalf of
+somebody who never agreed to it, and a malformed id arriving as a database error rather than as a
+refusal.
+
+The rule now comes from the entry: a server the deployment holds one token for takes that token, and
+a server answered as the person asking takes no credential when it is added, because its client
+arrives through the call that mints it. Both add paths ask the same question in the same words, so a
+credential that does not exist and one of the wrong kind are still refused identically and the
+endpoint cannot be used to ask which ids are real. Adding a curated server the way the admin screen
+does is unchanged.
+
+Adding a curated server that is already there no longer clears the credential it points at. The
+column holds the OAuth client that registering one put there, and re-adding the server to change an
+instance host said nothing about that client, but cleared it anyway: the credential row was left
+behind with nothing pointing at it and nothing to revoke it, and everybody who had connected their
+account was told the deployment has no client registered. A re-add that names no credential now
+leaves the one that is there alone.
### Name the private addresses an agent may live at
@@ -233,6 +641,24 @@ without a word, because the input path looks for a viewer before it looks for an
A close now stops casting only when the socket closing is the one that was casting.
+### A sidebar channel row can be pinned or deleted
+
+Right-click on a channel in the sidebar and a menu opens with two entries: Pin channel and Delete
+channel.
+
+Pin is held per member rather than per channel, so pinning one holds it at the top of your own
+roster — newest first among pinned channels — and leaves every other member's roster unaffected.
+
+Delete is confirmed in a dialog first, and it is soft. The channel disappears from every member's
+roster and from a direct fetch of it, while the row, its transcript, and its Intelligence thread all
+survive. That disappearance is live, not just on next load: every member's open tabs drop the row as
+the delete lands, and a tab parked on the channel itself is sent home. The deletion is audited as its
+own `channel.deleted` row. A channel the deployment package defines is refused, with the reason
+named. Recovery today is clearing `channels.deleted_at` in the database directly; there is no restore
+control in the product.
+
+The deployment gains two nullable columns, via migration `0016`.
+
### An MCP server address that points inside the deployment is refused in three more spellings
Adding an MCP server by URL is checked before the address is stored, because that form is otherwise a
@@ -286,29 +712,44 @@ one they are, so those match too.
No configuration changes and nothing is stored differently; a deployment that was already on the
light theme sees no difference at all.
+### `start.sh` refuses a port that answers but is not OpenBot
+
+The startup checks asked whether a port answered, and treated that as proof the port belonged to this
+stack. Those are not the same claim. Any single-page app serves its index.html for every path it does
+not recognise, so an unrelated dashboard on a default port answers `200` to `/api/capabilities` as
+readily as this server does.
+
+The cost was not a wrong answer, it was a wrong answer three stages later. `require_free_or_ours`
+reported "already up", so the server was never started; `wait_for` then printed a green
+"server ready"; and the run failed at stage 3 inside `json.loads`, parsing that stranger's HTML. The
+error names `char 0`, which reads like an empty response rather than a `<`, so the visible symptom
+pointed nowhere near the port.
+
+Each surface is now asked for something only it can produce: a `licenseStatus` field for the server,
+its own `` for the app, `/health` for the compose services. When a check gives up it says
+whether the process failed to start or the port belongs to something else.
+
+The root cause was in `.env.example`, and is fixed there too. The server reads `PORT`, this script
+reads `SERVER_PORT`, `docs/configuration.md` documents `SERVER_PORT` as the setting, and only `PORT`
+shipped. Moving the server by editing that one line left the script still looking at 3001. Both names
+are now present, next to each other, saying they have to agree.
+
+**A run may now stop where it used to continue.** That is the point: it stops at the port that is
+wrong, naming it, rather than several steps later on a parse error.
+
+### `docker compose up -d` configures the same stack `scripts/start.sh` does
+
+`SUPERVISOR_TOKEN` and `COMPUTER_TOKEN` defaulted to the empty string in `docker-compose.yml`, so
+which stack you got depended on how you brought it up. `scripts/start.sh` resolves both to their
+`openbot-dev-*` defaults and exports them before calling compose. A plain `docker compose up -d` —
+which this project's own shutdown notes tell you to use — passed an empty string instead.
+
+`agent-computer` refuses to start without one, so that half failed loudly. The supervisor half was
+the quiet one: the server kept the token it started with while the supervisor held an empty string,
+and every call between them was refused at the door.
-### A conversation can be deleted
-
-Nothing removed a channel. Starting one was the only lever the product gave a person, and every
-conversation with every coworker sat in the roster forever, growing on every message the way
-`DEFAULT_CHANNEL_PAGE`'s own note already described: a page that was instant in a demo returns
-thousands of rows for anybody who has actually been using the product a while, one that never shrinks
-again.
-
-Deleting a channel now removes it for good. The channel row goes, and its memberships, its linked
-coworkers, and its Intelligence thread mapping go with it through the same foreign-key cascades that
-already existed for them — no migration needed, only a query that finally uses them. The deployment
-also asks Intelligence to permanently delete the thread itself, so the message history is not just
-unlisted, it is gone from the platform too.
-
-A thread the platform refuses to delete does not hold the channel hostage. The local removal already
-committed by the time that call runs, so a rejected or unreachable upstream delete leaves the channel
-gone from the roster regardless, with an audit row (`channel.deleted`) naming the thread and whether
-Intelligence actually forgot it. A channel that is gone locally with an orphaned thread still on the
-platform is a smaller, more honest failure than a channel sitting in the roster with its history
-silently wiped out from under it, and the audit trail is where an administrator finds the one that
-did not clean up completely. `DELETE /api/channels/:channelId` answers with `historyLeftBehind`, so a
-screen showing the outcome does not have to guess which of the two happened.
+Both now carry the same defaults `start.sh` applies, as `COMPUTER_IMAGE` already did two lines down.
+A value set in `.env` still wins, and a deployment should set one.
## 0.0.4
diff --git a/README.md b/README.md
index 63b1b726..f71a6a5f 100644
--- a/README.md
+++ b/README.md
@@ -127,7 +127,6 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
| `/bot` | Direct chat with a Bot; `?agent=` selects one. |
| `/skills` | Create and enable personal skills. |
| `/settings` | User preferences. |
-| `/admin/connectors` | Configure deployment knowledge sources. |
| `/admin/credentials` | Store write-only encrypted credentials. |
| `/admin/computers` | View, stop, and reset Bot computers. |
| `/admin/boundaries` | Configure browser/file/MCP action policy. |
@@ -147,7 +146,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you
- **Secrets never enter the transcript**: the trail records that a secret was requested and how long it was, not what it said.
- **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand-written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only.
- **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component.
-- **Governed MCP**: Google Drive ships in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks, and any tool not positively classified as a read is treated as a write. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website.
+- **Governed MCP**: Google Drive and Notion ship in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website.
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer.
- **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once.
- **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail.
diff --git a/agent-bot/src/history.ts b/agent-bot/src/history.ts
index b4f09a68..1c7165bb 100644
--- a/agent-bot/src/history.ts
+++ b/agent-bot/src/history.ts
@@ -63,16 +63,7 @@ export function toProviderMessages(
const toolCalls = message.toolCalls?.map((call) => ({
id: call.id,
type: "function" as const,
- function: {
- /*
- * A name is required by the provider and is not always present: read back from the thread
- * store these arrive undefined, and a payload carrying `"name": undefined` is rejected
- * outright. The call still has to be shown, or the model repeats an action it already
- * took, so it keeps its id and is named as something the model can read.
- */
- name: call.function?.name ?? "tool",
- arguments: call.function?.arguments ?? "{}",
- },
+ function: callDetails(call),
}));
messages.push({
role: "assistant",
@@ -109,3 +100,36 @@ export function toProviderMessages(
return messages;
}
+
+/**
+ * A tool call's name and arguments, in whichever dialect it arrived in.
+ *
+ * TWO SPELLINGS, ONE CALL. AG-UI describes `{id, type: "function", function: {name, arguments}}` and
+ * the history store writes `{id, name, args}`. Read back from a thread, every call arrives in the
+ * second, so code reaching straight for `call.function` finds nothing there.
+ *
+ * That was diagnosed here as "the name is not always present" and papered over with a default, which
+ * turned every restored call into a tool named `tool` with no arguments. The model is then shown a
+ * call it cannot recognise as the one it made, so it makes it again: the exact repetition the
+ * fallback was written to prevent.
+ */
+function callDetails(call: {
+ function?: { name?: unknown; arguments?: unknown };
+ name?: unknown;
+ args?: unknown;
+}): { name: string; arguments: string } {
+ const name = call.function?.name ?? call.name;
+ const args = call.function?.arguments ?? call.args;
+ return {
+ // Still defaulted, because a call with no name at all is rejected outright by the provider and
+ // showing the model something is better than losing the turn. It is now the last resort it was
+ // meant to be rather than the ordinary path.
+ name: typeof name === "string" && name ? name : "tool",
+ arguments:
+ typeof args === "string"
+ ? args
+ : args === undefined || args === null
+ ? "{}"
+ : JSON.stringify(args),
+ };
+}
diff --git a/agent-bot/tests/history.test.ts b/agent-bot/tests/history.test.ts
index 540d29d8..9aae3b0b 100644
--- a/agent-bot/tests/history.test.ts
+++ b/agent-bot/tests/history.test.ts
@@ -207,3 +207,47 @@ describe("a history that arrives out of order", () => {
expect(assistant.tool_calls?.[0]?.function.name).toBe("tool");
});
});
+
+/**
+ * A call read back from the thread store arrives in the store's dialect, not AG-UI's.
+ *
+ * `{id, name, args}` rather than `{id, type, function: {name, arguments}}`. Reaching straight for
+ * `call.function` finds nothing there, and the default underneath turned every restored call into a
+ * tool named `tool` with no arguments: the model is shown a call it cannot recognise as the one it
+ * made, so it makes it again. That is the repetition the default was written to prevent.
+ */
+describe("a tool call restored from the thread store", () => {
+ test("keeps the name and arguments it was made with", () => {
+ const messages = toProviderMessages({
+ messages: [
+ {
+ id: "m1",
+ role: "assistant",
+ content: null,
+ toolCalls: [
+ {
+ id: "call_1",
+ name: "computer_navigate",
+ args: '{"url":"https://news.ycombinator.com"}',
+ },
+ ],
+ },
+ {
+ id: "m2",
+ role: "tool",
+ toolCallId: "call_1",
+ content: '{"ok":true}',
+ },
+ ],
+ } as never);
+
+ const withCalls = messages.find(
+ (message: Record) => message.tool_calls,
+ ) as Record;
+ const call = (withCalls.tool_calls as Array>)[0];
+ const fn = call.function as Record;
+
+ expect(fn.name).toBe("computer_navigate");
+ expect(fn.arguments).toBe('{"url":"https://news.ycombinator.com"}');
+ });
+});
diff --git a/agent-computer/src/authorisation.ts b/agent-computer/src/authorisation.ts
index 667f2d44..cbeabb10 100644
--- a/agent-computer/src/authorisation.ts
+++ b/agent-computer/src/authorisation.ts
@@ -47,3 +47,30 @@ export function offeredToken(headers: Headers, url: URL): string {
export function isOpenPath(pathname: string): boolean {
return pathname === "/health";
}
+
+/**
+ * Which paths act on the computer, and so are refused while a person holds the wheel.
+ *
+ * One list, asked once per request, rather than a check inside each handler. The shell is the reason:
+ * `/exec` arrived after the wheel existed and was never given the guard the page paths had, so a Bot
+ * could keep running commands and writing files underneath somebody who had taken the browser at a
+ * login wall. A per-handler check is exactly the thing the next endpoint forgets, which is how that
+ * happened; a list the dispatcher consults is one an endpoint has to be added to.
+ *
+ * Reading is not acting. `/files/read` and `/files/list` stay open so a Bot that has been stopped can
+ * still read its own notes and explain what it was doing, which is the answer the person handing the
+ * wheel back usually wants.
+ */
+const ACTING_PATHS = new Set([
+ "/navigate",
+ "/click",
+ "/type",
+ "/key",
+ "/scroll",
+ "/exec",
+ "/files/write",
+]);
+
+export function actsOnTheComputer(pathname: string): boolean {
+ return ACTING_PATHS.has(pathname);
+}
diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts
index b4762635..5d7177a6 100644
--- a/agent-computer/src/index.ts
+++ b/agent-computer/src/index.ts
@@ -1,7 +1,12 @@
import { serve } from "bun";
import type { Page } from "playwright";
import { parseAriaSnapshot, type SnapshotElement } from "./aria-snapshot";
-import { isOpenPath, matchesToken, offeredToken } from "./authorisation";
+import {
+ actsOnTheComputer,
+ isOpenPath,
+ matchesToken,
+ offeredToken,
+} from "./authorisation";
import { isPlainBotId } from "./bot-id";
import {
type Control,
@@ -485,6 +490,26 @@ serve({
}
const session = sessionFor(botId);
+ /*
+ * The wheel, asked once for everything that acts.
+ *
+ * Refused here rather than inside each handler because the handler that forgets is the whole
+ * defect: the shell shipped without this check and ran commands underneath a person who had taken
+ * the browser at a login wall. `actsOnTheComputer` is the list, and a new acting endpoint is
+ * refused by being added to it rather than by remembering to repeat this.
+ */
+ if (actsOnTheComputer(url.pathname)) {
+ try {
+ session.control.assertBotMayAct();
+ } catch (error) {
+ // A person holding the wheel is not a failure of the action; the Bot should wait and say so.
+ if (error instanceof ControlError) {
+ return json({ error: error.message, humanHasControl: true }, 409);
+ }
+ throw error;
+ }
+ }
+
if (url.pathname === "/stream") {
/*
* The socket carries the Bot in the query because it cannot do it in a header. Every other call here names
@@ -696,7 +721,6 @@ serve({
const startedAt = Date.now();
try {
- session.control.assertBotMayAct();
const target = await currentPage(botId);
await target.goto(body.url, {
waitUntil: "domcontentloaded",
@@ -715,10 +739,6 @@ serve({
elapsedMs: Date.now() - startedAt,
});
} catch (error) {
- // A person holding the wheel is not a failed navigation; the Bot should wait.
- if (error instanceof ControlError) {
- return json({ error: error.message, humanHasControl: true }, 409);
- }
// The page is the Bot's working surface, so a failed navigation is reported rather than
// thrown: the transcript needs to say what happened, and the browser stays usable.
return json(
@@ -893,7 +913,6 @@ serve({
const startedAt = Date.now();
try {
- session.control.assertBotMayAct();
const target = await currentPage(botId);
const detail = await performAction(
session,
@@ -932,10 +951,6 @@ serve({
if (error instanceof StaleSnapshotError) {
return json({ error: error.message, stale: true }, 409);
}
- // 409 as well, and for the same reason: nothing is broken, the caller simply has to wait.
- if (error instanceof ControlError) {
- return json({ error: error.message, humanHasControl: true }, 409);
- }
return json({ error: describe(error, "The action failed.") }, 502);
}
}
diff --git a/agent-computer/src/profile-listing.ts b/agent-computer/src/profile-listing.ts
new file mode 100644
index 00000000..cd00047c
--- /dev/null
+++ b/agent-computer/src/profile-listing.ts
@@ -0,0 +1,38 @@
+/**
+ * Which entries under the profiles root are Bots.
+ *
+ * ITS OWN MODULE SO ONE ANSWER SERVES BOTH SIDES. The rule lived inline in `profiles.ts`, and the
+ * test that covered it had a second copy: delete the production filter and the suite stayed green,
+ * because it was checking its own copy rather than the shipped one. A predicate worth testing is
+ * worth importing.
+ *
+ * Free of Playwright on purpose. `profiles.ts` launches browsers, so a test that wanted this rule had
+ * to drag a browser runtime in with it, which is most of why the copy existed in the first place.
+ */
+import { isPlainBotId } from "./bot-id";
+
+/** The shape of a directory entry, as both `readdir` and a test can supply it. */
+export type ProfileEntry = { name: string; isDirectory: () => boolean };
+
+/**
+ * The Bot ids among a directory listing, sorted and deduplicated.
+ *
+ * ONLY ENTRIES THIS CODE COULD HAVE MADE. The root is a mounted volume, and a volume is not an empty
+ * directory: a real disk formatted ext4 arrives with `lost+found` already in it, so on a cloud the
+ * fleet page listed a Bot by that name, offered to reset it, and nobody could say where it came from.
+ * Never seen locally, because a bind mount and kind's local-path volumes have no such directory,
+ * which is exactly the shape of bug that ships.
+ *
+ * `isPlainBotId` is the same allow-list that stops a hostile id becoming a path, used here for the
+ * other half of the question: an entry it would refuse to create is not one of ours to list.
+ */
+export function botIdsIn(entries: readonly ProfileEntry[]): string[] {
+ return [
+ ...new Set(
+ entries
+ .filter((entry) => entry.isDirectory())
+ .filter((entry) => isPlainBotId(entry.name))
+ .map((entry) => entry.name),
+ ),
+ ].sort();
+}
diff --git a/agent-computer/src/profiles.ts b/agent-computer/src/profiles.ts
index 420931ee..28f6547f 100644
--- a/agent-computer/src/profiles.ts
+++ b/agent-computer/src/profiles.ts
@@ -40,6 +40,7 @@ import { profileDirectoryFor } from "./bot-id";
import { chooseEvictions, chooseIdle } from "./browser-eviction";
import { egressFor, egressLabel } from "./egress";
import { numberFromEnv } from "./env";
+import { botIdsIn } from "./profile-listing";
// Re-exported so callers that already import it from here do not change, while the test imports it
// from the playwright-free `./env` instead of pulling this module's browser driver in with it.
@@ -368,12 +369,11 @@ export function createProfiles(root: string) {
const onDisk = await readdir(root, { withFileTypes: true }).catch(
() => [],
);
- return [
- ...new Set([
- ...onDisk.filter((e) => e.isDirectory()).map((e) => e.name),
- ...live.keys(),
- ]),
- ].sort();
+ /*
+ * The rule lives in its own module, so the test that covers it imports the same one this uses.
+ * It had a copy before, which meant deleting this filter left the suite green.
+ */
+ return [...new Set([...botIdsIn(onDisk), ...live.keys()])].sort();
},
/** What the admin surface lists. Running or not, because a Bot that has a profile has a computer. */
diff --git a/agent-computer/tests/authorisation.test.ts b/agent-computer/tests/authorisation.test.ts
index 9ec6521b..34fcfdc4 100644
--- a/agent-computer/tests/authorisation.test.ts
+++ b/agent-computer/tests/authorisation.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test";
-import { isOpenPath, matchesToken, offeredToken } from "../src/authorisation";
+import {
+ actsOnTheComputer,
+ isOpenPath,
+ matchesToken,
+ offeredToken,
+} from "../src/authorisation";
/**
* The check that stands in front of a Bot's browser.
@@ -88,3 +93,50 @@ describe("what an unauthenticated caller may reach", () => {
}
});
});
+
+/**
+ * Which paths the wheel stops.
+ *
+ * A person takes the wheel at a login wall precisely because they no longer want the Bot acting, and
+ * `control.ts` states the property outright: "While a person holds control every acting call from the
+ * Bot is refused". That was true of the page from the start and untrue of the shell, which arrived
+ * later (#62) and was never wired to the wheel, so a Bot could run a command and rewrite the
+ * workspace underneath somebody mid-sign-in.
+ *
+ * The list lives here, beside the other path decision, rather than in `index.ts`, for the reason the
+ * header of this file gives: a decision next to `chromium.launch()` cannot be tested without Chrome.
+ *
+ * Reading is not acting. `/files/read` and `/files/list` stay open so a Bot waiting to be handed the
+ * wheel back can still say what it was doing.
+ */
+describe("what the wheel stops while a person is driving", () => {
+ test("every path that acts on the computer, the shell and a workspace write included", () => {
+ for (const path of [
+ "/navigate",
+ "/click",
+ "/type",
+ "/key",
+ "/scroll",
+ "/exec",
+ "/files/write",
+ ]) {
+ expect(actsOnTheComputer(path)).toBeTrue();
+ }
+ });
+
+ test("reading, looking and the handover itself are not acting", () => {
+ for (const path of [
+ "/files/read",
+ "/files/list",
+ "/snapshot",
+ "/screenshot",
+ "/health",
+ "/control",
+ "/control/take",
+ "/control/release",
+ "/stream",
+ ]) {
+ expect(actsOnTheComputer(path)).toBeFalse();
+ }
+ });
+});
diff --git a/agent-computer/tests/profile-listing.test.ts b/agent-computer/tests/profile-listing.test.ts
new file mode 100644
index 00000000..e790a80c
--- /dev/null
+++ b/agent-computer/tests/profile-listing.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from "bun:test";
+import { mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { isPlainBotId } from "../src/bot-id";
+import { botIdsIn } from "../src/profile-listing";
+
+/**
+ * Which directories under the profiles root are Bots.
+ *
+ * The root is a mounted volume, and a volume is not an empty directory. A real disk formatted ext4
+ * arrives with `lost+found` already in it, so on a cloud the fleet page listed a Bot by that name and
+ * offered to reset it. Never seen locally, because a bind mount and kind's local-path volumes have no
+ * such directory: the bug appears only on the deployment shape the feature is for.
+ *
+ * The rule is the one that already exists. `isPlainBotId` decides what may become a profile path, so
+ * an entry it would refuse to create is not one of ours to list.
+ */
+async function knownIn(root: string): Promise {
+ const onDisk = await readdir(root, { withFileTypes: true }).catch(() => []);
+ /*
+ * THE SHIPPED PREDICATE, imported rather than restated.
+ *
+ * This test used to carry its own copy of the filter, which meant it proved the copy rather than
+ * the product: deleting the real one left the suite green and the fleet page listing `lost+found`
+ * as a Bot again.
+ */
+ return botIdsIn(onDisk);
+}
+
+describe("listing the Bots that have a computer", () => {
+ test("lists Bot profiles and ignores what the filesystem put there", async () => {
+ const root = await mkdtemp(join(tmpdir(), "profiles-"));
+ await mkdir(join(root, "knowledge"));
+ await mkdir(join(root, "risk-analyst"));
+ // What an ext4 volume brings with it, which is the whole reason this test exists.
+ await mkdir(join(root, "lost+found"));
+ // A file is not a computer either.
+ await writeFile(join(root, "notes.txt"), "");
+
+ expect(await knownIn(root)).toEqual(["knowledge", "risk-analyst"]);
+ });
+
+ test("the name a real volume arrives with is not a usable Bot id", () => {
+ // Stated directly, because this is the property the filter leans on.
+ expect(isPlainBotId("lost+found")).toBe(false);
+ expect(isPlainBotId("knowledge")).toBe(true);
+ });
+});
diff --git a/agent-langgraph/src/history.ts b/agent-langgraph/src/history.ts
index e95f376a..ce16acf4 100644
--- a/agent-langgraph/src/history.ts
+++ b/agent-langgraph/src/history.ts
@@ -73,11 +73,11 @@ export function toLangChainMessages(input: RunAgentInput): BaseMessage[] {
tool_calls:
message.toolCalls?.map((call) => ({
id: call.id,
- name: call.function.name,
+ name: callDetails(call).name,
// LangChain wants parsed arguments where AG-UI carries the raw string. A call whose
// arguments did not parse is passed as empty rather than dropped: the model needs to
// see that it made the call, or it makes it again.
- args: parseArguments(call.function.arguments),
+ args: parseArguments(callDetails(call).arguments),
})) ?? [],
}),
);
@@ -95,7 +95,7 @@ export function toLangChainMessages(input: RunAgentInput): BaseMessage[] {
new ToolMessage({
tool_call_id: call.id,
content: NO_ANSWER_CAME,
- name: call.function.name,
+ name: callDetails(call).name,
}),
);
}
@@ -116,3 +116,28 @@ function parseArguments(raw: string): Record {
return {};
}
}
+
+/**
+ * A tool call's name and arguments, in whichever dialect it arrived in.
+ *
+ * TWO SPELLINGS, ONE CALL. AG-UI describes `{id, type: "function", function: {name, arguments}}` and
+ * the history store writes `{id, name, args}`. Read back from a thread, every call arrives in the
+ * second, so `call.function.name` here did not merely degrade: it threw, and took the run with it.
+ */
+function callDetails(call: {
+ function?: { name?: unknown; arguments?: unknown };
+ name?: unknown;
+ args?: unknown;
+}): { name: string; arguments: string } {
+ const name = call.function?.name ?? call.name;
+ const args = call.function?.arguments ?? call.args;
+ return {
+ name: typeof name === "string" && name ? name : "tool",
+ arguments:
+ typeof args === "string"
+ ? args
+ : args === undefined || args === null
+ ? "{}"
+ : JSON.stringify(args),
+ };
+}
diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx
index b6e8d2dc..99946b00 100644
--- a/app/src/components/app-sidebar/app-sidebar.tsx
+++ b/app/src/components/app-sidebar/app-sidebar.tsx
@@ -13,7 +13,12 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
-import { Link, type LinkOptions, useNavigate } from "@tanstack/react-router";
+import {
+ Link,
+ type LinkOptions,
+ useNavigate,
+ useParams,
+} from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type * as React from "react";
import { useState } from "react";
@@ -109,6 +114,44 @@ function matchingChannels(
);
}
+/**
+ * Pinned channels first, everything else after, newest activity first within each group.
+ *
+ * The mirror of a server rule, not the rule itself: the roster query orders pinned-first and its
+ * cursor carries the pin, so a pinned channel arrives on page one however long ago it was last
+ * spoken in. Sorting here as well is for what happens between refetches — the socket patches a pin
+ * onto a loaded row without moving it, and re-sorts a page by recency alone — which is the same
+ * reason `byRecency` in use-channel-events.ts mirrors the recency rule. A stable partition, so the
+ * recency order inside each group is whatever arrived.
+ */
+export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] {
+ return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned));
+}
+
+/**
+ * Whether a Bot has said something this member has not had on screen yet.
+ *
+ * A Bot's message, and only a Bot's: your own message carries a null agent id and reading your own
+ * words needs no marker. ISO-8601 strings compare correctly as strings, which is the same bet the
+ * server's recency sort already makes.
+ */
+export function hasUnseenActivity(channel: ChannelSummary): boolean {
+ if (channel.lastMessageAgentId === null || channel.lastMessageAt === null) {
+ return false;
+ }
+ return (
+ channel.lastReadAt === null || channel.lastMessageAt > channel.lastReadAt
+ );
+}
+
+/** Unseen activity somewhere you are not looking. The open channel never shows the dot. */
+export function isUnread(
+ channel: ChannelSummary,
+ openChannelId: string | undefined,
+): boolean {
+ return channel.id !== openChannelId && hasUnseenActivity(channel);
+}
+
/**
* A roster row that can animate.
*
@@ -124,6 +167,13 @@ function ChannelRow({
animateOrder: boolean;
}) {
const shouldReduceMotion = useReducedMotion();
+ // Whether this row is unread, as a boolean, for the same reason `Channel` computes `isOpen`
+ // that way: navigating re-renders the rows whose answer changed, not the whole roster.
+ const unread = useParams({
+ strict: false,
+ select: (params) =>
+ isUnread(channel, (params as { channelId?: string }).channelId),
+ });
return (
);
@@ -160,7 +212,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
useChannelEvents();
const [search, setSearch] = useState("");
const searching = search.trim().length > 0;
- const visibleChannels = matchingChannels(channels.data, search);
+ const visibleChannels = pinnedFirst(matchingChannels(channels.data, search));
/*
* FILTERING DOES NOT ANIMATE. Rows exit and relayout on every keystroke otherwise, which is a
* list thrashing under somebody who is still typing — and the moving target is the very thing
diff --git a/app/src/components/app-sidebar/channel.tsx b/app/src/components/app-sidebar/channel.tsx
index 9b15b799..18733576 100644
--- a/app/src/components/app-sidebar/channel.tsx
+++ b/app/src/components/app-sidebar/channel.tsx
@@ -1,34 +1,39 @@
-import { IconDots } from "@tabler/icons-react";
+import {
+ IconPin,
+ IconPinFilled,
+ IconPinnedOff,
+ IconTrash,
+} from "@tabler/icons-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { memo, useState } from "react";
-import { deleteChannelMutationOptions } from "@/lib/channels/mutations";
-import { ChannelAvatar } from "../channels/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
import {
- AlertDialog,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "../ui/alert-dialog";
-import { Button } from "../ui/button";
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "../ui/dropdown-menu";
+ deleteChannelMutationOptions,
+ setChannelPinnedMutationOptions,
+} from "@/lib/channels/mutations";
+import { ChannelAvatar } from "../channels/avatar";
/**
* Memoized roster row. `use-channel-events` preserves unchanged row identity, and
* `content-visibility` keeps off-screen rows cheap without virtualization.
*
- * State inside a row is no reason to drop the memo: `memo` compares the props it is handed and has
- * nothing to say about a hook. Dropping it re-renders every row in the roster whenever the sidebar
- * renders, which is the cost the identity-preserving patch in `use-channel-events` exists to avoid.
+ * Right-click opens Pin and Delete. Deleting is confirmed in a dialog that names the channel,
+ * because the row it was invoked on is one of several identical-looking rows.
*/
export const Channel = memo(function Channel({
channelId,
@@ -36,139 +41,175 @@ export const Channel = memo(function Channel({
name,
lastMessage,
lastMessageAt,
+ pinned,
+ unread,
}: {
channelId: string;
participantIds: string[];
name: string;
lastMessage?: string;
lastMessageAt?: string;
+ pinned: boolean;
+ unread: boolean;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
- // `strict: false`: this row renders in the sidebar on every screen, not only while its own
- // channel is open, so there may be no `channelId` route param to read at all.
- const { channelId: openChannelId } = useParams({ strict: false });
- const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ // Whether this row's channel is the one on screen, as a boolean, so navigating between
+ // channels re-renders the two rows whose answer changed rather than the whole roster.
+ const isOpen = useParams({
+ strict: false,
+ select: (params) =>
+ (params as { channelId?: string }).channelId === channelId,
+ });
+ const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient));
const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient));
+ const [confirming, setConfirming] = useState(false);
+ /**
+ * Why a pin did not take, said on the row it was asked of.
+ *
+ * Pinning used to fail in total silence: the menu closed, the pin did not move, and nothing on
+ * screen accounted for it — which reads as the app ignoring the click. There is no toast in this
+ * app, and the row is where the person was looking, so the sentence goes here and is replaced by
+ * the next attempt.
+ */
+ const [pinProblem, setPinProblem] = useState(null);
- const handleDelete = async () => {
- // Navigate away first: the row this menu lives on unmounts the moment the list invalidates,
- // and a screen still pointed at a channel id that no longer resolves is worse than a screen
- // that moved on a beat early.
- if (openChannelId === channelId) {
+ const confirmDelete = async () => {
+ /*
+ * Away first when this row's channel is the one on screen.
+ *
+ * The roster invalidates the moment the delete lands, so this row — and the dialog living inside
+ * it — unmounts while the rest of this function is still owed. Navigating after the mutation
+ * therefore ran in a component that was already gone, leaving somebody looking at a conversation
+ * that no longer exists. Leaving before asking is safe in the other direction: a refused delete
+ * puts them on the roster with the channel still in it, and says why in the dialog.
+ */
+ if (isOpen) {
await navigate({ to: "/" });
}
try {
await deleteChannel.mutateAsync(channelId);
- /*
- * Closed on success rather than left to the unmount.
- *
- * The row does go away when the roster invalidates, taking this dialog with it, but that is a
- * side effect of a cache write and not something this component controls.
- */
- setDeleteDialogOpen(false);
} catch {
- /*
- * Left open, deliberately. A delete that failed leaves the row exactly where it was, so
- * closing would return the person to a roster that still lists the conversation they just
- * asked to be rid of, with nothing anywhere saying why. The message is rendered below;
- * `mutateAsync` rejects rather than swallowing, which is why this catch exists at all.
- */
+ // The error is on the mutation and rendered in the dialog; leaving it open says "not done".
+ return;
}
+ setConfirming(false);
};
return (
-
-
+
+
+
+
+
+
+
+
+
+ {name}
+
+
+ {lastMessageAt}
+
+
+
+
+ {lastMessage}
+
+ {unread ? (
+ /* State about the message beats state about the row, so it sits first. */
+
+ ) : null}
+ {pinned ? (
+
+ ) : null}
+
+
+
+
+ >
);
});
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index 84edf602..f5044c1c 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -409,8 +409,17 @@ const TranscriptMessage = memo(function TranscriptMessage({
-
-
+ {/*
+ A Bot's message takes the whole column, not the width of its words: block content
+ inside it — a fenced code block, a table — should span the transcript rather than
+ shrink to its own text. A person's bubble keeps fitting what they said.
+ */}
+
+
{isUser ? (
// A person's own message is shown exactly as they typed it. Rendering it as markdown
// would silently reformat what they said, and an asterisk in a sentence is not
diff --git a/app/src/components/channels/composer/composer.tsx b/app/src/components/channels/composer/composer.tsx
index 0c4781b2..dc8ef92b 100644
--- a/app/src/components/channels/composer/composer.tsx
+++ b/app/src/components/channels/composer/composer.tsx
@@ -70,6 +70,17 @@ export type ComposerProps = {
* connecting and restoring its history, and the composer is on screen throughout.
*/
pending?: boolean;
+ /**
+ * Put the caret in the editor the first moment it can take one, and then leave it alone. For the
+ * screens where typing is the next thing a person does — choosing a coworker answers the "to"
+ * field, and the message is what remains.
+ *
+ * Once, not on every change: it used to re-claim the caret whenever the editor became interactive
+ * again, so a person who had clicked into something else — a search box, another channel's row —
+ * had the cursor yanked back the moment a turn finished. A send of their own still returns the
+ * caret, because that one they asked for.
+ */
+ autoFocus?: boolean;
/**
* There is a run on the wire for Stop to reach.
*
@@ -95,6 +106,7 @@ export function Composer({
onStop,
disabled = false,
pending = false,
+ autoFocus = false,
stoppable,
}: ComposerProps) {
const [value, setValue] = useState([]);
@@ -103,6 +115,8 @@ export function Composer({
const promptAreaRef = useRef(null);
/** A send has completed and the caret is owed back, as soon as the editor will take it. */
const wantsFocus = useRef(false);
+ /** `autoFocus` has been honoured once, and is not owed again for the life of this composer. */
+ const claimedAutoFocus = useRef(false);
const isBusy = pending || isSubmitting;
const triggers = useMemo(
@@ -191,14 +205,24 @@ export function Composer({
* Keyed off the editor becoming interactive rather than off the send resolving, so it survives
* whatever the parent does with `pending` in between — and it runs after the commit, which is the
* only point at which the element is enabled and focusable.
+ *
+ * Two different debts, and only one of them recurs. A finished send owes the caret back every
+ * time. `autoFocus` owes it exactly once, at the start: it used to be re-owed on every
+ * disabled/busy transition, so every completed turn stole the caret back from wherever the person
+ * had moved it, and a composer that had never been sent from would grab focus mid-conversation.
*/
useEffect(() => {
- if (!wantsFocus.current || disabled || isBusy) {
+ if (disabled || isBusy) {
+ return;
+ }
+ const owed = wantsFocus.current || (autoFocus && !claimedAutoFocus.current);
+ if (!owed) {
return;
}
wantsFocus.current = false;
+ claimedAutoFocus.current = true;
promptAreaRef.current?.focus();
- }, [disabled, isBusy]);
+ }, [autoFocus, disabled, isBusy]);
const handleFormSubmit = (event: FormEvent) => {
event.preventDefault();
diff --git a/app/src/components/channels/conversation-view.tsx b/app/src/components/channels/conversation-view.tsx
index 420e33ca..dd28b866 100644
--- a/app/src/components/channels/conversation-view.tsx
+++ b/app/src/components/channels/conversation-view.tsx
@@ -26,6 +26,7 @@ export function ConversationView({
commands,
disabled = false,
pending = false,
+ autoFocus = false,
stopped,
stoppable,
queueWhileBusy = false,
@@ -53,6 +54,8 @@ export function ConversationView({
* drains on this falling.
*/
pending?: boolean;
+ /** Focus the composer the moment it can take a caret; forwarded to the composer. */
+ autoFocus?: boolean;
/** Why the last turn ended without an answer. Drawn at the end of the transcript, not here. */
stopped?: string;
/**
@@ -224,6 +227,7 @@ export function ConversationView({
{notice}
- Nothing yet. Commands the Bot runs, and files it reads, appear here as
- they happen.
-
+
+
+
+ Nothing yet. Commands the Bot runs, and files it reads, appear here
+ as they happen.
+
+
+
);
}
diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx
index 9ba91baa..532c85b1 100644
--- a/app/src/components/computer/computer-view.tsx
+++ b/app/src/components/computer/computer-view.tsx
@@ -7,9 +7,13 @@ import {
supplySecret,
takeControl,
} from "@/lib/computers/control";
-import { readScreenshot, type Screenshot } from "@/lib/computers/screen";
+import {
+ readPageFrame,
+ readScreenshot,
+ type Screenshot,
+} from "@/lib/computers/screen";
+import { ChannelAvatar } from "../channels/avatar";
import { LiveScreen } from "./live-screen";
-import { ComputerPlaceholder } from "./placeholder";
/** Explicit blank-browser URLs use placeholder artwork; missing URL fields are treated as real pages. */
function isBlankBrowser(shot: Screenshot): boolean {
@@ -18,6 +22,53 @@ function isBlankBrowser(shot: Screenshot): boolean {
return url === "" || url === "about:blank";
}
+/** The part of a URL worth putting on screen; the whole thing is rarely readable at this size. */
+function hostOf(url: string): string {
+ try {
+ return new URL(url).host;
+ } catch {
+ return url;
+ }
+}
+
+/**
+ * What each finished turn opened, and the frame it ended on, kept outside any component.
+ *
+ * MODULE SCOPE, BECAUSE THE TILE DOES NOT SURVIVE. A transcript re-renders freely and remounts the
+ * tiles in it, and anything held in component state goes with it: the fresh mount has no page yet,
+ * behaves for one render like a live turn, and reaches for the live screen. Keyed on the tool call,
+ * which is the identity of the turn rather than of the component drawing it.
+ *
+ * Bounded, because a long conversation is a lot of screenshots. Oldest out first, and a turn whose
+ * frame has been dropped falls back to naming its page.
+ */
+type RememberedTurn = {
+ page?: { url?: string; title?: string };
+ frame?: { base64: string; url: string };
+ /** Whether the server has already been asked, so a turn with no frame is not asked again. */
+ asked?: boolean;
+};
+const REMEMBERED_TURNS = new Map();
+const MAX_REMEMBERED_TURNS = 40;
+
+function rememberTurn(toolCallId: string, patch: RememberedTurn): void {
+ const existing = REMEMBERED_TURNS.get(toolCallId) ?? {};
+ /*
+ * A FRAME IS WRITTEN ONCE, which is what the server's own insert says and what this has to agree
+ * with. Letting a later write win is exactly what went wrong: the tile restored the right frame and
+ * then replaced it, one render later, with a screenshot of whatever the Bot had open by then.
+ */
+ const merged: RememberedTurn = { ...existing, ...patch };
+ if (existing.frame) merged.frame = existing.frame;
+ REMEMBERED_TURNS.delete(toolCallId);
+ REMEMBERED_TURNS.set(toolCallId, merged);
+ while (REMEMBERED_TURNS.size > MAX_REMEMBERED_TURNS) {
+ const oldest = REMEMBERED_TURNS.keys().next().value;
+ if (oldest === undefined) break;
+ REMEMBERED_TURNS.delete(oldest);
+ }
+}
+
/** Default browser viewport ratio, reserved before the first screenshot arrives. */
const DEFAULT_ASPECT_RATIO = 1280 / 800;
@@ -45,6 +96,80 @@ const SETTLE_TIMEOUT_MS = 30_000;
/** Short confirmation window after a secret is sent to the page. */
const SECRET_CONFIRM_MS = 6_000;
+/**
+ * What the frame says when there is no picture in it.
+ *
+ * Shared by the card and the full-size view because it is the same fact at either size, and because
+ * the full-size view is now reachable with nothing to draw: the wheel lives down there, so a person
+ * whose Bot is looking at a blank browser — or whose screen cannot be read at all — has to be able
+ * to open it and be told why it is empty, rather than find a disabled frame and no way in.
+ */
+function NothingToSee({
+ problem,
+ blankBrowser,
+ settled,
+ page,
+}: {
+ problem: string | null;
+ blankBrowser: boolean;
+ /** Whether this is a turn that has finished, rather than the browser as it is now. */
+ settled?: boolean;
+ /** The page that turn opened, named when there is no picture of it. */
+ page?: { url?: string; title?: string } | undefined;
+}) {
+ return (
+
+ {settled ? (
+ <>
+ {/*
+ What this turn had open, named rather than drawn.
+
+ The picture is gone: nothing stored it, and fetching one now would show a different page.
+ Naming the page is the honest version of the same sentence, and it stays true however
+ many times the Bot has browsed since.
+
+ GATED ON THE TURN BEING OVER, not on whether a live frame happens to be in hand. A tile
+ that was live a moment ago keeps its last screenshot in state after it settles, and this
+ used to check for that: with one held and no frame stored, it fell through to "Waiting
+ for the assistant's screen…" and waited there for ever, because the poll that would have
+ ended the wait stops the moment a turn settles.
+ */}
+ {page?.url ? (
+ <>
+ {page.title || "A page"}
+ {hostOf(page.url)}
+
+ Opened during this turn. The screen has moved on since.
+
+ >
+ ) : (
+ /*
+ * A turn that ended without getting anywhere: refused by a boundary, stopped, or failed.
+ * Saying "opened during this turn" here would describe something that did not happen.
+ */
+ This turn did not open a page.
+ )}
+ >
+ ) : problem ? (
+ <>
+
+ You cannot see the screen right now
+
+ {problem}
+
+ The assistant may still be working. An administrator can check
+ whether its computer is running.
+
+ >
+ ) : blankBrowser ? (
+ The assistant has not opened a page yet.
+ ) : (
+ Waiting for the assistant's screen…
+ )}
+
+ );
+}
+
type Props = {
/** Which computer to watch. One shared computer unless each Bot has been given its own. */
computerId: string;
@@ -55,6 +180,32 @@ type Props = {
aspectRatio?: number;
minWidth?: number;
minHeight?: number;
+ /** Whose screen this is, drawn as a small badge over the frame. Absent, no badge is drawn. */
+ name?: string;
+ /**
+ * The page this turn left the browser on, for a turn that has finished.
+ *
+ * A conversation is a record, and a record must not change its mind. Without this, reopening a
+ * conversation made every past turn fetch the screen as it is now, so an answer about Hacker News
+ * from an hour ago sat under a picture of whatever the Bot has open today. The frame was live, the
+ * caption was not, and the turn read as though it had browsed somewhere it never went.
+ */
+ page?: { url?: string; title?: string };
+ /**
+ * Whether the turn this tile belongs to has ended.
+ *
+ * SEPARATE FROM HAVING A PAGE. A navigation that was refused, failed or stopped ends without one,
+ * and a tile that decided history by "do I have a page" left exactly those turns polling the live
+ * screen for ever, under an answer that had nothing to do with what was on it.
+ */
+ finished?: boolean;
+ /**
+ * The tool call this tile belongs to, which is what a kept frame is filed under.
+ *
+ * Without it the tile can still name the page; with it, it can show the page. Optional because the
+ * side panel is not a turn and has nothing to remember.
+ */
+ toolCallId?: string;
};
export function ComputerView({
@@ -64,6 +215,10 @@ export function ComputerView({
aspectRatio = DEFAULT_ASPECT_RATIO,
minWidth = DEFAULT_MIN_WIDTH,
minHeight = DEFAULT_MIN_HEIGHT,
+ name,
+ page,
+ finished,
+ toolCallId,
}: Props) {
const [shot, setShot] = useState(null);
const [problem, setProblem] = useState(null);
@@ -92,8 +247,76 @@ export function ComputerView({
/** Force a short watch window after non-Bot actions such as secret entry. */
const watchUntil = useRef(0);
+ /**
+ * A finished turn is history, and history is not polled.
+ *
+ * While a turn runs, the frames are that turn's own and freeze where it left them, which is right.
+ * Reopening the conversation later is the case this guards: the component mounts with no frame,
+ * and fetching one would put today's page under yesterday's answer. It shows the page that turn
+ * actually left open instead, which is the thing being remembered.
+ *
+ * `page` is what marks a turn as settled history rather than one still going, so a caller that
+ * knows nothing about the page keeps the old behaviour and nothing regresses.
+ *
+ * DELIBERATELY NOT "AND WE HAVE NO FRAME YET". That is what this said first, and it undid itself:
+ * restoring the kept frame set the frame, which made the turn stop counting as history, which
+ * restarted the polling this exists to prevent, which replaced the restored picture with the live
+ * one. The turn being over is the fact; whether a picture has arrived yet is not.
+ */
+ if (toolCallId && page?.url) rememberTurn(toolCallId, { page });
+ const knownPage =
+ page?.url !== undefined
+ ? page
+ : toolCallId
+ ? REMEMBERED_TURNS.get(toolCallId)?.page
+ : undefined;
+ const keptFrame = toolCallId
+ ? (REMEMBERED_TURNS.get(toolCallId)?.frame ?? null)
+ : null;
+ /** Bumped when a frame arrives, because the store it lands in is not React state. */
+ const [, setFrameArrived] = useState(0);
+
+ const settled = !active && (finished || Boolean(knownPage));
+
+ /*
+ * The frame this turn's page was showing, fetched once and then kept.
+ *
+ * A READ, AND ONLY A READ. The tile used to capture the frame itself once the turn went inactive,
+ * and it kept filing the wrong picture: a reopened turn and one that has just finished look
+ * identical from in here, the same computer is driven by other conversations between the two, and
+ * a resumed computer starts blank. The frame is now taken on the server the moment the navigation
+ * succeeds, which is the one moment the screen is certainly showing the page that was asked for,
+ * so there is nothing left here to race.
+ */
+ useEffect(() => {
+ if (!toolCallId || !settled) return;
+ const remembered = REMEMBERED_TURNS.get(toolCallId);
+ /*
+ * Asked once per turn, answer or not. Without remembering the empty answer, every turn from
+ * before this shipped refetched nothing on every remount, which on a long transcript is one
+ * pointless request per turn per scroll.
+ */
+ if (remembered?.frame || remembered?.asked) return;
+ let current = true;
+
+ void (async () => {
+ const stored = await readPageFrame(computerId, toolCallId);
+ if (!current) return;
+ rememberTurn(toolCallId, {
+ asked: true,
+ ...(stored ? { frame: { base64: stored.frame, url: stored.url } } : {}),
+ });
+ if (stored) setFrameArrived((n) => n + 1);
+ })();
+
+ return () => {
+ current = false;
+ };
+ }, [computerId, toolCallId, settled]);
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `secretPending` intentionally restarts settled polling.
useEffect(() => {
+ if (settled) return;
const mine = ++generation.current;
let timer: ReturnType;
// Consecutive identical frames observed during post-action settling.
@@ -141,10 +364,11 @@ export function ComputerView({
generation.current++;
clearTimeout(timer);
};
- }, [computerId, active, intervalMs, secretPending]);
+ }, [computerId, active, intervalMs, secretPending, settled]);
/** Poll control state independently from screenshot polling so help/secret prompts surface. */
useEffect(() => {
+ if (settled) return;
let live = true;
let timer: ReturnType;
const tick = async () => {
@@ -158,7 +382,7 @@ export function ComputerView({
live = false;
clearTimeout(timer);
};
- }, [computerId]);
+ }, [computerId, settled]);
// Input forwarding lives in LiveScreen on the socket.
// Escape is bound to the window so it works regardless of overlay focus.
@@ -172,27 +396,54 @@ export function ComputerView({
}, [expanded]);
// Always render the card frame; help/secret controls live below the conditional picture.
- const blankBrowser = shot ? isBlankBrowser(shot) : false;
+ /*
+ * A finished turn is never "blank": it opened a page, and that is what it shows or names. Only a
+ * live browser can be sitting on about:blank.
+ */
+ const blankBrowser = !settled && shot ? isBlankBrowser(shot) : false;
/*
* Sized from the ratio, never from the payload, so the frame is identical while a screen is
- * loading and once it arrives.
+ * loading, once it arrives, and while the browser has nothing open. A blank browser used to
+ * collapse to a strip of text; that made the panel change shape the moment a page opened, and a
+ * surface whose whole job is showing a screen kept surprising the layout around it.
+ */
+ const frameStyle = { aspectRatio, minWidth, minHeight };
+ /**
+ * What this tile draws: the kept frame for a turn that is over, the live one while it runs.
*
- * A browser that has opened nothing is the exception. Reserving a screen-sized frame for it put a
- * placeholder the height of a browser window into the middle of a conversation, above an answer
- * that never involved the browser at all: a Bot asked about Google Drive rendered a full-size
- * empty panel saying it had not opened a page. Nothing is loading there and nothing is coming, so
- * there is no layout jump to protect against and no reason to take the room.
+ * A finished turn never draws `shot`. It may hold one, caught in the render between mounting and
+ * its result arriving, and that frame is of whatever the Bot has open now rather than of this turn.
*/
- const frameStyle = blankBrowser
- ? { minWidth }
- : { aspectRatio, minWidth, minHeight };
- /** Blank browser placeholders should not be opened as readable screens. */
- const showScreen = shot !== null && !blankBrowser;
+ const drawn = settled
+ ? keptFrame
+ : shot
+ ? { base64: shot.base64, url: shot.url ?? "" }
+ : null;
+ /** Whether there is a page to draw. A blank browser and an unreadable screen are both "no". */
+ const showScreen = drawn !== null && !blankBrowser;
+ /**
+ * Whether the full-size view has a stream worth opening.
+ *
+ * Nothing to draw, and it says so in the same words the card does — but somebody holding the wheel
+ * gets the live socket whatever is on it, because once a person is driving the stream is the truth
+ * about the page and a placeholder over it would be the view arguing with them.
+ */
+ const showLiveScreen = !settled && (showScreen || driving);
+ /**
+ * Whether the wheel in somebody's hands is the wheel THIS tile is showing.
+ *
+ * A person can take control mid-navigation, and the turn then settles under them. `driving` stays
+ * true, because it is true: they are driving the browser. It is just not the browser in this
+ * picture any more. Left ungated, the frozen tile asserted "You have control" over a page from an
+ * hour ago, with the hand-back footer already gone and the backdrop refusing to close because it
+ * believed somebody was driving it.
+ */
+ const wheelHere = driving && !settled;
const polledScreen = showScreen ? (
setExpanded(true)}
- // Disabled while blank/waiting but still reserves the frame.
- disabled={!showScreen}
- className="relative block w-full bg-muted enabled:cursor-zoom-in"
+ /*
+ * Opens whether or not there is a picture in it. It used to be disabled without one, and
+ * the wheel is down there: a blank browser, a screen that had not arrived yet, or a
+ * computer that could not be reached left a person with no way to take control at all —
+ * the states where they most want it. With nothing to draw the full-size view shows these
+ * same words, and the wheel below them.
+ */
+ className="relative block w-full cursor-pointer bg-muted"
style={frameStyle}
aria-label="Open the assistant's screen full size"
>
{polledScreen}
- {blankBrowser ? (
-
+ {/* Whose computer this is — and whose hands are on it — said on the picture itself. */}
+ {name || wheelHere ? (
+
+ {name ? (
+
+
+ {name}
+
+ ) : null}
+ {wheelHere ? (
+
+ You have control
+
+ ) : null}
+
) : null}
- {/* The blank state is a line of text, so it needs its own height rather than the frame's. */}
- {blankBrowser ? : null}
{showScreen ? null : (
-
- {problem ? (
- <>
-
- You cannot see the screen right now
-
- {problem}
-
- The assistant may still be working. An administrator can
- check whether its computer is running.
-
- >
- ) : blankBrowser ? (
- The assistant has not opened a page yet.
- ) : (
- Waiting for the assistant's screen…
- )}
-
+
)}
+ {/*
+ * The Bot ASKING for the wheel, which is not the same thing as a person wanting it.
+ *
+ * The standing "who is driving" prose and the everyday Take control button live in the
+ * full-size view, where there is a page big enough to drive. This row is the exception: a
+ * request is an exceptional state with a reason attached, it is the one moment the screen is
+ * waiting on a person rather than the other way round, and making them open the full-size
+ * view to find out what was wanted would hide the reason behind a click. Taking the wheel
+ * from here opens that view, because driving is what they are being asked to do.
+ */}
+ {!driving && !settled && control?.requested ? (
+
+ ) : null}
+
{/*
Secret values go directly to the page path and are never included in the conversation.
Audit records that a secret was supplied, not the value.
@@ -303,77 +582,12 @@ export function ComputerView({
) : null}
- {driving ? (
-
- You have control of this browser.
-
-
-
-
-
- ) : null}
-
{/*
- * The wheel is offered whether or not the Bot asked for it.
- *
- * It only used to appear once the Bot called `computer_request_help`, which made the button
- * depend on the Bot getting one instruction right. It does not always: asked to open a page
- * behind a sign-in, a Bot answered "If you'd like, I can prompt you to take control … would
- * you like to proceed with signing in?" and called nothing. The person was told to take
- * control, and there was no control to take. The prompt already forbids that sentence in as
- * many words, so the answer is not more prose: it is that a person who wants their own
- * browser should not have to be offered it first.
- *
- * The amber row stays the Bot ASKING, which is a different thing and still worth its own
- * colour and its reason. Without a request this is a quiet control that says who is driving.
+ * The inline card carries no persistent footer: taking the wheel, handing it back, and the
+ * standing "who is driving" prose all live in the full-size view, where there is a page big
+ * enough to drive. The two rows above appear only while the Bot is stuck — waiting on a
+ * credential, or asking for the wheel — and go again when it is not.
*/}
- {!driving ? (
-
-
- {control?.requested ? (
- <>
-
- The assistant needs you.
- {" "}
- {control.reason}
- >
- ) : (
- "The assistant is driving. You can take over whenever you want."
- )}
-
-
-
- ) : null}
{/*
@@ -385,68 +599,117 @@ export function ComputerView({
role="dialog"
aria-modal="true"
aria-label="The assistant's screen"
- className="fixed inset-0 z-50 flex flex-col p-4 sm:p-8"
+ className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 sm:p-8"
>
- {/* Backdrop closes only while read-only; during driving, Escape remains the exit. */}
+ {/*
+ Backdrop closes only while read-only; during driving, Escape remains the exit. A
+ turn that is over is always read-only, whoever is holding the live browser.
+ */}