From fb72f1f29b07767c36bcacb1db6d7962bd2efab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 6 Jul 2026 23:17:45 -0400 Subject: [PATCH] Expose company connector plumbing to the renderer A ComposioConnectionService owns the toolkit catalog (cursor-paged, cached), managed auth-config reuse, connect-link initiation, and the local connected-account mirror that agent runs read. Five IPC surfaces carry it to the UI - list toolkits, connect a toolkit, refresh account statuses, and employee-side grant/revoke - and the snapshot now includes connectorAccounts and connectorGrants. The org API key resolves through one choke point that seeds itself once from the developer cred.txt next to the app when the database has none, so both the UI and agent runtime pick up the key without manual setup. --- AgentCompany-New/src/main/app/ipc-handlers.ts | 43 +++++++ .../composio/composio-connection-service.ts | 112 ++++++++++++++++++ .../services/composio/composio-credentials.ts | 32 +++++ .../services/composio/composio-tool-bridge.ts | 6 +- .../src/main/services/snapshot-service.ts | 2 + AgentCompany-New/src/preload/index.ts | 5 + .../src/shared/commands/composio.ts | 38 ++++++ AgentCompany-New/src/shared/desktop-api.ts | 11 ++ AgentCompany-New/src/shared/domain.ts | 21 ++++ AgentCompany-New/src/shared/ipc.ts | 5 + .../src/shared/snapshot-domain.ts | 4 + 11 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 AgentCompany-New/src/main/services/composio/composio-connection-service.ts create mode 100644 AgentCompany-New/src/main/services/composio/composio-credentials.ts diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index 509a33e0..fb8fa0f2 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -27,6 +27,8 @@ import { KnowledgeService } from "../services/knowledge-service"; import { AutomationService } from "../services/automation-service"; import { MeetingService } from "../services/meeting-service"; import { SprintService } from "../services/sprint-service"; +import { ComposioConnectionService } from "../services/composio/composio-connection-service"; +import { ConnectorGrantsService } from "../services/composio/connector-grants"; import { coalesce } from "./coalesce"; import { normalizeExternalBrowserUrl } from "./external-url"; @@ -319,6 +321,47 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); + const composioConnections = new ComposioConnectionService(deps.connection.db, process.cwd()); + const connectorGrants = new ConnectorGrantsService(deps.connection.db); + + ipcMain.handle(IPC_CHANNELS.listComposioToolkits, async (_event, input) => { + try { + return ok(await composioConnections.listToolkits(input.orgId)); + } catch (error) { + return fail("composio_toolkits_failed", error instanceof Error ? error.message : String(error)); + } + }); + ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => { + try { + const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug, input.toolkitName); + notifyDataChanged(); + return ok(result); + } catch (error) { + return fail("composio_connect_failed", error instanceof Error ? error.message : String(error)); + } + }); + ipcMain.handle(IPC_CHANNELS.refreshComposioAccounts, async (_event, input) => { + try { + await composioConnections.refreshAccounts(input.orgId); + notifyDataChanged(); + return ok(snapshot.load()); + } catch (error) { + return fail("composio_refresh_failed", error instanceof Error ? error.message : String(error)); + } + }); + ipcMain.handle(IPC_CHANNELS.grantConnector, (_event, input) => mutate(() => connectorGrants.grant({ + orgId: input.orgId, + agentId: input.agentId, + toolkitSlug: input.toolkitSlug, + grantedByType: "employee", + grantedById: input.employeeId, + }))); + ipcMain.handle(IPC_CHANNELS.revokeConnector, (_event, input) => mutate(() => connectorGrants.revoke({ + orgId: input.orgId, + agentId: input.agentId, + toolkitSlug: input.toolkitSlug, + }))); + ipcMain.handle(IPC_CHANNELS.saveComposioSettings, (_event, input) => mutate(() => { const now = new Date().toISOString(); const db = deps.connection.db; diff --git a/AgentCompany-New/src/main/services/composio/composio-connection-service.ts b/AgentCompany-New/src/main/services/composio/composio-connection-service.ts new file mode 100644 index 00000000..703e08d5 --- /dev/null +++ b/AgentCompany-New/src/main/services/composio/composio-connection-service.ts @@ -0,0 +1,112 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { ComposioToolkitSummaryDto, ConnectComposioToolkitResult } from "../../../shared/commands"; +import { ComposioClient, type ComposioToolkitSummary } from "./composio-client"; +import { resolveComposioApiKey } from "./composio-credentials"; +import { getComposioUserId } from "./composio-tool-bridge"; +import { normalizeSlug } from "./connector-grants"; + +const TOOLKIT_CATALOG_LIMIT = 200; +const CATALOG_TTL_MS = 10 * 60 * 1000; + +let catalogCache: { fetchedAt: number; toolkits: ComposioToolkitSummary[] } | null = null; + +/** + * Company-level Composio connections. One connected account per toolkit per + * organization, mirrored into connector_accounts so agent runs and the + * Connectors page read local state instead of calling out on every frame. + */ +export class ComposioConnectionService { + constructor(private readonly db: DatabaseSync, private readonly credentialFileDir: string) {} + + listToolkits(orgId: string): Promise { + const client = this.requireClient(orgId); + return this.catalog(client); + } + + /** + * Starts (or restarts) the company's connection for a toolkit: creates a + * Composio-managed auth config once per toolkit, initiates the connected + * account for the company user, and records the pending account locally. + * The returned redirect URL completes authorization in the browser. + */ + async connectToolkit(orgId: string, toolkitSlug: string, toolkitName: string): Promise { + const slug = normalizeSlug(toolkitSlug); + const client = this.requireClient(orgId); + const authConfigId = await this.ensureAuthConfig(client, orgId, slug); + const account = await client.createConnectedAccount({ + authConfigId, + userId: getComposioUserId(this.db, orgId), + }); + if (!account.id) throw new Error("Composio did not return a connected account id."); + const now = new Date().toISOString(); + 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 + id = excluded.id, + toolkit_name = excluded.toolkit_name, + auth_config_id = excluded.auth_config_id, + status = excluded.status, + updated_at = excluded.updated_at + `).run(account.id, orgId, slug, toolkitName.trim() || slug, authConfigId, account.status || "INITIATED", now, now); + return { accountId: account.id, status: account.status, redirectUrl: account.redirectUrl }; + } + + /** Pulls account statuses from Composio and updates the local mirror. */ + async refreshAccounts(orgId: string): Promise { + const client = this.requireClient(orgId); + const accounts = await client.listConnectedAccounts(getComposioUserId(this.db, orgId)); + const now = new Date().toISOString(); + for (const account of accounts) { + if (!account.toolkitSlug) continue; + 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 + id = excluded.id, + auth_config_id = excluded.auth_config_id, + status = excluded.status, + updated_at = excluded.updated_at + `).run( + account.id, + orgId, + account.toolkitSlug, + account.toolkitSlug, + account.authConfigId, + account.status, + account.createdAt || now, + now, + ); + } + } + + private requireClient(orgId: string): ComposioClient { + const apiKey = resolveComposioApiKey(this.db, orgId, this.credentialFileDir); + if (!apiKey) throw new Error("Composio API key is not configured for this company."); + return new ComposioClient(apiKey); + } + + private async ensureAuthConfig(client: ComposioClient, orgId: string, slug: string): Promise { + const key = `auth_config:${slug}`; + const existing = this.db.prepare( + "select value from composio_settings where org_id = ? and key = ? limit 1", + ).get(orgId, key) as { value: string } | undefined; + if (existing?.value) return existing.value; + const authConfigId = await client.createAuthConfig(slug); + this.db.prepare(` + insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?) + on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at + `).run(orgId, key, authConfigId, new Date().toISOString()); + return authConfigId; + } + + private async catalog(client: ComposioClient): Promise { + if (catalogCache && Date.now() - catalogCache.fetchedAt < CATALOG_TTL_MS) { + return catalogCache.toolkits; + } + const toolkits = await client.listToolkits(TOOLKIT_CATALOG_LIMIT); + catalogCache = { fetchedAt: Date.now(), toolkits }; + return toolkits; + } + +} diff --git a/AgentCompany-New/src/main/services/composio/composio-credentials.ts b/AgentCompany-New/src/main/services/composio/composio-credentials.ts new file mode 100644 index 00000000..1b525bd9 --- /dev/null +++ b/AgentCompany-New/src/main/services/composio/composio-credentials.ts @@ -0,0 +1,32 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { DatabaseSync } from "node:sqlite"; + +/** + * The org's Composio API key. When the database has none, a developer + * credential file next to the app (cred.txt, first line "Composio apikey: + * ak_...", gitignored) seeds it once for this org — the product owner's + * local path. Packaged installs configure the key in Settings instead; a + * missing file simply yields null. + */ +export function resolveComposioApiKey(db: DatabaseSync, orgId: string, credentialFileDir: string = process.cwd()): string | null { + const stored = db.prepare( + "select value from composio_settings where org_id = ? and key = 'api_key' limit 1", + ).get(orgId) as { value: string } | undefined; + if (stored?.value) return stored.value; + + const path = join(credentialFileDir, "cred.txt"); + if (!existsSync(path)) return null; + const firstLine = readFileSync(path, "utf8").split("\n")[0] ?? ""; + const key = firstLine.split(":").slice(1).join(":").trim(); + if (!key) return null; + + const now = new Date().toISOString(); + const upsert = db.prepare(` + insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?) + on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at + `); + upsert.run(orgId, "api_key", key, now); + upsert.run(orgId, "entity_id", orgId, now); + return key; +} diff --git a/AgentCompany-New/src/main/services/composio/composio-tool-bridge.ts b/AgentCompany-New/src/main/services/composio/composio-tool-bridge.ts index e1f48316..ae8487c8 100644 --- a/AgentCompany-New/src/main/services/composio/composio-tool-bridge.ts +++ b/AgentCompany-New/src/main/services/composio/composio-tool-bridge.ts @@ -1,6 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import type { ToolDefinition, ToolCall } from "../provider-tool-turn"; import { ComposioClient, type ComposioToolSchema } from "./composio-client"; +import { resolveComposioApiKey } from "./composio-credentials"; import { ConnectorGrantsService } from "./connector-grants"; const COMPOSIO_TOOL_PREFIX = "composio_"; @@ -114,10 +115,7 @@ export async function executeComposioTool( } function getComposioApiKey(db: DatabaseSync, orgId: string): string | null { - const row = db.prepare( - "select value from composio_settings where org_id = ? and key = 'api_key' limit 1", - ).get(orgId) as { value: string } | undefined; - return row?.value ?? null; + return resolveComposioApiKey(db, orgId); } export function getComposioUserId(db: DatabaseSync, orgId: string): string { diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index ee010340..e0536e7e 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -167,6 +167,8 @@ export class SnapshotService { meetings: list(this.db, "meetings", "scheduled_at desc"), sprints: list(this.db, "sprints", "start_date desc"), composioSettings: list(this.db, "composio_settings", "org_id asc, key asc"), + connectorAccounts: list(this.db, "connector_accounts", "toolkit_slug asc"), + connectorGrants: list(this.db, "agent_connector_grants", "toolkit_slug asc, created_at asc"), }; } diff --git a/AgentCompany-New/src/preload/index.ts b/AgentCompany-New/src/preload/index.ts index 1f96d429..b2617bd1 100644 --- a/AgentCompany-New/src/preload/index.ts +++ b/AgentCompany-New/src/preload/index.ts @@ -83,6 +83,11 @@ const api: DesktopApi = { createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input), setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input), saveComposioSettings: (input) => invoke(IPC_CHANNELS.saveComposioSettings, input), + listComposioToolkits: (input) => invoke(IPC_CHANNELS.listComposioToolkits, input), + connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input), + refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), + grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input), + revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), search: (input) => invoke(IPC_CHANNELS.search, input), subscribe: (callback) => { const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload); diff --git a/AgentCompany-New/src/shared/commands/composio.ts b/AgentCompany-New/src/shared/commands/composio.ts index 13548997..dcbd4a21 100644 --- a/AgentCompany-New/src/shared/commands/composio.ts +++ b/AgentCompany-New/src/shared/commands/composio.ts @@ -3,3 +3,41 @@ export interface SaveComposioSettingsInput { apiKey: string; toolkits: string[]; } + +export interface ComposioToolkitSummaryDto { + slug: string; + name: string; + description: string; + logo: string | null; + categories: string[]; + authSchemes: string[]; + noAuth: boolean; + toolsCount: number; +} + +export interface ListComposioToolkitsInput { + orgId: string; +} + +export interface ConnectComposioToolkitInput { + orgId: string; + toolkitSlug: string; + toolkitName: string; +} + +export interface ConnectComposioToolkitResult { + accountId: string; + status: string; + redirectUrl: string | null; +} + +export interface RefreshComposioAccountsInput { + orgId: string; +} + +export interface ConnectorGrantInput { + orgId: string; + agentId: string; + toolkitSlug: string; + employeeId: string; +} diff --git a/AgentCompany-New/src/shared/desktop-api.ts b/AgentCompany-New/src/shared/desktop-api.ts index 4d0bb4e9..e512dade 100644 --- a/AgentCompany-New/src/shared/desktop-api.ts +++ b/AgentCompany-New/src/shared/desktop-api.ts @@ -64,6 +64,12 @@ import type { RevokeProviderCredentialInput, SaveProviderCredentialInput, SaveComposioSettingsInput, + ComposioToolkitSummaryDto, + ConnectComposioToolkitInput, + ConnectComposioToolkitResult, + ConnectorGrantInput, + ListComposioToolkitsInput, + RefreshComposioAccountsInput, SearchInput, SendMessageInput, SetActiveOrganizationInput, @@ -174,6 +180,11 @@ export interface DesktopApi { createRetentionFreeze(input: CreateRetentionFreezeInput): Promise>; setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise>; saveComposioSettings(input: SaveComposioSettingsInput): Promise>; + listComposioToolkits(input: ListComposioToolkitsInput): Promise>; + connectComposioToolkit(input: ConnectComposioToolkitInput): Promise>; + refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise>; + grantConnector(input: ConnectorGrantInput): Promise>; + revokeConnector(input: ConnectorGrantInput): Promise>; search(input: SearchInput): Promise>; subscribe(callback: (event: RendererEvent) => void): () => void; platform: "win32" | "darwin" | "linux"; diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts index e6ec123e..e76e0f76 100644 --- a/AgentCompany-New/src/shared/domain.ts +++ b/AgentCompany-New/src/shared/domain.ts @@ -411,6 +411,27 @@ export interface KnowledgeEntryRecord { updatedAt: string; } +export interface ConnectorAccountRecord { + id: string; + orgId: string; + toolkitSlug: string; + toolkitName: string; + authConfigId: string; + status: string; + createdAt: string; + updatedAt: string; +} + +export interface ConnectorGrantRecord { + id: string; + orgId: string; + agentId: string; + toolkitSlug: string; + grantedByType: string; + grantedById: string | null; + createdAt: string; +} + export interface AutomationRuleRecord { id: string; orgId: string; diff --git a/AgentCompany-New/src/shared/ipc.ts b/AgentCompany-New/src/shared/ipc.ts index d1ec0077..bec9277c 100644 --- a/AgentCompany-New/src/shared/ipc.ts +++ b/AgentCompany-New/src/shared/ipc.ts @@ -75,6 +75,11 @@ export const IPC_CHANNELS = { createSprint: "agentcompany:create-sprint", deleteSprint: "agentcompany:delete-sprint", saveComposioSettings: "agentcompany:save-composio-settings", + listComposioToolkits: "agentcompany:list-composio-toolkits", + connectComposioToolkit: "agentcompany:connect-composio-toolkit", + refreshComposioAccounts: "agentcompany:refresh-composio-accounts", + grantConnector: "agentcompany:grant-connector", + revokeConnector: "agentcompany:revoke-connector", search: "agentcompany:search", rendererEvent: "agentcompany:renderer-event", windowMinimize: "agentcompany:window-minimize", diff --git a/AgentCompany-New/src/shared/snapshot-domain.ts b/AgentCompany-New/src/shared/snapshot-domain.ts index dbd35d70..3ae32093 100644 --- a/AgentCompany-New/src/shared/snapshot-domain.ts +++ b/AgentCompany-New/src/shared/snapshot-domain.ts @@ -17,6 +17,8 @@ import type { ApprovalRecord, AuditEventRecord, AutomationRuleRecord, + ConnectorAccountRecord, + ConnectorGrantRecord, DocumentRecord, EmployeeRecord, FileAccessGrantRecord, @@ -139,6 +141,8 @@ export interface AppSnapshot { meetings: MeetingRecord[]; sprints: SprintRecord[]; composioSettings: ComposioSettingRecord[]; + connectorAccounts: ConnectorAccountRecord[]; + connectorGrants: ConnectorGrantRecord[]; } export interface ComposioSettingRecord {