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:
ZacharyZhang-NY committed 2026-07-06 23:13:03 -04:00
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();
});