Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's
team account is kept, with who answered it and when. It starts empty and nothing is backfilled.

### A Bot can send, draft and reply in Gmail

OpenBot left out every Composio action that takes a file, because a Bot has no way to supply one.
That also left out actions where the file is optional, such as an email's attachment, so Gmail's
send, draft, reply and update-draft actions never appeared and a Bot could read mail but not send
it. These actions are now offered without the optional file, and a call that includes one anyway is
refused with a message saying to try again without it. An action that cannot run without a file,
such as uploading one to Google Drive, is still left out. For an app that is already on, press
Refresh tools on its page in Admin to list the new actions, then grant them to a Bot as usual.

### Connect works for an app Composio already holds your account for

Pressing Connect on a Composio app, from a conversation's Connect card or from Connections, could end
in a refusal saying Composio already held a connected account for the app and that disconnecting it
would clear the way. The app showed as not connected, so there was nothing to disconnect. This
happened when Composio held the account and OpenBot had no record of it: on a fresh or upgraded
install, or on a second local install signing in as the same person with the same Composio key. Connect
now asks Composio again, records the account it holds, and shows the app as connected, the same way
opening the app's page already did. A Shared app's team account is still recorded only by an admin.
If Composio then reports no account after all, Connect says nothing was connected and asks you to
press it again. It no longer tells you to disconnect anything.

### A Bot with an app or web research can use its browser in a conversation again

A Bot that held any app or research tool, such as Parallel Search, could not open a page, click or
Expand Down
6 changes: 5 additions & 1 deletion agent-microsoft/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
agent-framework-ag-ui
# agent-framework-ag-ui 1.7.0, released 2026-10-22, refuses to build an AG-UI endpoint unless its
# authorization mode is declared (authorization_scope_resolver, snapshot_scope_resolver or
# single_user=True), so every run in this harness fails at startup. 1.6.0 works. Lift this once
# the harness declares how it authorizes.
agent-framework-ag-ui<1.7
agent-framework-anthropic
agent-framework-openai
fastapi
Expand Down
17 changes: 14 additions & 3 deletions app/src/components/plugins/brokered-account-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
connectAccountMutationOptions,
connectBrokeredWithFieldsMutationOptions,
disconnectBrokeredMutationOptions,
invalidatePlugins,
recheckBrokeredConnectionMutationOptions,
} from "@/lib/plugins/mutations";

