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 {