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.
This commit is contained in:
1 parent
66aae376c0
commit
fb72f1f29b
11 files changed
+275
-4
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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<ComposioToolkitSummaryDto[]> {
|
||||
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<ConnectComposioToolkitResult> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<ComposioToolkitSummaryDto[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<DesktopResult<AppSnapshot>>;
|
||||
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
saveComposioSettings(input: SaveComposioSettingsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
listComposioToolkits(input: ListComposioToolkitsInput): Promise<DesktopResult<ComposioToolkitSummaryDto[]>>;
|
||||
connectComposioToolkit(input: ConnectComposioToolkitInput): Promise<DesktopResult<ConnectComposioToolkitResult>>;
|
||||
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
grantConnector(input: ConnectorGrantInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
revokeConnector(input: ConnectorGrantInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||
platform: "win32" | "darwin" | "linux";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user