diff --git a/AgentCompany-New/src/main/database/migrations/045-connector-grants.ts b/AgentCompany-New/src/main/database/migrations/045-connector-grants.ts new file mode 100644 index 00000000..759bed0e --- /dev/null +++ b/AgentCompany-New/src/main/database/migrations/045-connector-grants.ts @@ -0,0 +1,26 @@ +export const connectorGrantsSchema = ` + create table if not exists connector_accounts ( + 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, + auth_config_id text not null, + status text not null, + created_at text not null, + updated_at text not null, + unique (org_id, toolkit_slug) + ); + + create table if not exists agent_connector_grants ( + id text primary key, + org_id text not null references organizations(id) on delete cascade, + agent_id text not null references agents(id) on delete cascade, + toolkit_slug text not null, + granted_by_type text not null, + granted_by_id text, + created_at text not null, + unique (org_id, agent_id, toolkit_slug) + ); + + create index if not exists idx_connector_grants_agent on agent_connector_grants(org_id, agent_id); +`; diff --git a/AgentCompany-New/src/main/database/schema.ts b/AgentCompany-New/src/main/database/schema.ts index f3dce9ad..ff507503 100644 --- a/AgentCompany-New/src/main/database/schema.ts +++ b/AgentCompany-New/src/main/database/schema.ts @@ -43,6 +43,7 @@ import { taskSubtasksDeleteSchema } from "./migrations/041-task-subtasks-delete" import { approvalNotesMeetingsSprintsSchema } from "./migrations/042-approval-notes-meetings-sprints"; import { composioIntegrationSchema } from "./migrations/043-composio-integration"; import { mentionWakeStateSchema } from "./migrations/044-mention-wake-state"; +import { connectorGrantsSchema } from "./migrations/045-connector-grants"; interface Migration { version: number; @@ -95,6 +96,7 @@ const MIGRATIONS: Migration[] = [ { version: 42, name: "approval_notes_meetings_sprints", sql: approvalNotesMeetingsSprintsSchema }, { version: 43, name: "composio_integration", sql: composioIntegrationSchema }, { version: 44, name: "mention_wake_state", sql: mentionWakeStateSchema }, + { version: 45, name: "connector_grants", sql: connectorGrantsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/AgentCompany-New/src/main/services/agent-run-loop.ts b/AgentCompany-New/src/main/services/agent-run-loop.ts index 8a5556cb..9de2c558 100644 --- a/AgentCompany-New/src/main/services/agent-run-loop.ts +++ b/AgentCompany-New/src/main/services/agent-run-loop.ts @@ -69,7 +69,7 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn toolCallCount += 1; let output: string; if (isComposioTool(call.name)) { - output = await executeComposioTool(deps.tools.db, input.orgId, call); + output = await executeComposioTool(deps.tools.db, input.orgId, input.toolContext.agent.id, call); } else { output = dispatchAgentTool(deps.tools, input.toolContext, call, { announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls), diff --git a/AgentCompany-New/src/main/services/agent-tools.ts b/AgentCompany-New/src/main/services/agent-tools.ts index a8aad93e..8e23d08a 100644 --- a/AgentCompany-New/src/main/services/agent-tools.ts +++ b/AgentCompany-New/src/main/services/agent-tools.ts @@ -8,6 +8,7 @@ import { DocumentService } from "./document-service"; import { recordWorkspaceFile } from "./files/file-object-store"; import { canonicalMentionHandle } from "../../shared/mention-handles"; import { readMessagesTool } from "./agent-read-messages"; +import { ConnectorGrantsService } from "./composio/connector-grants"; import type { JsonRecord } from "./provider-http"; import type { ToolCall, ToolDefinition } from "./provider-tool-turn"; @@ -140,6 +141,37 @@ export const AGENT_TOOLS: ToolDefinition[] = [ required: ["title", "doc_type", "content"], }, }, + { + name: "list_connectors", + description: "See which external connectors (GitHub, Slack, Gmail, ...) the company has connected and who holds a grant for each. Check this before asking for access or planning work that needs an external service.", + parameters: { type: "object", additionalProperties: false, properties: {}, required: [] }, + }, + { + name: "grant_connector", + description: "CEO only: grant a teammate access to one of the company's connected external toolkits. Match the tool to the job — engineers get code tools, marketers get social tools.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + agent_name: { type: "string", description: "The teammate receiving access." }, + toolkit: { type: "string", description: "Connected toolkit slug, e.g. 'github'." }, + }, + required: ["agent_name", "toolkit"], + }, + }, + { + name: "revoke_connector", + description: "CEO only: withdraw a teammate's access to a connected external toolkit.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + agent_name: { type: "string", description: "The teammate losing access." }, + toolkit: { type: "string", description: "Connected toolkit slug, e.g. 'github'." }, + }, + required: ["agent_name", "toolkit"], + }, + }, ]; export function dispatchAgentTool( @@ -164,6 +196,12 @@ export function dispatchAgentTool( return readMessagesTool(deps, ctx, call.arguments); case "create_document": return createDocumentTool(deps, ctx, call.arguments); + case "list_connectors": + return listConnectorsTool(deps, ctx); + case "grant_connector": + return grantConnectorTool(deps, ctx, call.arguments, "grant"); + case "revoke_connector": + return grantConnectorTool(deps, ctx, call.arguments, "revoke"); default: return `ERROR: unknown tool "${call.name}".`; } @@ -411,6 +449,54 @@ function joinNames(names: string[]): string { return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`; } +function listConnectorsTool(deps: AgentToolDeps, ctx: AgentToolContext): string { + const grantsService = new ConnectorGrantsService(deps.db); + const accounts = grantsService.activeAccounts(ctx.orgId); + if (accounts.length === 0) { + return "The company has no connected external toolkits yet. The board connects them on the Connectors page."; + } + const grants = grantsService.grantsForOrg(ctx.orgId); + const agentNames = new Map( + (deps.db.prepare("select id, name from agents where org_id = ?").all(ctx.orgId) as Array<{ id: string; name: string }>) + .map((row) => [row.id, row.name]), + ); + const lines = accounts.map((account) => { + const holders = grants + .filter((grant) => grant.toolkitSlug === account.toolkitSlug) + .map((grant) => agentNames.get(grant.agentId)) + .filter(Boolean); + return `- ${account.toolkitName} (${account.toolkitSlug}): ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`; + }); + return ["Connected toolkits:", ...lines].join("\n"); +} + +function grantConnectorTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord, mode: "grant" | "revoke"): string { + const acting = deps.db.prepare("select role_level from agents where id = ? and org_id = ?") + .get(ctx.agent.id, ctx.orgId) as { role_level: string } | undefined; + if (acting?.role_level !== "c_suite") { + return "ERROR: Only the CEO can manage connector grants. Ask them to run this."; + } + const agentName = requireString(args.agent_name, "agent_name"); + const toolkit = requireString(args.toolkit, "toolkit"); + const target = deps.db.prepare( + "select id, name from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1", + ).get(ctx.orgId, agentName) as { id: string; name: string } | undefined; + if (!target) return `ERROR: No active agent named "${agentName}".`; + const grantsService = new ConnectorGrantsService(deps.db); + if (mode === "grant") { + grantsService.grant({ + orgId: ctx.orgId, + agentId: target.id, + toolkitSlug: toolkit, + grantedByType: "agent", + grantedById: ctx.agent.id, + }); + return `${target.name} now has access to the ${toolkit.toLowerCase()} connector; 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.`; +} + function createDocumentTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string { const title = requireString(args.title, "title"); const docType = requireString(args.doc_type, "doc_type"); diff --git a/AgentCompany-New/src/main/services/composio/composio-client.ts b/AgentCompany-New/src/main/services/composio/composio-client.ts index 6656dcc7..1ade5500 100644 --- a/AgentCompany-New/src/main/services/composio/composio-client.ts +++ b/AgentCompany-New/src/main/services/composio/composio-client.ts @@ -5,6 +5,7 @@ const BASE_URL = "https://backend.composio.dev/api/v3"; export interface ComposioToolSchema { name: string; description: string; + toolkitSlug: string | null; parameters: { type: string; properties: Record; @@ -12,6 +13,25 @@ export interface ComposioToolSchema { }; } +export interface ComposioToolkitSummary { + slug: string; + name: string; + description: string; + logo: string | null; + categories: string[]; + authSchemes: string[]; + noAuth: boolean; + toolsCount: number; +} + +export interface ComposioConnectedAccount { + id: string; + toolkitSlug: string; + status: string; + authConfigId: string; + createdAt: string; +} + export interface ComposioToolExecutionResult { data: Record; error: string | null; @@ -20,6 +40,36 @@ export interface ComposioToolExecutionResult { export class ComposioClient { constructor(private readonly apiKey: string) {} + /** The toolkit catalog, most popular first. */ + async listToolkits(limit: number): Promise { + const toolkits: ComposioToolkitSummary[] = []; + let cursor: string | null = null; + while (toolkits.length < limit) { + const params = new URLSearchParams({ limit: String(Math.min(100, limit - toolkits.length)) }); + if (cursor) params.set("cursor", cursor); + const response = await this.request("GET", `${BASE_URL}/toolkits?${params.toString()}`); + const items = Array.isArray(response.items) ? response.items as Array> : []; + for (const item of items) { + const meta = (item.meta ?? {}) as Record; + toolkits.push({ + slug: String(item.slug ?? ""), + name: String(item.name ?? ""), + description: String(meta.description ?? "").slice(0, 200), + logo: typeof meta.logo === "string" ? meta.logo : null, + categories: Array.isArray(meta.categories) + ? (meta.categories as Array>).map((c) => String(c.name ?? c.id ?? "")).filter(Boolean) + : [], + authSchemes: Array.isArray(item.auth_schemes) ? (item.auth_schemes as unknown[]).map(String) : [], + noAuth: item.no_auth === true, + toolsCount: Number(meta.tools_count ?? 0), + }); + } + cursor = typeof response.next_cursor === "string" && response.next_cursor ? response.next_cursor : null; + if (!cursor || items.length === 0) break; + } + return toolkits; + } + async getTools(toolkits: string[]): Promise { const all: ComposioToolSchema[] = []; for (const toolkit of toolkits) { @@ -33,19 +83,63 @@ export class ComposioClient { return all; } + /** Creates a Composio-managed auth config for a toolkit. */ + async createAuthConfig(toolkitSlug: string): Promise { + const response = await this.request("POST", `${BASE_URL}/auth_configs`, { + toolkit: { slug: toolkitSlug }, + }); + const authConfig = (response.auth_config ?? {}) as Record; + const id = String(authConfig.id ?? ""); + if (!id) throw new Error("Composio auth config creation returned no id."); + return id; + } + + /** Starts a connection for a user; the redirect URL completes it in the browser. */ + async createConnectedAccount(input: { authConfigId: string; userId: string }): Promise<{ + id: string; + status: string; + redirectUrl: string | null; + }> { + const response = await this.request("POST", `${BASE_URL}/connected_accounts`, { + auth_config: { id: input.authConfigId }, + connection: { user_id: input.userId }, + }); + return { + id: String(response.id ?? ""), + status: String(response.status ?? ""), + redirectUrl: typeof response.redirect_url === "string" ? response.redirect_url : null, + }; + } + + async listConnectedAccounts(userId: string): Promise { + const params = new URLSearchParams({ user_ids: userId, limit: "50" }); + const response = await this.request("GET", `${BASE_URL}/connected_accounts?${params.toString()}`); + if (!Array.isArray(response.items)) return []; + return (response.items as Array>).map((item) => { + const toolkit = (item.toolkit ?? {}) as Record; + const authConfig = (item.auth_config ?? {}) as Record; + return { + id: String(item.id ?? ""), + toolkitSlug: String(toolkit.slug ?? "").toLowerCase(), + status: String(item.status ?? ""), + authConfigId: String(authConfig.id ?? ""), + createdAt: String(item.created_at ?? ""), + }; + }); + } async executeTool(input: { toolName: string; arguments: Record; - entityId: string; + userId: string; connectedAccountId?: string; }): Promise { const url = `${BASE_URL}/tools/execute/${encodeURIComponent(input.toolName)}`; const body: Record = { - entityId: input.entityId, + user_id: input.userId, arguments: input.arguments, }; - if (input.connectedAccountId) body.connectedAccountId = input.connectedAccountId; + if (input.connectedAccountId) body.connected_account_id = input.connectedAccountId; const response = await this.request("POST", url, body); return { data: typeof response === "object" && response !== null ? response as Record : {}, @@ -53,36 +147,6 @@ export class ComposioClient { }; } - async getConnectedAccounts(entityId: string): Promise> { - const params = new URLSearchParams({ entity_id: entityId, showActiveOnly: "true" }); - const url = `${BASE_URL}/connectedAccounts?${params.toString()}`; - const response = await this.request("GET", url); - if (!Array.isArray(response.items)) return []; - return (response.items as Array>).map((item) => ({ - id: String(item.id ?? ""), - appName: String(item.appName ?? item.app_name ?? ""), - status: String(item.status ?? ""), - })); - } - - async initiateConnection(input: { - entityId: string; - appName: string; - redirectUrl?: string; - }): Promise<{ redirectUrl: string; connectedAccountId: string }> { - const url = `${BASE_URL}/connectedAccounts`; - const body: Record = { - entity_id: input.entityId, - integration_id: input.appName, - }; - if (input.redirectUrl) body.redirect_url = input.redirectUrl; - const response = await this.request("POST", url, body); - return { - redirectUrl: String(response.redirectUrl ?? response.redirect_url ?? ""), - connectedAccountId: String(response.connectedAccountId ?? response.id ?? ""), - }; - } - private async request(method: string, url: string, body?: Record): Promise> { return new Promise((resolve, reject) => { const request = net.request({ method, url }); @@ -116,9 +180,11 @@ export class ComposioClient { function normalizeToolSchema(raw: Record): ComposioToolSchema { const params = raw.input_parameters ?? raw.parameters ?? raw.inputSchema ?? {}; const schema = typeof params === "object" && params !== null ? params as Record : {}; + const toolkit = (raw.toolkit ?? {}) as Record; return { name: String(raw.slug ?? raw.name ?? ""), description: String(raw.description ?? "").slice(0, 300), + toolkitSlug: typeof toolkit.slug === "string" ? toolkit.slug : null, parameters: { type: "object", properties: (schema.properties ?? {}) as Record, 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 5af6b879..e1f48316 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 { ConnectorGrantsService } from "./connector-grants"; const COMPOSIO_TOOL_PREFIX = "composio_"; const CACHE_TTL_MS = 5 * 60 * 1000; @@ -12,6 +13,8 @@ interface CacheEntry { } const toolCache = new Map(); +/** tool slug (upper case) → toolkit slug, learned from the tools listing. */ +const toolkitByTool = new Map(); export function isComposioTool(toolName: string): boolean { return toolName.startsWith(COMPOSIO_TOOL_PREFIX); @@ -25,6 +28,11 @@ function originalToolName(prefixedName: string): string { return prefixedName.slice(COMPOSIO_TOOL_PREFIX.length).toUpperCase(); } +/** Toolkit a tool belongs to: learned mapping first, slug prefix as contract. */ +function toolkitForTool(toolSlug: string): string { + return toolkitByTool.get(toolSlug) ?? toolSlug.split("_")[0].toLowerCase(); +} + export function composioSchemaToToolDefinition(schema: ComposioToolSchema): ToolDefinition { return { name: composioToolName(schema.name), @@ -38,21 +46,31 @@ export function composioSchemaToToolDefinition(schema: ComposioToolSchema): Tool }; } -export async function loadComposioTools(db: DatabaseSync, orgId: string): Promise { +/** + * The connector tools one agent is allowed to carry into a run: the + * intersection of the company's active connections and the toolkits the CEO + * granted this agent. + */ +export async function loadComposioTools(db: DatabaseSync, orgId: string, agentId: string): Promise { const apiKey = getComposioApiKey(db, orgId); if (!apiKey) return []; - const toolkits = getEnabledToolkits(db, orgId); + const grants = new ConnectorGrantsService(db); + const toolkits = grants.toolkitsForAgent(orgId, agentId); if (toolkits.length === 0) return []; - const toolkitsKey = toolkits.sort().join(","); - const cached = toolCache.get(orgId); - if (cached && cached.toolkitsKey === toolkitsKey && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + const toolkitsKey = toolkits.join(","); + const cacheKey = `${orgId}:${toolkitsKey}`; + const cached = toolCache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return cached.tools; } try { const client = new ComposioClient(apiKey); const schemas = await client.getTools(toolkits); + for (const schema of schemas) { + if (schema.toolkitSlug) toolkitByTool.set(schema.name.toUpperCase(), schema.toolkitSlug.toLowerCase()); + } const tools = schemas.map(composioSchemaToToolDefinition); - toolCache.set(orgId, { tools, fetchedAt: Date.now(), toolkitsKey }); + toolCache.set(cacheKey, { tools, fetchedAt: Date.now(), toolkitsKey }); return tools; } catch { return cached?.tools ?? []; @@ -62,27 +80,36 @@ export async function loadComposioTools(db: DatabaseSync, orgId: string): Promis export async function executeComposioTool( db: DatabaseSync, orgId: string, + agentId: string, call: ToolCall, ): Promise { const apiKey = getComposioApiKey(db, orgId); - if (!apiKey) return "ERROR: Composio API key is not configured. Ask the admin to set it in Settings > Tools."; - const toolName = originalToolName(call.name); - const entityId = getComposioEntityId(db, orgId); + if (!apiKey) return "ERROR: Composio API key is not configured for this company."; + const toolSlug = originalToolName(call.name); + const toolkit = toolkitForTool(toolSlug); + const grants = new ConnectorGrantsService(db); + const allowed = grants.toolkitsForAgent(orgId, agentId); + if (!allowed.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.`; + } const client = new ComposioClient(apiKey); try { const result = await client.executeTool({ - toolName, + toolName: toolSlug, arguments: call.arguments, - entityId, + userId: getComposioUserId(db, orgId), + connectedAccountId: account.id, }); if (result.error) return `ERROR: ${result.error}`; return JSON.stringify(result.data); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("ConnectedAccount") || message.includes("connected account") || message.includes("entity_id")) { - return `ERROR: No connected account for this tool. The admin needs to connect the service at https://app.composio.dev before agents can use it.`; - } - return `ERROR: ${message}`; + return `ERROR: ${error instanceof Error ? error.message : String(error)}`; } } @@ -93,22 +120,9 @@ function getComposioApiKey(db: DatabaseSync, orgId: string): string | null { return row?.value ?? null; } -function getComposioEntityId(db: DatabaseSync, orgId: string): string { +export function getComposioUserId(db: DatabaseSync, orgId: string): string { const row = db.prepare( "select value from composio_settings where org_id = ? and key = 'entity_id' limit 1", ).get(orgId) as { value: string } | undefined; return row?.value ?? orgId; } - -function getEnabledToolkits(db: DatabaseSync, orgId: string): string[] { - const row = db.prepare( - "select value from composio_settings where org_id = ? and key = 'toolkits' limit 1", - ).get(orgId) as { value: string } | undefined; - if (!row?.value) return []; - try { - const parsed = JSON.parse(row.value); - return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; - } catch { - return []; - } -} diff --git a/AgentCompany-New/src/main/services/composio/connector-grants.ts b/AgentCompany-New/src/main/services/composio/connector-grants.ts new file mode 100644 index 00000000..446e1962 --- /dev/null +++ b/AgentCompany-New/src/main/services/composio/connector-grants.ts @@ -0,0 +1,92 @@ +import type { DatabaseSync } from "node:sqlite"; +import { createId } from "../../../shared/ids"; + +export interface ConnectorAccountRow { + id: string; + orgId: string; + toolkitSlug: string; + toolkitName: string; + status: string; +} + +export interface ConnectorGrantRow { + id: string; + orgId: string; + agentId: string; + toolkitSlug: string; +} + +/** + * Which agents may use which connected toolkits. Connections belong to the + * company (one connected account per toolkit per org); the CEO hands + * individual toolkits to individual agents, and both the tool loadout and + * the execution guard read from these grants. + */ +export class ConnectorGrantsService { + constructor(private readonly db: DatabaseSync) {} + + grant(input: { orgId: string; agentId: string; toolkitSlug: string; grantedByType: "agent" | "employee"; grantedById: string | null }): void { + 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.`); + 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()); + } + + revoke(input: { orgId: string; agentId: string; toolkitSlug: string }): void { + this.db.prepare("delete from agent_connector_grants where org_id = ? and agent_id = ? and toolkit_slug = ?") + .run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug)); + } + + /** Toolkits this agent may use: granted AND actively connected. */ + toolkitsForAgent(orgId: string, agentId: string): string[] { + 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' + 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); + } + + activeAccounts(orgId: string): ConnectorAccountRow[] { + const rows = this.db.prepare(` + select id, org_id, toolkit_slug, toolkit_name, 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 }>; + return rows.map((row) => ({ + id: row.id, + orgId: row.org_id, + toolkitSlug: row.toolkit_slug, + toolkitName: row.toolkit_name, + status: row.status, + })); + } + + grantsForOrg(orgId: string): ConnectorGrantRow[] { + const rows = this.db.prepare(` + select id, org_id, agent_id, toolkit_slug + 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 })); + } +} + +export function normalizeSlug(slug: string): string { + const normalized = slug.trim().toLowerCase(); + if (!normalized) throw new Error("Toolkit slug is required."); + return normalized; +} diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index aba970b9..ea198776 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -124,7 +124,7 @@ export class TaskExecutionService { const inboxContext = chatContext ? null : this.loadInboxContext(orgId, agent.id); const isCeo = !agent.manager_agent_id; const hasReports = this.agentHasReports(orgId, agent.id); - const externalTools = await loadComposioTools(this.db, orgId); + const externalTools = await loadComposioTools(this.db, orgId, agent.id); const result = await runAgentLoop( { providers: this.providers, diff --git a/AgentCompany-New/tests/unit/connector-grants.test.ts b/AgentCompany-New/tests/unit/connector-grants.test.ts new file mode 100644 index 00000000..7c5fb77f --- /dev/null +++ b/AgentCompany-New/tests/unit/connector-grants.test.ts @@ -0,0 +1,113 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { ConnectorGrantsService } from "../../src/main/services/composio/connector-grants"; +import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; + +function call(name: string, args: Record) { + return { id: `call_${randomUUID()}`, name, arguments: args }; +} + +interface Harness { + connection: DatabaseConnection; + orgId: string; + ceoId: string; + danaId: string; + ceoCtx: AgentToolContext; + danaCtx: AgentToolContext; + deps: { db: DatabaseConnection["db"]; organization: OrganizationService; chat: ChatService }; +} + +function harness(): Harness { + const connection = new DatabaseConnection(join(tmpdir(), `ac-connector-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let ceoId = ""; + let danaId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + ceoId = organization.createAgent({ + orgId, name: "CEO", role: "CEO", department: "Executive", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, roleLevel: "c_suite", + }); + danaId = organization.createAgent({ + orgId, name: "Dana", role: "Engineer", department: "Engineering", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: ceoId, + }); + }); + const context = (agentId: string): AgentToolContext => ({ + orgId, + agent: { id: agentId, modelProviderId: null, modelId: null }, + taskProjectId: null, + taskGoalId: null, + workspace: null, + }); + return { + connection, + orgId, + ceoId, + danaId, + ceoCtx: context(ceoId), + danaCtx: context(danaId), + deps: { db: connection.db, organization, chat: new ChatService(connection.db) }, + }; +} + +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', ?, ?) + `).run(`ca_${slug}`, h.orgId, slug, name, `ac_${slug}`, now, now); +} + +describe("connector grants", () => { + test("granting requires an actively connected toolkit", () => { + 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, + })).toThrow(/No active connection/); + }); + + test("the CEO grants and revokes toolkits for teammates", () => { + const h = harness(); + connectToolkit(h, "github", "GitHub"); + const granted = dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github" })); + expect(granted).toContain("Dana now has access to the github connector"); + + const grants = new ConnectorGrantsService(h.connection.db); + expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["github"]); + expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual([]); + + const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {})); + expect(listing).toContain("GitHub (github): 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("staff cannot hand out connector access", () => { + const h = harness(); + connectToolkit(h, "github", "GitHub"); + const denied = dispatchAgentTool(h.deps, h.danaCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github" })); + expect(denied).toContain("ERROR"); + expect(new ConnectorGrantsService(h.connection.db).toolkitsForAgent(h.orgId, h.danaId)).toEqual([]); + }); + + test("a disconnected toolkit stops counting for its 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 }); + 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/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts index 269fd777..83dbfd0c 100644 --- a/AgentCompany-New/tests/unit/domain-services.test.ts +++ b/AgentCompany-New/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]); + 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]); connection.close(); });