Q101: a connector can hold several labelled company accounts, each granted and used by name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 16:46:46 -04:00
1 parent 736c45c0ee
commit 4a6641fd49
23 files changed
+306 -151

No files matched your search

+3 -3
View File
@@ -154,11 +154,11 @@ describe("agent instruction wiring", () => {
});
const now = new Date().toISOString();
connection.db.prepare(`
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
values ('ca_github', ?, 'github', 'GitHub', 'ac_github', 'ACTIVE', ?, ?)
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
values ('ca_github', ?, 'github', 'GitHub', 'main', 'ac_github', 'ACTIVE', ?, ?)
`).run(orgId, now, now);
new ConnectorGrantsService(connection.db).grant({
orgId, agentId, toolkitSlug: "github", grantedByType: "employee", grantedById: null,
orgId, agentId, toolkitSlug: "github", accountLabel: null, grantedByType: "employee", grantedById: null,
});
taskId = organization.createTask({
orgId, title: "Ship it", description: "d", status: TASK_STATUS.IN_PROGRESS,
+1 -1
View File
@@ -49,7 +49,7 @@ describe("a run tells the user which capabilities it lacked", () => {
orgId = summary.orgId;
ceoId = summary.ceoAgentId;
const now = new Date().toISOString();
connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now);
connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'main', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now);
});
const keys = new IntegrationKeyStore(connection.db, vault);
keys.write("composio", "ak_capability");
+22 -8
View File
@@ -55,10 +55,10 @@ async function connectedCompany() {
const keys = new IntegrationKeyStore(connection.db, vault);
keys.write("composio", `ak_${randomUUID()}`);
const service = new ComposioConnectionService(connection.db, keys);
const first = await service.connectToolkit(orgId, "github");
const first = await service.connectToolkit(orgId, "github", "main");
remote.get(first.accountId)!.status = "ACTIVE";
await service.refreshAccounts(orgId);
new ConnectorGrantsService(connection.db).grant({ orgId, agentId: ceoId, toolkitSlug: "github", grantedByType: "agent", grantedById: ceoId });
new ConnectorGrantsService(connection.db).grant({ orgId, agentId: ceoId, toolkitSlug: "github", accountLabel: null, grantedByType: "agent", grantedById: ceoId });
const account = () => connection.db.prepare("select id, status, pending_account_id as pending from connector_accounts where org_id = ?").get(orgId) as { id: string; status: string; pending: string | null };
const grants = () => (connection.db.prepare("select count(*) as n from agent_connector_grants where org_id = ?").get(orgId) as { n: number }).n;
return { connection, orgId, service, oldId: first.accountId, account, grants };
@@ -67,7 +67,7 @@ async function connectedCompany() {
describe("reconnecting a connector keeps the working account until the new one is authorized", () => {
test("a reconnect the user abandons leaves the old account working", async () => {
const h = await connectedCompany();
const again = await h.service.connectToolkit(h.orgId, "github");
const again = await h.service.connectToolkit(h.orgId, "github", "main");
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: again.accountId });
expect(remote.has(h.oldId)).toBe(true);
await h.service.refreshAccounts(h.orgId);
@@ -82,7 +82,7 @@ describe("reconnecting a connector keeps the working account until the new one i
test("an authorized reconnect replaces the old account and keeps the grants", async () => {
const h = await connectedCompany();
const again = await h.service.connectToolkit(h.orgId, "github");
const again = await h.service.connectToolkit(h.orgId, "github", "main");
remote.get(again.accountId)!.status = "ACTIVE";
await h.service.refreshAccounts(h.orgId);
expect(h.account()).toEqual({ id: again.accountId, status: "ACTIVE", pending: null });
@@ -93,26 +93,40 @@ describe("reconnecting a connector keeps the working account until the new one i
test("an earlier reconnect that cannot be deleted never replaces the working account", async () => {
const h = await connectedCompany();
const first = await h.service.connectToolkit(h.orgId, "github");
const first = await h.service.connectToolkit(h.orgId, "github", "main");
failDelete.add(first.accountId);
await expect(h.service.connectToolkit(h.orgId, "github")).rejects.toThrow(`Composio could not delete ${first.accountId}`);
await expect(h.service.connectToolkit(h.orgId, "github", "main")).rejects.toThrow(`Composio could not delete ${first.accountId}`);
const second = [...remote.keys()].at(-1)!;
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: second });
await h.service.refreshAccounts(h.orgId);
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: second });
remote.get(first.accountId)!.status = "ACTIVE";
failDelete.clear();
await h.service.disconnectToolkit(h.orgId, "github");
await h.service.disconnectToolkit(h.orgId, "github", "main");
expect(remote.size).toBe(0);
await h.service.refreshAccounts(h.orgId);
expect(h.account()).toBeUndefined();
h.connection.close();
});
test("a second account of a toolkit lives beside the first, and removing it leaves the first", async () => {
const h = await connectedCompany();
const sales = await h.service.connectToolkit(h.orgId, "github", "Sales");
remote.get(sales.accountId)!.status = "ACTIVE";
await h.service.refreshAccounts(h.orgId);
const rows = () => h.connection.db.prepare("select id, label, status from connector_accounts where org_id = ? order by label").all(h.orgId);
expect(rows()).toEqual([{ id: h.oldId, label: "main", status: "ACTIVE" }, { id: sales.accountId, label: "sales", status: "ACTIVE" }]);
await h.service.disconnectToolkit(h.orgId, "github", "sales");
expect(rows()).toEqual([{ id: h.oldId, label: "main", status: "ACTIVE" }]);
expect([...remote.keys()]).toEqual([h.oldId]);
expect(h.grants()).toBe(1);
h.connection.close();
});
test("a reconnect that cannot start touches nothing", async () => {
const h = await connectedCompany();
failCreate = true;
await expect(h.service.connectToolkit(h.orgId, "github")).rejects.toThrow("Composio is down");
await expect(h.service.connectToolkit(h.orgId, "github", "main")).rejects.toThrow("Composio is down");
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: null });
expect([...remote.keys()]).toEqual([h.oldId]);
h.connection.close();
+2 -2
View File
@@ -39,8 +39,8 @@ describe("composio tool loading", () => {
const { connection, orgId, ceoId } = ceoHarness();
const now = new Date().toISOString();
connection.db.prepare(`
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
values ('ca_1', ?, 'gmail', 'Gmail', 'ac_1', 'ACTIVE', ?, ?)
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
values ('ca_1', ?, 'gmail', 'Gmail', 'main', 'ac_1', 'ACTIVE', ?, ?)
`).run(orgId, now, now);
expect(() => loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).toThrow("no Composio API key is saved");
connection.close();
+21 -5
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test, vi } from "vitest";
@@ -44,7 +44,7 @@ const { OrganizationService } = await import("../../src/main/services/organizati
const { IntegrationKeyStore } = await import("../../src/main/services/integration-key-store");
const { ConnectorGrantsService } = await import("../../src/main/services/composio/connector-grants");
const { recordRunAction } = await import("../../src/main/services/run-journal");
const { beginConnectorExecution } = await import("../../src/main/services/composio/connector-executions");
const { canonicalJson } = await import("../../src/main/services/composio/connector-executions");
const tool = (slug: string) => ({ slug, name: slug, description: "", input_parameters: {}, toolkit: { slug: "github" } });
@@ -90,7 +90,7 @@ describe("agents reach every action through search and run", () => {
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
ceoId = new OrganizationService(connection.db).createAgent({ orgId, name: "CEO", role: "CEO", department: "Executive", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: null, roleLevel: "c_suite" });
const now = new Date().toISOString();
connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now);
connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'main', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now);
});
const keys = new IntegrationKeyStore(connection.db, vault);
keys.write("composio", apiKey);
@@ -154,18 +154,34 @@ describe("agents reach every action through search and run", () => {
test("a call left pending by a crash blocks the same call", async () => {
pages.set("github:", { items: [tool("GITHUB_SEND")], next_cursor: null });
const h = company("ak_crash");
beginConnectorExecution(h.connection.db, { orgId: h.orgId, agentId: h.ceoId, runId: null, action: "GITHUB_SEND", args: { text: "x" }, confirmedNotDone: false });
// A row as written before accounts had labels: no account_label, a hash of the arguments alone.
const now = new Date().toISOString();
h.connection.db.prepare("insert into connector_executions(id, org_id, agent_id, action, arguments_hash, state, created_at, updated_at) values ('cex_old', ?, ?, 'GITHUB_SEND', ?, 'pending', ?, ?)")
.run(h.orgId, h.ceoId, createHash("sha256").update(canonicalJson({ text: "x" })).digest("hex"), now, now);
expect(await h.call("composio_run_action", { action: "GITHUB_SEND", arguments: { text: "x" } })).toMatch(/^ERROR: An identical GITHUB_SEND call at .+ may already have run; its reply was lost\. Check in github/);
h.connection.close();
});
test("an agent that reaches several accounts of a service names the one an action runs on", async () => {
pages.set("github:", { items: [tool("GITHUB_STAR"), tool("GITHUB_DROP")], next_cursor: null });
const h = company("ak_accounts");
const now = new Date().toISOString();
h.connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at) values ('ca_gh_sales', ?, 'github', 'GitHub', 'sales', 'ac_gh', 'ACTIVE', ?, ?)").run(h.orgId, now, now);
expect(await h.call("composio_run_action", { action: "GITHUB_STAR", arguments: {} })).toBe('ERROR: "github" has several accounts (main, sales); name one with account.');
expect(await h.call("composio_run_action", { action: "GITHUB_STAR", arguments: {}, account: "sales" })).toBe(JSON.stringify({ number: 42 }));
expect(written.at(-1)).toContain("\"connected_account_id\":\"ca_gh_sales\"");
expect(await h.call("composio_run_action", { action: "GITHUB_DROP", arguments: {}, account: "main" })).toContain("may or may not have run");
expect(await h.call("composio_run_action", { action: "GITHUB_DROP", arguments: {}, account: "sales" })).toContain("may or may not have run");
h.connection.close();
});
test("a grant revoked while the catalog loads stops the action, and a search is not journaled as work", async () => {
pages.set("github:", { items: [tool("GITHUB_STAR_A_REPOSITORY")], next_cursor: null });
const h = company("ak_revoke");
const staff = h.connection.transaction(() => {
const providerId = (h.connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(h.orgId) as { id: string }).id;
const id = new OrganizationService(h.connection.db).createAgent({ orgId: h.orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: h.ceoId });
new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", grantedByType: "agent", grantedById: h.ceoId });
new ConnectorGrantsService(h.connection.db).grant({ orgId: h.orgId, agentId: id, toolkitSlug: "github", accountLabel: null, grantedByType: "agent", grantedById: h.ceoId });
return id;
});
const running = executeComposioTool(h.connection.db, h.keys, h.orgId, staff, { id: "c2", name: "composio_run_action", arguments: { action: "GITHUB_STAR_A_REPOSITORY", arguments: {} } });
+33 -5
View File
@@ -61,8 +61,8 @@ function harness(): Harness {
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', ?, ?)
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
values (?, ?, ?, ?, 'main', ?, 'ACTIVE', ?, ?)
`).run(`ca_${slug}`, h.orgId, slug, name, `ac_${slug}`, now, now);
}
@@ -71,7 +71,7 @@ describe("connector grants", () => {
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,
orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: null, grantedByType: "employee", grantedById: null,
})).toThrow(/No active connection/);
});
@@ -87,13 +87,41 @@ describe("connector grants", () => {
expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]);
const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {}));
expect(listing).toContain("GitHub (github): granted to Dana");
expect(listing).toContain("GitHub (github), account \"main\": 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("a toolkit with several accounts: grants and actions name the account", () => {
const h = harness();
const now = new Date().toISOString();
for (const label of ["main", "sales"]) {
h.connection.db.prepare(`
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
values (?, ?, 'github', 'GitHub', ?, 'ac_github', 'ACTIVE', ?, ?)
`).run(`ca_gh_${label}`, h.orgId, label, now, now);
}
const grants = new ConnectorGrantsService(h.connection.db);
expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github" })))
.toBe('ERROR: "github" has several accounts (main, sales); name one with account.');
expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github", account: "Sales" })))
.toContain('through its "sales" account');
expect(dispatchAgentTool(h.deps, h.ceoCtx, call("grant_connector", { agent_name: "Dana", toolkit: "github", account: null })))
.toBe('ERROR: "account" is required.');
expect(grants.accountForAgent(h.orgId, h.danaId, "github", null).id).toBe("ca_gh_sales");
expect(() => grants.accountForAgent(h.orgId, h.danaId, "github", "main")).toThrow('Your grant for "github" is the "sales" account, not "main".');
expect(() => grants.accountForAgent(h.orgId, h.ceoId, "github", null)).toThrow('"github" has several accounts (main, sales)');
expect(grants.accountForAgent(h.orgId, h.ceoId, "github", "main").id).toBe("ca_gh_main");
const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {}));
expect(listing).toContain('GitHub (github), account "main": no grants yet');
expect(listing).toContain('GitHub (github), account "sales": granted to Dana');
h.connection.db.prepare("update connector_accounts set status = 'EXPIRED' where id = 'ca_gh_sales'").run();
expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]);
expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]);
});
test("staff cannot hand out connector access", () => {
const h = harness();
connectToolkit(h, "github", "GitHub");
@@ -106,7 +134,7 @@ describe("connector 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 });
grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "slack", accountLabel: null, 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([]);
+1 -1
View File
@@ -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, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82]);
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, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83]);
connection.close();
});