From 4a6641fd492e2ca7ca9617bace2ef1d06c959290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 16:46:46 -0400 Subject: [PATCH] Q101: a connector can hold several labelled company accounts, each granted and used by name Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-handlers.ts | 4 +- .../083-connector-account-labels.ts | 25 ++++ src/main/database/schema.ts | 2 + src/main/services/agent-connector-tools.ts | 9 +- src/main/services/agent-tools.ts | 1 + .../composio/composio-connection-service.ts | 108 +++++++++--------- .../services/composio/composio-tool-bridge.ts | 9 +- .../services/composio/connector-executions.ts | 18 +-- .../services/composio/connector-grants.ts | 103 ++++++++++++----- .../src/app/OnboardingConnectorsStep.tsx | 3 +- src/renderer/src/i18n/zh.ts | 3 + src/renderer/src/pages/ConnectorsPage.tsx | 5 +- .../src/pages/connectors/ConnectedList.tsx | 32 ++++-- .../pages/connectors/useConnectorCatalog.ts | 18 +-- src/shared/commands/composio.ts | 6 + src/shared/domain.ts | 3 + tests/unit/agent-instructions.test.ts | 6 +- tests/unit/capability-warnings.test.ts | 2 +- tests/unit/composio-reconnect.test.ts | 30 +++-- tests/unit/composio-tool-bridge.test.ts | 4 +- tests/unit/composio-tool-pages.test.ts | 26 ++++- tests/unit/connector-grants.test.ts | 38 +++++- tests/unit/domain-services.test.ts | 2 +- 23 files changed, 306 insertions(+), 151 deletions(-) create mode 100644 src/main/database/migrations/083-connector-account-labels.ts diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 3ed89ddc..37e5c03f 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -397,7 +397,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { }); ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => { try { - const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug); + const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug, input.label); notifyDataChanged(); return ok(result); } catch (error) { @@ -406,7 +406,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { }); ipcMain.handle(IPC_CHANNELS.disconnectComposioToolkit, async (_event, input) => { try { - await composioConnections.disconnectToolkit(input.orgId, input.toolkitSlug); + await composioConnections.disconnectToolkit(input.orgId, input.toolkitSlug, input.label); notifyDataChanged(); return ok(snapshot.load()); } catch (error) { diff --git a/src/main/database/migrations/083-connector-account-labels.ts b/src/main/database/migrations/083-connector-account-labels.ts new file mode 100644 index 00000000..780835ac --- /dev/null +++ b/src/main/database/migrations/083-connector-account-labels.ts @@ -0,0 +1,25 @@ +export const connectorAccountLabelsSchema = ` + create table connector_accounts_next ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + toolkit_slug text not null, + toolkit_name text not null, + label text not null, + auth_config_id text not null, + status text not null, + pending_account_id text, + created_at text not null, + updated_at text not null, + unique (org_id, toolkit_slug, label) + ); + insert into connector_accounts_next(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, pending_account_id, created_at, updated_at) + select id, org_id, toolkit_slug, toolkit_name, 'main', auth_config_id, status, pending_account_id, created_at, updated_at from connector_accounts; + drop table connector_accounts; + alter table connector_accounts_next rename to connector_accounts; + + alter table agent_connector_grants add column account_label text not null default 'main'; + alter table connector_executions add column account_label text not null default 'main'; + + insert into schema_migrations(version, name, applied_at) + values (83, 'connector_account_labels', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 6421aa20..e1f5905b 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -81,6 +81,7 @@ import { eraseRetiredCredentialsSchema } from "./migrations/079-erase-retired-cr import { runCapabilityWarningsSchema } from "./migrations/080-run-capability-warnings"; import { connectorExecutionsSchema } from "./migrations/081-connector-executions"; import { connectorPendingAccountSchema } from "./migrations/082-connector-pending-account"; +import { connectorAccountLabelsSchema } from "./migrations/083-connector-account-labels"; interface Migration { version: number; @@ -171,6 +172,7 @@ const MIGRATIONS: Migration[] = [ { version: 80, name: "run_capability_warnings", sql: runCapabilityWarningsSchema }, { version: 81, name: "connector_executions", sql: connectorExecutionsSchema }, { version: 82, name: "connector_pending_account", sql: connectorPendingAccountSchema }, + { version: 83, name: "connector_account_labels", sql: connectorAccountLabelsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-connector-tools.ts b/src/main/services/agent-connector-tools.ts index 64259b3f..9ce74eb1 100644 --- a/src/main/services/agent-connector-tools.ts +++ b/src/main/services/agent-connector-tools.ts @@ -17,10 +17,10 @@ export function listConnectorsTool(deps: AgentToolDeps, ctx: AgentToolContext): ); const lines = accounts.map((account) => { const holders = grants - .filter((grant) => grant.toolkitSlug === account.toolkitSlug) + .filter((grant) => grant.toolkitSlug === account.toolkitSlug && grant.accountLabel === account.label) .map((grant) => agentNames.get(grant.agentId)) .filter(Boolean); - return `- ${account.toolkitName} (${account.toolkitSlug}): ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`; + return `- ${account.toolkitName} (${account.toolkitSlug}), account "${account.label}": ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`; }); return ["Connected toolkits:", ...lines].join("\n"); } @@ -40,14 +40,15 @@ export function grantConnectorTool(deps: AgentToolDeps, ctx: AgentToolContext, a if (!target) return `ERROR: No active agent named "${agentName}".`; const grantsService = new ConnectorGrantsService(deps.db); if (mode === "grant") { - grantsService.grant({ + const label = grantsService.grant({ orgId: ctx.orgId, agentId: target.id, toolkitSlug: toolkit, + accountLabel: args.account === undefined ? null : requireString(args.account, "account"), grantedByType: "agent", grantedById: ctx.agent.id, }); - return `${target.name} now has access to the ${toolkit.toLowerCase()} connector; its tools join their next run.`; + return `${target.name} now has access to the ${toolkit.toLowerCase()} connector through its "${label}" account; its tools join their next run.`; } grantsService.revoke({ orgId: ctx.orgId, agentId: target.id, toolkitSlug: toolkit }); return `${target.name} no longer has access to the ${toolkit.toLowerCase()} connector.`; diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index c0c9efff..40f6cce4 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -188,6 +188,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [ properties: { agent_name: { type: "string", description: "The teammate receiving access." }, toolkit: { type: "string", description: "Connected toolkit slug, e.g. 'github'." }, + account: { type: "string", description: "Which of the toolkit's accounts, by label (see list_connectors); required when it has several." }, }, required: ["agent_name", "toolkit"], }, diff --git a/src/main/services/composio/composio-connection-service.ts b/src/main/services/composio/composio-connection-service.ts index 8817ac91..dbc6ce3d 100644 --- a/src/main/services/composio/composio-connection-service.ts +++ b/src/main/services/composio/composio-connection-service.ts @@ -4,7 +4,7 @@ import { ComposioClient, type ComposioConnectedAccount } from "./composio-client import { IntegrationKeyStore } from "../integration-key-store"; import { getComposioUserId } from "./composio-tool-bridge"; import { connectorDisplayName, connectorLogoUrl } from "./connector-brands"; -import { normalizeSlug } from "./connector-grants"; +import { DEFAULT_ACCOUNT_LABEL, normalizeAccountLabel, normalizeSlug } from "./connector-grants"; const CATALOG_TTL_MS = 10 * 60 * 1000; const WAITING_STATUSES = new Set(["INITIALIZING", "INITIATED"]); @@ -35,9 +35,9 @@ export class ComposioConnectionService { return this.catalog(this.requireClient()); } - /** Starts a toolkit's connection; a reconnect waits beside an active account until refresh finds it authorized. */ - connectToolkit(orgId: string, toolkitSlug: string): Promise { - return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug)); + /** Starts a toolkit account's connection; a reconnect waits beside an active account until refresh finds it authorized. */ + connectToolkit(orgId: string, toolkitSlug: string, label: string): Promise { + return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug, label)); } /** See class docs: linearizes connect/refresh/disconnect for one org. */ @@ -48,14 +48,15 @@ export class ComposioConnectionService { return next; } - private async connectToolkitNow(orgId: string, toolkitSlug: string): Promise { + private async connectToolkitNow(orgId: string, toolkitSlug: string, rawLabel: string): Promise { const slug = normalizeSlug(toolkitSlug); + const label = normalizeAccountLabel(rawLabel); const client = this.requireClient(); const entry = (await this.catalog(client)).find((item) => item.slug === slug); if (!entry) throw new Error(`No auth config for "${slug}" exists in the Composio project.`); const previous = this.db.prepare( - "select id, status, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ?", - ).get(orgId, slug) as { id: string; status: string; pending_account_id: string | null } | undefined; + "select id, status, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?", + ).get(orgId, slug, label) as { id: string; status: string; pending_account_id: string | null } | undefined; const account = await client.createConnectedAccount({ authConfigId: entry.authConfigId, userId: getComposioUserId(this.db, orgId), @@ -64,29 +65,28 @@ export class ComposioConnectionService { const now = new Date().toISOString(); // The local row moves to the new account before any old one is deleted; refresh ignores accounts no row tracks. if (previous?.status === "ACTIVE") { - this.db.prepare("update connector_accounts set pending_account_id = ?, updated_at = ? where org_id = ? and toolkit_slug = ?") - .run(account.id, now, orgId, slug); - console.info(`[connectors] ${slug} reconnect ${account.id} started; ${previous.id} stays in use until it is authorized`); + this.db.prepare("update connector_accounts set pending_account_id = ?, updated_at = ? where id = ?").run(account.id, now, previous.id); + console.info(`[connectors] ${slug}/${label} reconnect ${account.id} started; ${previous.id} stays in use until it is authorized`); if (previous.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id); return { accountId: account.id, redirectUrl: account.redirectUrl }; } this.db.prepare(` - insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?) - on conflict(org_id, toolkit_slug) do update set + insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(org_id, toolkit_slug, label) do update set id = excluded.id, toolkit_name = excluded.toolkit_name, auth_config_id = excluded.auth_config_id, status = excluded.status, pending_account_id = null, updated_at = excluded.updated_at - `).run(account.id, orgId, slug, entry.name, entry.authConfigId, "INITIALIZING", now, now); + `).run(account.id, orgId, slug, entry.name, label, entry.authConfigId, "INITIALIZING", now, now); if (previous?.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id); if (previous) await client.deleteConnectedAccount(previous.id); return { accountId: account.id, redirectUrl: account.redirectUrl }; } - /** Mirrors Composio: tracked accounts take its status, gone ones are removed with their grants, untracked toolkits are adopted. */ + /** Mirrors Composio: tracked accounts take its status, gone ones are removed with their grants, toolkits with no account here are adopted. */ refreshAccounts(orgId: string): Promise { return this.serialize(orgId, () => this.refreshAccountsNow(orgId)); } @@ -99,36 +99,27 @@ export class ComposioConnectionService { const reconnects = this.db.prepare("select id, toolkit_slug, pending_account_id from connector_accounts where org_id = ? and pending_account_id is not null") .all(orgId) as Array<{ id: string; toolkit_slug: string; pending_account_id: string }>; for (const row of reconnects) await this.settleReconnect(client, row, accounts.find((account) => account.id === row.pending_account_id), liveIds.has(row.id)); - const localRows = this.db.prepare("select id, toolkit_slug, pending_account_id from connector_accounts where org_id = ?") - .all(orgId) as Array<{ id: string; toolkit_slug: string; pending_account_id: string | null }>; + const localRows = this.db.prepare("select id, toolkit_slug, label from connector_accounts where org_id = ?") + .all(orgId) as Array<{ id: string; toolkit_slug: string; label: string }>; for (const row of localRows) { - if (liveIds.has(row.id)) continue; - this.removeLocalAccount(orgId, row.toolkit_slug); + if (!liveIds.has(row.id)) this.removeLocalAccount(orgId, row.toolkit_slug, row.label); } - const tracked = new Map(localRows.filter((row) => liveIds.has(row.id)).map((row) => [row.toolkit_slug, row])); + const tracked = this.trackedIds(orgId); + const toolkitsHere = new Set(localRows.filter((row) => liveIds.has(row.id)).map((row) => row.toolkit_slug)); for (const account of accounts) { if (!account.toolkitSlug) continue; - const row = tracked.get(account.toolkitSlug); - // A toolkit's row follows only its own account; others (a waiting reconnect, an orphan) never replace it. - if (row && row.id !== account.id) continue; - tracked.set(account.toolkitSlug, { id: account.id, toolkit_slug: account.toolkitSlug, pending_account_id: null }); + if (tracked.has(account.id)) { + this.db.prepare("update connector_accounts set auth_config_id = ?, status = ?, updated_at = ? where id = ?") + .run(account.authConfigId, account.status, now, account.id); + continue; + } + // Only a toolkit with no account here is adopted; other untracked accounts (a waiting reconnect, an orphan) never join. + if (toolkitsHere.has(account.toolkitSlug)) continue; + toolkitsHere.add(account.toolkitSlug); this.db.prepare(` - insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) - values (?, ?, ?, ?, ?, ?, ?, ?) - on conflict(org_id, toolkit_slug) do update set - auth_config_id = excluded.auth_config_id, - status = excluded.status, - updated_at = excluded.updated_at - `).run( - account.id, - orgId, - account.toolkitSlug, - connectorDisplayName(account.toolkitSlug), - account.authConfigId, - account.status, - account.createdAt || now, - now, - ); + insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(account.id, orgId, account.toolkitSlug, connectorDisplayName(account.toolkitSlug), DEFAULT_ACCOUNT_LABEL, account.authConfigId, account.status, account.createdAt || now, now); } } @@ -153,34 +144,39 @@ export class ComposioConnectionService { console.warn(`[connectors] ${row.toolkit_slug} reconnect ${row.pending_account_id} ended ${pending?.status ?? "missing"}; ${row.id} stays in use`); } - /** - * Deletes the company's connection for a toolkit: removes the connected - * account in Composio (already-gone counts as deleted), then the local - * mirror row and every agent grant that pointed at it. - */ - disconnectToolkit(orgId: string, toolkitSlug: string): Promise { - return this.serialize(orgId, () => this.disconnectToolkitNow(orgId, toolkitSlug)); + /** Deletes one account of a toolkit in Composio, with untracked leftovers of that toolkit, then its row and the grants that named it. */ + disconnectToolkit(orgId: string, toolkitSlug: string, label: string): Promise { + return this.serialize(orgId, () => this.disconnectToolkitNow(orgId, toolkitSlug, label)); } - private async disconnectToolkitNow(orgId: string, toolkitSlug: string): Promise { + private async disconnectToolkitNow(orgId: string, toolkitSlug: string, rawLabel: string): Promise { const slug = normalizeSlug(toolkitSlug); + const label = normalizeAccountLabel(rawLabel); const row = this.db.prepare( - "select id, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ?", - ).get(orgId, slug) as { id: string; pending_account_id: string | null } | undefined; + "select id, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?", + ).get(orgId, slug, label) as { id: string; pending_account_id: string | null } | undefined; if (!row) return; const client = this.requireClient(); - // Every account Composio holds for the toolkit goes, so an orphan from a failed delete cannot be adopted back. + // Untracked accounts of the toolkit go too, so an orphan from a failed delete cannot be adopted later. + const tracked = this.trackedIds(orgId); const ids = new Set([row.id, ...(row.pending_account_id ? [row.pending_account_id] : [])]); for (const account of await client.listConnectedAccounts(getComposioUserId(this.db, orgId))) { - if (account.toolkitSlug === slug) ids.add(account.id); + if (account.toolkitSlug === slug && !tracked.has(account.id)) ids.add(account.id); } for (const id of ids) await client.deleteConnectedAccount(id); - this.removeLocalAccount(orgId, slug); + this.removeLocalAccount(orgId, slug, label); } - private removeLocalAccount(orgId: string, slug: string): void { - this.db.prepare("delete from connector_accounts where org_id = ? and toolkit_slug = ?").run(orgId, slug); - this.db.prepare("delete from agent_connector_grants where org_id = ? and toolkit_slug = ?").run(orgId, slug); + /** Every Composio account a row here stands for, waiting reconnects included. */ + private trackedIds(orgId: string): Set { + const rows = this.db.prepare("select id, pending_account_id from connector_accounts where org_id = ?") + .all(orgId) as Array<{ id: string; pending_account_id: string | null }>; + return new Set(rows.flatMap((row) => (row.pending_account_id ? [row.id, row.pending_account_id] : [row.id]))); + } + + private removeLocalAccount(orgId: string, slug: string, label: string): void { + this.db.prepare("delete from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?").run(orgId, slug, label); + this.db.prepare("delete from agent_connector_grants where org_id = ? and toolkit_slug = ? and account_label = ?").run(orgId, slug, label); } private requireClient(): ComposioClient { diff --git a/src/main/services/composio/composio-tool-bridge.ts b/src/main/services/composio/composio-tool-bridge.ts index 7b9fd428..2d8e1280 100644 --- a/src/main/services/composio/composio-tool-bridge.ts +++ b/src/main/services/composio/composio-tool-bridge.ts @@ -49,6 +49,7 @@ export function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, o properties: { action: { type: "string", description: "The action's name exactly as found, e.g. GITHUB_CREATE_A_PULL_REQUEST." }, arguments: { type: "object", description: "The action's input, following the schema the search returned." }, + account: { type: "string", description: "Which account of the service, by label; needed only when you can reach several." }, confirm_not_done: { type: "boolean", description: "Only after an identical call's reply was lost and you checked in the service that it did not take effect." }, }, required: ["action", "arguments"], @@ -109,14 +110,12 @@ async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentI if (!new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId).includes(toolkit)) { return `ERROR: You have no grant for the "${toolkit}" connector. Ask the CEO to grant it to you (grant_connector).`; } - const account = db.prepare( - "select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'", - ).get(orgId, toolkit) as { id: string } | undefined; - if (!account) return `ERROR: The "${toolkit}" connector is no longer connected. Ask the board to reconnect it on the Connectors page.`; + if (args.account !== undefined && typeof args.account !== "string") return "ERROR: account must be the label of one of the service's accounts."; + const account = new ConnectorGrantsService(db).accountForAgent(orgId, agentId, toolkit, args.account ?? null); const input = typeof args.arguments === "object" && args.arguments !== null && !Array.isArray(args.arguments) ? args.arguments as Record : null; if (!input) return "ERROR: arguments must be an object matching the action's input."; if (args.confirm_not_done !== undefined && typeof args.confirm_not_done !== "boolean") return "ERROR: confirm_not_done must be true or false."; - const started = beginConnectorExecution(db, { orgId, agentId, runId: currentRunId(), action: action.name, args: input, confirmedNotDone: args.confirm_not_done === true }); + const started = beginConnectorExecution(db, { orgId, agentId, runId: currentRunId(), action: action.name, accountLabel: account.label, args: input, confirmedNotDone: args.confirm_not_done === true }); if ("blockedBy" in started) { const lost = started.blockedBy.detail ? `: ${started.blockedBy.detail}` : ""; return `ERROR: An identical ${action.name} call at ${started.blockedBy.createdAt} may already have run; its reply was lost${lost}. Check in ${toolkit} whether it took effect. If it did not, call again with confirm_not_done: true.`; diff --git a/src/main/services/composio/connector-executions.ts b/src/main/services/composio/connector-executions.ts index 2f55ab6d..2823468c 100644 --- a/src/main/services/composio/connector-executions.ts +++ b/src/main/services/composio/connector-executions.ts @@ -5,27 +5,27 @@ import { createId } from "../../../shared/ids"; /** How an action run ended: unknown means it was sent and its reply was lost, so it may have taken effect. */ export type ConnectorExecutionOutcome = "succeeded" | "failed" | "unknown"; -/** Records an action run before it is sent; an identical call whose outcome is pending or unknown blocks it until the agent confirms it did not happen. */ +/** Records an action run before it is sent; an identical call on the same account whose outcome is pending or unknown blocks it until the agent confirms it did not happen. */ export function beginConnectorExecution( db: DatabaseSync, - input: { orgId: string; agentId: string; runId: string | null; action: string; args: Record; confirmedNotDone: boolean }, + input: { orgId: string; agentId: string; runId: string | null; action: string; accountLabel: string; args: Record; confirmedNotDone: boolean }, ): { id: string } | { blockedBy: { createdAt: string; detail: string | null } } { const hash = createHash("sha256").update(canonicalJson(input.args)).digest("hex"); const open = db.prepare(` select created_at, detail from connector_executions - where org_id = ? and action = ? and arguments_hash = ? and state in ('pending', 'unknown') order by rowid desc limit 1 - `).get(input.orgId, input.action, hash) as { created_at: string; detail: string | null } | undefined; + where org_id = ? and action = ? and account_label = ? and arguments_hash = ? and state in ('pending', 'unknown') order by rowid desc limit 1 + `).get(input.orgId, input.action, input.accountLabel, hash) as { created_at: string; detail: string | null } | undefined; const now = new Date().toISOString(); if (open && !input.confirmedNotDone) return { blockedBy: { createdAt: open.created_at, detail: open.detail } }; if (open) { - db.prepare("update connector_executions set state = 'resolved', updated_at = ? where org_id = ? and action = ? and arguments_hash = ? and state in ('pending', 'unknown')") - .run(now, input.orgId, input.action, hash); + db.prepare("update connector_executions set state = 'resolved', updated_at = ? where org_id = ? and action = ? and account_label = ? and arguments_hash = ? and state in ('pending', 'unknown')") + .run(now, input.orgId, input.action, input.accountLabel, hash); } const id = createId("cex"); db.prepare(` - insert into connector_executions(id, org_id, agent_id, run_id, action, arguments_hash, state, created_at, updated_at) - values (?, ?, ?, ?, ?, ?, 'pending', ?, ?) - `).run(id, input.orgId, input.agentId, input.runId, input.action, hash, now, now); + insert into connector_executions(id, org_id, agent_id, run_id, action, account_label, arguments_hash, state, created_at, updated_at) + values (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?) + `).run(id, input.orgId, input.agentId, input.runId, input.action, input.accountLabel, hash, now, now); return { id }; } diff --git a/src/main/services/composio/connector-grants.ts b/src/main/services/composio/connector-grants.ts index 03dc3118..c199bc38 100644 --- a/src/main/services/composio/connector-grants.ts +++ b/src/main/services/composio/connector-grants.ts @@ -1,11 +1,16 @@ import type { DatabaseSync } from "node:sqlite"; +import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands/composio"; import { createId } from "../../../shared/ids"; +export const DEFAULT_ACCOUNT_LABEL = DEFAULT_CONNECTOR_ACCOUNT_LABEL; +const MAX_LABEL_CHARS = 40; + export interface ConnectorAccountRow { id: string; orgId: string; toolkitSlug: string; toolkitName: string; + label: string; status: string; } @@ -14,31 +19,28 @@ export interface ConnectorGrantRow { orgId: string; agentId: string; toolkitSlug: string; + accountLabel: string; } -/** - * Which agents may use which connected toolkits. Connections belong to the - * company (one connected account per toolkit per org); the CEO carries every - * active connection by role and hands individual toolkits to individual - * agents. Both the tool loadout and the execution guard read from here. - */ +/** Which agents may use which labelled company accounts: the CEO all active ones by role, others one per toolkit by grant. */ export class ConnectorGrantsService { constructor(private readonly db: DatabaseSync) {} - grant(input: { orgId: string; agentId: string; toolkitSlug: string; grantedByType: "agent" | "employee"; grantedById: string | null }): void { + grant(input: { orgId: string; agentId: string; toolkitSlug: string; accountLabel: string | null; grantedByType: "agent" | "employee"; grantedById: string | null }): string { const agent = this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'") .get(input.agentId, input.orgId); if (!agent) throw new Error("Grant target must be an active agent in this organization."); const slug = normalizeSlug(input.toolkitSlug); - const account = this.db.prepare( - "select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'", - ).get(input.orgId, slug); - if (!account) throw new Error(`No active connection for toolkit "${slug}". Connect it on the Connectors page first.`); + const label = this.chooseAccount(input.orgId, slug, input.accountLabel).label; this.db.prepare(` - insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, granted_by_type, granted_by_id, created_at) - values (?, ?, ?, ?, ?, ?, ?) - on conflict(org_id, agent_id, toolkit_slug) do nothing - `).run(createId("cgr"), input.orgId, input.agentId, slug, input.grantedByType, input.grantedById, new Date().toISOString()); + insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, account_label, granted_by_type, granted_by_id, created_at) + values (?, ?, ?, ?, ?, ?, ?, ?) + on conflict(org_id, agent_id, toolkit_slug) do update set + account_label = excluded.account_label, + granted_by_type = excluded.granted_by_type, + granted_by_id = excluded.granted_by_id + `).run(createId("cgr"), input.orgId, input.agentId, slug, label, input.grantedByType, input.grantedById, new Date().toISOString()); + return label; } revoke(input: { orgId: string; agentId: string; toolkitSlug: string }): void { @@ -46,51 +48,82 @@ export class ConnectorGrantsService { .run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug)); } - /** - * Toolkits this agent may use. The CEO carries every actively connected - * toolkit by role; every other agent needs a grant AND an active - * connection. - */ + /** Toolkits this agent may use: every active one for the CEO; for others, those whose granted account is active. */ toolkitsForAgent(orgId: string, agentId: string): string[] { - const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?") - .get(agentId, orgId) as { role_level: string } | undefined; - if (acting?.role_level === "c_suite") { - return this.activeAccounts(orgId).map((account) => account.toolkitSlug); + if (this.isCeo(orgId, agentId)) { + return [...new Set(this.activeAccounts(orgId).map((account) => account.toolkitSlug))]; } const rows = this.db.prepare(` select g.toolkit_slug as slug from agent_connector_grants g - join connector_accounts a on a.org_id = g.org_id and a.toolkit_slug = g.toolkit_slug and a.status = 'ACTIVE' + join connector_accounts a on a.org_id = g.org_id and a.toolkit_slug = g.toolkit_slug and a.label = g.account_label and a.status = 'ACTIVE' where g.org_id = ? and g.agent_id = ? order by g.toolkit_slug asc `).all(orgId, agentId) as Array<{ slug: string }>; return rows.map((row) => row.slug); } + /** The active account this agent acts through for a toolkit; the CEO names one when the toolkit has several. */ + accountForAgent(orgId: string, agentId: string, toolkitSlug: string, accountLabel: string | null): ConnectorAccountRow { + const slug = normalizeSlug(toolkitSlug); + if (this.isCeo(orgId, agentId)) return this.chooseAccount(orgId, slug, accountLabel); + const grant = this.db.prepare("select account_label from agent_connector_grants where org_id = ? and agent_id = ? and toolkit_slug = ?") + .get(orgId, agentId, slug) as { account_label: string } | undefined; + if (!grant) throw new Error(`You have no grant for the "${slug}" connector. Ask the CEO to grant it to you (grant_connector).`); + if (accountLabel !== null && normalizeAccountLabel(accountLabel) !== grant.account_label) { + throw new Error(`Your grant for "${slug}" is the "${grant.account_label}" account, not "${accountLabel}".`); + } + const account = this.activeAccounts(orgId).find((row) => row.toolkitSlug === slug && row.label === grant.account_label); + if (!account) throw new Error(`The "${slug}" account "${grant.account_label}" is no longer connected. Ask the board to reconnect it on the Connectors page.`); + return account; + } + activeAccounts(orgId: string): ConnectorAccountRow[] { const rows = this.db.prepare(` - select id, org_id, toolkit_slug, toolkit_name, status + select id, org_id, toolkit_slug, toolkit_name, label, status from connector_accounts where org_id = ? and status = 'ACTIVE' - order by toolkit_slug asc - `).all(orgId) as Array<{ id: string; org_id: string; toolkit_slug: string; toolkit_name: string; status: string }>; + order by toolkit_slug asc, label asc + `).all(orgId) as Array<{ id: string; org_id: string; toolkit_slug: string; toolkit_name: string; label: string; status: string }>; return rows.map((row) => ({ id: row.id, orgId: row.org_id, toolkitSlug: row.toolkit_slug, toolkitName: row.toolkit_name, + label: row.label, status: row.status, })); } grantsForOrg(orgId: string): ConnectorGrantRow[] { const rows = this.db.prepare(` - select id, org_id, agent_id, toolkit_slug + select id, org_id, agent_id, toolkit_slug, account_label from agent_connector_grants where org_id = ? order by toolkit_slug asc - `).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string }>; - return rows.map((row) => ({ id: row.id, orgId: row.org_id, agentId: row.agent_id, toolkitSlug: row.toolkit_slug })); + `).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string; account_label: string }>; + return rows.map((row) => ({ id: row.id, orgId: row.org_id, agentId: row.agent_id, toolkitSlug: row.toolkit_slug, accountLabel: row.account_label })); + } + + /** The named active account of a toolkit, or its only one; several accounts with no name is an error that lists them. */ + private chooseAccount(orgId: string, slug: string, accountLabel: string | null): ConnectorAccountRow { + const accounts = this.activeAccounts(orgId).filter((account) => account.toolkitSlug === slug); + if (accounts.length === 0) throw new Error(`No active connection for toolkit "${slug}". Connect it on the Connectors page first.`); + const labels = accounts.map((account) => account.label).join(", "); + if (accountLabel === null) { + if (accounts.length > 1) throw new Error(`"${slug}" has several accounts (${labels}); name one with account.`); + return accounts[0]; + } + const label = normalizeAccountLabel(accountLabel); + const account = accounts.find((row) => row.label === label); + if (!account) throw new Error(`"${slug}" has no active account "${label}"; its accounts: ${labels}.`); + return account; + } + + private isCeo(orgId: string, agentId: string): boolean { + const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?") + .get(agentId, orgId) as { role_level: string } | undefined; + return acting?.role_level === "c_suite"; } } @@ -99,3 +132,11 @@ export function normalizeSlug(slug: string): string { if (!normalized) throw new Error("Toolkit slug is required."); return normalized; } + +/** Account labels are trimmed and lower-case so "Sales" and "sales " name the same account. */ +export function normalizeAccountLabel(label: string): string { + const normalized = label.trim().toLowerCase(); + if (!normalized) throw new Error("Account label is required."); + if (normalized.length > MAX_LABEL_CHARS) throw new Error(`Account label must be at most ${MAX_LABEL_CHARS} characters.`); + return normalized; +} diff --git a/src/renderer/src/app/OnboardingConnectorsStep.tsx b/src/renderer/src/app/OnboardingConnectorsStep.tsx index badbb3e3..502cc91b 100644 --- a/src/renderer/src/app/OnboardingConnectorsStep.tsx +++ b/src/renderer/src/app/OnboardingConnectorsStep.tsx @@ -3,6 +3,7 @@ import type { AppSnapshot } from "../../../shared/domain"; import { Button } from "../shared/ui"; import { ToolkitCatalog } from "../pages/connectors/ToolkitCatalog"; import { useConnectorCatalog } from "../pages/connectors/useConnectorCatalog"; +import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands"; import type { SnapshotActions } from "./useSnapshot"; import { t } from "../i18n/i18n"; @@ -60,7 +61,7 @@ export function ConnectorsStep({ entries={catalog.entries} connectedSlugs={connectedSlugs} busySlug={busySlug} - onConnect={connect} + onConnect={(slug) => connect(slug, DEFAULT_CONNECTOR_ACCOUNT_LABEL)} /> )} diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index d99b488a..feb40829 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -383,6 +383,9 @@ export const zh: Record = { "Recently decided": "最近已决", "Reconnect": "重新连接", "Reconnect waiting for authorization": "重新连接等待授权", + "Service": "服务", + "Account label, e.g. sales": "账号标签,例如 sales", + "Add account": "添加账号", "Refresh models": "刷新模型", "Region": "区域", "Rejected": "已驳回", diff --git a/src/renderer/src/pages/ConnectorsPage.tsx b/src/renderer/src/pages/ConnectorsPage.tsx index a2dd22e2..12dab85f 100644 --- a/src/renderer/src/pages/ConnectorsPage.tsx +++ b/src/renderer/src/pages/ConnectorsPage.tsx @@ -6,6 +6,7 @@ import { activeOrgId } from "./types"; import { ConnectedList } from "./connectors/ConnectedList"; import { ToolkitCatalog } from "./connectors/ToolkitCatalog"; import { useConnectorCatalog } from "./connectors/useConnectorCatalog"; +import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands"; /** * The company's connector control room: what is connected, who holds it, and @@ -66,7 +67,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { logoBySlug={logoBySlug} busySlug={busySlug} busyAction={busyAction} - onReconnect={connect} + onConnect={connect} onDelete={disconnect} /> @@ -93,7 +94,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { entries={catalog.entries} connectedSlugs={connectedSlugs} busySlug={busySlug} - onConnect={connect} + onConnect={(slug) => connect(slug, DEFAULT_CONNECTOR_ACCOUNT_LABEL)} /> )} diff --git a/src/renderer/src/pages/connectors/ConnectedList.tsx b/src/renderer/src/pages/connectors/ConnectedList.tsx index 67a51d13..3c557b22 100644 --- a/src/renderer/src/pages/connectors/ConnectedList.tsx +++ b/src/renderer/src/pages/connectors/ConnectedList.tsx @@ -1,3 +1,4 @@ +import { useState, type FormEvent } from "react"; import { Button, IconButton, Pill } from "../../shared/ui"; import type { AgentRecord, ConnectorAccountRecord, ConnectorGrantRecord } from "../../../../shared/domain"; import { ToolkitTile } from "./ToolkitCatalog"; @@ -26,7 +27,7 @@ export function ConnectedList({ logoBySlug, busySlug, busyAction, - onReconnect, + onConnect, onDelete, }: { accounts: ConnectorAccountRecord[]; @@ -35,18 +36,26 @@ export function ConnectedList({ logoBySlug: Map; busySlug: string | null; busyAction: "connect" | "disconnect" | null; - onReconnect: (toolkitSlug: string) => void; - onDelete: (toolkitSlug: string) => void; + onConnect: (toolkitSlug: string, label: string) => void; + onDelete: (toolkitSlug: string, label: string) => void; }) { + const [addSlug, setAddSlug] = useState(""); + const [addLabel, setAddLabel] = useState(""); if (accounts.length === 0) return null; const agentNames = new Map(agents.map((agent) => [agent.id, agent.name])); + const toolkits = [...new Map(accounts.map((account) => [account.toolkitSlug, { slug: account.toolkitSlug, name: account.toolkitName }])).values()]; + const addAccount = (event: FormEvent) => { + event.preventDefault(); + onConnect(addSlug, addLabel); + setAddLabel(""); + }; return (
{t("Connected")}
{accounts.map((account) => { const holders = grants - .filter((grant) => grant.toolkitSlug === account.toolkitSlug) + .filter((grant) => grant.toolkitSlug === account.toolkitSlug && grant.accountLabel === account.label) .map((grant) => agentNames.get(grant.agentId)) .filter((name): name is string => Boolean(name)); const busy = busySlug !== null; @@ -60,17 +69,18 @@ export function ConnectedList({ size={30} /> {account.toolkitName} + {account.label} {account.status.charAt(0) + account.status.slice(1).toLowerCase()} {account.pendingAccountId && {t("Reconnect waiting for authorization")}} - onDelete(account.toolkitSlug)} + onClick={() => onDelete(account.toolkitSlug, account.label)} />
@@ -83,6 +93,14 @@ export function ConnectedList({ ); })} +
+ + setAddLabel(event.target.value)} required maxLength={40} /> + +
); } diff --git a/src/renderer/src/pages/connectors/useConnectorCatalog.ts b/src/renderer/src/pages/connectors/useConnectorCatalog.ts index 854f02ea..865934e5 100644 --- a/src/renderer/src/pages/connectors/useConnectorCatalog.ts +++ b/src/renderer/src/pages/connectors/useConnectorCatalog.ts @@ -22,8 +22,8 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { busySlug: string | null; busyAction: "connect" | "disconnect" | null; connectError: string | null; - connect: (toolkitSlug: string) => void; - disconnect: (toolkitSlug: string) => void; + connect: (toolkitSlug: string, label: string) => void; + disconnect: (toolkitSlug: string, label: string) => void; retry: () => void; } { const [catalogSlot, setCatalogSlot] = useState(null); @@ -75,13 +75,13 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { [actions, orgId], ); - const pollUntilActive = useCallback((toolkitSlug: string) => { + const pollUntilActive = useCallback((toolkitSlug: string, label: string) => { const poll = (attempt: number) => { if (attempt >= CONNECT_POLL_ATTEMPTS) return; pollTimer.current = setTimeout(() => { void refreshAccounts().then((data) => { const account = data?.connectorAccounts.find( - (item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug, + (item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug && item.label === label, ); if (account?.status === "ACTIVE" && !account.pendingAccountId) return; poll(attempt + 1); @@ -91,17 +91,17 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { poll(0); }, [refreshAccounts, orgId]); - const connect = useCallback((toolkitSlug: string) => { + const connect = useCallback((toolkitSlug: string, label: string) => { setBusy({ slug: toolkitSlug, action: "connect" }); setConnectError(null); void window.agentCompany - .connectComposioToolkit({ orgId, toolkitSlug }) + .connectComposioToolkit({ orgId, toolkitSlug, label }) .then(async (result) => { if (!result.ok) { setConnectError(result.error.message); return; } - pollUntilActive(toolkitSlug); + pollUntilActive(toolkitSlug, label.trim().toLowerCase()); if (result.data.redirectUrl) { const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl); if (!opened.ok) setConnectError(opened.error.message); @@ -110,10 +110,10 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { .finally(() => setBusy(null)); }, [orgId, pollUntilActive]); - const disconnect = useCallback((toolkitSlug: string) => { + const disconnect = useCallback((toolkitSlug: string, label: string) => { setBusy({ slug: toolkitSlug, action: "disconnect" }); setConnectError(null); - void actions.run(() => window.agentCompany.disconnectComposioToolkit({ orgId, toolkitSlug })) + void actions.run(() => window.agentCompany.disconnectComposioToolkit({ orgId, toolkitSlug, label })) .catch((error: unknown) => setConnectError(error instanceof Error ? error.message : String(error))) .finally(() => setBusy(null)); }, [actions, orgId]); diff --git a/src/shared/commands/composio.ts b/src/shared/commands/composio.ts index f70e07b0..46666173 100644 --- a/src/shared/commands/composio.ts +++ b/src/shared/commands/composio.ts @@ -6,9 +6,14 @@ export interface ConnectorCatalogEntry { logoUrl: string | null; } +/** The label of a toolkit's first account, and of every account from before labels existed. */ +export const DEFAULT_CONNECTOR_ACCOUNT_LABEL = "main"; + export interface ConnectComposioToolkitInput { orgId: string; toolkitSlug: string; + /** The account to connect or reconnect; a new label adds another account of the toolkit. */ + label: string; } export interface ConnectComposioToolkitResult { @@ -19,6 +24,7 @@ export interface ConnectComposioToolkitResult { export interface DisconnectComposioToolkitInput { orgId: string; toolkitSlug: string; + label: string; } export interface RefreshComposioAccountsInput { diff --git a/src/shared/domain.ts b/src/shared/domain.ts index fd2640d8..eae8b418 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -451,6 +451,8 @@ export interface ConnectorAccountRecord { orgId: string; toolkitSlug: string; toolkitName: string; + /** Names the account among the toolkit's company accounts, e.g. "main" or "sales". */ + label: string; authConfigId: string; status: string; /** A reconnect waiting for authorization; the current account keeps working until it is active. */ @@ -464,6 +466,7 @@ export interface ConnectorGrantRecord { orgId: string; agentId: string; toolkitSlug: string; + accountLabel: string; grantedByType: string; grantedById: string | null; createdAt: string; diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index d7fa180c..4d71309a 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -154,11 +154,11 @@ describe("agent instruction wiring", () => { }); const now = new Date().toISOString(); connection.db.prepare(` - insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) - values ('ca_github', ?, 'github', 'GitHub', 'ac_github', 'ACTIVE', ?, ?) + insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) + values ('ca_github', ?, 'github', 'GitHub', 'main', 'ac_github', 'ACTIVE', ?, ?) `).run(orgId, now, now); new ConnectorGrantsService(connection.db).grant({ - orgId, agentId, toolkitSlug: "github", grantedByType: "employee", grantedById: null, + orgId, agentId, toolkitSlug: "github", accountLabel: null, grantedByType: "employee", grantedById: null, }); taskId = organization.createTask({ orgId, title: "Ship it", description: "d", status: TASK_STATUS.IN_PROGRESS, diff --git a/tests/unit/capability-warnings.test.ts b/tests/unit/capability-warnings.test.ts index 5fd95f6e..0f0cca5a 100644 --- a/tests/unit/capability-warnings.test.ts +++ b/tests/unit/capability-warnings.test.ts @@ -49,7 +49,7 @@ describe("a run tells the user which capabilities it lacked", () => { orgId = summary.orgId; ceoId = summary.ceoAgentId; const now = new Date().toISOString(); - connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now); + connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'main', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now); }); const keys = new IntegrationKeyStore(connection.db, vault); keys.write("composio", "ak_capability"); diff --git a/tests/unit/composio-reconnect.test.ts b/tests/unit/composio-reconnect.test.ts index 8fe11a69..5cb8f06f 100644 --- a/tests/unit/composio-reconnect.test.ts +++ b/tests/unit/composio-reconnect.test.ts @@ -55,10 +55,10 @@ async function connectedCompany() { const keys = new IntegrationKeyStore(connection.db, vault); keys.write("composio", `ak_${randomUUID()}`); const service = new ComposioConnectionService(connection.db, keys); - const first = await service.connectToolkit(orgId, "github"); + const first = await service.connectToolkit(orgId, "github", "main"); remote.get(first.accountId)!.status = "ACTIVE"; await service.refreshAccounts(orgId); - new ConnectorGrantsService(connection.db).grant({ orgId, agentId: ceoId, toolkitSlug: "github", grantedByType: "agent", grantedById: ceoId }); + new ConnectorGrantsService(connection.db).grant({ orgId, agentId: ceoId, toolkitSlug: "github", accountLabel: null, grantedByType: "agent", grantedById: ceoId }); const account = () => connection.db.prepare("select id, status, pending_account_id as pending from connector_accounts where org_id = ?").get(orgId) as { id: string; status: string; pending: string | null }; const grants = () => (connection.db.prepare("select count(*) as n from agent_connector_grants where org_id = ?").get(orgId) as { n: number }).n; return { connection, orgId, service, oldId: first.accountId, account, grants }; @@ -67,7 +67,7 @@ async function connectedCompany() { describe("reconnecting a connector keeps the working account until the new one is authorized", () => { test("a reconnect the user abandons leaves the old account working", async () => { const h = await connectedCompany(); - const again = await h.service.connectToolkit(h.orgId, "github"); + const again = await h.service.connectToolkit(h.orgId, "github", "main"); expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: again.accountId }); expect(remote.has(h.oldId)).toBe(true); await h.service.refreshAccounts(h.orgId); @@ -82,7 +82,7 @@ describe("reconnecting a connector keeps the working account until the new one i test("an authorized reconnect replaces the old account and keeps the grants", async () => { const h = await connectedCompany(); - const again = await h.service.connectToolkit(h.orgId, "github"); + const again = await h.service.connectToolkit(h.orgId, "github", "main"); remote.get(again.accountId)!.status = "ACTIVE"; await h.service.refreshAccounts(h.orgId); expect(h.account()).toEqual({ id: again.accountId, status: "ACTIVE", pending: null }); @@ -93,26 +93,40 @@ describe("reconnecting a connector keeps the working account until the new one i test("an earlier reconnect that cannot be deleted never replaces the working account", async () => { const h = await connectedCompany(); - const first = await h.service.connectToolkit(h.orgId, "github"); + const first = await h.service.connectToolkit(h.orgId, "github", "main"); failDelete.add(first.accountId); - await expect(h.service.connectToolkit(h.orgId, "github")).rejects.toThrow(`Composio could not delete ${first.accountId}`); + await expect(h.service.connectToolkit(h.orgId, "github", "main")).rejects.toThrow(`Composio could not delete ${first.accountId}`); const second = [...remote.keys()].at(-1)!; expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: second }); await h.service.refreshAccounts(h.orgId); expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: second }); remote.get(first.accountId)!.status = "ACTIVE"; failDelete.clear(); - await h.service.disconnectToolkit(h.orgId, "github"); + await h.service.disconnectToolkit(h.orgId, "github", "main"); expect(remote.size).toBe(0); await h.service.refreshAccounts(h.orgId); expect(h.account()).toBeUndefined(); h.connection.close(); }); + test("a second account of a toolkit lives beside the first, and removing it leaves the first", async () => { + const h = await connectedCompany(); + const sales = await h.service.connectToolkit(h.orgId, "github", "Sales"); + remote.get(sales.accountId)!.status = "ACTIVE"; + await h.service.refreshAccounts(h.orgId); + const rows = () => h.connection.db.prepare("select id, label, status from connector_accounts where org_id = ? order by label").all(h.orgId); + expect(rows()).toEqual([{ id: h.oldId, label: "main", status: "ACTIVE" }, { id: sales.accountId, label: "sales", status: "ACTIVE" }]); + await h.service.disconnectToolkit(h.orgId, "github", "sales"); + expect(rows()).toEqual([{ id: h.oldId, label: "main", status: "ACTIVE" }]); + expect([...remote.keys()]).toEqual([h.oldId]); + expect(h.grants()).toBe(1); + h.connection.close(); + }); + test("a reconnect that cannot start touches nothing", async () => { const h = await connectedCompany(); failCreate = true; - await expect(h.service.connectToolkit(h.orgId, "github")).rejects.toThrow("Composio is down"); + await expect(h.service.connectToolkit(h.orgId, "github", "main")).rejects.toThrow("Composio is down"); expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: null }); expect([...remote.keys()]).toEqual([h.oldId]); h.connection.close(); diff --git a/tests/unit/composio-tool-bridge.test.ts b/tests/unit/composio-tool-bridge.test.ts index 86d91c8b..a30af224 100644 --- a/tests/unit/composio-tool-bridge.test.ts +++ b/tests/unit/composio-tool-bridge.test.ts @@ -39,8 +39,8 @@ describe("composio tool loading", () => { const { connection, orgId, ceoId } = ceoHarness(); const now = new Date().toISOString(); connection.db.prepare(` - insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) - values ('ca_1', ?, 'gmail', 'Gmail', 'ac_1', 'ACTIVE', ?, ?) + insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) + values ('ca_1', ?, 'gmail', 'Gmail', 'main', 'ac_1', 'ACTIVE', ?, ?) `).run(orgId, now, now); expect(() => loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).toThrow("no Composio API key is saved"); connection.close(); diff --git a/tests/unit/composio-tool-pages.test.ts b/tests/unit/composio-tool-pages.test.ts index 5b812505..1d5cf78e 100644 --- a/tests/unit/composio-tool-pages.test.ts +++ b/tests/unit/composio-tool-pages.test.ts @@ -1,5 +1,5 @@ import { EventEmitter } from "node:events"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test, vi } from "vitest"; @@ -44,7 +44,7 @@ const { OrganizationService } = await import("../../src/main/services/organizati const { IntegrationKeyStore } = await import("../../src/main/services/integration-key-store"); const { ConnectorGrantsService } = await import("../../src/main/services/composio/connector-grants"); const { recordRunAction } = await import("../../src/main/services/run-journal"); -const { beginConnectorExecution } = await import("../../src/main/services/composio/connector-executions"); +const { canonicalJson } = await import("../../src/main/services/composio/connector-executions"); const tool = (slug: string) => ({ slug, name: slug, description: "", input_parameters: {}, toolkit: { slug: "github" } }); @@ -90,7 +90,7 @@ describe("agents reach every action through search and run", () => { const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; ceoId = new OrganizationService(connection.db).createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null, roleLevel: "c_suite" }); const now = new Date().toISOString(); - connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now); + connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'main', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now); }); const keys = new IntegrationKeyStore(connection.db, vault); keys.write("composio", apiKey); @@ -154,18 +154,34 @@ describe("agents reach every action through search and run", () => { test("a call left pending by a crash blocks the same call", async () => { pages.set("github:", { items: [tool("GITHUB_SEND")], next_cursor: null }); const h = company("ak_crash"); - beginConnectorExecution(h.connection.db, { orgId: h.orgId, agentId: h.ceoId, runId: null, action: "GITHUB_SEND", args: { text: "x" }, confirmedNotDone: false }); + // A row as written before accounts had labels: no account_label, a hash of the arguments alone. + const now = new Date().toISOString(); + h.connection.db.prepare("insert into connector_executions(id, org_id, agent_id, action, arguments_hash, state, created_at, updated_at) values ('cex_old', ?, ?, 'GITHUB_SEND', ?, 'pending', ?, ?)") + .run(h.orgId, h.ceoId, createHash("sha256").update(canonicalJson({ text: "x" })).digest("hex"), now, now); expect(await h.call("composio_run_action", { action: "GITHUB_SEND", arguments: { text: "x" } })).toMatch(/^ERROR: An identical GITHUB_SEND call at .+ may already have run; its reply was lost\. Check in github/); h.connection.close(); }); + test("an agent that reaches several accounts of a service names the one an action runs on", async () => { + pages.set("github:", { items: [tool("GITHUB_STAR"), tool("GITHUB_DROP")], next_cursor: null }); + const h = company("ak_accounts"); + const now = new Date().toISOString(); + h.connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) values ('ca_gh_sales', ?, 'github', 'GitHub', 'sales', 'ac_gh', 'ACTIVE', ?, ?)").run(h.orgId, now, now); + expect(await h.call("composio_run_action", { action: "GITHUB_STAR", arguments: {} })).toBe('ERROR: "github" has several accounts (main, sales); name one with account.'); + expect(await h.call("composio_run_action", { action: "GITHUB_STAR", arguments: {}, account: "sales" })).toBe(JSON.stringify({ number: 42 })); + expect(written.at(-1)).toContain("\"connected_account_id\":\"ca_gh_sales\""); + expect(await h.call("composio_run_action", { action: "GITHUB_DROP", arguments: {}, account: "main" })).toContain("may or may not have run"); + expect(await h.call("composio_run_action", { action: "GITHUB_DROP", arguments: {}, account: "sales" })).toContain("may or may not have run"); + h.connection.close(); + }); + test("a grant revoked while the catalog loads stops the action, and a search is not journaled as work", async () => { pages.set("github:", { items: [tool("GITHUB_STAR_A_REPOSITORY")], next_cursor: null }); const h = company("ak_revoke"); const staff = h.connection.transaction(() => { const providerId = (h.connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id; const id = new OrganizationService(h.connection.db).createAgent({ orgId: h.orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: h.ceoId }); - new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", grantedByType: "agent", grantedById: h.ceoId }); + new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", accountLabel: null, grantedByType: "agent", grantedById: h.ceoId }); return id; }); const running = executeComposioTool(h.connection.db, h.keys, h.orgId, staff, { id: "c2", name: "composio_run_action", arguments: { action: "GITHUB_STAR_A_REPOSITORY", arguments: {} } }); diff --git a/tests/unit/connector-grants.test.ts b/tests/unit/connector-grants.test.ts index bfc177e2..6d61afee 100644 --- a/tests/unit/connector-grants.test.ts +++ b/tests/unit/connector-grants.test.ts @@ -61,8 +61,8 @@ function harness(): Harness { function connectToolkit(h: Harness, slug: string, name: string): void { const now = new Date().toISOString(); h.connection.db.prepare(` - insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) - values (?, ?, ?, ?, ?, 'ACTIVE', ?, ?) + insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) + values (?, ?, ?, ?, 'main', ?, 'ACTIVE', ?, ?) `).run(`ca_${slug}`, h.orgId, slug, name, `ac_${slug}`, now, now); } @@ -71,7 +71,7 @@ describe("connector grants", () => { const h = harness(); const grants = new ConnectorGrantsService(h.connection.db); expect(() => grants.grant({ - orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", grantedByType: "employee", grantedById: null, + orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: null, grantedByType: "employee", grantedById: null, })).toThrow(/No active connection/); }); @@ -87,13 +87,41 @@ describe("connector grants", () => { expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]); const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {})); - expect(listing).toContain("GitHub (github): granted to Dana"); + expect(listing).toContain("GitHub (github), account \"main\": granted to Dana"); const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_connector", { agent_name: "Dana", toolkit: "github" })); expect(revoked).toContain("no longer has access"); expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]); }); + test("a toolkit with several accounts: grants and actions name the account", () => { + const h = harness(); + const now = new Date().toISOString(); + for (const label of ["main", "sales"]) { + h.connection.db.prepare(` + insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) + values (?, ?, 'github', 'GitHub', ?, 'ac_github', 'ACTIVE', ?, ?) + `).run(`ca_gh_${label}`, h.orgId, label, now, now); + } + const grants = new ConnectorGrantsService(h.connection.db); + expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github" }))) + .toBe('ERROR: "github" has several accounts (main, sales); name one with account.'); + expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github", account: "Sales" }))) + .toContain('through its "sales" account'); + expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github", account: null }))) + .toBe('ERROR: "account" is required.'); + expect(grants.accountForAgent(h.orgId, h.danaId, "github", null).id).toBe("ca_gh_sales"); + expect(() => grants.accountForAgent(h.orgId, h.danaId, "github", "main")).toThrow('Your grant for "github" is the "sales" account, not "main".'); + expect(() => grants.accountForAgent(h.orgId, h.ceoId, "github", null)).toThrow('"github" has several accounts (main, sales)'); + expect(grants.accountForAgent(h.orgId, h.ceoId, "github", "main").id).toBe("ca_gh_main"); + const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {})); + expect(listing).toContain('GitHub (github), account "main": no grants yet'); + expect(listing).toContain('GitHub (github), account "sales": granted to Dana'); + h.connection.db.prepare("update connector_accounts set status = 'EXPIRED' where id = 'ca_gh_sales'").run(); + expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]); + expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]); + }); + test("staff cannot hand out connector access", () => { const h = harness(); connectToolkit(h, "github", "GitHub"); @@ -106,7 +134,7 @@ describe("connector grants", () => { const h = harness(); connectToolkit(h, "slack", "Slack"); const grants = new ConnectorGrantsService(h.connection.db); - grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "slack", grantedByType: "employee", grantedById: null }); + grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "slack", accountLabel: null, grantedByType: "employee", grantedById: null }); expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["slack"]); h.connection.db.prepare("update connector_accounts set status = 'EXPIRED' where org_id = ? and toolkit_slug = 'slack'").run(h.orgId); expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]); diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 6ab58d17..3b69ff45 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83]); connection.close(); });