Expand Down Expand Up @@ -398,9 +399,19 @@ export function useBrokeredAccount(input: {
* shown to this person in their own browser; there is deliberately nothing here that could
* complete it for them, and nothing about being an administrator changes that.
*/
onSuccess: (authorizationUrl) => {
onSuccess: (start) => {
forgetConfirmation();
forgetRecheck();
/*
* ALREADY CONNECTED, AND NOTHING TO LEAVE FOR. The vendor held this account and this
* deployment had no row for it, so the route recorded it on this press. The held answers
* were just dropped, so the row falls back to the recorded one, and the refetch is what
* brings that row in.
*/
if (start !== null && "connected" in start) {
void invalidatePlugins(queryClient);
return;
}
/*
* NO URL IS AN ANSWER, NOT A URL TO FOLLOW.
*
Expand All @@ -417,13 +428,13 @@ export function useBrokeredAccount(input: {
* would leave nobody any reason to correct it — and the sentence names the one act that ends
* the state, which is a reload against a deployment whose app has caught up.
*/
if (authorizationUrl === null) {
if (start === null) {
report(
"This app connects with a key, not a sign-in page, so nothing was connected. Reload the page and press Connect again.",
);
return;
}
window.location.href = authorizationUrl;
window.location.href = start.authorizationUrl;
},
});

Expand Down
28 changes: 23 additions & 5 deletions app/src/components/plugins/connect-in-new-tab.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { connectAccountMutationOptions } from "@/lib/plugins/mutations";
import { pluginKeys } from "@/lib/plugins/queries";
import { queryClient } from "@/query-client";

/**
* Start this person's own connection to one app in a new tab, the way Settings starts it.
Expand All @@ -21,6 +20,12 @@ import { queryClient } from "@/query-client";
* a connection with the same code rather than a second copy of the popup rules below.
*/
export function useConnectInNewTab() {
/*
* The provider's client, which is the one whoever drew the button reads from. In the app it is
* the module's own singleton; a screen mounted under another provider is refetched through its
* own, so an account recorded on the press is drawn where the press was made.
*/
const queryClient = useQueryClient();
const [waiting, setWaiting] = useState(false);
/** This app takes a key somebody types, so there is no page to send them to. */
const [needsKey, setNeedsKey] = useState(false);
Expand Down Expand Up @@ -65,7 +70,7 @@ export function useConnectInNewTab() {
window.addEventListener("focus", finish, { once: true });
}, 1_000);
return () => clearInterval(timer);
}, [waiting]);
}, [waiting, queryClient]);
// Goes with whoever drew the button, and with a wait that ended some other way first.
useEffect(() => {
if (waiting || !onReturn.current) return;
Expand Down Expand Up @@ -103,18 +108,31 @@ export function useConnectInNewTab() {
const tab = claimTab();
handle.current = tab;
connect.mutate(serverId, {
onSuccess: (authorizationUrl) => {
onSuccess: (start) => {
/*
* ALREADY CONNECTED: the account was held at the vendor with no row here, and the route
* recorded it on this press. There is no page to visit, so the claimed tab goes, and every
* query is refetched once, as the card does on its own connected edge. Whoever drew the
* button reads the new row there, and the Bot's next turn is asked against it.
*/
if (start !== null && "connected" in start) {
tab?.close();
handle.current = null;
void queryClient.invalidateQueries();
return;
}
/*
* No url means this app is connected by typing a key rather than by visiting a page — the
* route answers `{ fields }` and the mutation unwraps nothing. Saying so, with the page
* that has the form, beats a button that silently does nothing.
*/
if (!authorizationUrl) {
if (start === null) {
tab?.close();
handle.current = null;
setNeedsKey(true);
return;
}
const { authorizationUrl } = start;
setWaiting(true);
if (tab) tab.location.replace(authorizationUrl);
// The tab was blocked. The url is still good, so offer it as something to click.
Expand Down
39 changes: 34 additions & 5 deletions app/src/lib/plugins/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,22 +398,51 @@ export function registerOAuthClientMutationOptions(queryClient: QueryClient) {
* assignment to `window.location.href` typechecks, and the compiler has been told the same lie in
* a second place. `null` is the one absent-value this inference cannot quietly discard, which is
* what makes the branch below it obligatory rather than advisory.
*
* AND A THIRD ANSWER, WHICH IS NOT A URL AND NOT A FORM: `{ connected: true }`. A brokered app
* whose account the vendor already holds, with no row for it here, is recorded by the route on
* the spot, so the press ends connected with no page to visit. Unwrapping only the url read that
* answer as `null`, and every caller drew `null` as "this app takes a key" or as a failure, over an
* account that had just been recorded as connected. It is its own member of {@link ConnectStart}
* so a caller has to say what it draws for it.
*/
export function connectAccountMutationOptions(
returnTo: "settings" | "admin" = "settings",
) {
return mutationOptions({
mutationFn: async (serverId: string): Promise<string | null> => {
const authorizationUrl = await client<string | undefined>(
mutationFn: async (serverId: string): Promise<ConnectStart> => {
const fallback = "Couldn't connect that account.";
const response = await client(
`/api/plugins/servers/${encodeURIComponent(serverId)}/connect?returnTo=${returnTo}`,
"authorizationUrl",
{ method: "POST", fallback: "Couldn't connect that account." },
{ method: "POST", fallback },
);
return authorizationUrl ?? null;
// Read as `client` reads an envelope: a body that is not an object is a failed request.
const body = (await response.json().catch(() => null)) as unknown;
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new Error(fallback);
}
const answer = body as {
authorizationUrl?: unknown;
connected?: unknown;
};
if (answer.connected === true) return { connected: true };
return typeof answer.authorizationUrl === "string"
? { authorizationUrl: answer.authorizationUrl }
: null;
},
});
}

/**
* What one press of Connect started: a consent page to visit, an account that is already
* connected, or `null` for neither (an app whose key somebody types). See
* {@link connectAccountMutationOptions}.
*/
export type ConnectStart =
| { authorizationUrl: string }
| { connected: true }
| null;

/**
* What asking the vendor about an account comes back with.
*
Expand Down
9 changes: 8 additions & 1 deletion app/src/routes/_authed/_app/connections.$key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,14 +182,21 @@ function AppConnectionPage() {
* A full page navigation, not a fetch. The consent screen is the vendor's own and has to be shown
* to you in your own browser; there is deliberately nothing here that could complete it for you.
*/
onSuccess: (authorizationUrl) => {
onSuccess: (start) => {
/*
* A 200 with no url on it is not a url to follow. Every press that reaches here is on a
* `user-oauth` catalogue entry, whose half of that route always mints one or refuses — so
* this is the server having answered something this page does not understand, and saying so
* is the only honest thing left. Assigning it navigated to a page called `undefined` on this
* deployment's own origin. See `connectAccountMutationOptions`.
*
* `{ connected: true }` is a brokered app's answer and never this half's, so it is the same
* not-understood answer here.
*/
const authorizationUrl =
start !== null && "authorizationUrl" in start
? start.authorizationUrl
: null;
if (authorizationUrl === null) {
setNotice(
"Couldn't start connecting, so nothing was connected. Try again, or ask an admin if it keeps happening.",
Expand Down
9 changes: 8 additions & 1 deletion app/src/routes/_authed/admin/plugins/$key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,13 +312,20 @@ function RouteComponent() {
* shown to this person in their own browser; there is deliberately nothing here that could
* complete it for them, and nothing about being an administrator changes that.
*/
onSuccess: (authorizationUrl) => {
onSuccess: (start) => {
/*
* A 200 with no url on it is not a url to follow. This press is on a `user-oauth` catalogue
* entry, whose half of that route always mints one or refuses, so this is the server having
* answered something this page does not understand. Assigning it navigated to a page called
* `undefined` on this deployment's own origin. See `connectAccountMutationOptions`.
*
* `{ connected: true }` is a brokered app's answer and never this half's, so it is the same
* not-understood answer here.
*/
const authorizationUrl =
start !== null && "authorizationUrl" in start
? start.authorizationUrl
: null;
if (authorizationUrl === null) {
setError(
"OpenBot got no consent link back, so nothing was connected. Try again, or check this connector's setup if it keeps happening.",
Expand Down
41 changes: 38 additions & 3 deletions app/tests/connect-app-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,11 @@ type Row = { serverId: string; holder?: string; revoked?: boolean };
function mounted(
options: {
connections?: Row[];
/** What `POST /connect` answers: a page to visit, or a form to fill in. */
answers?: "url" | "fields" | "failure";
/**
* What `POST /connect` answers: a page to visit, a form to fill in, or an account the vendor
* already held, which the route records on the press and lists from then on.
*/
answers?: "url" | "fields" | "failure" | "connected";
/** False stands in for a browser that refused the tab, which answers null. */
popupsAllowed?: boolean;
} = {},
Expand Down Expand Up @@ -84,6 +87,8 @@ function mounted(
return tab as unknown as Window;
}) as typeof window.open;

/** Whether the route has recorded the account, which is what the connections read lists. */
let recorded = false;
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const json = (body: unknown, status = 200) =>
Expand All @@ -92,13 +97,23 @@ function mounted(
headers: { "content-type": "application/json" },
});
if (url.includes("/api/plugins/connections"))
return json({ connections, redirectUri: null });
return json({
connections: recorded
? [...connections, { serverId: "srv_notion", holder: "person" }]
: connections,
redirectUri: null,
});
if (url.includes("/connect") && init?.method === "POST") {
if (answers === "failure")
return json({ error: "That account could not be connected." }, 502);
// What a key-entry app answers: a form to fill in, and no page to visit.
if (answers === "fields")
return json({ fields: [{ name: "api_key", type: "string" }] });
// What the route answers once it has recorded an account the vendor already held.
if (answers === "connected") {
recorded = true;
return json({ connected: true });
}
return json({ authorizationUrl: "https://notion.test/consent" });
}
return json({});
Expand Down Expand Up @@ -215,6 +230,26 @@ test("says where to type a key for an app that has no page to visit", async () =
expect(card.tabsClosed()).toBe(1);
});

test("says connected when the press records an account the vendor already held", async () => {
/*
* The vendor refused a second link because it already held this person's account, and the route
* recorded that account on the press. Read as "no url", the card told the person to type a key
* for an app that had just been connected through a sign-in.
*/
const card = mounted({ answers: "connected" });
await waitFor(() => card.view.getByRole("button", { name: /^Connect$/ }));

press(card);

await waitFor(() =>
expect(card.view.getByText(/Notion is connected/)).toBeTruthy(),
);
expect(card.view.queryByText(/needs a key from you/)).toBeNull();
// Nothing to visit: the claimed tab is let go without ever being pointed anywhere.
expect(card.tabsClosed()).toBe(1);
expect(card.sentTo).toEqual([]);
});

test("lets go of the claimed tab when connecting fails", async () => {
const card = mounted({ answers: "failure" });
await waitFor(() => card.view.getByRole("button", { name: /^Connect$/ }));
Expand Down
22 changes: 22 additions & 0 deletions server/src/plugins/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,28 @@ export class BrokerAccountGoneError extends BrokerRefusalError {
}
}

/**
* An account the vendor ALREADY HOLDS for this person and this app, met while beginning another.
*
* {@link BrokerAccountGoneError} TURNED AROUND, AND ITS OWN CLASS FOR THE SAME REASON. That one is
* a row this deployment still has over an account the vendor no longer holds; this one is an
* account the vendor holds under a row this deployment does not have. A fresh install, an upgrade
* that lost the table, or a second install signing in as the same person against the same vendor
* key all arrive here, and the person sees an app drawn as not connected that the vendor refuses to
* connect a second time. Telling it apart by type is what lets the connect route ask the vendor
* once more and RECORD the account it already holds, which is the confirm route's own act, instead
* of handing the person a refusal that names a Disconnect button their page does not show.
*
* A {@link BrokerRefusalError} because it keeps that class's promise about the message, so a
* reader that never learned this class still passes on a sentence this deployment wrote.
*/
export class BrokerAccountHeldError extends BrokerRefusalError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "BrokerAccountHeldError";
}
}

/**
* A return address that could not bring anybody back, refused before a consent is spent on it.
*
Expand Down
13 changes: 7 additions & 6 deletions server/src/plugins/composio-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,13 @@ export function createComposioClient(apiKey: string): {
* have to re-derive it.
*
* `dangerouslyAllowAutoUploadDownloadFiles` defaults OFF (`ConfigDefaults.node.ts:4`) and is
* the premise `./composio`'s file filter is written on; that filter drops every action whose
* schema stages a file under EITHER setting, so the premise is defended by a tested guard
* rather than by this literal, and turning the flag on here would be a change to make against
* that filter. `sensitiveFileUploadProtection`, `fileUploadPathDenySegments`, `fileUploadDirs`
* and `fileDownloadDir` only bear on uploads and downloads that the same filter means never
* happen. `provider` defaults to the SDK's `OpenAIProvider`, which this adapter never asks to
* the premise `./composio`'s file filter is written on; under EITHER setting that filter drops
* every action that needs a file and offers one that only accepts a file without that
* parameter, and the execute path refuses a call that carries it anyway. So the premise is
* defended by tested guards rather than by this literal, and turning the flag on here would be
* a change to make against that filter. `sensitiveFileUploadProtection`,
* `fileUploadPathDenySegments`, `fileUploadDirs` and `fileDownloadDir` only bear on uploads
* and downloads that the same filter means never happen. `provider` defaults to the SDK's `OpenAIProvider`, which this adapter never asks to
* format anything — every read here goes through the raw client or the SDK's own models.
* `host` and `defaultHeaders` are telemetry and header cosmetics, and telemetry is off above.
* `toolkitVersions` defaults to "latest", which is the literal every listing in this file
Expand Down
Loading
Loading