Repository navigation
Releases: amarthaid/workbench
Release list
v0.32.0
v0.32.0 — cookie integrations log themselves back in, and agent browser tabs can no longer reach loopback
Cookie-auth integrations (internal tools you log into with SSO or a password) used to die quietly. The session cookie expired, every tool call failed with "reconnect in the portal", and someone had to open the portal and log in again. This release adds auto-reconnect. A plugin declares how to tell a dead session from a live one, and the steps to log back in. When a session dies, workbench runs those steps in the user's own browser, captures fresh cookies and retries the call. It is the cookie equivalent of an OAuth refresh on 401. SSO logins usually need only one click, because the identity provider's session in the browser profile outlives the app's. Password logins are filled from the user's vault.
Hardening this surfaced an older problem. Every user's chromium listens for DevTools on a loopback port, and an agent's browser tab could reach it, along with any other loopback service. A tab could list or close another user's tabs. Agent tabs can no longer reach loopback at all, and this is enforced inside the browser on every request, not only in browser_navigate.
Ships as a release candidate first: it touches auth (vault credentials typed by the server, new portal-session-only routes), the browser and how requests reach it (a network guard on every agent tab, a new DevTools origin, a new forwarding timeout), and chromium's launch flags.
Features
-
Cookie auth: auto-reconnect. A cookie plugin's manifest can now declare
sessionandreconnect.sessionsets whichctx.httpresponse means "dead" (status codes, optionally a redirect to the login page). It can also name a probe request that proves the session is alive.reconnectlists declarative login steps:goto,click,fill,press,waitForandwaitUrl. It can also declare credential slots, such as username and password.
When a dead response comes back, the server first checks whether the browser profile still holds a live session, which needs no typing. Only if it doesn't does it run the recipe in a hidden tab, then verify, store the cookies and retry the request once. The
execute_toolsconnected-check revives a session that died overnight, so the first call of the day works. Plugins without these blocks behave exactly as before. See the plugin guide and the finding. -
Cookie auth: the credential stays out of reach. Vault values are typed by the server, never handed to the page or the agent.
- Isolated world. The value goes to one element through
Runtime.callFunctionOnin a CDP isolated world, so page scripts cannot patch the check or intercept the call. - Exact host and secure page. The value is delivered only when the page's host exactly matches the app's domain or a declared
allowHostsentry. The page must also be a secure context (https, or http on loopback), and the element must belong to the page that was checked. - Private tab. The recipe runs in a tab that
browser_tabs, everybrowser_*tool and the portal live view cannot see or reach. - Never echoed back. The value never appears in errors, logs, audit rows or tool results.
- Not saved by chromium. The password manager is turned off in spawned profiles, so chromium never saves it either.
- Isolated world. The value goes to one element through
-
Cookie auth: login attempts are limited.
- Only one recipe runs per user and app at a time.
- After a recipe runs, whether it succeeded or failed, another can't start for 10 minutes. That is at most six password attempts an hour, below typical lockout thresholds.
- A run has a hard deadline. Any step still in flight when it expires is aborted before it can type anything.
- Only a portal session can lift the cooldown: a manual reconnect, or saving new bindings. An agent's API key cannot, not even by deleting and re-importing the connection.
- An API-key disconnect inside the window answers 409. Disconnecting from the portal always works.
-
Portal: Auto-reconnect panel. A cookie app with a recipe now shows an Auto-reconnect panel on its detail page.
- Password logins: the user picks which vault entries fill which slots.
- SSO logins: nothing to set up.
- Status line: shows the last outcome, such as "Auto-reconnected 2h ago" or "Auto-reconnect failed (step 3: SELECTOR_NOT_FOUND) — reconnect manually".
- Who can bind:
PUT /api/connections/:integration/reconnectaccepts a portal session only, never an API key or OAuth bearer.
-
Cluster: recipe apps run where the browser lives. Under
CLUSTER_ENABLED, tool calls on apps with a recipe are routed to the replica that owns the user's chromium, the same waybrowser_*calls already are. The/mcphandler moved intosrc/mcp/route.ts; its behaviour is unchanged. -
Browser:
BROWSER_LOOPBACK_ALLOW_PORTS. A comma-separated list of loopback ports that agent tabs may open, for local development (for example8080). A live chromium debug port, the server's ownPORTand its internal host stay refused even if listed. No chromium is ever started on a listed port. Don't list a port that proxies to the workbench server, such as the portal's Vite dev server on 5173. See the browser guide and environment. -
Server: not crawlable.
/robots.txtnow disallows everything, every response carriesX-Robots-Tag: noindex, nofollow, noarchive, nosnippet, noimageindex, and the portal shell has a robotsnoindexmeta tag. The workbench is a private console and API, so nothing should be indexed. Link unfurlers such as Slack still fetch pages.
Fixes
-
Browser: agent tabs cannot reach loopback, on any port. Each user's chromium listens for DevTools on its own
127.0.0.1port. Before this release, an agent tab could load/json/listfor any user's chromium and see their tabs, or call/json/closeon them. That worked throughbrowser_navigate, a script settinglocation, a redirect, a popup, an iframe, a worker or a plain<img>.- Checked inside the browser. Every agent tab's requests are now checked in the browser, and anything resolving to loopback, unspecified addresses or the server's internal host is refused. That includes redirect hops, popups, iframes, and dedicated, shared and service workers.
- Fails closed. The guard is in place before the session is handed out. On any doubt it refuses the request: a DNS error or timeout, an unparsable URL, a target it couldn't guard, a hostname over 253 characters, an unreadable
/etc/hosts. - At least as strict as chromium's own resolver. It checks both IPv4 and IPv6 answers, honours
/etc/hostsand search domains, and refuses single-label names. - Each user's browser gets its own DNS budget, so one user's page cannot slow down or block another's.
- DevTools origin. DevTools now accepts WebSocket connections only from an origin no page can have.
- Behaviour change: agent tabs can no longer open
localhost, hostnames that exist only in/etc/hosts, or Kubernetes short service names (use the FQDN). UseBROWSER_LOOPBACK_ALLOW_PORTSfor local dev servers.
-
Browser: Enter now submits forms.
pressKeysent Enter as a raw key-down with no text, so chromium never fired the keypress that submits a form. This affectedbrowser_keyas well as recipes. Enter now carries\r. Chords such as ctrl+Enter are unchanged. -
Browser: no Keychain prompts on macOS. Chromium launches with
--use-mock-keychain --password-store=basic. Local runs no longer ask for the login password. The Linux image already used the basic store, so nothing changes there. -
Cluster: a slow forward no longer runs a tool twice. A forwarded call that timed out after being sent used to fall back to running locally as well. Now:
- It answers 504 instead. Only a connection error before sending falls back.
- Recipe apps get 150 s to cover a login. Other forwarded calls keep 30 s.
-
Browser audio: the uplink answers at once and heartbeats. The uplink POST used to send its response only when the call's body ended, so a proxy that waits for response headers killed every call at its timer. Behind Cloudflare that was a 524 about 100 s after the uplink opened, and the voice client saw the call as lost. The uplink now answers
200(application/json) as soon as it is accepted, writes a newline every 20 s, and ends with{"played_ms"}as before; the body is still one valid JSON document. A refused uplink still gets its 409, 404 or 415 before any headers. See the finding.
Known gaps
- Plain WebSockets. WebSockets from agent tabs to loopback services other than DevTools are not blocked. The browser's request interception does not see WebSocket handshakes.
- DNS rebinding. A hostname whose answer flips from public to loopback between the guard's lookup and chromium's own can still get through.
- Recipe tab is unguarded. The private recipe tab runs a trusted plugin recipe and is not covered by the loopback guard. Agents cannot reach it.
Commits
fix(browser-audio): answer the uplink at once and heartbeat, so a proxy waiting for headers keeps the call(f6a0d8c)feat(shared): cookie auth session + reconnect recipe manifest contract(39917d1)feat(cookie): per-connection reconnect state in connections.config(192ce44)feat(cookie): single-flight reconnect runner with cooldown and host guard(1620a2a)feat(cookie): reconnect and retry on a dead-session response(e3983f5)feat(cookie): route recipe integrations to the chromium-owning replica(e44405d)feat(api): auto-reconnect credential bindings and status(0a63900)- `feat(portal): auto-reconnect credential binding...
v0.32.0-rc.2
v0.32.0 — cookie integrations log themselves back in, and agent browser tabs can no longer reach loopback
Cookie-auth integrations (internal tools you log into with SSO or a password) used to die quietly. The session cookie expired, every tool call failed with "reconnect in the portal", and someone had to open the portal and log in again. This release adds auto-reconnect. A plugin declares how to tell a dead session from a live one, and the steps to log back in. When a session dies, workbench runs those steps in the user's own browser, captures fresh cookies and retries the call. It is the cookie equivalent of an OAuth refresh on 401. SSO logins usually need only one click, because the identity provider's session in the browser profile outlives the app's. Password logins are filled from the user's vault.
Hardening this surfaced an older problem. Every user's chromium listens for DevTools on a loopback port, and an agent's browser tab could reach it, along with any other loopback service. A tab could list or close another user's tabs. Agent tabs can no longer reach loopback at all, and this is enforced inside the browser on every request, not only in browser_navigate.
Ships as a release candidate first: it touches auth (vault credentials typed by the server, new portal-session-only routes), the browser and how requests reach it (a network guard on every agent tab, a new DevTools origin, a new forwarding timeout), and chromium's launch flags.
Features
-
Cookie auth: auto-reconnect. A cookie plugin's manifest can now declare
sessionandreconnect.sessionsets whichctx.httpresponse means "dead" (status codes, optionally a redirect to the login page). It can also name a probe request that proves the session is alive.reconnectlists declarative login steps:goto,click,fill,press,waitForandwaitUrl. It can also declare credential slots, such as username and password.
When a dead response comes back, the server first checks whether the browser profile still holds a live session, which needs no typing. Only if it doesn't does it run the recipe in a hidden tab, then verify, store the cookies and retry the request once. The
execute_toolsconnected-check revives a session that died overnight, so the first call of the day works. Plugins without these blocks behave exactly as before. See the plugin guide and the finding. -
Cookie auth: the credential stays out of reach. Vault values are typed by the server, never handed to the page or the agent.
- Isolated world. The value goes to one element through
Runtime.callFunctionOnin a CDP isolated world, so page scripts cannot patch the check or intercept the call. - Exact host and secure page. The value is delivered only when the page's host exactly matches the app's domain or a declared
allowHostsentry. The page must also be a secure context (https, or http on loopback), and the element must belong to the page that was checked. - Private tab. The recipe runs in a tab that
browser_tabs, everybrowser_*tool and the portal live view cannot see or reach. - Never echoed back. The value never appears in errors, logs, audit rows or tool results.
- Not saved by chromium. The password manager is turned off in spawned profiles, so chromium never saves it either.
- Isolated world. The value goes to one element through
-
Cookie auth: login attempts are limited.
- Only one recipe runs per user and app at a time.
- After a recipe runs, whether it succeeded or failed, another can't start for 10 minutes. That is at most six password attempts an hour, below typical lockout thresholds.
- A run has a hard deadline. Any step still in flight when it expires is aborted before it can type anything.
- Only a portal session can lift the cooldown: a manual reconnect, or saving new bindings. An agent's API key cannot, not even by deleting and re-importing the connection.
- An API-key disconnect inside the window answers 409. Disconnecting from the portal always works.
-
Portal: Auto-reconnect panel. A cookie app with a recipe now shows an Auto-reconnect panel on its detail page.
- Password logins: the user picks which vault entries fill which slots.
- SSO logins: nothing to set up.
- Status line: shows the last outcome, such as "Auto-reconnected 2h ago" or "Auto-reconnect failed (step 3: SELECTOR_NOT_FOUND) — reconnect manually".
- Who can bind:
PUT /api/connections/:integration/reconnectaccepts a portal session only, never an API key or OAuth bearer.
-
Cluster: recipe apps run where the browser lives. Under
CLUSTER_ENABLED, tool calls on apps with a recipe are routed to the replica that owns the user's chromium, the same waybrowser_*calls already are. The/mcphandler moved intosrc/mcp/route.ts; its behaviour is unchanged. -
Browser:
BROWSER_LOOPBACK_ALLOW_PORTS. A comma-separated list of loopback ports that agent tabs may open, for local development (for example8080). A live chromium debug port, the server's ownPORTand its internal host stay refused even if listed. No chromium is ever started on a listed port. Don't list a port that proxies to the workbench server, such as the portal's Vite dev server on 5173. See the browser guide and environment.
Fixes
-
Browser: agent tabs cannot reach loopback, on any port. Each user's chromium listens for DevTools on its own
127.0.0.1port. Before this release, an agent tab could load/json/listfor any user's chromium and see their tabs, or call/json/closeon them. That worked throughbrowser_navigate, a script settinglocation, a redirect, a popup, an iframe, a worker or a plain<img>.- Checked inside the browser. Every agent tab's requests are now checked in the browser, and anything resolving to loopback, unspecified addresses or the server's internal host is refused. That includes redirect hops, popups, iframes, and dedicated, shared and service workers.
- Fails closed. The guard is in place before the session is handed out. On any doubt it refuses the request: a DNS error or timeout, an unparsable URL, a target it couldn't guard, a hostname over 253 characters, an unreadable
/etc/hosts. - At least as strict as chromium's own resolver. It checks both IPv4 and IPv6 answers, honours
/etc/hostsand search domains, and refuses single-label names. - Each user's browser gets its own DNS budget, so one user's page cannot slow down or block another's.
- DevTools origin. DevTools now accepts WebSocket connections only from an origin no page can have.
- Behaviour change: agent tabs can no longer open
localhost, hostnames that exist only in/etc/hosts, or Kubernetes short service names (use the FQDN). UseBROWSER_LOOPBACK_ALLOW_PORTSfor local dev servers.
-
Browser: Enter now submits forms.
pressKeysent Enter as a raw key-down with no text, so chromium never fired the keypress that submits a form. This affectedbrowser_keyas well as recipes. Enter now carries\r. Chords such as ctrl+Enter are unchanged. -
Browser: no Keychain prompts on macOS. Chromium launches with
--use-mock-keychain --password-store=basic. Local runs no longer ask for the login password. The Linux image already used the basic store, so nothing changes there. -
Cluster: a slow forward no longer runs a tool twice. A forwarded call that timed out after being sent used to fall back to running locally as well. Now:
- It answers 504 instead. Only a connection error before sending falls back.
- Recipe apps get 150 s to cover a login. Other forwarded calls keep 30 s.
-
Browser audio: the uplink answers at once and heartbeats. The uplink POST used to send its response only when the call's body ended, so a proxy that waits for response headers killed every call at its timer. Behind Cloudflare that was a 524 about 100 s after the uplink opened, and the voice client saw the call as lost. The uplink now answers
200(application/json) as soon as it is accepted, writes a newline every 20 s, and ends with{"played_ms"}as before; the body is still one valid JSON document. A refused uplink still gets its 409, 404 or 415 before any headers. See the finding.
Known gaps
- Plain WebSockets. WebSockets from agent tabs to loopback services other than DevTools are not blocked. The browser's request interception does not see WebSocket handshakes.
- DNS rebinding. A hostname whose answer flips from public to loopback between the guard's lookup and chromium's own can still get through.
- Recipe tab is unguarded. The private recipe tab runs a trusted plugin recipe and is not covered by the loopback guard. Agents cannot reach it.
Commits
fix(browser-audio): answer the uplink at once and heartbeat, so a proxy waiting for headers keeps the call(f6a0d8c)feat(shared): cookie auth session + reconnect recipe manifest contract(39917d1)feat(cookie): per-connection reconnect state in connections.config(192ce44)feat(cookie): single-flight reconnect runner with cooldown and host guard(1620a2a)feat(cookie): reconnect and retry on a dead-session response(e3983f5)feat(cookie): route recipe integrations to the chromium-owning replica(e44405d)feat(api): auto-reconnect credential bindings and status(0a63900)feat(portal): auto-reconnect credential bindings and status on app detail(20bf1fa)fix(cookie): deliver fill in an isolated world with exact host match(488af7f)fix(reconnect): run recipes in a private tab no browser tool can reach(ff34f13)fix(reconnect): overnight expiry, retry cooldown, no duplicate run on forward timeout(1c1a1f6)- `fix(reconnect): cooldown survi...
v0.32.0-rc.1
v0.32.0 — cookie integrations log themselves back in, and agent browser tabs can no longer reach loopback
Cookie-auth integrations (internal tools you log into with SSO or a password) used to die quietly. The session cookie expired, every tool call failed with "reconnect in the portal", and someone had to open the portal and log in again. This release adds auto-reconnect. A plugin declares how to tell a dead session from a live one, and the steps to log back in. When a session dies, workbench runs those steps in the user's own browser, captures fresh cookies and retries the call. It is the cookie equivalent of an OAuth refresh on 401. SSO logins usually need only one click, because the identity provider's session in the browser profile outlives the app's. Password logins are filled from the user's vault.
Hardening this surfaced an older problem. Every user's chromium listens for DevTools on a loopback port, and an agent's browser tab could reach it, along with any other loopback service. A tab could list or close another user's tabs. Agent tabs can no longer reach loopback at all, and this is enforced inside the browser on every request, not only in browser_navigate.
Ships as a release candidate first: it touches auth (vault credentials typed by the server, new portal-session-only routes), the browser and how requests reach it (a network guard on every agent tab, a new DevTools origin, a new forwarding timeout), and chromium's launch flags.
Features
-
Cookie auth: auto-reconnect. A cookie plugin's manifest can now declare
sessionandreconnect.sessionsets whichctx.httpresponse means "dead" (status codes, optionally a redirect to the login page). It can also name a probe request that proves the session is alive.reconnectlists declarative login steps:goto,click,fill,press,waitForandwaitUrl. It can also declare credential slots, such as username and password.
When a dead response comes back, the server first checks whether the browser profile still holds a live session, which needs no typing. Only if it doesn't does it run the recipe in a hidden tab, then verify, store the cookies and retry the request once. The
execute_toolsconnected-check revives a session that died overnight, so the first call of the day works. Plugins without these blocks behave exactly as before. See the plugin guide and the finding. -
Cookie auth: the credential stays out of reach. Vault values are typed by the server, never handed to the page or the agent.
- Isolated world. The value goes to one element through
Runtime.callFunctionOnin a CDP isolated world, so page scripts cannot patch the check or intercept the call. - Exact host and secure page. The value is delivered only when the page's host exactly matches the app's domain or a declared
allowHostsentry. The page must also be a secure context (https, or http on loopback), and the element must belong to the page that was checked. - Private tab. The recipe runs in a tab that
browser_tabs, everybrowser_*tool and the portal live view cannot see or reach. - Never echoed back. The value never appears in errors, logs, audit rows or tool results.
- Not saved by chromium. The password manager is turned off in spawned profiles, so chromium never saves it either.
- Isolated world. The value goes to one element through
-
Cookie auth: login attempts are limited.
- Only one recipe runs per user and app at a time.
- After a recipe runs, whether it succeeded or failed, another can't start for 10 minutes. That is at most six password attempts an hour, below typical lockout thresholds.
- A run has a hard deadline. Any step still in flight when it expires is aborted before it can type anything.
- Only a portal session can lift the cooldown: a manual reconnect, or saving new bindings. An agent's API key cannot, not even by deleting and re-importing the connection.
- An API-key disconnect inside the window answers 409. Disconnecting from the portal always works.
-
Portal: Auto-reconnect panel. A cookie app with a recipe now shows an Auto-reconnect panel on its detail page.
- Password logins: the user picks which vault entries fill which slots.
- SSO logins: nothing to set up.
- Status line: shows the last outcome, such as "Auto-reconnected 2h ago" or "Auto-reconnect failed (step 3: SELECTOR_NOT_FOUND) — reconnect manually".
- Who can bind:
PUT /api/connections/:integration/reconnectaccepts a portal session only, never an API key or OAuth bearer.
-
Cluster: recipe apps run where the browser lives. Under
CLUSTER_ENABLED, tool calls on apps with a recipe are routed to the replica that owns the user's chromium, the same waybrowser_*calls already are. The/mcphandler moved intosrc/mcp/route.ts; its behaviour is unchanged. -
Browser:
BROWSER_LOOPBACK_ALLOW_PORTS. A comma-separated list of loopback ports that agent tabs may open, for local development (for example8080). A live chromium debug port, the server's ownPORTand its internal host stay refused even if listed. No chromium is ever started on a listed port. Don't list a port that proxies to the workbench server, such as the portal's Vite dev server on 5173. See the browser guide and environment.
Fixes
- Browser: agent tabs cannot reach loopback, on any port. Each user's chromium listens for DevTools on its own
127.0.0.1port. Before this release, an agent tab could load/json/listfor any user's chromium and see their tabs, or call/json/closeon them. That worked throughbrowser_navigate, a script settinglocation, a redirect, a popup, an iframe, a worker or a plain<img>.- Checked inside the browser. Every agent tab's requests are now checked in the browser, and anything resolving to loopback, unspecified addresses or the server's internal host is refused. That includes redirect hops, popups, iframes, and dedicated, shared and service workers.
- Fails closed. The guard is in place before the session is handed out. On any doubt it refuses the request: a DNS error or timeout, an unparsable URL, a target it couldn't guard, a hostname over 253 characters, an unreadable
/etc/hosts. - At least as strict as chromium's own resolver. It checks both IPv4 and IPv6 answers, honours
/etc/hostsand search domains, and refuses single-label names. - Each user's browser gets its own DNS budget, so one user's page cannot slow down or block another's.
- DevTools origin. DevTools now accepts WebSocket connections only from an origin no page can have.
- Behaviour change: agent tabs can no longer open
localhost, hostnames that exist only in/etc/hosts, or Kubernetes short service names (use the FQDN). UseBROWSER_LOOPBACK_ALLOW_PORTSfor local dev servers.
- Browser: Enter now submits forms.
pressKeysent Enter as a raw key-down with no text, so chromium never fired the keypress that submits a form. This affectedbrowser_keyas well as recipes. Enter now carries\r. Chords such as ctrl+Enter are unchanged. - Browser: no Keychain prompts on macOS. Chromium launches with
--use-mock-keychain --password-store=basic. Local runs no longer ask for the login password. The Linux image already used the basic store, so nothing changes there. - Cluster: a slow forward no longer runs a tool twice. A forwarded call that timed out after being sent used to fall back to running locally as well. Now:
- It answers 504 instead. Only a connection error before sending falls back.
- Recipe apps get 150 s to cover a login. Other forwarded calls keep 30 s.
Known gaps
- Plain WebSockets. WebSockets from agent tabs to loopback services other than DevTools are not blocked. The browser's request interception does not see WebSocket handshakes.
- DNS rebinding. A hostname whose answer flips from public to loopback between the guard's lookup and chromium's own can still get through.
- Recipe tab is unguarded. The private recipe tab runs a trusted plugin recipe and is not covered by the loopback guard. Agents cannot reach it.
Commits
feat(shared): cookie auth session + reconnect recipe manifest contract(39917d1)feat(cookie): per-connection reconnect state in connections.config(192ce44)feat(cookie): single-flight reconnect runner with cooldown and host guard(1620a2a)feat(cookie): reconnect and retry on a dead-session response(e3983f5)feat(cookie): route recipe integrations to the chromium-owning replica(e44405d)feat(api): auto-reconnect credential bindings and status(0a63900)feat(portal): auto-reconnect credential bindings and status on app detail(20bf1fa)fix(cookie): deliver fill in an isolated world with exact host match(488af7f)fix(reconnect): run recipes in a private tab no browser tool can reach(ff34f13)fix(reconnect): overnight expiry, retry cooldown, no duplicate run on forward timeout(1c1a1f6)fix(reconnect): cooldown survives delete + import, and a recipe success holds it too(f091eb3)fix(reconnect): deliver a credential only to a secure context, into the checked document(77b8d4f)fix(reconnect): a fast-path success can no longer shorten the recipe window(6b1415b)fix(browser): agent tabs cannot reach loopback on any port (cross-tenant)(fade34f)fix(browser): network guard fails closed; workers, DNS, WebSocket origin, localhost allow-list(a761811)fix(browser): per-session DNS budget for the network guard, every wait bounded(834d94d)fix(browser): guard verdict at least as strict as chromium's resolver(72f5512)fix(browser): no ReDoS on hostile hosts; unreadable system files fail closed(5bb5da5)fix(browser): Enter key carries text so it submits forms(53d...
v0.31.0-rc.4
v0.31.0 — an admin page, agents in browser calls, composed tool chains, and header-auth custom apps
Until now the only way to see what a workbench instance was doing was to read the database. This release adds an Admin page for the people who run it: instance health, every user's activity, who is connected to what, and the controls to disable a user, hide an integration, or stop people adding custom apps. It is gated by an email allowlist and is invisible to everyone else.
Since rc.1 three more things landed. An agent can now join a browser-based call (Zoom web client, Slack huddle, Google Meet) in its workbench browser, hear it as a raw audio stream and speak into it. execute_tools can compose a chain of tools server-side so large intermediate payloads never round-trip through the chat. And custom MCP apps can authenticate with static headers (API keys) instead of only OAuth.
Ships as a release candidate first: it touches auth (a disabled user is now refused at every credential check; header credentials for custom apps), adds columns and a table, adds a streaming proxy path, and changes the Docker image (PulseAudio).
Features
-
Admin: the page and its gate. Set
ADMIN_EMAILSto a comma-separated list of emails. A signed-in user on that list gets an Admin link in the sidebar; everyone else gets a 403 from/api/admin/*and no link. The gate accepts the portal session only, never an API key or an OAuth bearer, so an admin's agent cannot act as the admin. Empty or unset means no admins and no admin page. See the finding and the admin guide. -
Admin: routed tabs. Overview, Activity, Connections, Custom apps, Browser profiles, Vault, Files, Users and Config each have their own URL (
/admin,/admin/activity, …), so the browser's back button works. A tab loads only when opened, so nothing walks the profiles or the workspace on disk until you ask. -
Admin: Overview. Headline numbers (tool calls and error rate over 24 hours with the change against the day before, users active in 7 days, connections that need a reconnect, disabled users), the most-called tools over 7 days, the last five failed calls, and the instance summary: version, database backend, cluster mode, audit destination, user and admin counts. Usage figures need
AUDIT_LOG_DEST=sqlite; with stdout or kafka they show a dash and say why instead of reading as zero. -
Admin: Activity. Every user's tool calls, newest first, with email. Filter by app, errors only, or user email (search as you type). Tool names and error text only, never arguments.
-
Admin: Connections, Custom apps, Browser profiles. Connected users per integration (with a derived "needs reconnect"), every custom app with its owner and URL, and browser-profile disk use per user. Credentials are never shown. See the finding.
-
Admin: Vault and Files. Aggregates only. Vault shows secrets stored, users holding any, secrets unused for 90 days, pending one-time links and the top holders; Files shows disk used, file count, the age of the oldest file (older than
WORKSPACE_TTL_HOURSmeans the reaper is not running) and the largest users and files. A secret's name is information too, so no name, value, file name or file content is ever returned. -
Admin: disable a user. The Users tab lists everyone with connection and custom-app counts, last activity and API-key state, and can disable, enable or revoke the API key of a user. You cannot disable yourself or any email on
ADMIN_EMAILS. A disabled user is refused everywhere a credential is checked: API key, portal session, OAuth access token, curl-session token, both OAuth/tokengrants, and both SSO callbacks (the login page says "Your account has been disabled by an administrator"). Disabling also deletes the user's refresh tokens, so an existing client cannot mint a new access token. The check lives inside the verifiers, not at call sites, so a new entry point cannot forget it. See the finding. -
Admin: Config. Turn an integration off for the whole instance and it disappears from every lookup (Apps,
search_tools,execute_tools) at once, because the registry filters through one predicate. Set who may create custom apps (everyone, admins only, or no one); the Apps page hides New custom app when/api/auth/mereportscanCreateCustomApps: false. Settings are an in-memory snapshot refreshed on write and by a poll, so other workers and pods lag by up toINSTANCE_SETTINGS_POLL_SECONDS(default 5). -
Admin: every write is audited. Disable, enable, revoke-key and both config changes write an audit row naming the acting admin.
-
Browser: agents in browser calls (behind
BROWSER_AUDIO_ENABLED, default off).browser_audio_start { session_id, sample_rate? }gives a tab a live audio pipe and returns astream_url, aclear_url(capability URLs, below) and a routing header.GET stream_urlis an SSE stream of the call audio as base64 PCM16 mono in 40 ms frames. One long chunkedPOST stream_url(Content-Type: audio/pcm) carries the agent's voice into the page's microphone, paced at real time server-side with a 120 s queue and backpressure.POST clear_urldrops unplayed audio at once and returnsplayed_ms, which maps onto a realtime voice API'sconversation.item.truncatewhen the agent is interrupted. Workbench is only the pipe; speech recognition and voice come from the agent, and it is built for full-duplex realtime models. One call per user, bound to one tab. Mic permission is granted to one origin at a time and follows the meeting page across origin hops when joining. The browser is kept alive while a stream or uplink is attached and reaped as usual once the agent goes away. See the browser audio guide and the finding. -
Browser audio: one PulseAudio daemon per user. Each user's chromium is spawned onto a private virtual speaker and microphone. They are per user, not per server, because with a shared audio server chromium's
enumerateDevices()lists every user's devices, and a page in one user's browser could record or inject another user's call. -
Browser audio: start audio before opening the meeting (since rc.3). On Jitsi the agent's microphone stayed muted ("You need to enable microphone and camera access"). The camera was not the cause: meeting pages request the mic while loading, and the grant from
browser_audio_startarrived after that request, so headless chromium denied it and the page never asked again. The tool description and the guide now say to callbrowser_audio_starton the blank tab and navigate afterwards (or reload), and therestart: truepath now grants the mic before it reopens the page instead of after. Measured in a container against meet.jit.si: granted before load, Jitsi takes the virtual mic and an 880 Hz test tone comes through. The banner remains only because there is no camera. See the finding. -
Browser audio: the session ends when the page leaves the call (since rc.3). The voice client has no browser access and learns that a call is over only from
ended. Landing on a meeting app's post-call page now ends the session withpage_left. Today that covers Jitsi's/static/close*.html, which meet.jit.si shows after any hangup, kick or meeting end; that page is where the smoke test's tab ended up. Origin changes alone don't count, because join flows hop origins (landing page, web client, SSO). -
Browser audio: ten minutes for the first connection (since rc.3). With audio started before the meeting opens, the client may wait on a human approving the call before it connects, so a fresh session now waits
BROWSER_AUDIO_FIRST_ATTACH_SECONDS(default 600) for its first reader or uplink. After that, 60 s with nothing attached still ends it asidle. -
Browser audio: capability URLs, no OAuth on the audio routes (since rc.2). The client that holds a call open (a voice bot, say) is usually not the agent that started it and has no workbench token, so in rc.2 every audio request answered 401 and no call audio flowed. Each
browser_audio_startnow mints 128 random bits for that audio session and puts them in the path:/api/browser/audio/<capability>/streamand/clear, on the server's own origin. The capability is the only credential; anAuthorizationheader is ignored, and an unknown or revoked one is a plain 404, never a redirect. It dies with the session:browser_audio_stop,ended, tab close, browser restart,max_duration, or 60 s with neither a reader nor an uplink attached (new end reasonidle). A repeat start of a running session returns the same URLs. One reader and one uplink at a time: a second concurrentGETis now409 stream_busyinstead of replacing the first (thereplacedend reason is gone), and a reader whose socket stops draining for 10 s is dropped so a reconnect after a half-open TCP drop is not refused. The capability is masked in request logs and the paths get no trace spans. The result shape is fixed at{ stream_url, clear_url, sample_rate, format, channels, session_id, restarted, headers }, with nostream_token. -
Browser audio: works across replicas. The agent sends the returned
X-Browser-Sessionheader so the mesh routes it to the pod running its browser. A request that lands elsewhere is piped to the owning pod by the server itself over a streamingnode:httpforward (needsINTERNAL_MCP_URL). Since the capability names no user, that forward relays the caller's ownX-Browser-Session, sends no credentials, and makes at most one hop. -
MCP: compose tool chains in
execute_tools. With `c...
v0.31.0-rc.3
v0.31.0 — an admin page, agents in browser calls, composed tool chains, and header-auth custom apps
Until now the only way to see what a workbench instance was doing was to read the database. This release adds an Admin page for the people who run it: instance health, every user's activity, who is connected to what, and the controls to disable a user, hide an integration, or stop people adding custom apps. It is gated by an email allowlist and is invisible to everyone else.
Since rc.1 three more things landed. An agent can now join a browser-based call (Zoom web client, Slack huddle, Google Meet) in its workbench browser, hear it as a raw audio stream and speak into it. execute_tools can compose a chain of tools server-side so large intermediate payloads never round-trip through the chat. And custom MCP apps can authenticate with static headers (API keys) instead of only OAuth.
Ships as a release candidate first: it touches auth (a disabled user is now refused at every credential check; header credentials for custom apps), adds columns and a table, adds a streaming proxy path, and changes the Docker image (PulseAudio).
Features
-
Admin: the page and its gate. Set
ADMIN_EMAILSto a comma-separated list of emails. A signed-in user on that list gets an Admin link in the sidebar; everyone else gets a 403 from/api/admin/*and no link. The gate accepts the portal session only, never an API key or an OAuth bearer, so an admin's agent cannot act as the admin. Empty or unset means no admins and no admin page. See the finding and the admin guide. -
Admin: routed tabs. Overview, Activity, Connections, Custom apps, Browser profiles, Vault, Files, Users and Config each have their own URL (
/admin,/admin/activity, …), so the browser's back button works. A tab loads only when opened, so nothing walks the profiles or the workspace on disk until you ask. -
Admin: Overview. Headline numbers (tool calls and error rate over 24 hours with the change against the day before, users active in 7 days, connections that need a reconnect, disabled users), the most-called tools over 7 days, the last five failed calls, and the instance summary: version, database backend, cluster mode, audit destination, user and admin counts. Usage figures need
AUDIT_LOG_DEST=sqlite; with stdout or kafka they show a dash and say why instead of reading as zero. -
Admin: Activity. Every user's tool calls, newest first, with email. Filter by app, errors only, or user email (search as you type). Tool names and error text only, never arguments.
-
Admin: Connections, Custom apps, Browser profiles. Connected users per integration (with a derived "needs reconnect"), every custom app with its owner and URL, and browser-profile disk use per user. Credentials are never shown. See the finding.
-
Admin: Vault and Files. Aggregates only. Vault shows secrets stored, users holding any, secrets unused for 90 days, pending one-time links and the top holders; Files shows disk used, file count, the age of the oldest file (older than
WORKSPACE_TTL_HOURSmeans the reaper is not running) and the largest users and files. A secret's name is information too, so no name, value, file name or file content is ever returned. -
Admin: disable a user. The Users tab lists everyone with connection and custom-app counts, last activity and API-key state, and can disable, enable or revoke the API key of a user. You cannot disable yourself or any email on
ADMIN_EMAILS. A disabled user is refused everywhere a credential is checked: API key, portal session, OAuth access token, curl-session token, both OAuth/tokengrants, and both SSO callbacks (the login page says "Your account has been disabled by an administrator"). Disabling also deletes the user's refresh tokens, so an existing client cannot mint a new access token. The check lives inside the verifiers, not at call sites, so a new entry point cannot forget it. See the finding. -
Admin: Config. Turn an integration off for the whole instance and it disappears from every lookup (Apps,
search_tools,execute_tools) at once, because the registry filters through one predicate. Set who may create custom apps (everyone, admins only, or no one); the Apps page hides New custom app when/api/auth/mereportscanCreateCustomApps: false. Settings are an in-memory snapshot refreshed on write and by a poll, so other workers and pods lag by up toINSTANCE_SETTINGS_POLL_SECONDS(default 5). -
Admin: every write is audited. Disable, enable, revoke-key and both config changes write an audit row naming the acting admin.
-
Browser: agents in browser calls (behind
BROWSER_AUDIO_ENABLED, default off).browser_audio_start { session_id, sample_rate? }gives a tab a live audio pipe and returns astream_url, aclear_url(capability URLs, below) and a routing header.GET stream_urlis an SSE stream of the call audio as base64 PCM16 mono in 40 ms frames. One long chunkedPOST stream_url(Content-Type: audio/pcm) carries the agent's voice into the page's microphone, paced at real time server-side with a 120 s queue and backpressure.POST clear_urldrops unplayed audio at once and returnsplayed_ms, which maps onto a realtime voice API'sconversation.item.truncatewhen the agent is interrupted. Workbench is only the pipe; speech recognition and voice come from the agent, and it is built for full-duplex realtime models. One call per user, bound to one tab. Mic permission is granted to one origin at a time and follows the meeting page across origin hops when joining. The browser is kept alive while a stream or uplink is attached and reaped as usual once the agent goes away. See the browser audio guide and the finding. -
Browser audio: one PulseAudio daemon per user. Each user's chromium is spawned onto a private virtual speaker and microphone. They are per user, not per server, because with a shared audio server chromium's
enumerateDevices()lists every user's devices, and a page in one user's browser could record or inject another user's call. -
Browser audio: capability URLs, no OAuth on the audio routes (since rc.2). The client that holds a call open (a voice bot, say) is usually not the agent that started it and has no workbench token, so in rc.2 every audio request answered 401 and no call audio flowed. Each
browser_audio_startnow mints 128 random bits for that audio session and puts them in the path:/api/browser/audio/<capability>/streamand/clear, on the server's own origin. The capability is the only credential; anAuthorizationheader is ignored, and an unknown or revoked one is a plain 404, never a redirect. It dies with the session:browser_audio_stop,ended, tab close, browser restart,max_duration, or 60 s with neither a reader nor an uplink attached (new end reasonidle). A repeat start of a running session returns the same URLs. One reader and one uplink at a time: a second concurrentGETis now409 stream_busyinstead of replacing the first (thereplacedend reason is gone), and a reader whose socket stops draining for 10 s is dropped so a reconnect after a half-open TCP drop is not refused. The capability is masked in request logs and the paths get no trace spans. The result shape is fixed at{ stream_url, clear_url, sample_rate, format, channels, session_id, restarted, headers }, with nostream_token. -
Browser audio: works across replicas. The agent sends the returned
X-Browser-Sessionheader so the mesh routes it to the pod running its browser. A request that lands elsewhere is piped to the owning pod by the server itself over a streamingnode:httpforward (needsINTERNAL_MCP_URL). Since the capability names no user, that forward relays the caller's ownX-Browser-Session, sends no credentials, and makes at most one hop. -
MCP: compose tool chains in
execute_tools. Withcompose: true, executions run in order and each can read earlier results with{{step:<ref_id>.path}}, andreturnpicks exactly which fields come back. A chain like export a CSV → upload it to Drive no longer moves the CSV body through the conversation (and into the 60k-token MCP result cap). Refs are checked before a step runs (BAD_REF), the chain stops at the first failing step and names it, and every step goes through the same auth, vault resolution, validation, audit and scrubbing as a normal call.returnalso works withoutcomposeto trim a parallel batch. MCP only for now. -
Custom apps: static header auth. A custom MCP app can authenticate with request headers (an API key,
X-Api-Key, a customAuthorization) instead of OAuth. Add it with Headers in the portal orauthType: "headers". The server verifies it live against the app (tools/list, 15 s bound) before saving, so a wrong key returns 400 and nothing is stored. Header values are encrypted at rest and write-only: APIs return header names only, and values are redacted from call-time errors. Header apps show as connected immediately; there is no Connect/Disconnect. See the custom apps guide and the finding.
Fixes
-
Docker: the image reports its real version. The image shipped no
package.json, so every container reported0.0.0, on the admin Overview and to custom MCP servers. The Dockerfile now copies it andreadVersiontries both the checkout and the image layout. -
MCP:
search_toolsacceptslimitas a numeric string. Some clients send"20"rather than20; it is now coerced instead of rejected. -
**Browser: a stale c...
v0.31.0-rc.2
v0.31.0 — an admin page, agents in browser calls, composed tool chains, and header-auth custom apps
Until now the only way to see what a workbench instance was doing was to read the database. This release adds an Admin page for the people who run it: instance health, every user's activity, who is connected to what, and the controls to disable a user, hide an integration, or stop people adding custom apps. It is gated by an email allowlist and is invisible to everyone else.
Since rc.1 three more things landed. An agent can now join a browser-based call (Zoom web client, Slack huddle, Google Meet) in its workbench browser, hear it as a raw audio stream and speak into it. execute_tools can compose a chain of tools server-side so large intermediate payloads never round-trip through the chat. And custom MCP apps can authenticate with static headers (API keys) instead of only OAuth.
Ships as a release candidate first: it touches auth (a disabled user is now refused at every credential check; header credentials for custom apps), adds columns and a table, adds a streaming proxy path, and changes the Docker image (PulseAudio).
Features
-
Admin: the page and its gate. Set
ADMIN_EMAILSto a comma-separated list of emails. A signed-in user on that list gets an Admin link in the sidebar; everyone else gets a 403 from/api/admin/*and no link. The gate accepts the portal session only, never an API key or an OAuth bearer, so an admin's agent cannot act as the admin. Empty or unset means no admins and no admin page. See the finding and the admin guide. -
Admin: routed tabs. Overview, Activity, Connections, Custom apps, Browser profiles, Vault, Files, Users and Config each have their own URL (
/admin,/admin/activity, …), so the browser's back button works. A tab loads only when opened, so nothing walks the profiles or the workspace on disk until you ask. -
Admin: Overview. Headline numbers (tool calls and error rate over 24 hours with the change against the day before, users active in 7 days, connections that need a reconnect, disabled users), the most-called tools over 7 days, the last five failed calls, and the instance summary: version, database backend, cluster mode, audit destination, user and admin counts. Usage figures need
AUDIT_LOG_DEST=sqlite; with stdout or kafka they show a dash and say why instead of reading as zero. -
Admin: Activity. Every user's tool calls, newest first, with email. Filter by app, errors only, or user email (search as you type). Tool names and error text only, never arguments.
-
Admin: Connections, Custom apps, Browser profiles. Connected users per integration (with a derived "needs reconnect"), every custom app with its owner and URL, and browser-profile disk use per user. Credentials are never shown. See the finding.
-
Admin: Vault and Files. Aggregates only. Vault shows secrets stored, users holding any, secrets unused for 90 days, pending one-time links and the top holders; Files shows disk used, file count, the age of the oldest file (older than
WORKSPACE_TTL_HOURSmeans the reaper is not running) and the largest users and files. A secret's name is information too, so no name, value, file name or file content is ever returned. -
Admin: disable a user. The Users tab lists everyone with connection and custom-app counts, last activity and API-key state, and can disable, enable or revoke the API key of a user. You cannot disable yourself or any email on
ADMIN_EMAILS. A disabled user is refused everywhere a credential is checked: API key, portal session, OAuth access token, curl-session token, both OAuth/tokengrants, and both SSO callbacks (the login page says "Your account has been disabled by an administrator"). Disabling also deletes the user's refresh tokens, so an existing client cannot mint a new access token. The check lives inside the verifiers, not at call sites, so a new entry point cannot forget it. See the finding. -
Admin: Config. Turn an integration off for the whole instance and it disappears from every lookup (Apps,
search_tools,execute_tools) at once, because the registry filters through one predicate. Set who may create custom apps (everyone, admins only, or no one); the Apps page hides New custom app when/api/auth/mereportscanCreateCustomApps: false. Settings are an in-memory snapshot refreshed on write and by a poll, so other workers and pods lag by up toINSTANCE_SETTINGS_POLL_SECONDS(default 5). -
Admin: every write is audited. Disable, enable, revoke-key and both config changes write an audit row naming the acting admin.
-
Browser: agents in browser calls (behind
BROWSER_AUDIO_ENABLED, default off).browser_audio_start { session_id, sample_rate? }gives a tab a live audio pipe and returns astream_url, aclear_urland a routing header.GET stream_urlis an SSE stream of the call audio as base64 PCM16 mono in 40 ms frames. One long chunkedPOST stream_url(Content-Type: audio/pcm) carries the agent's voice into the page's microphone, paced at real time server-side with a 120 s queue and backpressure.POST clear_urldrops unplayed audio at once and returnsplayed_ms, which maps onto a realtime voice API'sconversation.item.truncatewhen the agent is interrupted. Workbench is only the pipe; speech recognition and voice come from the agent, and it is built for full-duplex realtime models. One call per user, bound to one tab. Mic permission is granted to one origin at a time and follows the meeting page across origin hops when joining. The browser is kept alive while a stream or uplink is attached and reaped as usual once the agent goes away. See the browser audio guide and the finding. -
Browser audio: one PulseAudio daemon per user. Each user's chromium is spawned onto a private virtual speaker and microphone. They are per user, not per server, because with a shared audio server chromium's
enumerateDevices()lists every user's devices, and a page in one user's browser could record or inject another user's call. -
Browser audio: works across replicas. The agent sends the returned
X-Browser-Sessionheader so the mesh routes it to the pod running its browser. A request that lands elsewhere is piped to the owning pod by the server itself over a streamingnode:httpforward (needsINTERNAL_MCP_URL). -
MCP: compose tool chains in
execute_tools. Withcompose: true, executions run in order and each can read earlier results with{{step:<ref_id>.path}}, andreturnpicks exactly which fields come back. A chain like export a CSV → upload it to Drive no longer moves the CSV body through the conversation (and into the 60k-token MCP result cap). Refs are checked before a step runs (BAD_REF), the chain stops at the first failing step and names it, and every step goes through the same auth, vault resolution, validation, audit and scrubbing as a normal call.returnalso works withoutcomposeto trim a parallel batch. MCP only for now. -
Custom apps: static header auth. A custom MCP app can authenticate with request headers (an API key,
X-Api-Key, a customAuthorization) instead of OAuth. Add it with Headers in the portal orauthType: "headers". The server verifies it live against the app (tools/list, 15 s bound) before saving, so a wrong key returns 400 and nothing is stored. Header values are encrypted at rest and write-only: APIs return header names only, and values are redacted from call-time errors. Header apps show as connected immediately; there is no Connect/Disconnect. See the custom apps guide and the finding.
Fixes
-
Docker: the image reports its real version. The image shipped no
package.json, so every container reported0.0.0, on the admin Overview and to custom MCP servers. The Dockerfile now copies it andreadVersiontries both the checkout and the image layout. -
MCP:
search_toolsacceptslimitas a numeric string. Some clients send"20"rather than20; it is now coerced instead of rejected. -
Browser: a stale chromium exit no longer tears down a newer session. If a user's browser was closed and immediately restarted, the old process's exit handler could remove the new session from the registry. It now only cleans up its own session.
-
MCP: compose no longer leaks vault secrets to custom apps. For a custom-app step, compose re-ran vault resolution over args already filled from earlier steps. Literal
{{vault:x}}text that arrived inside an earlier step's output (a PR title, a CSV cell) was therefore substituted with the secret and sent to the remote MCP server. Vault refs now resolve once, against the agent's own template only. Step refs also follow own properties only, so{{step:a.__proto__}}is missing rather than resolving to something that was never in the result.
Security
- Browser audio forward pins its target to the internal service. The first version built the upstream URL with
new URL(request.url, internal). An absolute-form request target (POST http://other-host/... HTTP/1.1) would have sent the forward, carrying the caller's bearer or API key, to that host. The target is now the internal origin plus path and query only, and a raw-request regression test covers it. This never shipped in a tag. - Browser audio isolates users at the audio-server level (one daemon per user; see Features), keeps no standing mic grant (with a
Browser.resetPermissionsfallback if a revoke fails), and never logs audio content.
Compatibility
- **Schema (additive...
v0.31.0-rc.1
v0.31.0 — an admin page, and the power to switch users and integrations off
Until now the only way to see what a workbench instance was doing was to read the database. This release adds an Admin page for the people who run it: instance health, every user's activity, who is connected to what, and the controls to disable a user, hide an integration, or stop people adding custom apps. It is gated by an email allowlist and is invisible to everyone else. Ships as a release candidate first: it touches auth (a disabled user is now refused at every credential check), adds a column and a table, and changes the Docker image.
Features
- Admin: the page and its gate. Set
ADMIN_EMAILSto a comma-separated list of emails. A signed-in user on that list gets an Admin link in the sidebar; everyone else gets a 403 from/api/admin/*and no link. The gate accepts the portal session only, never an API key or an OAuth bearer, so an admin's agent cannot act as the admin. Empty or unset means no admins and no admin page. See the finding and the admin guide. - Admin: routed tabs. Overview, Activity, Connections, Custom apps, Browser profiles, Vault, Files, Users and Config each have their own URL (
/admin,/admin/activity, …), so the browser's back button works. A tab loads only when opened, so nothing walks the profiles or the workspace on disk until you ask. - Admin: Overview. Headline numbers (tool calls and error rate over 24 hours with the change against the day before, users active in 7 days, connections that need a reconnect, disabled users), the most-called tools over 7 days, the last five failed calls, and the instance summary: version, database backend, cluster mode, audit destination, user and admin counts. Usage figures need
AUDIT_LOG_DEST=sqlite; with stdout or kafka they show a dash and say why instead of reading as zero. - Admin: Activity. Every user's tool calls, newest first, with email. Filter by app, errors only, or user email (search as you type). Tool names and error text only, never arguments.
- Admin: Connections, Custom apps, Browser profiles. Connected users per integration (with a derived "needs reconnect"), every custom app with its owner and URL, and browser-profile disk use per user. Credentials are never shown. See the finding.
- Admin: Vault and Files. Aggregates only. Vault shows secrets stored, users holding any, secrets unused for 90 days, pending one-time links and the top holders; Files shows disk used, file count, the age of the oldest file (older than
WORKSPACE_TTL_HOURSmeans the reaper is not running) and the largest users and files. A secret's name is information too, so no name, value, file name or file content is ever returned. - Admin: disable a user. The Users tab lists everyone with connection and custom-app counts, last activity and API-key state, and can disable, enable or revoke the API key of a user. You cannot disable yourself or any email on
ADMIN_EMAILS. A disabled user is refused everywhere a credential is checked: API key, portal session, OAuth access token, curl-session token, both OAuth/tokengrants, and both SSO callbacks (the login page says "Your account has been disabled by an administrator"). Disabling also deletes the user's refresh tokens, so an existing client cannot mint a new access token. The check lives inside the verifiers, not at call sites, so a new entry point cannot forget it. See the finding. - Admin: Config. Turn an integration off for the whole instance and it disappears from every lookup (Apps,
search_tools,execute_tools) at once, because the registry filters through one predicate. Set who may create custom apps (everyone, admins only, or no one); the Apps page hides New custom app when/api/auth/mereportscanCreateCustomApps: false. Settings are an in-memory snapshot refreshed on write and by a poll, so other workers and pods lag by up toINSTANCE_SETTINGS_POLL_SECONDS(default 5). - Admin: every write is audited. Disable, enable, revoke-key and both config changes write an audit row naming the acting admin.
Fixes
- Docker: the image reports its real version. The image shipped no
package.json, so every container reported0.0.0, on the admin Overview and to custom MCP servers. The Dockerfile now copies it andreadVersiontries both the checkout and the image layout. - MCP:
search_toolsacceptslimitas a numeric string. Some clients send"20"rather than20; it is now coerced instead of rejected.
Compatibility
- Schema (additive, idempotent, SQLite and PostgreSQL):
users.disabled_at INTEGERand a newinstance_settingstable (key,value,updated_at,updated_by). Both are created on boot. Rolling back leaves them unused, and an older build simply ignoresdisabled_at, which means a rollback re-enables every disabled user. - New settings:
ADMIN_EMAILS(default empty: no admins) andINSTANCE_SETTINGS_POLL_SECONDS(default 5). The Overview usage cards, Activity and Top tools needAUDIT_LOG_DEST=sqlite, which is already the default. - A user with no row in
usersis not treated as disabled, so existing API keys and sessions are unaffected until an admin disables someone. - Known limits. The Users list is capped at 500 and search covers only what was loaded (the tab says so). Internal plugins (browser, jots, files, vault) appear in Config and can be turned off.
disabled_integrationsis one JSON list written read-modify-write, so two admins on different workers within one poll interval can lose an update; the write re-reads the stored list first, which narrows the window.
Commits
feat(admin): ADMIN_EMAILS allowlist and isAdminEmail(2b0f05f)feat(admin): session-only gate for /api/admin(e5f9737)fix(admin): gate in preHandler so rejected requests reach metrics hooks(09730fa)feat(admin): instance overview endpoint; gate in its own module(5d3273a)fix(version): read the version in the docker image, not just a checkout(1786e11)feat(admin): users.disabled_at and instance_settings schema(8277491)feat(admin): credential verifiers refuse a disabled user(6ab5e1f)feat(admin): token endpoint and SSO callbacks refuse a disabled user(c0abe1b)feat(admin): instance settings store with snapshot and poll(b895d80)feat(admin): registry hides disabled integrations(ee48774)feat(admin): custom-app policy enforcement(eaef34f)feat(admin): users tab with disable, enable and revoke key(0984796)feat(admin): config tab and custom-app gating in the Apps page(0b21fb3)feat(admin): routed tabs, overview stats, vault and files tabs(e910487)fix(mcp): accept search_tools limit as a numeric string(0ec3842)
Full diff: v0.30.0...v0.31.0
v0.30.0
v0.30.0 — browser tabs, custom apps, ranked tool search
The browser toolset moves from one page per user to one Chromium per user with as many tabs as the work needs. Two agents, or one agent with subagents, each open their own tab and stop stepping on each other. search_tools now understands how agents phrase a task. Workbench also stops being limited to its built-in plugins. A user can point it at any MCP server as a custom app, and its tools appear beside the native ones. Ships as a release candidate first: it touches the routing path behind CLUSTER_ENABLED, adds a table, and changes the /mcp request path.
Features
- Browser: tabs.
browser_startnow opens a new tab and returns itssession_id; every other tool acts on that tab.browser_tabslists open tabs,browser_closecloses one and leaves the browser up. Tabs share the profile and cookie jar, so a login in one is visible in the others. Cap per user:BROWSER_TAB_LIMIT(default 8). - Browser: routing key derived from the bearer. The replica-affinity header is set by the server from the authenticated user; the agent no longer carries a routing key in
session_id. See the finding. - Custom apps: register any MCP server. On the Apps page, New custom app takes a name and an MCP server URL. Workbench discovers the server's OAuth metadata, registers itself as a client (dynamic client registration), and runs the OAuth 2.1 flow per user. The server's tools are exposed as
appName__toolnext to the native plugins, throughsearch_toolsandexecute_tools. Custom apps are per user: nobody else sees or can connect to an app you add. Streamable HTTP is used with an SSE fallback for older servers; sessions are cached, and tool calls get a 120s timeout (10 min ceiling with progress). See the finding. - Custom apps: connect on create. The dialog's button reads Connect. It registers the app and goes straight into its OAuth, showing Registering… then Connecting… while the dialog stays up, so the human no longer has to find the new app and press Connect. The trigger is now a square
+button in the Apps toolbar, with a tooltip. - MCP:
search_toolsranks by word, forgives typos. Search was a literal substring match on the whole query. On the built-in catalog, 10 of 12 common agent phrasings (create jira issue,send email,list github pull requests, …) returned[], and hits came back unranked and unlimited. Now the query and every tool's name, integration and description are split into words and stemmed. A word matches exactly, by prefix, or through a small synonym list (ticket→ issue,email→ gmail,pull request↔pr). A word no tool uses is first corrected to the nearest one that does (emial→ email). Name matches outrank integration matches, which outrank description matches, and rare words count more. All 12 phrasings, and five misspelt ones, now put the right tool first. Built-in and custom-app tools are ranked together. Each row gains ascore, the top 10 come back by default, and a new optionallimitgoes up to 50. It costs about 0.5 MB of cached word lists and 1–3 ms per query. See the finding. - MCP:
curl_sessiontoken lifetime. Callers can passexpiresInSeconds, an integer from 60 to 3600 (default 900). The cap bounds how long a leaked token stays live, since there is no revoke. The response'sexpiresInechoes the lifetime minted. - Portal: one-time link page in one column. The create form is down to one field; the expiry moved into a compact popover beside Create link. Once minted, the link takes the value's place with Copy beside it. Home gets a vault quick-access card.
- Portal: copy the MCP endpoint from Home. The Connect your agent card has an icon-only copy button beside the URL.
Fixes
- REST:
POST /rest/browseris now routed to the owning replica. It ran wherever it landed, which underCLUSTER_ENABLEDcould start a second Chromium on the shared profile. - Custom apps: a custom app can no longer loop back to workbench. This workbench's own
/mcppassed the SSRF guard (its public URL is not private), so it could be added as a custom app. Its tools are then the meta-tools, andexecute_toolscould call itself through it. Adding one is now refused when the server's protected-resource metadata names this instance, which also catches another hostname, a CNAME or an IP. At call time every outbound custom-app request carriesX-Workbench-Via, the chain of instances it has passed through./mcpanswers508 Loop Detectedwhen the chain already names this instance, which also catches A → B → A across two deployments, or when the chain reaches 4 hops. Apps registered before this release are covered by the call-time check. See the finding. - Vault: one-time links survive chat link previews. Pasting a one-time link into Slack spent it, because the redeem was a
GETand Slack's unfurl was that firstGET. Teams, Discord, iMessage and mail scanners do the same. OnlyPOST /api/vault/otl/:tokenspends the token now. A browserGETgets a no-script page with a Download secret button, and any otherGETgets405 Allow: POST. The link's preview card reads "Open once, then it burns." See the finding. - Vault: the one-time link card now renders in Slack. The page served its
og:tags only whenAcceptcontainedtext/html. Slack's unfurler sendsAccept: */*, so it got the 405 and chat showed no card. Known link-preview bots (Slack, Twitter/X, Facebook, Discord, Telegram, WhatsApp, LinkedIn, Teams, …) are now served the page byUser-Agent. This only picks page vs 405, and noGETspends the token. Verified against real Slack through a tunnelled build. - Google Drive: Shared Drive files and folders are reachable. Drive v3 hides Shared Drive items unless each call opts in. A Shared Drive id 404'd as "File not found" and listing a Shared Drive folder returned
[]. Everygoogle-drivetool now sendssupportsAllDrives=true, and list and search also sendincludeItemsFromAllDrives=true. No scope change, no reconnect. See the finding.
Compatibility
- Callers of one-time links must
POST.curl "$URL"now fails with a 405; usecurl -X POST "$URL". - A
session_idminted by a pre-v0.30browser_startkeeps working for this release and drives the default tab. The fallback is removed in v0.31. BAD_SESSION_KEYis gone; the new errors areBROWSER_TAB_NOT_FOUNDandBROWSER_TAB_LIMIT.browser_live_urlno longer takessession_id.- The portal live view shows the default tab.
- Schema: a
custom_appstable is created on boot (SQLite and PostgreSQL). It is additive; rolling back leaves it unused. - bcryptjs 2 → 3. New API-key hashes are written as
$2b$instead of$2a$. Existing$2a$hashes still verify, and bcryptjs 2.4.3 reads$2b$, so existing keys keep working and a rollback does not lock anyone out. search_toolsreturns at most 10 tools by default. Before, it returned every match. An agent that enumerated a whole plugin with one keyword ("gitlab") should passlimit: 50. Rows gain ascorefield; clients that ignore unknown fields are unaffected.- Toolchain: TypeScript 7,
@types/node26. The MCP SDK, Fastify and the portal's React Query/Router get patch updates.
Commits since v0.30.0-rc.1
feat(mcp): correct misspelt search words before matching them(0594ce0) — rc.4feat(mcp): rank search_tools results by word, with typos and synonyms(80651cf) — rc.4fix(vault): show the one-time link card to Slack and other unfurlers(b10f424) — rc.3fix(custom-apps): refuse a custom app that loops back to workbench(47f6e81)feat(portal): copy button beside the MCP endpoint on Home(7f245d9)feat(portal): keep the custom app modal up while it connects(c48afa2)feat(portal): connect a custom app as soon as it is created(54010fa)feat(portal): icon-only New custom app button with a tooltip(234c6c7)fix(vault): shorten one-time link card copy(43e4950)feat(mcp): let curl_session callers choose the token lifetime(4736e4b)fix(google-drive): reach Shared Drive files and folders(44db919)feat(portal): one-time link page in one column; vault quick access on Home(9c3c038)fix(vault): one-time links survive chat link previews; redeem is POST(8cc58dd)feat: register external MCP servers as custom apps (#107)(00a52d2)chore(deps): bump bcryptjs and @types/bcryptjs(9ef563d), plus TypeScript 7,@types/node26 and grouped dependency/actions updates
Full diff: v0.27.0...v0.30.0
v0.30.0-rc.4
v0.30.0 — browser tabs, custom apps, ranked tool search
The browser toolset moves from one page per user to one Chromium per user with as many tabs as the work needs. Two agents, or one agent with subagents, each open their own tab and stop stepping on each other. search_tools now understands how agents phrase a task. Workbench also stops being limited to its built-in plugins. A user can point it at any MCP server as a custom app, and its tools appear beside the native ones. Ships as a release candidate first: it touches the routing path behind CLUSTER_ENABLED, adds a table, and changes the /mcp request path.
Features
- Browser: tabs.
browser_startnow opens a new tab and returns itssession_id; every other tool acts on that tab.browser_tabslists open tabs,browser_closecloses one and leaves the browser up. Tabs share the profile and cookie jar, so a login in one is visible in the others. Cap per user:BROWSER_TAB_LIMIT(default 8). - Browser: routing key derived from the bearer. The replica-affinity header is set by the server from the authenticated user; the agent no longer carries a routing key in
session_id. See the finding. - Custom apps: register any MCP server. On the Apps page, New custom app takes a name and an MCP server URL. Workbench discovers the server's OAuth metadata, registers itself as a client (dynamic client registration), and runs the OAuth 2.1 flow per user. The server's tools are exposed as
appName__toolnext to the native plugins, throughsearch_toolsandexecute_tools. Custom apps are per user: nobody else sees or can connect to an app you add. Streamable HTTP is used with an SSE fallback for older servers; sessions are cached, and tool calls get a 120s timeout (10 min ceiling with progress). See the finding. - Custom apps: connect on create. The dialog's button reads Connect. It registers the app and goes straight into its OAuth, showing Registering… then Connecting… while the dialog stays up, so the human no longer has to find the new app and press Connect. The trigger is now a square
+button in the Apps toolbar, with a tooltip. - MCP:
search_toolsranks by word, forgives typos. Search was a literal substring match on the whole query. On the built-in catalog, 10 of 12 common agent phrasings (create jira issue,send email,list github pull requests, …) returned[], and hits came back unranked and unlimited. Now the query and every tool's name, integration and description are split into words and stemmed. A word matches exactly, by prefix, or through a small synonym list (ticket→ issue,email→ gmail,pull request↔pr). A word no tool uses is first corrected to the nearest one that does (emial→ email). Name matches outrank integration matches, which outrank description matches, and rare words count more. All 12 phrasings, and five misspelt ones, now put the right tool first. Built-in and custom-app tools are ranked together. Each row gains ascore, the top 10 come back by default, and a new optionallimitgoes up to 50. It costs about 0.5 MB of cached word lists and 1–3 ms per query. See the finding. - MCP:
curl_sessiontoken lifetime. Callers can passexpiresInSeconds, an integer from 60 to 3600 (default 900). The cap bounds how long a leaked token stays live, since there is no revoke. The response'sexpiresInechoes the lifetime minted. - Portal: one-time link page in one column. The create form is down to one field; the expiry moved into a compact popover beside Create link. Once minted, the link takes the value's place with Copy beside it. Home gets a vault quick-access card.
- Portal: copy the MCP endpoint from Home. The Connect your agent card has an icon-only copy button beside the URL.
Fixes
- REST:
POST /rest/browseris now routed to the owning replica. It ran wherever it landed, which underCLUSTER_ENABLEDcould start a second Chromium on the shared profile. - Custom apps: a custom app can no longer loop back to workbench. This workbench's own
/mcppassed the SSRF guard (its public URL is not private), so it could be added as a custom app. Its tools are then the meta-tools, andexecute_toolscould call itself through it. Adding one is now refused when the server's protected-resource metadata names this instance, which also catches another hostname, a CNAME or an IP. At call time every outbound custom-app request carriesX-Workbench-Via, the chain of instances it has passed through./mcpanswers508 Loop Detectedwhen the chain already names this instance, which also catches A → B → A across two deployments, or when the chain reaches 4 hops. Apps registered before this release are covered by the call-time check. See the finding. - Vault: one-time links survive chat link previews. Pasting a one-time link into Slack spent it, because the redeem was a
GETand Slack's unfurl was that firstGET. Teams, Discord, iMessage and mail scanners do the same. OnlyPOST /api/vault/otl/:tokenspends the token now. A browserGETgets a no-script page with a Download secret button, and any otherGETgets405 Allow: POST. The link's preview card reads "Open once, then it burns." See the finding. - Vault: the one-time link card now renders in Slack. The page served its
og:tags only whenAcceptcontainedtext/html. Slack's unfurler sendsAccept: */*, so it got the 405 and chat showed no card. Known link-preview bots (Slack, Twitter/X, Facebook, Discord, Telegram, WhatsApp, LinkedIn, Teams, …) are now served the page byUser-Agent. This only picks page vs 405, and noGETspends the token. Verified against real Slack through a tunnelled build. - Google Drive: Shared Drive files and folders are reachable. Drive v3 hides Shared Drive items unless each call opts in. A Shared Drive id 404'd as "File not found" and listing a Shared Drive folder returned
[]. Everygoogle-drivetool now sendssupportsAllDrives=true, and list and search also sendincludeItemsFromAllDrives=true. No scope change, no reconnect. See the finding.
Compatibility
- Callers of one-time links must
POST.curl "$URL"now fails with a 405; usecurl -X POST "$URL". - A
session_idminted by a pre-v0.30browser_startkeeps working for this release and drives the default tab. The fallback is removed in v0.31. BAD_SESSION_KEYis gone; the new errors areBROWSER_TAB_NOT_FOUNDandBROWSER_TAB_LIMIT.browser_live_urlno longer takessession_id.- The portal live view shows the default tab.
- Schema: a
custom_appstable is created on boot (SQLite and PostgreSQL). It is additive; rolling back leaves it unused. - bcryptjs 2 → 3. New API-key hashes are written as
$2b$instead of$2a$. Existing$2a$hashes still verify, and bcryptjs 2.4.3 reads$2b$, so existing keys keep working and a rollback does not lock anyone out. search_toolsreturns at most 10 tools by default. Before, it returned every match. An agent that enumerated a whole plugin with one keyword ("gitlab") should passlimit: 50. Rows gain ascorefield; clients that ignore unknown fields are unaffected.- Toolchain: TypeScript 7,
@types/node26. The MCP SDK, Fastify and the portal's React Query/Router get patch updates.
Commits since v0.30.0-rc.1
feat(mcp): correct misspelt search words before matching them(0594ce0) — rc.4feat(mcp): rank search_tools results by word, with typos and synonyms(80651cf) — rc.4fix(vault): show the one-time link card to Slack and other unfurlers(b10f424) — rc.3fix(custom-apps): refuse a custom app that loops back to workbench(47f6e81)feat(portal): copy button beside the MCP endpoint on Home(7f245d9)feat(portal): keep the custom app modal up while it connects(c48afa2)feat(portal): connect a custom app as soon as it is created(54010fa)feat(portal): icon-only New custom app button with a tooltip(234c6c7)fix(vault): shorten one-time link card copy(43e4950)feat(mcp): let curl_session callers choose the token lifetime(4736e4b)fix(google-drive): reach Shared Drive files and folders(44db919)feat(portal): one-time link page in one column; vault quick access on Home(9c3c038)fix(vault): one-time links survive chat link previews; redeem is POST(8cc58dd)feat: register external MCP servers as custom apps (#107)(00a52d2)chore(deps): bump bcryptjs and @types/bcryptjs(9ef563d), plus TypeScript 7,@types/node26 and grouped dependency/actions updates
Full diff: v0.27.0...v0.30.0
v0.30.0-rc.3
v0.30.0 — browser tabs, custom apps
The browser toolset moves from one page per user to one Chromium per user with as many tabs as the work needs. Two agents, or one agent with subagents, each open their own tab and stop stepping on each other. Workbench also stops being limited to its built-in plugins. A user can point it at any MCP server as a custom app, and its tools appear beside the native ones. Ships as a release candidate first: it touches the routing path behind CLUSTER_ENABLED, adds a table, and changes the /mcp request path.
Features
- Browser: tabs.
browser_startnow opens a new tab and returns itssession_id; every other tool acts on that tab.browser_tabslists open tabs,browser_closecloses one and leaves the browser up. Tabs share the profile and cookie jar, so a login in one is visible in the others. Cap per user:BROWSER_TAB_LIMIT(default 8). - Browser: routing key derived from the bearer. The replica-affinity header is set by the server from the authenticated user; the agent no longer carries a routing key in
session_id. See the finding. - Custom apps: register any MCP server. On the Apps page, New custom app takes a name and an MCP server URL. Workbench discovers the server's OAuth metadata, registers itself as a client (dynamic client registration), and runs the OAuth 2.1 flow per user. The server's tools are exposed as
appName__toolnext to the native plugins, throughsearch_toolsandexecute_tools. Custom apps are per user: nobody else sees or can connect to an app you add. Streamable HTTP is used with an SSE fallback for older servers; sessions are cached, and tool calls get a 120s timeout (10 min ceiling with progress). See the finding. - Custom apps: connect on create. The dialog's button reads Connect. It registers the app and goes straight into its OAuth, showing Registering… then Connecting… while the dialog stays up, so the human no longer has to find the new app and press Connect. The trigger is now a square
+button in the Apps toolbar, with a tooltip. - MCP:
curl_sessiontoken lifetime. Callers can passexpiresInSeconds, an integer from 60 to 3600 (default 900). The cap bounds how long a leaked token stays live, since there is no revoke. The response'sexpiresInechoes the lifetime minted. - Portal: one-time link page in one column. The create form is down to one field; the expiry moved into a compact popover beside Create link. Once minted, the link takes the value's place with Copy beside it. Home gets a vault quick-access card.
- Portal: copy the MCP endpoint from Home. The Connect your agent card has an icon-only copy button beside the URL.
Fixes
- REST:
POST /rest/browseris now routed to the owning replica. It ran wherever it landed, which underCLUSTER_ENABLEDcould start a second Chromium on the shared profile. - Custom apps: a custom app can no longer loop back to workbench. This workbench's own
/mcppassed the SSRF guard (its public URL is not private), so it could be added as a custom app. Its tools are then the meta-tools, andexecute_toolscould call itself through it. Adding one is now refused when the server's protected-resource metadata names this instance, which also catches another hostname, a CNAME or an IP. At call time every outbound custom-app request carriesX-Workbench-Via, the chain of instances it has passed through./mcpanswers508 Loop Detectedwhen the chain already names this instance, which also catches A → B → A across two deployments, or when the chain reaches 4 hops. Apps registered before this release are covered by the call-time check. See the finding. - Vault: one-time links survive chat link previews. Pasting a one-time link into Slack spent it, because the redeem was a
GETand Slack's unfurl was that firstGET. Teams, Discord, iMessage and mail scanners do the same. OnlyPOST /api/vault/otl/:tokenspends the token now. A browserGETgets a no-script page with a Download secret button, and any otherGETgets405 Allow: POST. The link's preview card reads "Open once, then it burns." See the finding. - Vault: the one-time link card now renders in Slack. The page served its
og:tags only whenAcceptcontainedtext/html. Slack's unfurler sendsAccept: */*, so it got the 405 and chat showed no card. Known link-preview bots (Slack, Twitter/X, Facebook, Discord, Telegram, WhatsApp, LinkedIn, Teams, …) are now served the page byUser-Agent. This only picks page vs 405, and noGETspends the token. Verified against real Slack through a tunnelled build. - Google Drive: Shared Drive files and folders are reachable. Drive v3 hides Shared Drive items unless each call opts in. A Shared Drive id 404'd as "File not found" and listing a Shared Drive folder returned
[]. Everygoogle-drivetool now sendssupportsAllDrives=true, and list and search also sendincludeItemsFromAllDrives=true. No scope change, no reconnect. See the finding.
Compatibility
- Callers of one-time links must
POST.curl "$URL"now fails with a 405; usecurl -X POST "$URL". - A
session_idminted by a pre-v0.30browser_startkeeps working for this release and drives the default tab. The fallback is removed in v0.31. BAD_SESSION_KEYis gone; the new errors areBROWSER_TAB_NOT_FOUNDandBROWSER_TAB_LIMIT.browser_live_urlno longer takessession_id.- The portal live view shows the default tab.
- Schema: a
custom_appstable is created on boot (SQLite and PostgreSQL). It is additive; rolling back leaves it unused. - bcryptjs 2 → 3. New API-key hashes are written as
$2b$instead of$2a$. Existing$2a$hashes still verify, and bcryptjs 2.4.3 reads$2b$, so existing keys keep working and a rollback does not lock anyone out. - Toolchain: TypeScript 7,
@types/node26. The MCP SDK, Fastify and the portal's React Query/Router get patch updates.
Commits since v0.30.0-rc.1
fix(vault): show the one-time link card to Slack and other unfurlers(b10f424) — rc.3fix(custom-apps): refuse a custom app that loops back to workbench(47f6e81)feat(portal): copy button beside the MCP endpoint on Home(7f245d9)feat(portal): keep the custom app modal up while it connects(c48afa2)feat(portal): connect a custom app as soon as it is created(54010fa)feat(portal): icon-only New custom app button with a tooltip(234c6c7)fix(vault): shorten one-time link card copy(43e4950)feat(mcp): let curl_session callers choose the token lifetime(4736e4b)fix(google-drive): reach Shared Drive files and folders(44db919)feat(portal): one-time link page in one column; vault quick access on Home(9c3c038)fix(vault): one-time links survive chat link previews; redeem is POST(8cc58dd)feat: register external MCP servers as custom apps (#107)(00a52d2)chore(deps): bump bcryptjs and @types/bcryptjs(9ef563d), plus TypeScript 7,@types/node26 and grouped dependency/actions updates
Full diff: v0.27.0...v0.30.0