Let the CEO hand out connector toolkits agent by agent
Connections belong to the company - one Composio connected account per toolkit, mirrored locally in connector_accounts - and access belongs to individuals: a new agent_connector_grants table records which agent may use which toolkit. Agent runs now load composio tools from the intersection of active connections and that agent's grants, and tool execution re-checks the grant server-side so an ungranted agent cannot call a connector tool it heard about. Any agent can list_connectors to see what the company has connected and who holds access; grant_connector/revoke_connector are CEO-only. The Composio client speaks the v3 contract (user_id, connected_account_id, toolkit catalog with cursor paging, managed auth config creation, connect-link initiation). Covered by four grants contract tests.
This commit is contained in:
1 parent
4775b23d94
commit
66aae376c0
10 files changed
+465
-66
No files matched your search
@@ -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);
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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<string, unknown>;
|
||||
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<ComposioToolkitSummary[]> {
|
||||
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<Record<string, unknown>> : [];
|
||||
for (const item of items) {
|
||||
const meta = (item.meta ?? {}) as Record<string, unknown>;
|
||||
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<Record<string, unknown>>).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<ComposioToolSchema[]> {
|
||||
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<string> {
|
||||
const response = await this.request("POST", `${BASE_URL}/auth_configs`, {
|
||||
toolkit: { slug: toolkitSlug },
|
||||
});
|
||||
const authConfig = (response.auth_config ?? {}) as Record<string, unknown>;
|
||||
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<ComposioConnectedAccount[]> {
|
||||
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<Record<string, unknown>>).map((item) => {
|
||||
const toolkit = (item.toolkit ?? {}) as Record<string, unknown>;
|
||||
const authConfig = (item.auth_config ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
entityId: string;
|
||||
userId: string;
|
||||
connectedAccountId?: string;
|
||||
}): Promise<ComposioToolExecutionResult> {
|
||||
const url = `${BASE_URL}/tools/execute/${encodeURIComponent(input.toolName)}`;
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown> : {},
|
||||
@@ -53,36 +147,6 @@ export class ComposioClient {
|
||||
};
|
||||
}
|
||||
|
||||
async getConnectedAccounts(entityId: string): Promise<Array<{ id: string; appName: string; status: string }>> {
|
||||
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<Record<string, unknown>>).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<string, unknown> = {
|
||||
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<string, unknown>): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = net.request({ method, url });
|
||||
@@ -116,9 +180,11 @@ export class ComposioClient {
|
||||
function normalizeToolSchema(raw: Record<string, unknown>): ComposioToolSchema {
|
||||
const params = raw.input_parameters ?? raw.parameters ?? raw.inputSchema ?? {};
|
||||
const schema = typeof params === "object" && params !== null ? params as Record<string, unknown> : {};
|
||||
const toolkit = (raw.toolkit ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
|
||||
@@ -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<string, CacheEntry>();
|
||||
/** tool slug (upper case) → toolkit slug, learned from the tools listing. */
|
||||
const toolkitByTool = new Map<string, string>();
|
||||
|
||||
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<ToolDefinition[]> {
|
||||
/**
|
||||
* 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<ToolDefinition[]> {
|
||||
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<string> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user