From b7e365aedd38cbd864f615dbf78470e9a441022c Mon Sep 17 00:00:00 2001 From: davidmckayv <1574202+davidmckayv@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:30:10 +0000 Subject: [PATCH 1/5] Release v1.0.0-rc.1 --- charts/openbot/Chart.yaml | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/charts/openbot/Chart.yaml b/charts/openbot/Chart.yaml index e213ec1df4..9de41f949e 100644 --- a/charts/openbot/Chart.yaml +++ b/charts/openbot/Chart.yaml @@ -6,7 +6,7 @@ type: application version: 0.2.0 # The OpenBot release this chart's default image tag points at. Kept equal to package.json's version # (the last version published) by the release workflow, and CI refuses a tree where they have drifted. -appVersion: "0.3.4" +appVersion: "1.0.0-rc.1" home: https://github.com/CopilotKit/OpenBot sources: - https://github.com/CopilotKit/OpenBot diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index a328222521..5dd9fae457 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2746,7 +2746,7 @@ dependencies = [ [[package]] name = "openbot-desktop" -version = "0.3.4" +version = "1.0.0-rc.1" dependencies = [ "base64 0.22.1", "block2", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b8f580a7c8..65097cd8fe 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openbot-desktop" -version = "0.3.4" +version = "1.0.0-rc.1" description = "OpenBot Desktop" edition = "2021" rust-version = "1.90" diff --git a/package.json b/package.json index 30ac141992..b8e37624d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openbot", - "version": "0.3.4", + "version": "1.0.0-rc.1", "license": "MIT", "private": true, "packageManager": "bun@1.4.3", From 0df439cf27e9cff3cbb247a9d3415003225832b8 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 8 Nov 2026 17:31:04 -0800 Subject: [PATCH 2/5] Set the release policy for v1.0.0-rc.1 1.0 upgrades from v0.3.4 (database at 0083_learning_skill_approvals), v0.3.3 (0082_routine_goal) and v0.3.0 to v0.3.2 (0077_channel_opening). There are no migrations since v0.3.4, so the database stays at 0083. Not rollback compatible, since upgrades from v0.3.3 and earlier still apply migrations up to 0083: recovery is restoring the backup. --- release-policy.json | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/release-policy.json b/release-policy.json index f2935d9c93..981f8389a5 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,19 +1,23 @@ { "$schema": "./shared/release-policy.schema.json", - "version": "v0.3.4", + "version": "v1.0.0-rc.1", "compatibility": { "launcherApi": 1, "configSchema": 1, "extensionApi": 1, "database": { - "from": ["0082_routine_goal", "0077_channel_opening"], + "from": [ + "0083_learning_skill_approvals", + "0082_routine_goal", + "0077_channel_opening" + ], "to": "0083_learning_skill_approvals", "rollbackCompatible": false }, "intelligenceCapabilities": [] }, "upgrade": { - "fromVersions": ["v0.3.3", "v0.3.2", "v0.3.1", "v0.3.0"], + "fromVersions": ["v0.3.4", "v0.3.3", "v0.3.2", "v0.3.1", "v0.3.0"], "requiresDowntime": true, "recovery": "restore-backup" } From 148fd0273f1fd683a2462c5a7cb1d4272b392ad9 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 8 Nov 2026 17:31:04 -0800 Subject: [PATCH 3/5] Write the 1.0.0-rc.1 notes from the code A candidate's notes are Unreleased at its cut. This is the same CHANGELOG.md as the changelog pull request on main (#1323), so the two branches carry identical notes and the merge back after the final has nothing to reconcile. --- CHANGELOG.md | 225 +++++++++++++++++++++++++++++---------------------- 1 file changed, 130 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be69c4643d..94dc37ab7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,118 +8,153 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -- OpenBot Desktop's updates download from downloads.copilotkit.ai too. The update feed there names - that address for the Windows installer and the Mac update archives, instead of GitHub; every - signature is unchanged, and the feed is only published once each of those files answers there. +**Before upgrading.** There are no migrations: the database stays at +`0083_learning_skill_approvals`. Four things change for an existing deployment: +- OpenBot Desktop updates its back end to CopilotKit Intelligence 0.16.0-rc.10 at the next Start, + saying "Updating the back end to 0.16.0-rc.10. Its data stays." With it, a first back end setup + downloads all of its images through downloads.copilotkit.ai first, where its own images came + straight from ghcr.io and Docker Hub, and a setup step that fails reports its own error, which + OpenBot's log keeps (below). +- The Helm chart's bundled back end (the `intelligence` subchart) is 0.16.0-rc.10, from rc.9. +- The first time anybody opens OpenBot in a browser or in OpenBot Desktop after the upgrade, every + built-in component description nobody has edited takes this release's wording, so Bots read the + new Notice, Checklist, Record, Headline figures, Data table, Bar chart, Donut chart and Progress + against target descriptions (below). Until then, Bots keep the old wording. A component whose + description an admin saved, or that an admin published or unpublished, on Admin → UI components + keeps what they chose. Each change is one row in Admin → Audit, "Update a component's + description", started by this deployment. In the personal edition every built-in description + follows, and Activity leaves these rows out. +- On OpenBot Desktop, a Bot on LangChain that follows the installer has "Powered by LangChain + (LangGraph)" in its subtitle changed to "Powered by LangChain" at the next Start, keeping the rest + of the subtitle, and a Bot picked in setup before 0.3.3 and named "LangChain (LangGraph)" is + renamed "LangChain". A Bot whose connection you set yourself keeps its subtitle. + ### OpenBot Desktop -- **An update the mirror cannot send comes from GitHub.** When downloading the new version from - downloads.copilotkit.ai fails, OpenBot reads GitHub's update feed and downloads the same signed - version from there once, instead of failing until the mirror is fixed. The log says which one it - came from. - **A back end setup that stops says why.** OpenBot's log now records how every back end setup, start, update and stop ended: how long it took, its exit code, what its report says went wrong and the end of its error output. A setup that stopped used to leave nothing in the log. The card says whether the back end stopped with an error partway, quit without saying why, or could not be run, where all three said "The back end could not start." -- The first Bot to need you after OpenBot Desktop starts raises its notification. It used to raise - nothing while the app was still checking that notifications were on, so the first question after - each launch, asked while OpenBot was minimised or behind another window, went unannounced. -- A Bot with no folders, as every Bot has after OpenBot restarts, now says so in plain words: it - has no folders it can read, access ends when you stop OpenBot, and Choose folder in Settings → - Computer gives it one. It said "No host folders are granted to this Bot.", and the Bot told you - an administrator could restore it. - -- **CopilotKit Intelligence 0.16.0-rc.10.** OpenBot Desktop's back end and the Helm chart move to - Intelligence 0.16.0-rc.10 (CLI `copilotkit@647a03b`). A first back end setup now downloads its - images through downloads.copilotkit.ai too, because the CLI's own pulls read the cluster's - mirrors, and a setup step that fails now reports its own error instead of only that the command - did not finish. -- **Setup and Home say what is true.** "OpenBot is running" no longer flashes Start's own steps, and - points to the system tray on Windows instead of the menu bar; after Accept the account step says - it is setting up your apps; a relaunch says "Starting the engine machine." while it does; the - personal edition's Home no longer says nobody has shared a Bot; and Teach a Bot says "No - microphone found. Narration is off." on a computer with none, keeping your choice for when there - is one. - -### Automatic Learning and Teach a Bot - -- **Teach a Bot reads as one page**: its title and how in one sentence, the browser, and your - recordings under it, all as wide as the browser. The browser is drawn as a window (tabs on its - frame, a pill address bar with a lock, a start page for an empty tab), sized to its page so no - blank band sits under it, and the page is laid out while the browser opens. **Record** is one - button at the toolbar's end that asks for the task's name and narration in a popover, with an - (i) saying what narration is for; the narration bar shows only while recording, or to say there - is no microphone. Your recordings links to Settings → Learning, where what a recording teaches - shows up. +- **The first notification after OpenBot Desktop starts is raised**, for a Bot that needs you or + replies while OpenBot is minimised or behind another window. The first one after each launch used + to raise nothing, because the app was still checking that notifications were on. OpenBot's log + now says whether each notification was shown, never what it said. +- **Setup says what is happening.** The screen shown while OpenBot runs says you can show or stop + it from its icon in the system tray on Windows, where it said the menu bar everywhere, and no + longer flashes Start's own steps before OpenBot opens. After you accept the terms, the account + step says "Setting up your apps" while they connect, where it flashed "Sign in to CopilotKit". + Opening OpenBot after a restart says "Starting the engine machine." while it starts OpenBot's + Podman machine, and Start's steps say "OpenBot's services" where they said "Containers". +- **App updates download from downloads.copilotkit.ai.** The update feed there names + downloads.copilotkit.ai for the Windows installer and the Mac update archives, where they came + from GitHub, and every signature is unchanged. If that download fails, OpenBot Desktop reads + GitHub's update feed and downloads the same signed version from there, where the update failed. + The log says where it came from. +- **A Bot with no folders says so in plain words**, as every Bot does after OpenBot restarts: "This + Bot has no folders it can read right now. Folder access ends when you stop OpenBot. To give it a + folder, use Choose folder in Settings → Computer." It said "No host folders are granted to this + Bot." + +### Teach a Bot + +- **Teach a Bot is one page.** Its title and one line on how sit above the browser, and Your + recordings sits under it at the same width and scrolls on its own, so a long list no longer makes + the browser smaller. The page was headed "Browser". Where the browser opens as a window on your + desktop, the page is as it was. +- **The browser looks like a browser**: tabs along the top, a rounded address bar with a lock on + https pages, and a search field in an empty tab, sized so the page fills it. While it starts it + says "Opening your browser…", where the page showed no browser. +- **Record is one button** at the end of the toolbar. It opens **Record a task**, with the task's + name, the Narration switch (its (i) says what narration is for) and **Start recording**, where the + name field, the switch and Record sat in a bar above the browser. The narration line shows under + the browser only while recording, or to say "No microphone found. Narration is off." on a + computer with none, which keeps your narration choice for when there is one. +- Your recordings links to Settings → Learning ("See what your Bots learned"). ### Conversations -- **The sidebar's + opens a new conversation with To: empty** and the Bot menu open, where it - picked a Bot for you, as do Shift+N and a skin's new conversation link. A Bot's own Message link - still starts with that Bot, and a message sent before a Bot is picked stays in the box with - "Choose a Bot first." -- The Bot's browser beside a conversation is a 360px glimpse with the address it is on and Take - control on the picture, and its full-size view fits its window to the page. -- Home says which Bot is working, with a way to it, and a conversation says "Working" in the - sidebar while its Bot works. A Bot's browsing reads "Browsed example.com", where it read "Browsed - 1 page". -- Bots answer ordinary questions, summaries and short lists directly in Markdown by default. A - notice card is kept for a distinct alert that needs you to act, or for when you ask for one, and - other components for when a chart, table or card makes the answer easier to use. A Bot asked for - its top emails used to wrap the list in a notice. -- A component description nobody has edited now follows OpenBot's own when you upgrade, so the new - Notice, Checklist, Record, Headline figures, Data table, Bar chart, Donut chart and Progress - against target descriptions reach an existing deployment. The text comes from OpenBot itself, - never from a browser. A description an admin has saved, or a component an admin has published or - unpublished, keeps what they chose. Each update is one row in Admin → Audit, started by OpenBot. -- A notice card's supporting point that is itself a list is drawn as that list, without a stray dot - and an extra indent beside it. -- A long link in a card's text (a notice, a checklist, a quotation, an approval, a choice's - options) wraps, where it ran under the card's edge and lost its end. -- A choice card's summary is drawn as Markdown, as an approval's is, where it showed the raw - markers, such as `**two**`. -- A Bot can link a bar, a progress row, a headline figure or a donut chart's legend row to where - the figure came from, such as the record or the search, when it has that address. Clicking it - opens the page in a new tab, as a source under a chart does. Only web addresses become links, and - a figure without one draws as before. -- A chart whose figures are still arriving shimmers with "Drawing the chart…" at its own height, - where it said "There is no data to chart." until its first figure came in. A chart that was - stopped, or that finished with no figures, still says there is no data. -- A bar chart whose figures don't fit their bars, such as ten bars on a phone, writes them - compactly ("15K", "3.7K"), where they ran into one another. The exact figure stays in the - tooltip and is what a screen reader reads. +- **Bots answer in Markdown by default.** Ordinary questions, explanations, summaries and short + lists come back as a reply. A notice card is kept for an alert that needs you to act, or for when + you ask for one, and a chart, table or card is drawn when it makes the answer easier to use. Bots + used to be told to answer with a component whenever an answer had structure, so ordinary answers + came back as cards. +- **A figure can link to where it came from.** A bar, a progress row, a headline figure or a donut + chart's legend row links to the record or search its figure came from when the Bot has that + address, and opens it in a new tab (in OpenBot Desktop, in your own browser). Only web addresses + become links. +- A chart whose figures are still arriving shows "Drawing the chart…" at the chart's own height, + where it said "There is no data to chart." A chart that was stopped, or has no figures, still says + so. +- **+ and Shift+N open a new conversation with To: empty** and the Bot menu open, in the personal + edition and wherever there is only one Bot, where since 0.3.3 they started with your Bot picked. A + Bot's own Message link still starts with that Bot, and Send with nobody in To: still keeps the + message and says "Choose a Bot first." +- **The Bot's screen shows where it is.** In a conversation and in the panel beside it, the address + the Bot's browser is on sits above the picture. While the Bot has control, a strip over the + picture names it with **Take control**, where "The Bot has control." and Take control sat in a bar + under the picture. The panel beside a conversation is narrower, and the full-size view is sized to + the page. +- **A Bot that is working says so.** Its conversation says "Working" in the sidebar where its time + was, and Home shows that the Bot is working, with the conversation's name and **Open**. +- A Bot's browsing names the site when it stayed on one, "Browsed example.com" or "Browsed 3 pages + on example.com", where it said "Browsed 1 page". +- **Approvals for a folder or a Bot's own computer use the words of their step.** The approval + card, Needs you and a task's waiting line say "Save a file todo.txt in share", "Read a file" and + "Run a command", where they said "Write file on todo.txt in share". A file on the Bot's own + computer is named by its path, and its save step reads "Saved a file". +- An opened "Looked for a tool" step lists the tools the Bot found by name ("Send email on Gmail"), + where it showed their ids and the descriptions the search returned to the Bot. ### Personal edition -- In Activity, a Bot's call to an app you haven't connected reads "Not connected" and "Gmail isn't - connected. Connect it in Connections.", rows already recorded included. It read "The server - didn't answer" and printed raw `[connect-app]` text, which Admin → Audit no longer shows either. -- Home no longer shows an empty Explore Bots box. +- Home no longer shows an empty Explore Bots box saying "Nobody has shared a Bot with you yet." +- In Activity, a Bot's call to an app you haven't connected reads "Not connected" with "Gmail isn't + connected. Connect it in Connections.", or "No longer connected" when it needs connecting again, + rows already recorded included. It read "The server didn't answer" with raw `[connect-app]` text + under it, which Admin → Audit no longer shows either. +- Activity names a skill by its title ("Write a skill", where it said "Skill creator"), on older + rows too. An app a Bot got when you pressed Connect in a conversation reads "Let a Bot use an + app", "Allowed automatically", where it read "Approve a request for an app", as if somebody had + approved it. +- An app's page names the setting that makes a Bot ask first by its name, "Always ask before making + changes", where it said "Ask before acting". ### Smaller changes -- LangChain is listed as **LangChain**, with LangChain's own symbol, where it said "LangChain - (LangGraph)". This covers New Bot, a Bot's "Powered by" subtitle, its setup row and OpenBot - Desktop's plan note. A subtitle saved under the old name still follows a change of harness. New - Bot's custom agents are one line each, and the four named frameworks show without scrolling. -- **New Bot** shows custom agents on its first screen. Below the simple form, **Connect a custom - agent** lists every framework with its logo, Claude Agent SDK, LangChain, Google ADK and AWS - Strands first, and on a desktop its heading and first row show without scrolling. Pressing one - goes straight to its address, and Back returns to the list. Its labels are small and uppercase, - and Isolated and apps are plain rows. - The sidebar floats as frosted glass over the colours of OpenBot's orb, the orb sits beside - OpenBot's name, and the app is set in Atkinson Hyperlegible Next. A skin keeps its own faces. -- Settings → General lists your answers as rows that open a dialog. Settings → Notifications shows - a switch only for channels that are set up, and says how to set them up, or that your admin does. - In OpenBot Desktop it says those channels need OpenBot running on a server. -- **Activity, approvals and a Bot's page say things the way a person would.** Activity names a skill - by its title ("Write a skill", not "Skill creator"), says "Set up an app's sign-in" for "Register - an OAuth client", and says an app the personal edition gave a Bot on Connect was "Allowed - automatically" where it read as a person's approval. A file save's approval card reads "Save a - file todo.txt in share", as its step and its Activity row do, and an opened "Looked for a tool" - names the tools it found in words. A Bot's Your skills lists each skill by its title, and an app's - page names the "Always ask before making changes" setting by its name. + OpenBot's name, and the app is set in Atkinson Hyperlegible Next. The glass turns off with the + system's reduce transparency or increase contrast setting, and a skin keeps its own look. +- Settings → General, headed "Preferences" before, shows your name and standing instructions as + rows under About you, each opening a dialog to change it, where both were edited on the page. Dark + theme, Skin and Message list emphasis sit under Appearance. +- Settings → Notifications shows a switch only for channels that are set up, where the rest showed + as "Not set up". A choice saved for one is kept and comes back once it is set up. With none set up + it says "Updates stay in OpenBot", with **How to set them up** for admins and anyone running + OpenBot themselves, and that your admin sets them up for everyone else. In OpenBot Desktop it says + they need OpenBot running on a server. +- The skill OpenBot ships as Make a coworker is called **Make a Bot**, and Write a skill's + description says it saves a new skill rather than a `/` command. Their `/` names are unchanged. +- Activity and Admin → Audit say "Set up an app's sign-in" and "Check an app's sign-in", where they + said "Register an OAuth client" and "Read an OAuth client". +- Your skills on a Bot's page lists each skill by its title ("Tide report"), where it showed + "/tide-report". +- A notice card's supporting point that is itself a list is drawn as that list, without a stray + dot and an extra indent beside it. +- A long link or path in a card's text wraps where it was cut off at the card's edge: in a notice, + a checklist, a record, a quotation, a data table, an approval, and a choice card's summary and + options. A choice card's summary is drawn as Markdown, as an approval's is, where it showed the + raw text, such as `**two**`. +- When a bar chart's figures don't fit above their bars, every bar shows a shorter figure, such as + "15K" or "3.7K", where they ran into each other. The exact figure shows when you point at it and + is read out by screen readers. +- **New Bot** lists the frameworks a custom agent can run on under **Connect a custom agent** on its + first screen, below the form, one line each with its logo, Claude Agent SDK, LangChain, Google ADK + and AWS Strands first. Choosing one goes straight to its address, and Back returns to the list. + They were behind a Connect a custom agent button. +- LangChain is called **LangChain**, with LangChain's own mark, where it said "LangChain + (LangGraph)": in New Bot, a Bot's "Powered by" subtitle and its Runs on row, and OpenBot Desktop's + note under a ChatGPT plan. ## 0.3.4 From 1863231b77844a6c5af12b7f18862156188d386c Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 8 Nov 2026 17:41:42 -0800 Subject: [PATCH 4/5] Name a waiting browser action in words on Activity and its card Brings #1322 into 1.0.0-rc.1, as asked: a computer-use approval's Activity row, Needs you card, Slack approval and saved rule are named in words ("Click the link ... on en.wikipedia.org"), where they showed the tool id, its effect and the click's raw JSON. Same change as #1322 on main, applied to the candidate, with its notes line. --- CHANGELOG.md | 8 + app/src/components/approvals/rules.tsx | 71 ++++++++- app/src/components/approvals/waiting.tsx | 46 ++++-- app/tests/approval-card-internals.test.tsx | 139 ++++++++++++++++++ app/tests/approval-rule-line.test.tsx | 122 +++++++++++++++ app/tests/approvals-rules.test.tsx | 56 ++++++- app/tests/bot-approval-rules.test.tsx | 18 ++- app/tests/skins-openmuse.test.tsx | 7 +- server/src/activity/activity.ts | 11 +- server/src/approvals/wiring.ts | 3 +- server/src/delivery/opentag-agent.ts | 9 +- server/src/delivery/opentag.ts | 8 +- ...pproval-activity-words.integration.test.ts | 108 ++++++++++++++ server/tests/approvals.test.ts | 2 +- server/tests/delivery-routes.test.ts | 74 +++++++++- shared/tool-name.ts | 136 +++++++++++++++++ 16 files changed, 770 insertions(+), 48 deletions(-) create mode 100644 app/tests/approval-card-internals.test.tsx create mode 100644 app/tests/approval-rule-line.test.tsx create mode 100644 server/tests/approval-activity-words.integration.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 94dc37ab7b..3336a64d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. computer is named by its path, and its save step reads "Saved a file". - An opened "Looked for a tool" step lists the tools the Bot found by name ("Send email on Gmail"), where it showed their ids and the descriptions the search returned to the Bot. +- A browser action waiting for your approval is named the way its card names it. A Bot's Activity + row read "Approve computer_click" with "write" beside it, and now reads "Click the link “Python” + on en.wikipedia.org". The Needs you card names the link too and lists what the action would send + by label, without the page handles a click carried as raw JSON. An approval in Slack says it the + same way ("Research Bot wants to click the link “Python” on en.wikipedia.org"), and a saved rule + is titled "Click" or "Send email on Gmail" instead of its tool id. The line under it names the + folder and the Bot by name and the kind of action in words ("changes in share for Shop"), where + it read "write on host-grant-15407-… for shopper-bot". ### Personal edition diff --git a/app/src/components/approvals/rules.tsx b/app/src/components/approvals/rules.tsx index a3e844dd10..a76c35a4c3 100644 --- a/app/src/components/approvals/rules.tsx +++ b/app/src/components/approvals/rules.tsx @@ -1,10 +1,11 @@ +import { ruleTitle } from "@openbot/shared/tool-name"; import { IconChevronRight, IconLock, IconPlus, IconShieldCheck, } from "@tabler/icons-react"; -import type { UseMutationResult } from "@tanstack/react-query"; +import { type UseMutationResult, useQuery } from "@tanstack/react-query"; import { useId, useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -33,6 +34,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { GONE_BOT, useBotNames } from "@/lib/agents/bot-names"; import { type ApprovalRuleInput, type ApprovalRuleRow, @@ -40,6 +42,10 @@ import { type HostCommandPolicy, type RuleBehaviour, } from "@/lib/approvals"; +import { + type HostFolderGrant, + hostAccessQueryOptions, +} from "@/lib/computers/host-access"; export const HOST_LABELS: Record = { ask: "Ask every time", @@ -97,6 +103,38 @@ const TEAM_BEHAVIOUR_LABELS = Object.fromEntries( ]), ) as Record; +/** What kind of action a rule covers, in words. Any other kind is left unsaid. */ +const EFFECT_WORDS: Record = { + read: "reading", + write: "changes", + destructive: "deleting", + delegate: "handing off work", +}; + +/** "for Expenses"; nothing until the names arrive, so a Bot is never named by its id. */ +function forBot(name: string): string | null { + if (!name) return null; + return name === GONE_BOT ? "for a Bot no longer here" : `for ${name}`; +} + +/** A rule saved from a folder's card is on the id OpenBot gave that folder (`host-access/broker.ts`). */ +const FOLDER_GRANT = "host-grant-"; + +/** + * A folder rule's place by the folder's name, as Settings → Computer shows it: "in share". A grant + * ends when OpenBot stops, so a rule on one that is gone says so rather than naming its id. + */ +function folderWords( + grantId: string, + grants: readonly HostFolderGrant[] | undefined, +): string { + if (!grants) return "in a folder"; + const grant = grants.find((candidate) => candidate.id === grantId); + return grant && !grant.revoked + ? `in ${grant.displayName}` + : "in a folder that's no longer shared"; +} + /** * One saved rule, as a row: what it matches, and what a Bot does about it. A locked rule — a team * rule seen by a member, or a personal rule the team has switched off — states its behaviour and @@ -118,12 +156,29 @@ export function RuleRow({ onChange: (behaviour: RuleBehaviour) => void; }) { const labels = team ? TEAM_BEHAVIOUR_LABELS : BEHAVIOUR_LABELS; - // The tool is the title; the rest of what the rule matches is the line beneath, never clamped, - // since two rules for one tool differ only there. + // The action in its approval card's words is the title ("Click", "Any action on Gmail"); the rest + // of what the rule matches is the line beneath, never clamped, since two rules for one action + // differ only there. The title already names an app's rule's place. + const { title, place } = ruleTitle(rule); + const botName = useBotNames(); + const folderRule = rule.scope.startsWith(FOLDER_GRANT); + const folders = useQuery({ + ...hostAccessQueryOptions(), + refetchInterval: false, + enabled: folderRule, + }); const match = [ - rule.effect !== "*" ? rule.effect : null, - rule.scope !== "*" ? `on ${rule.scope}` : null, - rule.botId !== "*" ? `for ${rule.botId}` : null, + EFFECT_WORDS[rule.effect] ?? null, + rule.scope === "*" || place + ? null + : folderRule + ? folderWords(rule.scope, folders.data?.grants) + : `on ${rule.scope}`, + rule.botId === "*" + ? null + : rule.botId.includes("*") + ? `for Bots matching ${rule.botId}` + : forBot(botName(rule.botId)), ] .filter(Boolean) .join(" "); @@ -133,7 +188,7 @@ export function RuleRow({ {locked ? : } - {rule.toolRef} + {title} {match ? ( {match} @@ -150,7 +205,7 @@ export function RuleRow({ <> + {details.map((detail) => ( +
+
{detail.label}
+
+ {detail.value} +
+
+ ))} + + ); +} + /** * An action waiting on a decision, decided on the row's own footer line, in from the edge by the * icon and its gap so the buttons line up with the text, as a task's and an item's do beside it. @@ -160,9 +194,7 @@ function RequestRow({ {request.action.policy.reason}

) : null} -
-          {JSON.stringify(request.action.args, null, 2)}
-        
+
{request.action.policy?.behaviour === "hand_off" ? ( @@ -520,11 +552,7 @@ function RequestBoardRow({ request }: { request: WaitingRequest }) { ) : null, ]} - more={ -
-          {JSON.stringify(request.action.args, null, 2)}
-        
- } + more={} open title={action} waiting diff --git a/app/tests/approval-card-internals.test.tsx b/app/tests/approval-card-internals.test.tsx new file mode 100644 index 0000000000..eff3429e21 --- /dev/null +++ b/app/tests/approval-card-internals.test.tsx @@ -0,0 +1,139 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { WaitingForYou } from "@/components/approvals/waiting"; +import { BotNeedsYou } from "@/components/needs-you/board"; +import { settleReactWork } from "./settle-react-work"; + +/* + * FOUND IN THE 1.0 RC1 MAC PASS: a pending click's "Needs you" card read "Click / en.wikipedia.org" + * over a raw block, `{ "ref": "f3e43", "snapshotId": 4 }`. Those are the handles the model cites for + * an element on a page snapshot, and nobody deciding can read them. The card names the link from the + * label the server resolved, and shows only what the action would send. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +/** A pending computer action as `GET /api/approvals` returns it (`publicApproval`). */ +const request = ( + id: string, + toolRef: string, + args: Record, + element: { ref: string; role: string; name: string }, +) => ({ + id, + status: "pending", + createdAt: "2026-11-08T10:00:00Z", + action: { + botId: "researcher", + toolRef, + effect: "write", + scope: "en.wikipedia.org", + threadId: `thread-${id}`, + toolCallId: `call-${id}`, + args, + target: { + pageUrl: "https://en.wikipedia.org/wiki/Main_Page", + element: { ...element, type: "" }, + snapshotId: 4, + }, + }, +}); + +function serving() { + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/approvals") + return Response.json({ + enabled: true, + rules: [], + teamRules: [], + questions: [], + requests: [ + request( + "click", + "computer_click", + { ref: "f3e43", snapshotId: 4 }, + { + ref: "f3e43", + role: "link", + name: "Python (programming language)", + }, + ), + request( + "type", + "computer_type", + { ref: "f7", snapshotId: 4, text: "monty python" }, + { ref: "f7", role: "searchbox", name: "Search Wikipedia" }, + ), + ], + }); + if (url === "/api/proactive/suggestions") + return Response.json({ suggestions: [] }); + if (url === "/api/tasks") return Response.json({ tasks: [] }); + if (url.startsWith("/api/channels")) + return Response.json({ channels: [], nextCursor: null }); + return Response.json({ + agents: [{ id: "researcher", name: "Researcher" }], + }); + }) as unknown as typeof fetch; +} + +function draw(node: ReactNode) { + return render( + + {node} + , + ); +} + +/** Nothing the model cites, and no raw JSON, anywhere a person reads. */ +function expectNoInternals(text: string) { + expect(text).not.toContain("snapshotId"); + expect(text).not.toContain("f3e43"); + expect(text).not.toContain('"ref"'); + expect(text).not.toContain("computer_"); + expect(text).not.toContain("{"); +} + +test("on the Needs you board, a click names the link and shows no raw JSON", async () => { + serving(); + const view = draw(); + expect( + await view.findByText("Click the link “Python (programming language)”"), + ).toBeTruthy(); + expect(view.getByText("Fill in “Search Wikipedia”")).toBeTruthy(); + // What a typing step would send is still shown whole, by its label. + expect(view.getByText("Text")).toBeTruthy(); + expect(view.getByText("monty python")).toBeTruthy(); + expect(view.getAllByText("en.wikipedia.org").length).toBeGreaterThan(0); + expectNoInternals(view.container.textContent ?? ""); +}); + +test("on the Bot's page, a click says what the Bot wants to do and shows no raw JSON", async () => { + serving(); + const view = draw(); + expect( + await view.findByText( + "Researcher wants to click the link “Python (programming language)”", + ), + ).toBeTruthy(); + expect(view.getByText("monty python")).toBeTruthy(); + expectNoInternals(view.container.textContent ?? ""); +}); diff --git a/app/tests/approval-rule-line.test.tsx b/app/tests/approval-rule-line.test.tsx new file mode 100644 index 0000000000..b5471e85fa --- /dev/null +++ b/app/tests/approval-rule-line.test.tsx @@ -0,0 +1,122 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render } from "@testing-library/react"; +import { RuleRow } from "@/components/approvals/rules"; +import type { ApprovalRuleRow } from "@/lib/approvals"; +import { settleReactWork } from "./settle-react-work"; + +/* + * FOUND IN THE 1.0 RC1 MAC PASS: under a saved rule's title, the line that says what else it matches + * read "write on host-grant-15407-1794112361547002000 for shopper-bot": the raw kind of action, the + * id OpenBot gave a folder, and the Bot's id. It names the folder as Settings → Computer does, the + * Bot by its name, and the kind of action in words. + */ + +beforeAll(() => GlobalRegistrator.register()); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = global.fetch; +afterEach(() => { + global.fetch = originalFetch; +}); + +const GRANT = "host-grant-15407-1794112361547002000"; + +function serving() { + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/host-access") + return Response.json({ + connected: true, + grants: [ + { + id: GRANT, + botId: "shopper-bot", + actorId: "me", + displayName: "share", + }, + ], + pending: [], + }); + if (url.startsWith("/api/agents")) + return Response.json({ agents: [{ id: "shopper-bot", name: "Shop" }] }); + return new Response(null, { status: 404 }); + }) as unknown as typeof fetch; +} + +const rule = (overrides: Partial): ApprovalRuleRow => + ({ + id: "rule", + botId: "*", + toolRef: "host/write_file", + effect: "*", + scope: "*", + behaviour: "allow", + ...overrides, + }) as ApprovalRuleRow; + +function draw(row: ApprovalRuleRow) { + return render( + + {}} + onRevoke={() => {}} + rule={row} + /> + , + ); +} + +test("a folder rule names the folder, the Bot and the kind of action in words", async () => { + serving(); + const view = draw( + rule({ id: "folder", botId: "shopper-bot", effect: "write", scope: GRANT }), + ); + expect(await view.findByText("changes in share for Shop")).toBeTruthy(); + const text = view.container.textContent ?? ""; + expect(text).not.toContain("host-grant"); + expect(text).not.toContain("shopper-bot"); + expect(text).not.toMatch(/\bwrite\b/); +}); + +test("a rule on a folder no longer shared, for a Bot no longer here, says so", async () => { + serving(); + const view = draw( + rule({ + id: "gone", + botId: "old-bot", + effect: "read", + scope: "host-grant-1-2", + }), + ); + expect( + await view.findByText( + "reading in a folder that's no longer shared for a Bot no longer here", + ), + ).toBeTruthy(); + expect(view.container.textContent ?? "").not.toContain("host-grant"); +}); + +test("a kind of action with no words of its own is left unsaid", async () => { + serving(); + const view = draw( + rule({ + id: "custom", + toolRef: "computer_click", + effect: "teleport", + scope: "shop.example", + }), + ); + expect(await view.findByText("on shop.example")).toBeTruthy(); + expect(view.container.textContent ?? "").not.toContain("teleport"); +}); diff --git a/app/tests/approvals-rules.test.tsx b/app/tests/approvals-rules.test.tsx index ee16804b4b..0e99d5e88d 100644 --- a/app/tests/approvals-rules.test.tsx +++ b/app/tests/approvals-rules.test.tsx @@ -163,7 +163,7 @@ test("team rules are locked for members, enforced auto-review is shown as requir const view = draw(); await view.findByText("Ask before taking action (team rule)"); const mine = view.getByRole("combobox", { - name: "Behaviour for mcp/gmail/*", + name: "Behaviour for Any action on Gmail", }); expect(mine.textContent).toContain("Take action without asking"); expect(view.getByText("Locked")).toBeTruthy(); @@ -179,7 +179,7 @@ test("team rules are locked for members, enforced auto-review is shown as requir test("a member changes a saved rule in place", async () => { const view = draw(); const mine = await view.findByRole("combobox", { - name: "Behaviour for mcp/gmail/*", + name: "Behaviour for Any action on Gmail", }); const user = userEvent.setup({ document }); await user.click(mine); @@ -194,7 +194,7 @@ test("a member changes a saved rule in place", async () => { }), ); expect( - view.queryByRole("combobox", { name: "Behaviour for mcp/drive/share" }), + view.queryByRole("combobox", { name: "Behaviour for Share on Drive" }), ).toBeNull(); }); @@ -282,11 +282,11 @@ test("two rules for one tool are told apart by the rest of what they match, neve }; const view = draw(); const match = await view.findByText( - "write on checkout.a-very-long-shop-name.example", + "changes on checkout.a-very-long-shop-name.example", ); expect(match.className).toContain("line-clamp-none"); - // The tool stays the row's title. - expect(view.getAllByText("computer_click")).toHaveLength(2); + // The action, in its approval card's words, stays the row's title. + expect(view.getAllByText("Click")).toHaveLength(2); }); test("with personal rules switched off by the team, they are shown as not applying and none can be added", async () => { @@ -350,7 +350,7 @@ test("rules for every Bot point to where rules for a single Bot are", async () = expect(link.getAttribute("href")).toBe("/bots"); // A rule naming one Bot lives on that Bot's page, not here. expect( - view.queryByRole("combobox", { name: "Behaviour for computer_click" }), + view.queryByRole("combobox", { name: "Behaviour for Click" }), ).toBeNull(); }); @@ -371,7 +371,7 @@ test("a rule naming no Bot this person can open is listed so it can be removed", const view = draw(); expect(await view.findByText("Rules for other Bots")).toBeTruthy(); expect( - view.getByRole("combobox", { name: "Behaviour for computer_click" }), + view.getByRole("combobox", { name: "Behaviour for Click" }), ).toBeTruthy(); }); @@ -519,3 +519,43 @@ test.each(["team", "personal"] as const)( ); }, ); + +/* + * FOUND IN THE 1.0 RC1 MAC PASS: a rule saved from an approval card's "Always allow here" was + * titled by its tool id ("computer_click", "composio-gmail/GMAIL_SEND_EMAIL" over "on + * composio-gmail"). It is titled in the words the card used for the same action. + */ +test("a saved rule is titled in its approval card's words, never by its tool id", async () => { + inboxOverride = { + rules: [ + { + id: "click-here", + botId: "*", + toolRef: "computer_click", + effect: "*", + scope: "en.wikipedia.org", + behaviour: "allow", + }, + { + id: "send-mail", + botId: "*", + toolRef: "composio-gmail/GMAIL_SEND_EMAIL", + effect: "*", + scope: "composio-gmail", + behaviour: "allow", + }, + ], + teamRules: [], + }; + const view = draw(); + expect( + await view.findByRole("combobox", { name: "Behaviour for Click" }), + ).toBeTruthy(); + expect(view.getByText("Click")).toBeTruthy(); + expect(view.getByText("on en.wikipedia.org")).toBeTruthy(); + expect(view.getByText("Send email on Gmail")).toBeTruthy(); + const text = view.container.textContent ?? ""; + expect(text).not.toContain("computer_click"); + expect(text).not.toContain("composio"); + expect(text).not.toContain("GMAIL_SEND_EMAIL"); +}); diff --git a/app/tests/bot-approval-rules.test.tsx b/app/tests/bot-approval-rules.test.tsx index 8c5d36d51b..4e8f9b4cae 100644 --- a/app/tests/bot-approval-rules.test.tsx +++ b/app/tests/bot-approval-rules.test.tsx @@ -94,17 +94,19 @@ test("only this Bot's own rules can be changed, and the team rules that reach it serving(); const view = draw(); expect( - await view.findByRole("combobox", { name: "Behaviour for mcp/gmail/*" }), + await view.findByRole("combobox", { + name: "Behaviour for Any action on Gmail", + }), ).toBeTruthy(); expect( - view.queryByRole("combobox", { name: "Behaviour for mcp/drive/*" }), + view.queryByRole("combobox", { name: "Behaviour for Any action on Drive" }), ).toBeNull(); expect( - view.queryByRole("combobox", { name: "Behaviour for mcp/slack/*" }), + view.queryByRole("combobox", { name: "Behaviour for Any action on Slack" }), ).toBeNull(); - expect(view.getByText("mcp/drive/share")).toBeTruthy(); - expect(view.getByText(/^computer_click/)).toBeTruthy(); - expect(view.queryByText(/^host\/\*/)).toBeNull(); + expect(view.getByText("Share on Drive")).toBeTruthy(); + expect(view.getByText("Click")).toBeTruthy(); + expect(view.queryByText("Any action in your folders")).toBeNull(); }); test("a rule added here is for this Bot, with no Bot to type", async () => { @@ -146,6 +148,8 @@ test("a personal rule whose Bot is a pattern matching this Bot is shown and can }) as unknown as typeof fetch; const view = draw(); expect( - await view.findByRole("combobox", { name: "Behaviour for mcp/linear/*" }), + await view.findByRole("combobox", { + name: "Behaviour for Any action on Linear", + }), ).toBeTruthy(); }); diff --git a/app/tests/skins-openmuse.test.tsx b/app/tests/skins-openmuse.test.tsx index 905bb45822..5fe9a0fb7a 100644 --- a/app/tests/skins-openmuse.test.tsx +++ b/app/tests/skins-openmuse.test.tsx @@ -872,7 +872,9 @@ describe("each tab is OpenBot's own sections for the centre Bot", () => { // OpenBot's Needs you section, under the Bot's name, with its approval's details always shown. expect(await page().findByText("Open url")).toBeTruthy(); expect(page().getAllByText("Muse").length).toBeGreaterThan(0); - expect(page().getByText(/"url": "https:\/\/example.com"/)).toBeTruthy(); + // Labelled, as the card in OpenBot lists what it would send: the URL is the target and the value. + expect(page().getByText("URL")).toBeTruthy(); + expect(page().getAllByText("https://example.com").length).toBe(2); // OpenBot's activity sections for the Bot. expect(await page().findByText("Morning briefing")).toBeTruthy(); expect(page().getByText("Draft the launch post")).toBeTruthy(); @@ -935,7 +937,8 @@ describe("each tab is OpenBot's own sections for the centre Bot", () => { const sheet = within(await page().findByRole("dialog")); expect(await sheet.findByText("Muse wants to open url")).toBeTruthy(); // What it will send is always on screen, as in OpenBot. - expect(sheet.getByText(/"url": "https:\/\/example.com"/)).toBeTruthy(); + expect(sheet.getByText("URL")).toBeTruthy(); + expect(sheet.getAllByText("https://example.com").length).toBe(2); expect(sheet.queryByText(/send email/)).toBeNull(); fireEvent.click(sheet.getByRole("button", { name: "Allow once" })); await waitFor(() => diff --git a/server/src/activity/activity.ts b/server/src/activity/activity.ts index fb84ade1e6..f3f97690bb 100644 --- a/server/src/activity/activity.ts +++ b/server/src/activity/activity.ts @@ -9,6 +9,7 @@ * EVERY QUERY IS NARROWED TO THE PERSON FIRST. A public Bot works for many people; this screen shows * the work done in the caller's name and nothing of anybody else's. */ +import { approvalPhrase } from "@openbot/shared/tool-name"; import { and, desc, @@ -447,12 +448,15 @@ export function createActivityStore(database: Database) { const approvalChannels = await channelsForThreads( approvals.map((approval) => approval.action.threadId), ); - for (const approval of approvals) + for (const approval of approvals) { + // In the words its card uses: "Click the link “Python” on en.wikipedia.org", never the + // tool's id and its effect ("Approve computer_click", "write"). + const phrase = approvalPhrase(approval.action); activity.inProgress.push({ id: `approval:${approval.id}`, kind: "approval", - title: `Approve ${approval.action.toolRef}`, - detail: approval.action.effect, + title: phrase.charAt(0).toUpperCase() + phrase.slice(1), + detail: approval.action.policy?.reason ?? null, status: "waiting for you", at: iso(approval.createdAt), channelId: approvalChannels.get(approval.action.threadId) ?? null, @@ -460,6 +464,7 @@ export function createActivityStore(database: Database) { stoppable: false, cancellable: false, }); + } // Questions this Bot asked this person and is waiting on. const questions = await database diff --git a/server/src/approvals/wiring.ts b/server/src/approvals/wiring.ts index c50389a978..df5c841c4a 100644 --- a/server/src/approvals/wiring.ts +++ b/server/src/approvals/wiring.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from "node:util"; +import { approvalPhrase } from "@openbot/shared/tool-name"; import type { HeadlessTool } from "@openbot/toolkit/headless-tools"; import type { IntelligenceLike } from "@openbot/toolkit/run-turn"; import { type RunAssertion, readRunAssertion } from "../agents/callback-token"; @@ -145,7 +146,7 @@ export function createServerApprovalService(deps: { id: request.id, kind: "approval", requestId: request.id, - text: `${botName} asks for approval to use ${action.toolRef}. Open the Bot's page in OpenBot to review this action.`, + text: `${botName} wants to ${approvalPhrase(action)}. Open the Bot's page in OpenBot to review it.`, }, ); } diff --git a/server/src/delivery/opentag-agent.ts b/server/src/delivery/opentag-agent.ts index 0a90c9860c..a095c296d5 100644 --- a/server/src/delivery/opentag-agent.ts +++ b/server/src/delivery/opentag-agent.ts @@ -7,6 +7,7 @@ * runs. */ import { randomUUID } from "node:crypto"; +import { approvalPhrase } from "@openbot/shared/tool-name"; import type { DrawnComponent } from "@openbot/toolkit/run-turn"; import { z } from "zod"; import { @@ -59,6 +60,9 @@ export type PairedApprovals = { botId: string; toolRef: string; effect: string; + /** Where it acts, and what it is aimed at: the card names the action by both. */ + scope: string; + target?: unknown; threadId: string; args: unknown; /** Why the gate asked; a check-in task's approval offers no "Always allow". */ @@ -530,7 +534,8 @@ async function approvalCard( const bot = await deps.store.botIdentity(record.action.botId); return confirmWriteInterrupt({ approvalId, - action: `${bot?.name ?? "Your Bot"}: ${record.action.toolRef}`, + // In the words the approval card in OpenBot uses, never the tool's id ("Your Bot: computer_click"). + action: `${bot?.name ?? "Your Bot"} wants to ${approvalPhrase(record.action)}`, approver: `${binding.transport}:${binding.identity}`, effect: record.action.effect, // The approvals preview already redacts secrets and private content. @@ -594,7 +599,7 @@ async function decideFromCard( return { text: decision === "deny" - ? `Denied. ${name} will not run ${record.action.toolRef}.` + ? `Denied. ${name} won't ${approvalPhrase(record.action)}.` : `${decision === "allow_always" ? "Always allowed" : "Allowed once"}. ${name} is continuing and will send its result here.`, }; } diff --git a/server/src/delivery/opentag.ts b/server/src/delivery/opentag.ts index 2f117cbfef..2833bc3fd5 100644 --- a/server/src/delivery/opentag.ts +++ b/server/src/delivery/opentag.ts @@ -18,6 +18,7 @@ * `/openbot/deliver`, because a managed Channel can only post inside a live delivery. */ import { timingSafeEqual } from "node:crypto"; +import { approvalDetails } from "@openbot/shared/tool-name"; import { z } from "zod"; import { cutAtCodeUnits } from "../channels/text"; import { DELIVERY_REQUEST_TIMEOUT_MS } from "../timeouts"; @@ -385,11 +386,12 @@ export function approvalFields(preview: unknown) { return preview === undefined ? [] : [{ label: "Arguments", value: clip(JSON.stringify(preview)) }]; - return Object.entries(preview) + // Labelled as the card in OpenBot labels them, without the page handles a click carries. + return approvalDetails(preview) .slice(0, 10) - .map(([label, value]) => ({ + .map(({ label, value }) => ({ label: clip(label, 60), - value: clip(typeof value === "string" ? value : JSON.stringify(value)), + value: clip(value), })); } function clip(value: string | undefined, max = 300) { diff --git a/server/tests/approval-activity-words.integration.test.ts b/server/tests/approval-activity-words.integration.test.ts new file mode 100644 index 0000000000..712aae9b47 --- /dev/null +++ b/server/tests/approval-activity-words.integration.test.ts @@ -0,0 +1,108 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createActivityStore } from "../src/activity/activity"; +import { createDatabase } from "../src/db/client"; +import { approvalRequests } from "../src/db/schema/approvals"; +import { users } from "../src/db/schema/core"; +import { TEST_POOL, testDatabaseUrl } from "./support/database"; + +/** + * FOUND IN THE 1.0 RC1 MAC PASS: a Bot's Activity row for a pending click read "Approve + * computer_click · Approval · needs you · 10 hours ago · write", the tool's id and its effect. It is + * said in the words its approval card uses, from the element the server resolved, on a real database + * through the store the Bot's page reads. + */ + +const database = createDatabase(testDatabaseUrl(), TEST_POOL); +const activity = createActivityStore(database); +const suite = randomUUID().slice(0, 8); +const owner = `approval-words-${suite}`; +const bot = `approval-words-bot-${suite}`; + +/** A pending computer action, as the gateway's approval gate saves it. */ +function pending( + id: string, + toolRef: string, + args: Record, + element?: { ref: string; role: string; name: string }, +) { + return { + id: `${suite}-${id}`, + ownerUserId: owner, + runId: "run", + toolCallId: `call-${id}`, + actionDigest: `digest-${id}`, + action: { + actorId: owner, + botId: bot, + toolRef, + effect: "write" as const, + scope: "en.wikipedia.org", + args, + target: { + pageUrl: "https://en.wikipedia.org/wiki/Main_Page", + ...(element + ? { element: { ...element, type: "" }, snapshotId: 4 } + : {}), + }, + runId: "run", + threadId: `thread-${suite}`, + toolCallId: `call-${id}`, + actionDigest: `digest-${id}`, + }, + }; +} + +beforeAll(async () => { + await database + .insert(users) + .values({ id: owner, email: `${owner}@example.test` }); + await database.insert(approvalRequests).values([ + pending( + "click", + "computer_click", + { ref: "f3e43", snapshotId: 4 }, + { ref: "f3e43", role: "link", name: "Python (programming language)" }, + ), + pending("click-unnamed", "computer_click", { ref: "f9", snapshotId: 4 }), + pending("open", "computer_navigate", { + url: "https://en.wikipedia.org/wiki/Monty_Python", + }), + pending("type", "computer_type", { + ref: "f7", + snapshotId: 4, + text: "monty python", + }), + ]); +}); + +afterAll(async () => { + await database + .delete(approvalRequests) + .where(eq(approvalRequests.ownerUserId, owner)); + await database.delete(users).where(inArray(users.id, [owner])); + await database.$client.end({ timeout: 5 }); +}); + +test("a pending computer approval's Activity row is said in words, with no tool id or effect", async () => { + const rows = (await activity.forBot(owner, bot)).inProgress.filter( + (item) => item.kind === "approval", + ); + const byId = Object.fromEntries( + rows.map((item) => [item.id.replace(`approval:${suite}-`, ""), item]), + ); + expect(byId.click).toMatchObject({ + title: "Click the link “Python (programming language)” on en.wikipedia.org", + detail: null, + status: "waiting for you", + needsYou: true, + }); + expect(byId["click-unnamed"]?.title).toBe("Click on en.wikipedia.org"); + expect(byId.open?.title).toBe("Open a page on en.wikipedia.org"); + expect(byId.type?.title).toBe("Fill in a field on en.wikipedia.org"); + for (const row of rows) { + expect(row.title).not.toMatch(/computer_|Approve /); + expect(row.detail).not.toBe("write"); + } +}); diff --git a/server/tests/approvals.test.ts b/server/tests/approvals.test.ts index 4724b2d7ab..2809210d9d 100644 --- a/server/tests/approvals.test.ts +++ b/server/tests/approvals.test.ts @@ -765,7 +765,7 @@ test("an approval preview shows what will be typed, written or sent, and still h approvalFields( approvalPreview({ channel: "C1", text: "We are shipping today" }), ), - ).toContainEqual({ label: "text", value: "We are shipping today" }); + ).toContainEqual({ label: "Text", value: "We are shipping today" }); // Secrets stay hidden by key and inside values. expect( approvalPreview({ diff --git a/server/tests/delivery-routes.test.ts b/server/tests/delivery-routes.test.ts index b92b44b162..412bca8171 100644 --- a/server/tests/delivery-routes.test.ts +++ b/server/tests/delivery-routes.test.ts @@ -38,6 +38,13 @@ function fixture( challengeTransport?: "slack" | "teams"; /** Why the gate asked, as the stored request carries it; "task" for a check-in task's. */ policySource?: string; + /** What the stored request asks to do, in place of opening example.com. */ + action?: { + toolRef: string; + scope: string; + args: Record; + target?: unknown; + }; } = {}, ) { const calls: unknown[] = []; @@ -132,10 +139,12 @@ function fixture( status: "pending", action: { botId: "bot", - toolRef: "browser/navigate", + toolRef: "computer_navigate", + scope: "example.com", effect: "write", threadId: "canonical", args: { url: "https://example.com" }, + ...options.action, ...(options.policySource ? { policy: { @@ -597,8 +606,8 @@ test("bound sender runs the canonical turn and an approval becomes an OpenTag ca __copilotkit_interrupt_value__: { action: "confirm_write", args: { - action: "Research Bot: browser/navigate", - fields: [{ label: "url", value: "https://example.com" }], + action: "Research Bot wants to open a page on example.com", + fields: [{ label: "URL", value: "https://example.com" }], approver: "slack:U1", effect: "write", allow_always: true, @@ -626,7 +635,7 @@ test("a check-in task's approval becomes a card without Always allow, and an old (e) => e.type === "CUSTOM" && e.name === "on_interrupt", ); expect(card.value.__copilotkit_interrupt_value__.args).toMatchObject({ - action: "Research Bot: browser/navigate", + action: "Research Bot wants to open a page on example.com", allow_always: false, }); const resumed = await run(f, { @@ -766,6 +775,63 @@ test("card resume decides through the approvals service as the binding owner onl await resume({ confirmed: false, by: "slack:T1:U1" }); expect(f.decisions[1]).toEqual(["owner", APPROVAL, "deny"]); }); +/* + * FOUND IN THE 1.0 RC1 MAC PASS: a click waiting in Slack read "Research Bot: computer_click" over + * "ref" and "snapshotId" fields, and denying it said "will not run computer_click". Both are said in + * the words the approval card in OpenBot uses, and the click's page handles are left out. + */ +test("a click waiting in Slack is named in words, with no tool id or page handles", async () => { + const f = fixture({ + action: { + toolRef: "computer_click", + scope: "en.wikipedia.org", + args: { ref: "f3e43", snapshotId: 4 }, + target: { + pageUrl: "https://en.wikipedia.org/wiki/Main_Page", + element: { ref: "f3e43", role: "link", name: "Python", type: "" }, + snapshotId: 4, + }, + }, + converse: { + kind: "waiting", + binding: bound, + inboxId: "in1", + replyText: "", + extras: { approvals: [APPROVAL], questions: [] }, + }, + }); + const asked = await run(f, { + context: [sender()], + messages: [ + { id: "m9", role: "user", content: "<@UBOT> read about Python" }, + ], + }); + const card = asked.events.find( + (e) => e.type === "CUSTOM" && e.name === "on_interrupt", + ); + expect(card.value.__copilotkit_interrupt_value__.args).toMatchObject({ + action: "Research Bot wants to click the link “Python” on en.wikipedia.org", + fields: [], + }); + const denied = await run(f, { + forwardedProps: { + command: { + resume: { + "0f8fad5bd9cb469fa16570867728950e": { + confirmed: false, + by: "slack:T1:U1", + }, + }, + }, + }, + }); + expect(said(denied.events)).toBe( + "Denied. Research Bot won't click the link “Python” on en.wikipedia.org.", + ); + expect(JSON.stringify([asked.events, denied.events])).not.toMatch( + /computer_click|snapshotId|f3e43/, + ); +}); test("SMS STOP and START record opt-out without a turn or a second reply", async () => { const f = fixture(); const signedRequest = (params: URLSearchParams) => { diff --git a/shared/tool-name.ts b/shared/tool-name.ts index 71d209a382..16a503c26a 100644 --- a/shared/tool-name.ts +++ b/shared/tool-name.ts @@ -197,6 +197,12 @@ function approvalParts(action: { joiner: "on", }; } + /* + * A browser action on the site it acts on, with the control it presses when the page named one: + * "click the link “Python” on en.wikipedia.org". Activity said "Approve computer_click". + */ + const browser = browserAction(action.toolRef, action.target); + if (browser) return { action: browser, place: action.scope, joiner: "on" }; /* * A computer's or a folder's own action in the words its step and its Activity row use. The card * said "write file on todo.txt in share" above a step that read "Save a file todo.txt in share". @@ -228,6 +234,136 @@ function approvalParts(action: { return { action: words, place: action.scope, joiner: "on" }; } +/** Handles the model cites and the run carries, which name nothing a person could check. */ +const INTERNAL_ARGS = new Set(["ref", "snapshotId", "toolCallId", "signal"]); + +/** + * What an approval would send, as labelled lines for its card: "Text: hello", "To: sam@example.test". + * The card showed the arguments as JSON, and a click read `{ "ref": "f3e43", "snapshotId": 4 }`, two + * handles to a page snapshot nobody can see. Those are left out, so a click has nothing to add to its + * title and shows no lines at all. Everything else stays whole: nobody allows what they were not shown. + */ +export function approvalDetails( + args: unknown, +): { label: string; value: string }[] { + if (!args || typeof args !== "object" || Array.isArray(args)) return []; + return Object.entries(args).flatMap(([key, value]) => { + if (INTERNAL_ARGS.has(key) || value === undefined || value === null) + return []; + const text = + typeof value === "string" + ? value + : typeof value === "boolean" + ? value + ? "Yes" + : "No" + : typeof value === "number" + ? String(value) + : Array.isArray(value) && + value.every((entry) => typeof entry === "string") + ? value.join(", ") + : JSON.stringify(value, null, 2); + const words = wordsOf(key) + .map((word) => ACRONYMS[word] ?? word) + .join(" "); + const label = words ? words.charAt(0).toUpperCase() + words.slice(1) : key; + return [{ label, value: text }]; + }); +} + +/** Argument words a person reads in capitals. */ +const ACRONYMS: Record = { url: "URL", id: "ID" }; + +/** + * A saved approval rule's title, in the words its approval card used for the action it covers: + * "Click", "Send email on Gmail", "Any action on Gmail". The list titled each rule by its tool id + * ("computer_click", "mcp/gmail/*"). `place` is true when the title already names where the rule + * applies, so the line under it need not say "on composio-gmail" again. A pattern a person typed + * that matches several actions some other way is kept as they wrote it. + */ +export function ruleTitle(rule: { toolRef: string; scope: string }): { + title: string; + place: boolean; +} { + const upper = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); + if (rule.toolRef === "*") return { title: "Any action", place: false }; + if (rule.toolRef === "host/*") + return { title: "Any action in your folders", place: false }; + const ref = rule.toolRef.replace(/^mcp\//, ""); + const slash = ref.indexOf("/"); + if (slash > 0 && !ref.startsWith("host/")) { + const app = ref.slice(0, slash); + const tool = ref.slice(slash + 1); + if (app.includes("*")) return { title: rule.toolRef, place: false }; + const named = rule.scope === "*" || rule.scope === app; + if (tool === "*") + return { title: `Any action on ${connectorName(app)}`, place: named }; + if (tool.includes("*")) return { title: rule.toolRef, place: false }; + const { action, place } = approvalWords({ toolRef: ref, scope: app }); + return { title: `${upper(action)} on ${place}`, place: named }; + } + if (ref.includes("*")) return { title: rule.toolRef, place: false }; + return { + title: upper(approvalWords({ toolRef: ref, scope: rule.scope }).action), + place: false, + }; +} + +/** What a browser action asks to do, said as its step says it. */ +const BROWSER_ACTIONS: Record = { + computer_navigate: "open a page", + computer_click: "click", + computer_type: "fill in a field", + computer_key: "press a key", + computer_scroll: "scroll the page", +}; + +/** A page control's role in the words a person uses for it. */ +const CONTROL_WORDS: Record = { + link: "link", + button: "button", + checkbox: "checkbox", + radio: "option", + option: "option", + tab: "tab", + menuitem: "menu item", + switch: "switch", + textbox: "field", + searchbox: "search field", + combobox: "list", +}; + +/** + * A browser action in words, naming the control it presses from the label the server resolved + * (`target.element`), never the ref and snapshot the model cited. Undefined for any other action. + */ +function browserAction(toolRef: string, target: unknown): string | undefined { + const base = BROWSER_ACTIONS[toolRef]; + if (!base) return undefined; + const element = + target && typeof target === "object" + ? (target as { element?: { role?: unknown; name?: unknown } }).element + : undefined; + const name = + typeof element?.name === "string" + ? element.name.replace(/\s+/g, " ").trim() + : ""; + const role = + typeof element?.role === "string" ? CONTROL_WORDS[element.role] : undefined; + if (toolRef === "computer_click") { + if (name) return `click ${role ? `the ${role} ` : ""}“${clipName(name)}”`; + return role ? `click ${/^[aeiou]/.test(role) ? "an" : "a"} ${role}` : base; + } + if (toolRef === "computer_type" && name) return `fill in “${clipName(name)}”`; + return base; +} + +/** A control's label, short enough for a title. */ +function clipName(name: string): string { + const chars = Array.from(name); + return chars.length > 60 ? `${chars.slice(0, 59).join("")}…` : name; +} + /** The path a file action on the Bot's own computer names (`target.filePath`), or undefined. */ function computerFile(target: unknown): string | undefined { if (!target || typeof target !== "object") return undefined; From 1076dbf174d8a1b504c099bbbab5bfa427e08974 Mon Sep 17 00:00:00 2001 From: David McKay Date: Sun, 8 Nov 2026 17:57:09 -0800 Subject: [PATCH 5/5] Let the Recorder package test take a prerelease version The fixture wrote the release version straight into the manifest. For a prerelease such as 1.0.0-rc.1 the build writes version 1.0.0 with version_name 1.0.0-rc.1 (toChromeManifestVersion), and the package check expects exactly that, so every store-package test failed on the first release candidate. The fixture now builds its manifest version the way the build does. --- extension/scripts/store-package.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extension/scripts/store-package.test.ts b/extension/scripts/store-package.test.ts index 5d5f1a9d32..3f6178351b 100644 --- a/extension/scripts/store-package.test.ts +++ b/extension/scripts/store-package.test.ts @@ -18,6 +18,7 @@ import { import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { unzipSync } from "fflate"; +import { toChromeManifestVersion } from "../src/manifest"; import { CHROME_PACKAGE, EDGE_PACKAGE, @@ -72,7 +73,8 @@ function builtRecorder( const manifest = { manifest_version: 3, name: "OpenBot Recorder", - version: ROOT_VERSION, + // As the build writes it: a prerelease such as 1.0.0-rc.1 is version 1.0.0 with that version_name. + ...toChromeManifestVersion(ROOT_VERSION), description: "Record a task in your browser so an OpenBot Bot can learn to do it.", icons: { "16": "icons/16.png", "128": "icons/128.png" },