Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c7440a1
feat(composio): store which connected account a company means, per to…
CodeGhost21 Aug 13, 2026
e0cbcb0
feat(harness): carry the chosen connection id on composio_execute (#820)
CodeGhost21 Aug 13, 2026
9df3c5b
feat(server): choose and report which Composio account agents act as …
CodeGhost21 Aug 13, 2026
f943973
feat(console): let a company name the account its agents act as (#820)
CodeGhost21 Aug 13, 2026
b3ac07d
docs(credentials): record which connected account a call acts as (#820)
CodeGhost21 Aug 13, 2026
80f7d93
test(e2e): drive the account choice from the page to the wire (#820)
CodeGhost21 Aug 13, 2026
b3599f0
fix(e2e): stop the composio spec contaminating the specs after it (#820)
CodeGhost21 Aug 13, 2026
7ec3928
Merge upstream/main into feat/820-composio-connected-account
CodeGhost21 Aug 13, 2026
a664248
Merge upstream/main into feat/820-composio-connected-account
CodeGhost21 Aug 13, 2026
6610c01
test(composio): pin the refusal to choose an account that cannot send
CodeGhost21 Aug 13, 2026
793d4b0
test(composio): pin the dangling-choice cleanup, and give the gated o…
CodeGhost21 Aug 13, 2026
28a6c13
fix(console): do not answer a new company's page with the previous on…
CodeGhost21 Aug 13, 2026
e11d078
fix(console): key the provider usage figure to the subject it was rea…
CodeGhost21 Aug 13, 2026
928a99f
test(e2e): reset the composio fixture's connections, and make the sla…
CodeGhost21 Aug 13, 2026
9a3ba29
test(console): flush the pending usage read rather than trusting it t…
CodeGhost21 Aug 13, 2026
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
29 changes: 28 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,17 @@ jobs:
- name: Test the Composio ops helpers
run: scripts/ci/run-scoped-suite.sh "composio ops helpers" openhuman,tinycortex,composio harness::composio::ops_helper_tests

# Issue #820 — which connected account an agent acts as. A third narrow
# filter for the same reason the two above are narrow, and the same reason
# they exist at all: these assert on the `composio_execute` REQUEST BODY —
# that an unpinned toolkit still carries no connection id, and a pinned one
# carries the operator's. Both are decidable only under `composio`, so
# without a lane they would be twenty-four's worth of silence again, and
# the negative half is the one protecting every existing single-account
# company from having its account resolution changed.
- name: Test which Composio account an execute acts as
run: scripts/ci/run-scoped-suite.sh "composio account choice" openhuman,tinycortex,composio harness::composio::live::live_tests

# Issue #477. Before this step, `tinyplace` was COMPILED by CI and
# EXECUTED by nothing. `Check (--all-features)` above builds every
# `tinyplace`-gated line and deliberately runs none of them, and no other
Expand Down Expand Up @@ -933,10 +944,18 @@ jobs:
# `openhuman_core/mcp` enabled, so what recompiles here is this crate plus
# its bin, not the vendored tree.
#
# `composio` on the same grounds as `mcp`, and for one spec:
# `composio-account-choice.spec.ts` asserts WHICH connected account an
# agent acts as (issue #820), and `composio_execute` exists on a belt only
# under this feature. Without it the spec would skip — which is how the
# four specs #467 rescued came to sit unrun for months. Cheap for the same
# reason: `composio` adds no `openhuman_core/*` subfeature, so the
# vendored tree does not rebuild.
#
# `--bin`, not `--all-targets`: the lane needs a binary, and the tests and
# examples under this feature set are already covered above.
- name: Build the gated host binary for the live-brain e2e lane
run: cargo build --locked --features openhuman,tinycortex,mcp --bin opencompany
run: cargo build --locked --features openhuman,tinycortex,mcp,composio --bin opencompany

- name: Upload the gated host binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down Expand Up @@ -1227,9 +1246,17 @@ jobs:
# what makes `playwright.config.ts` start the two fixtures and hand the
# host their addresses. See `frontend/test/e2e/capabilities.ts` for why
# this cannot be detected from the host instead.
# `PW_COMPOSIO=1` is the same kind of declaration as `PW_LIVE_BRAIN`: the
# binary downloaded above carries `--features composio`, so this run can
# stand up `test/e2e/composio-backend.mjs` and point the host at it
# (issue #820). Set here rather than in `e2e:live` so a developer running
# that script against a host without the feature still skips cleanly
# instead of failing on routes that answer `409 not in this build`.
- name: Run the end-to-end suite against a live brain
run: npm run e2e:live
working-directory: frontend
env:
PW_COMPOSIO: "1"

- name: Upload failure artifacts
if: failure()
Expand Down
2 changes: 1 addition & 1 deletion docs/modules/server/authority.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ the world as, and which third-party accounts its agents act through:

| Surface | Admin-scoped |
|---|---|
| `composio` | `PUT …/composio/token`, `POST …/composio/authorize` |
| `composio` | `PUT …/composio/token`, `POST …/composio/authorize`, `DELETE …/composio/connections/{id}`, `PUT`/`DELETE …/composio/connections/{id}/default` |
| `connections` (`oauth`) | `POST …/connections/{p}/start`, `POST …/connections/{p}/disconnect` |
| `inference` | `PUT …/inference`, `DELETE …/inference` |
| `smtp` | `PUT …/smtp`, `POST …/smtp/test` (the caller names the recipient) |
Expand Down
47 changes: 47 additions & 0 deletions docs/spec/runtime/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,53 @@ that made `PUT …/composio/token` admin-only in issue #403. Both a set and a
clear are journaled as `ToolAccessChanged`, told apart from each other, and
attributed to whoever made the change.

## Which connected account (issue #820)

The credential decides **whose** accounts a call can reach. It does not decide
**which** of them, and for a company holding two accounts for one toolkit —
`ops@` and `billing@` Gmail — those are different questions.

Until #820 the second had no answer at all. `composio_execute` built its body as
`{tool, arguments}` and carried no connection id, so the account was resolved by
Composio for the entity, outside this codebase entirely. Two consequences worth
naming: "send from the billing account, not ops" was not sayable, and *which
Gmail did the agent send from* was unanswerable even after the fact. The only
lever was to disconnect the account you did not want.

The choice is now a per-company, per-toolkit preference:

- **Stored** as one JSON blob under `composio/defaults`
(`{"gmail": "ca_billing"}`), beside the credential it qualifies and read the
same way `inference/config` is. Not a secret — the ids are the same ones
`GET …/composio/connections` already hands the console, and are useless
without the bearer that scopes them — but company state, so it moves, backs up
and is deleted with the rest of the company's Composio state.
- **Resolved** into `TenantComposio` by the same `resolve` the credential goes
through, and folded into the roster fingerprint, so a change reaches the
agents on their next turn with no restart — exactly like a rotated token.
- **Sent** as `connectionId` on the execute body, which the platform backend
forwards to Composio as `connectedAccountId`.
- **Set** through `PUT …/composio/connections/{id}/default` (admin-only), which
validates the id against this company's own filtered connection list first and
refuses an account that is not usable. Cleared through the matching `DELETE`,
which deliberately makes **no** upstream call: clearing has to work when the
account is gone or the provider is unreachable, which is when a validating
clear would refuse.

**Absent is the ordinary state, and it is not a degraded one.** A company that
has chosen nothing sends no connection id and gets Composio's own resolution,
byte-for-byte the behaviour that existed before — which is what keeps this
change invisible to every single-account company. Nothing invents a default from
the connection list: `list_connections_detailed`'s `(toolkit, id)` sort is a
stable render order for a read, never a choice, and a default the console
claimed but the harness did not honour would read as a guarantee. The console
says "Composio picks" rather than pointing at a row.

Two pins are dropped automatically, because a pin to a connection that no longer
exists would be sent on the next execute and refused — turning the disconnect of
one account into a broken toolkit: when the console revokes an account, and when
`GET …/composio/connections` finds a chosen id that Composio no longer lists.

## Not the inference key

`inference/key` is a different thing and must stay a different slot. It holds
Expand Down
53 changes: 47 additions & 6 deletions frontend/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import { LIVE_BRAIN, MCP_FIXTURE_BIND, MOCK_BRAIN_BIND } from "./test/e2e/capabilities";
import {
COMPOSIO,
COMPOSIO_FIXTURE_BIND,
LIVE_BRAIN,
MCP_FIXTURE_BIND,
MOCK_BRAIN_BIND,
} from "./test/e2e/capabilities";

// `package.json` is `"type": "module"`, so this file is ESM and `__dirname`
// does not exist here — it type-checks against `@types/node` and then throws at
Expand Down Expand Up @@ -98,10 +104,31 @@ const inferenceEnv: Record<string, string> = managesFixtures
? {
OPENCOMPANY_INFERENCE_KEY: "mock-brain",
OPENCOMPANY_INFERENCE_URL: `http://${MOCK_BRAIN_BIND}/v1`,
PW_HOST_PASSTHROUGH: "OPENCOMPANY_INFERENCE_KEY OPENCOMPANY_INFERENCE_URL",
}
: {};

/** Whether this run also brings up the Composio fixture backend (issue #820). */
const managesComposio = managesHost && COMPOSIO;

/**
* Where the host's Composio calls go, when this run is standing a fixture up.
*
* The same `PW_HOST_PASSTHROUGH` caveat applies as above and is the reason the
* two blocks are joined below rather than each setting the variable: `host.sh`
* copies an allowlist into an empty environment, so a second assignment here
* would quietly replace the first and the inference URL would never arrive.
*/
const composioEnv: Record<string, string> = managesComposio
? { OPENCOMPANY_COMPOSIO_BACKEND_URL: `http://${COMPOSIO_FIXTURE_BIND}` }
: {};

const passthrough = [...Object.keys(inferenceEnv), ...Object.keys(composioEnv)];
const hostEnv: Record<string, string> = {
...inferenceEnv,
...composioEnv,
...(passthrough.length > 0 ? { PW_HOST_PASSTHROUGH: passthrough.join(" ") } : {}),
};

/**
* One `webServer` entry per fixture, ahead of the host.
*
Expand All @@ -111,7 +138,20 @@ const inferenceEnv: Record<string, string> = managesFixtures
* only — the host reads its inference URL at boot but does not dial it until a
* turn runs, well after every server here is ready.
*/
const fixtureServers = managesFixtures
const fixtureServers = [
...(managesComposio
? [
{
command: `node ./test/e2e/composio-backend.mjs --bind ${COMPOSIO_FIXTURE_BIND}`,
url: `http://${COMPOSIO_FIXTURE_BIND}/healthz`,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
stdout: "pipe" as const,
stderr: "pipe" as const,
},
]
: []),
...(managesFixtures
? [
{
command: `node ./test/e2e/mock-brain.mjs --bind ${MOCK_BRAIN_BIND}`,
Expand All @@ -129,8 +169,9 @@ const fixtureServers = managesFixtures
stdout: "pipe" as const,
stderr: "pipe" as const,
},
]
: [];
]
: []),
];

export default defineConfig({
testDir: "./test/e2e",
Expand Down Expand Up @@ -163,7 +204,7 @@ export default defineConfig({
stderr: "pipe" as const,
env: {
PW_HOST_BIND: new URL(baseURL).host,
...inferenceEnv,
...hostEnv,
},
},
]
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/api/composio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,52 @@ export interface ComposioAuthorize {
connectUrl: string;
}

/** One connected account inside a {@link ComposioConnection} (issue #404). */
export interface ComposioAccount {
/** Composio's connection id — what a disconnect or a default is named by. */
id: string;
/** Composio's raw status string (`ACTIVE`, `INITIATED`, `EXPIRED`, …). */
status: string;
/** Whether this individual account is usable. */
connected: boolean;
/** When Composio recorded the connection, when it says. */
createdAt?: string;
/**
* The account label the provider published — an email, a workspace, a handle.
* Absent for the many toolkits that publish no identity; the console shows the
* id rather than inventing one.
*/
account?: string;
/**
* Whether this is the account the company chose to act as for the toolkit
* (issue #820). False on every account until somebody chooses.
*/
isDefault: boolean;
}

/** One toolkit's connected state, as returned by `GET …/composio/connections`. */
export interface ComposioConnection {
/** Toolkit slug, e.g. `gmail`. */
toolkit: string;
/** Whether the company has at least one active connection for this toolkit. */
connected: boolean;
/**
* Every connection the company holds for this toolkit (issue #404).
*
* Optional on the wire only for a host predating it — the field is always sent
* by a current host, and the console treats a missing one as "no detail
* available" rather than "no accounts".
*/
accounts?: ComposioAccount[];
/**
* The account the company chose for this toolkit (issue #820), or absent.
*
* **Absent is the ordinary state and means nothing is chosen** — Composio
* resolves the account itself, exactly as it did before a company could
* express a preference. The console must not fill this in from the account
* list: a default the harness does not honour reads as a guarantee.
*/
defaultConnectionId?: string;
}

/** The company's Composio status. */
Expand Down Expand Up @@ -191,3 +231,47 @@ export function listComposioConnections(
): Promise<ComposioConnection[]> {
return client.get<ComposioConnection[]>(`${client.scopeFor(company)}/composio/connections`);
}

/** The `…/default` response: what the company now acts as, and a sentence. */
export interface ComposioDefaultMutation {
/** The toolkit the change applied to. Empty on a clear. */
toolkit: string;
/** The account now acting for that toolkit — absent after a clear. */
connectionId?: string;
/** Plain-language confirmation, in the host's own words. */
note: string;
}

/**
* Make `connectionId` the account this company's agents act as for its toolkit
* (issue #820). Admin-only; 404 when the id names no connection this company
* holds, or names one that is connected but not usable.
*
* The toolkit is not passed: it is a property of the connection, and asking the
* caller to repeat it would only let the two disagree.
*/
export function setComposioDefaultAccount(
client: OpenCompanyClient,
company: string | null,
connectionId: string,
): Promise<ComposioDefaultMutation> {
return client.put<ComposioDefaultMutation>(
`${client.scopeFor(company)}/composio/connections/${encodeURIComponent(connectionId)}/default`,
{},
);
}

/**
* Stop naming an account for that connection's toolkit — Composio resolves it
* again, as it did before. Needs no live provider, so it still works when the
* account is gone or the backend is unreachable.
*/
export function clearComposioDefaultAccount(
client: OpenCompanyClient,
company: string | null,
connectionId: string,
): Promise<ComposioDefaultMutation> {
return client.del<ComposioDefaultMutation>(
`${client.scopeFor(company)}/composio/connections/${encodeURIComponent(connectionId)}/default`,
);
}
18 changes: 18 additions & 0 deletions frontend/src/views/ConnectionsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { buildGridProviders, type GridProvider } from "@/lib/provider-grid";
import { armTourResume } from "@/tour/state";
import { InferenceSection } from "@/views/connections/InferenceSection";
import { McpServersSection } from "@/views/connections/McpServersSection";
import { AccountChoiceSection } from "@/views/connections/AccountChoiceSection";
import { CompanyCredentialCard } from "@/views/connections/CompanyCredentialCard";
import { ComposioSection } from "@/views/connections/ComposioSection";
import { ProvidersSection } from "@/views/connections/ProvidersSection";
Expand Down Expand Up @@ -87,6 +88,11 @@ export function ConnectionsView({ client, company }: Props) {
// switch or unmount cannot leave one running.
const pollTimers = useRef<Record<string, number>>({});

// Bumped on every reconciled re-read, so the account-choice section re-reads
// with it: connecting a second Gmail is exactly when that section appears,
// and releasing one is exactly when it stops being a choice (issue #820).
const [connectionsGeneration, setConnectionsGeneration] = useState(0);

const refresh = useCallback(async () => {
try {
const list = await client.listConnections(company);
Expand All @@ -95,6 +101,8 @@ export function ConnectionsView({ client, company }: Props) {
} catch {
// No connections surface on this host yet — show the catalog read-only.
setLoad("unavailable");
} finally {
setConnectionsGeneration((n) => n + 1);
}
}, [client, company]);

Expand Down Expand Up @@ -454,6 +462,16 @@ export function ConnectionsView({ client, company }: Props) {
onDisconnect={(p) => void disconnect(p)}
onConnectSlug={(slug) => void connectSlug(slug)}
/>

{/* Only renders for a provider this company holds two or more accounts
for — the one case where "which account do agents act as" is a
question the product can answer (issue #820). */}
<AccountChoiceSection
client={client}
company={company}
canManage={canManage}
generation={connectionsGeneration + credentialGeneration}
/>
</div>
</div>
);
Expand Down
Loading
Loading