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:
1 parent
736c45c0ee
commit
4a6641fd49
23 files changed
+306
-151
No files matched your search
@@ -397,7 +397,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
|||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => {
|
ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => {
|
||||||
try {
|
try {
|
||||||
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug);
|
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug, input.label);
|
||||||
notifyDataChanged();
|
notifyDataChanged();
|
||||||
return ok(result);
|
return ok(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -406,7 +406,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
|||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.disconnectComposioToolkit, async (_event, input) => {
|
ipcMain.handle(IPC_CHANNELS.disconnectComposioToolkit, async (_event, input) => {
|
||||||
try {
|
try {
|
||||||
await composioConnections.disconnectToolkit(input.orgId, input.toolkitSlug);
|
await composioConnections.disconnectToolkit(input.orgId, input.toolkitSlug, input.label);
|
||||||
notifyDataChanged();
|
notifyDataChanged();
|
||||||
return ok(snapshot.load());
|
return ok(snapshot.load());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const connectorAccountLabelsSchema = `
|
||||||
|
create table connector_accounts_next (
|
||||||
|
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,
|
||||||
|
label text not null,
|
||||||
|
auth_config_id text not null,
|
||||||
|
status text not null,
|
||||||
|
pending_account_id text,
|
||||||
|
created_at text not null,
|
||||||
|
updated_at text not null,
|
||||||
|
unique (org_id, toolkit_slug, label)
|
||||||
|
);
|
||||||
|
insert into connector_accounts_next(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, pending_account_id, created_at, updated_at)
|
||||||
|
select id, org_id, toolkit_slug, toolkit_name, 'main', auth_config_id, status, pending_account_id, created_at, updated_at from connector_accounts;
|
||||||
|
drop table connector_accounts;
|
||||||
|
alter table connector_accounts_next rename to connector_accounts;
|
||||||
|
|
||||||
|
alter table agent_connector_grants add column account_label text not null default 'main';
|
||||||
|
alter table connector_executions add column account_label text not null default 'main';
|
||||||
|
|
||||||
|
insert into schema_migrations(version, name, applied_at)
|
||||||
|
values (83, 'connector_account_labels', datetime('now'));
|
||||||
|
`;
|
||||||
@@ -81,6 +81,7 @@ import { eraseRetiredCredentialsSchema } from "./migrations/079-erase-retired-cr
|
|||||||
import { runCapabilityWarningsSchema } from "./migrations/080-run-capability-warnings";
|
import { runCapabilityWarningsSchema } from "./migrations/080-run-capability-warnings";
|
||||||
import { connectorExecutionsSchema } from "./migrations/081-connector-executions";
|
import { connectorExecutionsSchema } from "./migrations/081-connector-executions";
|
||||||
import { connectorPendingAccountSchema } from "./migrations/082-connector-pending-account";
|
import { connectorPendingAccountSchema } from "./migrations/082-connector-pending-account";
|
||||||
|
import { connectorAccountLabelsSchema } from "./migrations/083-connector-account-labels";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -171,6 +172,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 80, name: "run_capability_warnings", sql: runCapabilityWarningsSchema },
|
{ version: 80, name: "run_capability_warnings", sql: runCapabilityWarningsSchema },
|
||||||
{ version: 81, name: "connector_executions", sql: connectorExecutionsSchema },
|
{ version: 81, name: "connector_executions", sql: connectorExecutionsSchema },
|
||||||
{ version: 82, name: "connector_pending_account", sql: connectorPendingAccountSchema },
|
{ version: 82, name: "connector_pending_account", sql: connectorPendingAccountSchema },
|
||||||
|
{ version: 83, name: "connector_account_labels", sql: connectorAccountLabelsSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ export function listConnectorsTool(deps: AgentToolDeps, ctx: AgentToolContext):
|
|||||||
);
|
);
|
||||||
const lines = accounts.map((account) => {
|
const lines = accounts.map((account) => {
|
||||||
const holders = grants
|
const holders = grants
|
||||||
.filter((grant) => grant.toolkitSlug === account.toolkitSlug)
|
.filter((grant) => grant.toolkitSlug === account.toolkitSlug && grant.accountLabel === account.label)
|
||||||
.map((grant) => agentNames.get(grant.agentId))
|
.map((grant) => agentNames.get(grant.agentId))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return `- ${account.toolkitName} (${account.toolkitSlug}): ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`;
|
return `- ${account.toolkitName} (${account.toolkitSlug}), account "${account.label}": ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`;
|
||||||
});
|
});
|
||||||
return ["Connected toolkits:", ...lines].join("\n");
|
return ["Connected toolkits:", ...lines].join("\n");
|
||||||
}
|
}
|
||||||
@@ -40,14 +40,15 @@ export function grantConnectorTool(deps: AgentToolDeps, ctx: AgentToolContext, a
|
|||||||
if (!target) return `ERROR: No active agent named "${agentName}".`;
|
if (!target) return `ERROR: No active agent named "${agentName}".`;
|
||||||
const grantsService = new ConnectorGrantsService(deps.db);
|
const grantsService = new ConnectorGrantsService(deps.db);
|
||||||
if (mode === "grant") {
|
if (mode === "grant") {
|
||||||
grantsService.grant({
|
const label = grantsService.grant({
|
||||||
orgId: ctx.orgId,
|
orgId: ctx.orgId,
|
||||||
agentId: target.id,
|
agentId: target.id,
|
||||||
toolkitSlug: toolkit,
|
toolkitSlug: toolkit,
|
||||||
|
accountLabel: args.account === undefined ? null : requireString(args.account, "account"),
|
||||||
grantedByType: "agent",
|
grantedByType: "agent",
|
||||||
grantedById: ctx.agent.id,
|
grantedById: ctx.agent.id,
|
||||||
});
|
});
|
||||||
return `${target.name} now has access to the ${toolkit.toLowerCase()} connector; its tools join their next run.`;
|
return `${target.name} now has access to the ${toolkit.toLowerCase()} connector through its "${label}" account; its tools join their next run.`;
|
||||||
}
|
}
|
||||||
grantsService.revoke({ orgId: ctx.orgId, agentId: target.id, toolkitSlug: toolkit });
|
grantsService.revoke({ orgId: ctx.orgId, agentId: target.id, toolkitSlug: toolkit });
|
||||||
return `${target.name} no longer has access to the ${toolkit.toLowerCase()} connector.`;
|
return `${target.name} no longer has access to the ${toolkit.toLowerCase()} connector.`;
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
agent_name: { type: "string", description: "The teammate receiving access." },
|
agent_name: { type: "string", description: "The teammate receiving access." },
|
||||||
toolkit: { type: "string", description: "Connected toolkit slug, e.g. 'github'." },
|
toolkit: { type: "string", description: "Connected toolkit slug, e.g. 'github'." },
|
||||||
|
account: { type: "string", description: "Which of the toolkit's accounts, by label (see list_connectors); required when it has several." },
|
||||||
},
|
},
|
||||||
required: ["agent_name", "toolkit"],
|
required: ["agent_name", "toolkit"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ComposioClient, type ComposioConnectedAccount } from "./composio-client
|
|||||||
import { IntegrationKeyStore } from "../integration-key-store";
|
import { IntegrationKeyStore } from "../integration-key-store";
|
||||||
import { getComposioUserId } from "./composio-tool-bridge";
|
import { getComposioUserId } from "./composio-tool-bridge";
|
||||||
import { connectorDisplayName, connectorLogoUrl } from "./connector-brands";
|
import { connectorDisplayName, connectorLogoUrl } from "./connector-brands";
|
||||||
import { normalizeSlug } from "./connector-grants";
|
import { DEFAULT_ACCOUNT_LABEL, normalizeAccountLabel, normalizeSlug } from "./connector-grants";
|
||||||
|
|
||||||
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||||
const WAITING_STATUSES = new Set(["INITIALIZING", "INITIATED"]);
|
const WAITING_STATUSES = new Set(["INITIALIZING", "INITIATED"]);
|
||||||
@@ -35,9 +35,9 @@ export class ComposioConnectionService {
|
|||||||
return this.catalog(this.requireClient());
|
return this.catalog(this.requireClient());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Starts a toolkit's connection; a reconnect waits beside an active account until refresh finds it authorized. */
|
/** Starts a toolkit account's connection; a reconnect waits beside an active account until refresh finds it authorized. */
|
||||||
connectToolkit(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
|
connectToolkit(orgId: string, toolkitSlug: string, label: string): Promise<ConnectComposioToolkitResult> {
|
||||||
return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug));
|
return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug, label));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** See class docs: linearizes connect/refresh/disconnect for one org. */
|
/** See class docs: linearizes connect/refresh/disconnect for one org. */
|
||||||
@@ -48,14 +48,15 @@ export class ComposioConnectionService {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connectToolkitNow(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
|
private async connectToolkitNow(orgId: string, toolkitSlug: string, rawLabel: string): Promise<ConnectComposioToolkitResult> {
|
||||||
const slug = normalizeSlug(toolkitSlug);
|
const slug = normalizeSlug(toolkitSlug);
|
||||||
|
const label = normalizeAccountLabel(rawLabel);
|
||||||
const client = this.requireClient();
|
const client = this.requireClient();
|
||||||
const entry = (await this.catalog(client)).find((item) => item.slug === slug);
|
const entry = (await this.catalog(client)).find((item) => item.slug === slug);
|
||||||
if (!entry) throw new Error(`No auth config for "${slug}" exists in the Composio project.`);
|
if (!entry) throw new Error(`No auth config for "${slug}" exists in the Composio project.`);
|
||||||
const previous = this.db.prepare(
|
const previous = this.db.prepare(
|
||||||
"select id, status, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ?",
|
"select id, status, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?",
|
||||||
).get(orgId, slug) as { id: string; status: string; pending_account_id: string | null } | undefined;
|
).get(orgId, slug, label) as { id: string; status: string; pending_account_id: string | null } | undefined;
|
||||||
const account = await client.createConnectedAccount({
|
const account = await client.createConnectedAccount({
|
||||||
authConfigId: entry.authConfigId,
|
authConfigId: entry.authConfigId,
|
||||||
userId: getComposioUserId(this.db, orgId),
|
userId: getComposioUserId(this.db, orgId),
|
||||||
@@ -64,29 +65,28 @@ export class ComposioConnectionService {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// The local row moves to the new account before any old one is deleted; refresh ignores accounts no row tracks.
|
// The local row moves to the new account before any old one is deleted; refresh ignores accounts no row tracks.
|
||||||
if (previous?.status === "ACTIVE") {
|
if (previous?.status === "ACTIVE") {
|
||||||
this.db.prepare("update connector_accounts set pending_account_id = ?, updated_at = ? where org_id = ? and toolkit_slug = ?")
|
this.db.prepare("update connector_accounts set pending_account_id = ?, updated_at = ? where id = ?").run(account.id, now, previous.id);
|
||||||
.run(account.id, now, orgId, slug);
|
console.info(`[connectors] ${slug}/${label} reconnect ${account.id} started; ${previous.id} stays in use until it is authorized`);
|
||||||
console.info(`[connectors] ${slug} reconnect ${account.id} started; ${previous.id} stays in use until it is authorized`);
|
|
||||||
if (previous.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id);
|
if (previous.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id);
|
||||||
return { accountId: account.id, redirectUrl: account.redirectUrl };
|
return { accountId: account.id, redirectUrl: account.redirectUrl };
|
||||||
}
|
}
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
on conflict(org_id, toolkit_slug) do update set
|
on conflict(org_id, toolkit_slug, label) do update set
|
||||||
id = excluded.id,
|
id = excluded.id,
|
||||||
toolkit_name = excluded.toolkit_name,
|
toolkit_name = excluded.toolkit_name,
|
||||||
auth_config_id = excluded.auth_config_id,
|
auth_config_id = excluded.auth_config_id,
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
pending_account_id = null,
|
pending_account_id = null,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
`).run(account.id, orgId, slug, entry.name, entry.authConfigId, "INITIALIZING", now, now);
|
`).run(account.id, orgId, slug, entry.name, label, entry.authConfigId, "INITIALIZING", now, now);
|
||||||
if (previous?.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id);
|
if (previous?.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id);
|
||||||
if (previous) await client.deleteConnectedAccount(previous.id);
|
if (previous) await client.deleteConnectedAccount(previous.id);
|
||||||
return { accountId: account.id, redirectUrl: account.redirectUrl };
|
return { accountId: account.id, redirectUrl: account.redirectUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors Composio: tracked accounts take its status, gone ones are removed with their grants, untracked toolkits are adopted. */
|
/** Mirrors Composio: tracked accounts take its status, gone ones are removed with their grants, toolkits with no account here are adopted. */
|
||||||
refreshAccounts(orgId: string): Promise<void> {
|
refreshAccounts(orgId: string): Promise<void> {
|
||||||
return this.serialize(orgId, () => this.refreshAccountsNow(orgId));
|
return this.serialize(orgId, () => this.refreshAccountsNow(orgId));
|
||||||
}
|
}
|
||||||
@@ -99,36 +99,27 @@ export class ComposioConnectionService {
|
|||||||
const reconnects = this.db.prepare("select id, toolkit_slug, pending_account_id from connector_accounts where org_id = ? and pending_account_id is not null")
|
const reconnects = this.db.prepare("select id, toolkit_slug, pending_account_id from connector_accounts where org_id = ? and pending_account_id is not null")
|
||||||
.all(orgId) as Array<{ id: string; toolkit_slug: string; pending_account_id: string }>;
|
.all(orgId) as Array<{ id: string; toolkit_slug: string; pending_account_id: string }>;
|
||||||
for (const row of reconnects) await this.settleReconnect(client, row, accounts.find((account) => account.id === row.pending_account_id), liveIds.has(row.id));
|
for (const row of reconnects) await this.settleReconnect(client, row, accounts.find((account) => account.id === row.pending_account_id), liveIds.has(row.id));
|
||||||
const localRows = this.db.prepare("select id, toolkit_slug, pending_account_id from connector_accounts where org_id = ?")
|
const localRows = this.db.prepare("select id, toolkit_slug, label from connector_accounts where org_id = ?")
|
||||||
.all(orgId) as Array<{ id: string; toolkit_slug: string; pending_account_id: string | null }>;
|
.all(orgId) as Array<{ id: string; toolkit_slug: string; label: string }>;
|
||||||
for (const row of localRows) {
|
for (const row of localRows) {
|
||||||
if (liveIds.has(row.id)) continue;
|
if (!liveIds.has(row.id)) this.removeLocalAccount(orgId, row.toolkit_slug, row.label);
|
||||||
this.removeLocalAccount(orgId, row.toolkit_slug);
|
|
||||||
}
|
}
|
||||||
const tracked = new Map(localRows.filter((row) => liveIds.has(row.id)).map((row) => [row.toolkit_slug, row]));
|
const tracked = this.trackedIds(orgId);
|
||||||
|
const toolkitsHere = new Set(localRows.filter((row) => liveIds.has(row.id)).map((row) => row.toolkit_slug));
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
if (!account.toolkitSlug) continue;
|
if (!account.toolkitSlug) continue;
|
||||||
const row = tracked.get(account.toolkitSlug);
|
if (tracked.has(account.id)) {
|
||||||
// A toolkit's row follows only its own account; others (a waiting reconnect, an orphan) never replace it.
|
this.db.prepare("update connector_accounts set auth_config_id = ?, status = ?, updated_at = ? where id = ?")
|
||||||
if (row && row.id !== account.id) continue;
|
.run(account.authConfigId, account.status, now, account.id);
|
||||||
tracked.set(account.toolkitSlug, { id: account.id, toolkit_slug: account.toolkitSlug, pending_account_id: null });
|
continue;
|
||||||
|
}
|
||||||
|
// Only a toolkit with no account here is adopted; other untracked accounts (a waiting reconnect, an orphan) never join.
|
||||||
|
if (toolkitsHere.has(account.toolkitSlug)) continue;
|
||||||
|
toolkitsHere.add(account.toolkitSlug);
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
on conflict(org_id, toolkit_slug) do update set
|
`).run(account.id, orgId, account.toolkitSlug, connectorDisplayName(account.toolkitSlug), DEFAULT_ACCOUNT_LABEL, account.authConfigId, account.status, account.createdAt || now, now);
|
||||||
auth_config_id = excluded.auth_config_id,
|
|
||||||
status = excluded.status,
|
|
||||||
updated_at = excluded.updated_at
|
|
||||||
`).run(
|
|
||||||
account.id,
|
|
||||||
orgId,
|
|
||||||
account.toolkitSlug,
|
|
||||||
connectorDisplayName(account.toolkitSlug),
|
|
||||||
account.authConfigId,
|
|
||||||
account.status,
|
|
||||||
account.createdAt || now,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,34 +144,39 @@ export class ComposioConnectionService {
|
|||||||
console.warn(`[connectors] ${row.toolkit_slug} reconnect ${row.pending_account_id} ended ${pending?.status ?? "missing"}; ${row.id} stays in use`);
|
console.warn(`[connectors] ${row.toolkit_slug} reconnect ${row.pending_account_id} ended ${pending?.status ?? "missing"}; ${row.id} stays in use`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Deletes one account of a toolkit in Composio, with untracked leftovers of that toolkit, then its row and the grants that named it. */
|
||||||
* Deletes the company's connection for a toolkit: removes the connected
|
disconnectToolkit(orgId: string, toolkitSlug: string, label: string): Promise<void> {
|
||||||
* account in Composio (already-gone counts as deleted), then the local
|
return this.serialize(orgId, () => this.disconnectToolkitNow(orgId, toolkitSlug, label));
|
||||||
* mirror row and every agent grant that pointed at it.
|
|
||||||
*/
|
|
||||||
disconnectToolkit(orgId: string, toolkitSlug: string): Promise<void> {
|
|
||||||
return this.serialize(orgId, () => this.disconnectToolkitNow(orgId, toolkitSlug));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async disconnectToolkitNow(orgId: string, toolkitSlug: string): Promise<void> {
|
private async disconnectToolkitNow(orgId: string, toolkitSlug: string, rawLabel: string): Promise<void> {
|
||||||
const slug = normalizeSlug(toolkitSlug);
|
const slug = normalizeSlug(toolkitSlug);
|
||||||
|
const label = normalizeAccountLabel(rawLabel);
|
||||||
const row = this.db.prepare(
|
const row = this.db.prepare(
|
||||||
"select id, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ?",
|
"select id, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?",
|
||||||
).get(orgId, slug) as { id: string; pending_account_id: string | null } | undefined;
|
).get(orgId, slug, label) as { id: string; pending_account_id: string | null } | undefined;
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
const client = this.requireClient();
|
const client = this.requireClient();
|
||||||
// Every account Composio holds for the toolkit goes, so an orphan from a failed delete cannot be adopted back.
|
// Untracked accounts of the toolkit go too, so an orphan from a failed delete cannot be adopted later.
|
||||||
|
const tracked = this.trackedIds(orgId);
|
||||||
const ids = new Set([row.id, ...(row.pending_account_id ? [row.pending_account_id] : [])]);
|
const ids = new Set([row.id, ...(row.pending_account_id ? [row.pending_account_id] : [])]);
|
||||||
for (const account of await client.listConnectedAccounts(getComposioUserId(this.db, orgId))) {
|
for (const account of await client.listConnectedAccounts(getComposioUserId(this.db, orgId))) {
|
||||||
if (account.toolkitSlug === slug) ids.add(account.id);
|
if (account.toolkitSlug === slug && !tracked.has(account.id)) ids.add(account.id);
|
||||||
}
|
}
|
||||||
for (const id of ids) await client.deleteConnectedAccount(id);
|
for (const id of ids) await client.deleteConnectedAccount(id);
|
||||||
this.removeLocalAccount(orgId, slug);
|
this.removeLocalAccount(orgId, slug, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeLocalAccount(orgId: string, slug: string): void {
|
/** Every Composio account a row here stands for, waiting reconnects included. */
|
||||||
this.db.prepare("delete from connector_accounts where org_id = ? and toolkit_slug = ?").run(orgId, slug);
|
private trackedIds(orgId: string): Set<string> {
|
||||||
this.db.prepare("delete from agent_connector_grants where org_id = ? and toolkit_slug = ?").run(orgId, slug);
|
const rows = this.db.prepare("select id, pending_account_id from connector_accounts where org_id = ?")
|
||||||
|
.all(orgId) as Array<{ id: string; pending_account_id: string | null }>;
|
||||||
|
return new Set(rows.flatMap((row) => (row.pending_account_id ? [row.id, row.pending_account_id] : [row.id])));
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeLocalAccount(orgId: string, slug: string, label: string): void {
|
||||||
|
this.db.prepare("delete from connector_accounts where org_id = ? and toolkit_slug = ? and label = ?").run(orgId, slug, label);
|
||||||
|
this.db.prepare("delete from agent_connector_grants where org_id = ? and toolkit_slug = ? and account_label = ?").run(orgId, slug, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireClient(): ComposioClient {
|
private requireClient(): ComposioClient {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, o
|
|||||||
properties: {
|
properties: {
|
||||||
action: { type: "string", description: "The action's name exactly as found, e.g. GITHUB_CREATE_A_PULL_REQUEST." },
|
action: { type: "string", description: "The action's name exactly as found, e.g. GITHUB_CREATE_A_PULL_REQUEST." },
|
||||||
arguments: { type: "object", description: "The action's input, following the schema the search returned." },
|
arguments: { type: "object", description: "The action's input, following the schema the search returned." },
|
||||||
|
account: { type: "string", description: "Which account of the service, by label; needed only when you can reach several." },
|
||||||
confirm_not_done: { type: "boolean", description: "Only after an identical call's reply was lost and you checked in the service that it did not take effect." },
|
confirm_not_done: { type: "boolean", description: "Only after an identical call's reply was lost and you checked in the service that it did not take effect." },
|
||||||
},
|
},
|
||||||
required: ["action", "arguments"],
|
required: ["action", "arguments"],
|
||||||
@@ -109,14 +110,12 @@ async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentI
|
|||||||
if (!new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId).includes(toolkit)) {
|
if (!new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId).includes(toolkit)) {
|
||||||
return `ERROR: You have no grant for the "${toolkit}" connector. Ask the CEO to grant it to you (grant_connector).`;
|
return `ERROR: You have no grant for the "${toolkit}" connector. Ask the CEO to grant it to you (grant_connector).`;
|
||||||
}
|
}
|
||||||
const account = db.prepare(
|
if (args.account !== undefined && typeof args.account !== "string") return "ERROR: account must be the label of one of the service's accounts.";
|
||||||
"select id from connector_accounts where org_id = ? and toolkit_slug = ? and status = 'ACTIVE'",
|
const account = new ConnectorGrantsService(db).accountForAgent(orgId, agentId, toolkit, args.account ?? null);
|
||||||
).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 input = typeof args.arguments === "object" && args.arguments !== null && !Array.isArray(args.arguments) ? args.arguments as Record<string, unknown> : null;
|
const input = typeof args.arguments === "object" && args.arguments !== null && !Array.isArray(args.arguments) ? args.arguments as Record<string, unknown> : null;
|
||||||
if (!input) return "ERROR: arguments must be an object matching the action's input.";
|
if (!input) return "ERROR: arguments must be an object matching the action's input.";
|
||||||
if (args.confirm_not_done !== undefined && typeof args.confirm_not_done !== "boolean") return "ERROR: confirm_not_done must be true or false.";
|
if (args.confirm_not_done !== undefined && typeof args.confirm_not_done !== "boolean") return "ERROR: confirm_not_done must be true or false.";
|
||||||
const started = beginConnectorExecution(db, { orgId, agentId, runId: currentRunId(), action: action.name, args: input, confirmedNotDone: args.confirm_not_done === true });
|
const started = beginConnectorExecution(db, { orgId, agentId, runId: currentRunId(), action: action.name, accountLabel: account.label, args: input, confirmedNotDone: args.confirm_not_done === true });
|
||||||
if ("blockedBy" in started) {
|
if ("blockedBy" in started) {
|
||||||
const lost = started.blockedBy.detail ? `: ${started.blockedBy.detail}` : "";
|
const lost = started.blockedBy.detail ? `: ${started.blockedBy.detail}` : "";
|
||||||
return `ERROR: An identical ${action.name} call at ${started.blockedBy.createdAt} may already have run; its reply was lost${lost}. Check in ${toolkit} whether it took effect. If it did not, call again with confirm_not_done: true.`;
|
return `ERROR: An identical ${action.name} call at ${started.blockedBy.createdAt} may already have run; its reply was lost${lost}. Check in ${toolkit} whether it took effect. If it did not, call again with confirm_not_done: true.`;
|
||||||
|
|||||||
@@ -5,27 +5,27 @@ import { createId } from "../../../shared/ids";
|
|||||||
/** How an action run ended: unknown means it was sent and its reply was lost, so it may have taken effect. */
|
/** How an action run ended: unknown means it was sent and its reply was lost, so it may have taken effect. */
|
||||||
export type ConnectorExecutionOutcome = "succeeded" | "failed" | "unknown";
|
export type ConnectorExecutionOutcome = "succeeded" | "failed" | "unknown";
|
||||||
|
|
||||||
/** Records an action run before it is sent; an identical call whose outcome is pending or unknown blocks it until the agent confirms it did not happen. */
|
/** Records an action run before it is sent; an identical call on the same account whose outcome is pending or unknown blocks it until the agent confirms it did not happen. */
|
||||||
export function beginConnectorExecution(
|
export function beginConnectorExecution(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
input: { orgId: string; agentId: string; runId: string | null; action: string; args: Record<string, unknown>; confirmedNotDone: boolean },
|
input: { orgId: string; agentId: string; runId: string | null; action: string; accountLabel: string; args: Record<string, unknown>; confirmedNotDone: boolean },
|
||||||
): { id: string } | { blockedBy: { createdAt: string; detail: string | null } } {
|
): { id: string } | { blockedBy: { createdAt: string; detail: string | null } } {
|
||||||
const hash = createHash("sha256").update(canonicalJson(input.args)).digest("hex");
|
const hash = createHash("sha256").update(canonicalJson(input.args)).digest("hex");
|
||||||
const open = db.prepare(`
|
const open = db.prepare(`
|
||||||
select created_at, detail from connector_executions
|
select created_at, detail from connector_executions
|
||||||
where org_id = ? and action = ? and arguments_hash = ? and state in ('pending', 'unknown') order by rowid desc limit 1
|
where org_id = ? and action = ? and account_label = ? and arguments_hash = ? and state in ('pending', 'unknown') order by rowid desc limit 1
|
||||||
`).get(input.orgId, input.action, hash) as { created_at: string; detail: string | null } | undefined;
|
`).get(input.orgId, input.action, input.accountLabel, hash) as { created_at: string; detail: string | null } | undefined;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
if (open && !input.confirmedNotDone) return { blockedBy: { createdAt: open.created_at, detail: open.detail } };
|
if (open && !input.confirmedNotDone) return { blockedBy: { createdAt: open.created_at, detail: open.detail } };
|
||||||
if (open) {
|
if (open) {
|
||||||
db.prepare("update connector_executions set state = 'resolved', updated_at = ? where org_id = ? and action = ? and arguments_hash = ? and state in ('pending', 'unknown')")
|
db.prepare("update connector_executions set state = 'resolved', updated_at = ? where org_id = ? and action = ? and account_label = ? and arguments_hash = ? and state in ('pending', 'unknown')")
|
||||||
.run(now, input.orgId, input.action, hash);
|
.run(now, input.orgId, input.action, input.accountLabel, hash);
|
||||||
}
|
}
|
||||||
const id = createId("cex");
|
const id = createId("cex");
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
insert into connector_executions(id, org_id, agent_id, run_id, action, arguments_hash, state, created_at, updated_at)
|
insert into connector_executions(id, org_id, agent_id, run_id, action, account_label, arguments_hash, state, created_at, updated_at)
|
||||||
values (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
values (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||||
`).run(id, input.orgId, input.agentId, input.runId, input.action, hash, now, now);
|
`).run(id, input.orgId, input.agentId, input.runId, input.action, input.accountLabel, hash, now, now);
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands/composio";
|
||||||
import { createId } from "../../../shared/ids";
|
import { createId } from "../../../shared/ids";
|
||||||
|
|
||||||
|
export const DEFAULT_ACCOUNT_LABEL = DEFAULT_CONNECTOR_ACCOUNT_LABEL;
|
||||||
|
const MAX_LABEL_CHARS = 40;
|
||||||
|
|
||||||
export interface ConnectorAccountRow {
|
export interface ConnectorAccountRow {
|
||||||
id: string;
|
id: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
toolkitSlug: string;
|
toolkitSlug: string;
|
||||||
toolkitName: string;
|
toolkitName: string;
|
||||||
|
label: string;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,31 +19,28 @@ export interface ConnectorGrantRow {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
toolkitSlug: string;
|
toolkitSlug: string;
|
||||||
|
accountLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Which agents may use which labelled company accounts: the CEO all active ones by role, others one per toolkit by grant. */
|
||||||
* Which agents may use which connected toolkits. Connections belong to the
|
|
||||||
* company (one connected account per toolkit per org); the CEO carries every
|
|
||||||
* active connection by role and hands individual toolkits to individual
|
|
||||||
* agents. Both the tool loadout and the execution guard read from here.
|
|
||||||
*/
|
|
||||||
export class ConnectorGrantsService {
|
export class ConnectorGrantsService {
|
||||||
constructor(private readonly db: DatabaseSync) {}
|
constructor(private readonly db: DatabaseSync) {}
|
||||||
|
|
||||||
grant(input: { orgId: string; agentId: string; toolkitSlug: string; grantedByType: "agent" | "employee"; grantedById: string | null }): void {
|
grant(input: { orgId: string; agentId: string; toolkitSlug: string; accountLabel: string | null; grantedByType: "agent" | "employee"; grantedById: string | null }): string {
|
||||||
const agent = this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'")
|
const agent = this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'")
|
||||||
.get(input.agentId, input.orgId);
|
.get(input.agentId, input.orgId);
|
||||||
if (!agent) throw new Error("Grant target must be an active agent in this organization.");
|
if (!agent) throw new Error("Grant target must be an active agent in this organization.");
|
||||||
const slug = normalizeSlug(input.toolkitSlug);
|
const slug = normalizeSlug(input.toolkitSlug);
|
||||||
const account = this.db.prepare(
|
const label = this.chooseAccount(input.orgId, slug, input.accountLabel).label;
|
||||||
"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(`
|
this.db.prepare(`
|
||||||
insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, granted_by_type, granted_by_id, created_at)
|
insert into agent_connector_grants(id, org_id, agent_id, toolkit_slug, account_label, granted_by_type, granted_by_id, created_at)
|
||||||
values (?, ?, ?, ?, ?, ?, ?)
|
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
on conflict(org_id, agent_id, toolkit_slug) do nothing
|
on conflict(org_id, agent_id, toolkit_slug) do update set
|
||||||
`).run(createId("cgr"), input.orgId, input.agentId, slug, input.grantedByType, input.grantedById, new Date().toISOString());
|
account_label = excluded.account_label,
|
||||||
|
granted_by_type = excluded.granted_by_type,
|
||||||
|
granted_by_id = excluded.granted_by_id
|
||||||
|
`).run(createId("cgr"), input.orgId, input.agentId, slug, label, input.grantedByType, input.grantedById, new Date().toISOString());
|
||||||
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
revoke(input: { orgId: string; agentId: string; toolkitSlug: string }): void {
|
revoke(input: { orgId: string; agentId: string; toolkitSlug: string }): void {
|
||||||
@@ -46,51 +48,82 @@ export class ConnectorGrantsService {
|
|||||||
.run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug));
|
.run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Toolkits this agent may use: every active one for the CEO; for others, those whose granted account is active. */
|
||||||
* Toolkits this agent may use. The CEO carries every actively connected
|
|
||||||
* toolkit by role; every other agent needs a grant AND an active
|
|
||||||
* connection.
|
|
||||||
*/
|
|
||||||
toolkitsForAgent(orgId: string, agentId: string): string[] {
|
toolkitsForAgent(orgId: string, agentId: string): string[] {
|
||||||
const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
if (this.isCeo(orgId, agentId)) {
|
||||||
.get(agentId, orgId) as { role_level: string } | undefined;
|
return [...new Set(this.activeAccounts(orgId).map((account) => account.toolkitSlug))];
|
||||||
if (acting?.role_level === "c_suite") {
|
|
||||||
return this.activeAccounts(orgId).map((account) => account.toolkitSlug);
|
|
||||||
}
|
}
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
select g.toolkit_slug as slug
|
select g.toolkit_slug as slug
|
||||||
from agent_connector_grants g
|
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'
|
join connector_accounts a on a.org_id = g.org_id and a.toolkit_slug = g.toolkit_slug and a.label = g.account_label and a.status = 'ACTIVE'
|
||||||
where g.org_id = ? and g.agent_id = ?
|
where g.org_id = ? and g.agent_id = ?
|
||||||
order by g.toolkit_slug asc
|
order by g.toolkit_slug asc
|
||||||
`).all(orgId, agentId) as Array<{ slug: string }>;
|
`).all(orgId, agentId) as Array<{ slug: string }>;
|
||||||
return rows.map((row) => row.slug);
|
return rows.map((row) => row.slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The active account this agent acts through for a toolkit; the CEO names one when the toolkit has several. */
|
||||||
|
accountForAgent(orgId: string, agentId: string, toolkitSlug: string, accountLabel: string | null): ConnectorAccountRow {
|
||||||
|
const slug = normalizeSlug(toolkitSlug);
|
||||||
|
if (this.isCeo(orgId, agentId)) return this.chooseAccount(orgId, slug, accountLabel);
|
||||||
|
const grant = this.db.prepare("select account_label from agent_connector_grants where org_id = ? and agent_id = ? and toolkit_slug = ?")
|
||||||
|
.get(orgId, agentId, slug) as { account_label: string } | undefined;
|
||||||
|
if (!grant) throw new Error(`You have no grant for the "${slug}" connector. Ask the CEO to grant it to you (grant_connector).`);
|
||||||
|
if (accountLabel !== null && normalizeAccountLabel(accountLabel) !== grant.account_label) {
|
||||||
|
throw new Error(`Your grant for "${slug}" is the "${grant.account_label}" account, not "${accountLabel}".`);
|
||||||
|
}
|
||||||
|
const account = this.activeAccounts(orgId).find((row) => row.toolkitSlug === slug && row.label === grant.account_label);
|
||||||
|
if (!account) throw new Error(`The "${slug}" account "${grant.account_label}" is no longer connected. Ask the board to reconnect it on the Connectors page.`);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
activeAccounts(orgId: string): ConnectorAccountRow[] {
|
activeAccounts(orgId: string): ConnectorAccountRow[] {
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
select id, org_id, toolkit_slug, toolkit_name, status
|
select id, org_id, toolkit_slug, toolkit_name, label, status
|
||||||
from connector_accounts
|
from connector_accounts
|
||||||
where org_id = ? and status = 'ACTIVE'
|
where org_id = ? and status = 'ACTIVE'
|
||||||
order by toolkit_slug asc
|
order by toolkit_slug asc, label asc
|
||||||
`).all(orgId) as Array<{ id: string; org_id: string; toolkit_slug: string; toolkit_name: string; status: string }>;
|
`).all(orgId) as Array<{ id: string; org_id: string; toolkit_slug: string; toolkit_name: string; label: string; status: string }>;
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
orgId: row.org_id,
|
orgId: row.org_id,
|
||||||
toolkitSlug: row.toolkit_slug,
|
toolkitSlug: row.toolkit_slug,
|
||||||
toolkitName: row.toolkit_name,
|
toolkitName: row.toolkit_name,
|
||||||
|
label: row.label,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
grantsForOrg(orgId: string): ConnectorGrantRow[] {
|
grantsForOrg(orgId: string): ConnectorGrantRow[] {
|
||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
select id, org_id, agent_id, toolkit_slug
|
select id, org_id, agent_id, toolkit_slug, account_label
|
||||||
from agent_connector_grants
|
from agent_connector_grants
|
||||||
where org_id = ?
|
where org_id = ?
|
||||||
order by toolkit_slug asc
|
order by toolkit_slug asc
|
||||||
`).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string }>;
|
`).all(orgId) as Array<{ id: string; org_id: string; agent_id: string; toolkit_slug: string; account_label: string }>;
|
||||||
return rows.map((row) => ({ id: row.id, orgId: row.org_id, agentId: row.agent_id, toolkitSlug: row.toolkit_slug }));
|
return rows.map((row) => ({ id: row.id, orgId: row.org_id, agentId: row.agent_id, toolkitSlug: row.toolkit_slug, accountLabel: row.account_label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The named active account of a toolkit, or its only one; several accounts with no name is an error that lists them. */
|
||||||
|
private chooseAccount(orgId: string, slug: string, accountLabel: string | null): ConnectorAccountRow {
|
||||||
|
const accounts = this.activeAccounts(orgId).filter((account) => account.toolkitSlug === slug);
|
||||||
|
if (accounts.length === 0) throw new Error(`No active connection for toolkit "${slug}". Connect it on the Connectors page first.`);
|
||||||
|
const labels = accounts.map((account) => account.label).join(", ");
|
||||||
|
if (accountLabel === null) {
|
||||||
|
if (accounts.length > 1) throw new Error(`"${slug}" has several accounts (${labels}); name one with account.`);
|
||||||
|
return accounts[0];
|
||||||
|
}
|
||||||
|
const label = normalizeAccountLabel(accountLabel);
|
||||||
|
const account = accounts.find((row) => row.label === label);
|
||||||
|
if (!account) throw new Error(`"${slug}" has no active account "${label}"; its accounts: ${labels}.`);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCeo(orgId: string, agentId: string): boolean {
|
||||||
|
const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?")
|
||||||
|
.get(agentId, orgId) as { role_level: string } | undefined;
|
||||||
|
return acting?.role_level === "c_suite";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,3 +132,11 @@ export function normalizeSlug(slug: string): string {
|
|||||||
if (!normalized) throw new Error("Toolkit slug is required.");
|
if (!normalized) throw new Error("Toolkit slug is required.");
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Account labels are trimmed and lower-case so "Sales" and "sales " name the same account. */
|
||||||
|
export function normalizeAccountLabel(label: string): string {
|
||||||
|
const normalized = label.trim().toLowerCase();
|
||||||
|
if (!normalized) throw new Error("Account label is required.");
|
||||||
|
if (normalized.length > MAX_LABEL_CHARS) throw new Error(`Account label must be at most ${MAX_LABEL_CHARS} characters.`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { AppSnapshot } from "../../../shared/domain";
|
|||||||
import { Button } from "../shared/ui";
|
import { Button } from "../shared/ui";
|
||||||
import { ToolkitCatalog } from "../pages/connectors/ToolkitCatalog";
|
import { ToolkitCatalog } from "../pages/connectors/ToolkitCatalog";
|
||||||
import { useConnectorCatalog } from "../pages/connectors/useConnectorCatalog";
|
import { useConnectorCatalog } from "../pages/connectors/useConnectorCatalog";
|
||||||
|
import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands";
|
||||||
import type { SnapshotActions } from "./useSnapshot";
|
import type { SnapshotActions } from "./useSnapshot";
|
||||||
import { t } from "../i18n/i18n";
|
import { t } from "../i18n/i18n";
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ export function ConnectorsStep({
|
|||||||
entries={catalog.entries}
|
entries={catalog.entries}
|
||||||
connectedSlugs={connectedSlugs}
|
connectedSlugs={connectedSlugs}
|
||||||
busySlug={busySlug}
|
busySlug={busySlug}
|
||||||
onConnect={connect}
|
onConnect={(slug) => connect(slug, DEFAULT_CONNECTOR_ACCOUNT_LABEL)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -383,6 +383,9 @@ export const zh: Record<string, string> = {
|
|||||||
"Recently decided": "最近已决",
|
"Recently decided": "最近已决",
|
||||||
"Reconnect": "重新连接",
|
"Reconnect": "重新连接",
|
||||||
"Reconnect waiting for authorization": "重新连接等待授权",
|
"Reconnect waiting for authorization": "重新连接等待授权",
|
||||||
|
"Service": "服务",
|
||||||
|
"Account label, e.g. sales": "账号标签,例如 sales",
|
||||||
|
"Add account": "添加账号",
|
||||||
"Refresh models": "刷新模型",
|
"Refresh models": "刷新模型",
|
||||||
"Region": "区域",
|
"Region": "区域",
|
||||||
"Rejected": "已驳回",
|
"Rejected": "已驳回",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { activeOrgId } from "./types";
|
|||||||
import { ConnectedList } from "./connectors/ConnectedList";
|
import { ConnectedList } from "./connectors/ConnectedList";
|
||||||
import { ToolkitCatalog } from "./connectors/ToolkitCatalog";
|
import { ToolkitCatalog } from "./connectors/ToolkitCatalog";
|
||||||
import { useConnectorCatalog } from "./connectors/useConnectorCatalog";
|
import { useConnectorCatalog } from "./connectors/useConnectorCatalog";
|
||||||
|
import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The company's connector control room: what is connected, who holds it, and
|
* The company's connector control room: what is connected, who holds it, and
|
||||||
@@ -66,7 +67,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
|||||||
logoBySlug={logoBySlug}
|
logoBySlug={logoBySlug}
|
||||||
busySlug={busySlug}
|
busySlug={busySlug}
|
||||||
busyAction={busyAction}
|
busyAction={busyAction}
|
||||||
onReconnect={connect}
|
onConnect={connect}
|
||||||
onDelete={disconnect}
|
onDelete={disconnect}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
|||||||
entries={catalog.entries}
|
entries={catalog.entries}
|
||||||
connectedSlugs={connectedSlugs}
|
connectedSlugs={connectedSlugs}
|
||||||
busySlug={busySlug}
|
busySlug={busySlug}
|
||||||
onConnect={connect}
|
onConnect={(slug) => connect(slug, DEFAULT_CONNECTOR_ACCOUNT_LABEL)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
import { Button, IconButton, Pill } from "../../shared/ui";
|
import { Button, IconButton, Pill } from "../../shared/ui";
|
||||||
import type { AgentRecord, ConnectorAccountRecord, ConnectorGrantRecord } from "../../../../shared/domain";
|
import type { AgentRecord, ConnectorAccountRecord, ConnectorGrantRecord } from "../../../../shared/domain";
|
||||||
import { ToolkitTile } from "./ToolkitCatalog";
|
import { ToolkitTile } from "./ToolkitCatalog";
|
||||||
@@ -26,7 +27,7 @@ export function ConnectedList({
|
|||||||
logoBySlug,
|
logoBySlug,
|
||||||
busySlug,
|
busySlug,
|
||||||
busyAction,
|
busyAction,
|
||||||
onReconnect,
|
onConnect,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
accounts: ConnectorAccountRecord[];
|
accounts: ConnectorAccountRecord[];
|
||||||
@@ -35,18 +36,26 @@ export function ConnectedList({
|
|||||||
logoBySlug: Map<string, string | null>;
|
logoBySlug: Map<string, string | null>;
|
||||||
busySlug: string | null;
|
busySlug: string | null;
|
||||||
busyAction: "connect" | "disconnect" | null;
|
busyAction: "connect" | "disconnect" | null;
|
||||||
onReconnect: (toolkitSlug: string) => void;
|
onConnect: (toolkitSlug: string, label: string) => void;
|
||||||
onDelete: (toolkitSlug: string) => void;
|
onDelete: (toolkitSlug: string, label: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [addSlug, setAddSlug] = useState("");
|
||||||
|
const [addLabel, setAddLabel] = useState("");
|
||||||
if (accounts.length === 0) return null;
|
if (accounts.length === 0) return null;
|
||||||
const agentNames = new Map(agents.map((agent) => [agent.id, agent.name]));
|
const agentNames = new Map(agents.map((agent) => [agent.id, agent.name]));
|
||||||
|
const toolkits = [...new Map(accounts.map((account) => [account.toolkitSlug, { slug: account.toolkitSlug, name: account.toolkitName }])).values()];
|
||||||
|
const addAccount = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onConnect(addSlug, addLabel);
|
||||||
|
setAddLabel("");
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<section style={{ marginBottom: 24 }}>
|
<section style={{ marginBottom: 24 }}>
|
||||||
<div className="section-label" style={{ marginBottom: 12 }}>{t("Connected")}</div>
|
<div className="section-label" style={{ marginBottom: 12 }}>{t("Connected")}</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
{accounts.map((account) => {
|
{accounts.map((account) => {
|
||||||
const holders = grants
|
const holders = grants
|
||||||
.filter((grant) => grant.toolkitSlug === account.toolkitSlug)
|
.filter((grant) => grant.toolkitSlug === account.toolkitSlug && grant.accountLabel === account.label)
|
||||||
.map((grant) => agentNames.get(grant.agentId))
|
.map((grant) => agentNames.get(grant.agentId))
|
||||||
.filter((name): name is string => Boolean(name));
|
.filter((name): name is string => Boolean(name));
|
||||||
const busy = busySlug !== null;
|
const busy = busySlug !== null;
|
||||||
@@ -60,17 +69,18 @@ export function ConnectedList({
|
|||||||
size={30}
|
size={30}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
|
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
|
||||||
|
<span style={{ color: "var(--text-2)", fontSize: 12.5 }}>{account.label}</span>
|
||||||
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
|
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
|
||||||
{account.pendingAccountId && <Pill tone="amber" dot>{t("Reconnect waiting for authorization")}</Pill>}
|
{account.pendingAccountId && <Pill tone="amber" dot>{t("Reconnect waiting for authorization")}</Pill>}
|
||||||
<span className="row" style={{ gap: 6, marginLeft: "auto" }}>
|
<span className="row" style={{ gap: 6, marginLeft: "auto" }}>
|
||||||
<Button size="small" disabled={busy} onClick={() => onReconnect(account.toolkitSlug)}>
|
<Button size="small" disabled={busy} onClick={() => onConnect(account.toolkitSlug, account.label)}>
|
||||||
{busySlug === account.toolkitSlug && busyAction === "connect" ? t("Opening…") : t("Reconnect")}
|
{busySlug === account.toolkitSlug && busyAction === "connect" ? t("Opening…") : t("Reconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="x"
|
icon="x"
|
||||||
label={t("Delete {name} connection", { name: account.toolkitName })}
|
label={t("Delete {name} connection", { name: `${account.toolkitName} ${account.label}` })}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onDelete(account.toolkitSlug)}
|
onClick={() => onDelete(account.toolkitSlug, account.label)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +93,14 @@ export function ConnectedList({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<form className="row" style={{ gap: 8, marginTop: 12 }} onSubmit={addAccount}>
|
||||||
|
<select className="input" style={{ width: 180 }} value={addSlug} onChange={(event) => setAddSlug(event.target.value)} required>
|
||||||
|
<option value="" disabled>{t("Service")}</option>
|
||||||
|
{toolkits.map((toolkit) => <option key={toolkit.slug} value={toolkit.slug}>{toolkit.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<input className="input" style={{ width: 200 }} placeholder={t("Account label, e.g. sales")} value={addLabel} onChange={(event) => setAddLabel(event.target.value)} required maxLength={40} />
|
||||||
|
<Button size="small" type="submit" disabled={busySlug !== null}>{t("Add account")}</Button>
|
||||||
|
</form>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,8 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
|||||||
busySlug: string | null;
|
busySlug: string | null;
|
||||||
busyAction: "connect" | "disconnect" | null;
|
busyAction: "connect" | "disconnect" | null;
|
||||||
connectError: string | null;
|
connectError: string | null;
|
||||||
connect: (toolkitSlug: string) => void;
|
connect: (toolkitSlug: string, label: string) => void;
|
||||||
disconnect: (toolkitSlug: string) => void;
|
disconnect: (toolkitSlug: string, label: string) => void;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
} {
|
} {
|
||||||
const [catalogSlot, setCatalogSlot] = useState<CatalogSlot>(null);
|
const [catalogSlot, setCatalogSlot] = useState<CatalogSlot>(null);
|
||||||
@@ -75,13 +75,13 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
|||||||
[actions, orgId],
|
[actions, orgId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pollUntilActive = useCallback((toolkitSlug: string) => {
|
const pollUntilActive = useCallback((toolkitSlug: string, label: string) => {
|
||||||
const poll = (attempt: number) => {
|
const poll = (attempt: number) => {
|
||||||
if (attempt >= CONNECT_POLL_ATTEMPTS) return;
|
if (attempt >= CONNECT_POLL_ATTEMPTS) return;
|
||||||
pollTimer.current = setTimeout(() => {
|
pollTimer.current = setTimeout(() => {
|
||||||
void refreshAccounts().then((data) => {
|
void refreshAccounts().then((data) => {
|
||||||
const account = data?.connectorAccounts.find(
|
const account = data?.connectorAccounts.find(
|
||||||
(item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug,
|
(item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug && item.label === label,
|
||||||
);
|
);
|
||||||
if (account?.status === "ACTIVE" && !account.pendingAccountId) return;
|
if (account?.status === "ACTIVE" && !account.pendingAccountId) return;
|
||||||
poll(attempt + 1);
|
poll(attempt + 1);
|
||||||
@@ -91,17 +91,17 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
|||||||
poll(0);
|
poll(0);
|
||||||
}, [refreshAccounts, orgId]);
|
}, [refreshAccounts, orgId]);
|
||||||
|
|
||||||
const connect = useCallback((toolkitSlug: string) => {
|
const connect = useCallback((toolkitSlug: string, label: string) => {
|
||||||
setBusy({ slug: toolkitSlug, action: "connect" });
|
setBusy({ slug: toolkitSlug, action: "connect" });
|
||||||
setConnectError(null);
|
setConnectError(null);
|
||||||
void window.agentCompany
|
void window.agentCompany
|
||||||
.connectComposioToolkit({ orgId, toolkitSlug })
|
.connectComposioToolkit({ orgId, toolkitSlug, label })
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setConnectError(result.error.message);
|
setConnectError(result.error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pollUntilActive(toolkitSlug);
|
pollUntilActive(toolkitSlug, label.trim().toLowerCase());
|
||||||
if (result.data.redirectUrl) {
|
if (result.data.redirectUrl) {
|
||||||
const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl);
|
const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl);
|
||||||
if (!opened.ok) setConnectError(opened.error.message);
|
if (!opened.ok) setConnectError(opened.error.message);
|
||||||
@@ -110,10 +110,10 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
|||||||
.finally(() => setBusy(null));
|
.finally(() => setBusy(null));
|
||||||
}, [orgId, pollUntilActive]);
|
}, [orgId, pollUntilActive]);
|
||||||
|
|
||||||
const disconnect = useCallback((toolkitSlug: string) => {
|
const disconnect = useCallback((toolkitSlug: string, label: string) => {
|
||||||
setBusy({ slug: toolkitSlug, action: "disconnect" });
|
setBusy({ slug: toolkitSlug, action: "disconnect" });
|
||||||
setConnectError(null);
|
setConnectError(null);
|
||||||
void actions.run(() => window.agentCompany.disconnectComposioToolkit({ orgId, toolkitSlug }))
|
void actions.run(() => window.agentCompany.disconnectComposioToolkit({ orgId, toolkitSlug, label }))
|
||||||
.catch((error: unknown) => setConnectError(error instanceof Error ? error.message : String(error)))
|
.catch((error: unknown) => setConnectError(error instanceof Error ? error.message : String(error)))
|
||||||
.finally(() => setBusy(null));
|
.finally(() => setBusy(null));
|
||||||
}, [actions, orgId]);
|
}, [actions, orgId]);
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ export interface ConnectorCatalogEntry {
|
|||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The label of a toolkit's first account, and of every account from before labels existed. */
|
||||||
|
export const DEFAULT_CONNECTOR_ACCOUNT_LABEL = "main";
|
||||||
|
|
||||||
export interface ConnectComposioToolkitInput {
|
export interface ConnectComposioToolkitInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
toolkitSlug: string;
|
toolkitSlug: string;
|
||||||
|
/** The account to connect or reconnect; a new label adds another account of the toolkit. */
|
||||||
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectComposioToolkitResult {
|
export interface ConnectComposioToolkitResult {
|
||||||
@@ -19,6 +24,7 @@ export interface ConnectComposioToolkitResult {
|
|||||||
export interface DisconnectComposioToolkitInput {
|
export interface DisconnectComposioToolkitInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
toolkitSlug: string;
|
toolkitSlug: string;
|
||||||
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshComposioAccountsInput {
|
export interface RefreshComposioAccountsInput {
|
||||||
|
|||||||
@@ -451,6 +451,8 @@ export interface ConnectorAccountRecord {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
toolkitSlug: string;
|
toolkitSlug: string;
|
||||||
toolkitName: string;
|
toolkitName: string;
|
||||||
|
/** Names the account among the toolkit's company accounts, e.g. "main" or "sales". */
|
||||||
|
label: string;
|
||||||
authConfigId: string;
|
authConfigId: string;
|
||||||
status: string;
|
status: string;
|
||||||
/** A reconnect waiting for authorization; the current account keeps working until it is active. */
|
/** A reconnect waiting for authorization; the current account keeps working until it is active. */
|
||||||
@@ -464,6 +466,7 @@ export interface ConnectorGrantRecord {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
toolkitSlug: string;
|
toolkitSlug: string;
|
||||||
|
accountLabel: string;
|
||||||
grantedByType: string;
|
grantedByType: string;
|
||||||
grantedById: string | null;
|
grantedById: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -154,11 +154,11 @@ describe("agent instruction wiring", () => {
|
|||||||
});
|
});
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
connection.db.prepare(`
|
connection.db.prepare(`
|
||||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
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', 'ac_github', 'ACTIVE', ?, ?)
|
values ('ca_github', ?, 'github', 'GitHub', 'main', 'ac_github', 'ACTIVE', ?, ?)
|
||||||
`).run(orgId, now, now);
|
`).run(orgId, now, now);
|
||||||
new ConnectorGrantsService(connection.db).grant({
|
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({
|
taskId = organization.createTask({
|
||||||
orgId, title: "Ship it", description: "d", status: TASK_STATUS.IN_PROGRESS,
|
orgId, title: "Ship it", description: "d", status: TASK_STATUS.IN_PROGRESS,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("a run tells the user which capabilities it lacked", () => {
|
|||||||
orgId = summary.orgId;
|
orgId = summary.orgId;
|
||||||
ceoId = summary.ceoAgentId;
|
ceoId = summary.ceoAgentId;
|
||||||
const now = new Date().toISOString();
|
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);
|
const keys = new IntegrationKeyStore(connection.db, vault);
|
||||||
keys.write("composio", "ak_capability");
|
keys.write("composio", "ak_capability");
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ async function connectedCompany() {
|
|||||||
const keys = new IntegrationKeyStore(connection.db, vault);
|
const keys = new IntegrationKeyStore(connection.db, vault);
|
||||||
keys.write("composio", `ak_${randomUUID()}`);
|
keys.write("composio", `ak_${randomUUID()}`);
|
||||||
const service = new ComposioConnectionService(connection.db, keys);
|
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";
|
remote.get(first.accountId)!.status = "ACTIVE";
|
||||||
await service.refreshAccounts(orgId);
|
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 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;
|
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 };
|
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", () => {
|
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 () => {
|
test("a reconnect the user abandons leaves the old account working", async () => {
|
||||||
const h = await connectedCompany();
|
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(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: again.accountId });
|
||||||
expect(remote.has(h.oldId)).toBe(true);
|
expect(remote.has(h.oldId)).toBe(true);
|
||||||
await h.service.refreshAccounts(h.orgId);
|
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 () => {
|
test("an authorized reconnect replaces the old account and keeps the grants", async () => {
|
||||||
const h = await connectedCompany();
|
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";
|
remote.get(again.accountId)!.status = "ACTIVE";
|
||||||
await h.service.refreshAccounts(h.orgId);
|
await h.service.refreshAccounts(h.orgId);
|
||||||
expect(h.account()).toEqual({ id: again.accountId, status: "ACTIVE", pending: null });
|
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 () => {
|
test("an earlier reconnect that cannot be deleted never replaces the working account", async () => {
|
||||||
const h = await connectedCompany();
|
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);
|
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)!;
|
const second = [...remote.keys()].at(-1)!;
|
||||||
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: second });
|
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: second });
|
||||||
await h.service.refreshAccounts(h.orgId);
|
await h.service.refreshAccounts(h.orgId);
|
||||||
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: second });
|
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: second });
|
||||||
remote.get(first.accountId)!.status = "ACTIVE";
|
remote.get(first.accountId)!.status = "ACTIVE";
|
||||||
failDelete.clear();
|
failDelete.clear();
|
||||||
await h.service.disconnectToolkit(h.orgId, "github");
|
await h.service.disconnectToolkit(h.orgId, "github", "main");
|
||||||
expect(remote.size).toBe(0);
|
expect(remote.size).toBe(0);
|
||||||
await h.service.refreshAccounts(h.orgId);
|
await h.service.refreshAccounts(h.orgId);
|
||||||
expect(h.account()).toBeUndefined();
|
expect(h.account()).toBeUndefined();
|
||||||
h.connection.close();
|
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 () => {
|
test("a reconnect that cannot start touches nothing", async () => {
|
||||||
const h = await connectedCompany();
|
const h = await connectedCompany();
|
||||||
failCreate = true;
|
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(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: null });
|
||||||
expect([...remote.keys()]).toEqual([h.oldId]);
|
expect([...remote.keys()]).toEqual([h.oldId]);
|
||||||
h.connection.close();
|
h.connection.close();
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ describe("composio tool loading", () => {
|
|||||||
const { connection, orgId, ceoId } = ceoHarness();
|
const { connection, orgId, ceoId } = ceoHarness();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
connection.db.prepare(`
|
connection.db.prepare(`
|
||||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
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', 'ac_1', 'ACTIVE', ?, ?)
|
values ('ca_1', ?, 'gmail', 'Gmail', 'main', 'ac_1', 'ACTIVE', ?, ?)
|
||||||
`).run(orgId, now, now);
|
`).run(orgId, now, now);
|
||||||
expect(() => loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).toThrow("no Composio API key is saved");
|
expect(() => loadComposioTools(connection.db, new IntegrationKeyStore(connection.db, plainVault), orgId, ceoId)).toThrow("no Composio API key is saved");
|
||||||
connection.close();
|
connection.close();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
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 { IntegrationKeyStore } = await import("../../src/main/services/integration-key-store");
|
||||||
const { ConnectorGrantsService } = await import("../../src/main/services/composio/connector-grants");
|
const { ConnectorGrantsService } = await import("../../src/main/services/composio/connector-grants");
|
||||||
const { recordRunAction } = await import("../../src/main/services/run-journal");
|
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" } });
|
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;
|
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" });
|
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();
|
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);
|
const keys = new IntegrationKeyStore(connection.db, vault);
|
||||||
keys.write("composio", apiKey);
|
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 () => {
|
test("a call left pending by a crash blocks the same call", async () => {
|
||||||
pages.set("github:", { items: [tool("GITHUB_SEND")], next_cursor: null });
|
pages.set("github:", { items: [tool("GITHUB_SEND")], next_cursor: null });
|
||||||
const h = company("ak_crash");
|
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/);
|
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();
|
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 () => {
|
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 });
|
pages.set("github:", { items: [tool("GITHUB_STAR_A_REPOSITORY")], next_cursor: null });
|
||||||
const h = company("ak_revoke");
|
const h = company("ak_revoke");
|
||||||
const staff = h.connection.transaction(() => {
|
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 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 });
|
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;
|
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: {} } });
|
const running = executeComposioTool(h.connection.db, h.keys, h.orgId, staff, { id: "c2", name: "composio_run_action", arguments: { action: "GITHUB_STAR_A_REPOSITORY", arguments: {} } });
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ function harness(): Harness {
|
|||||||
function connectToolkit(h: Harness, slug: string, name: string): void {
|
function connectToolkit(h: Harness, slug: string, name: string): void {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
h.connection.db.prepare(`
|
h.connection.db.prepare(`
|
||||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, label, auth_config_id, status, created_at, updated_at)
|
||||||
values (?, ?, ?, ?, ?, 'ACTIVE', ?, ?)
|
values (?, ?, ?, ?, 'main', ?, 'ACTIVE', ?, ?)
|
||||||
`).run(`ca_${slug}`, h.orgId, slug, name, `ac_${slug}`, now, now);
|
`).run(`ca_${slug}`, h.orgId, slug, name, `ac_${slug}`, now, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ describe("connector grants", () => {
|
|||||||
const h = harness();
|
const h = harness();
|
||||||
const grants = new ConnectorGrantsService(h.connection.db);
|
const grants = new ConnectorGrantsService(h.connection.db);
|
||||||
expect(() => grants.grant({
|
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/);
|
})).toThrow(/No active connection/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,13 +87,41 @@ describe("connector grants", () => {
|
|||||||
expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]);
|
expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]);
|
||||||
|
|
||||||
const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {}));
|
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" }));
|
const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_connector", { agent_name: "Dana", toolkit: "github" }));
|
||||||
expect(revoked).toContain("no longer has access");
|
expect(revoked).toContain("no longer has access");
|
||||||
expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]);
|
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", () => {
|
test("staff cannot hand out connector access", () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
connectToolkit(h, "github", "GitHub");
|
connectToolkit(h, "github", "GitHub");
|
||||||
@@ -106,7 +134,7 @@ describe("connector grants", () => {
|
|||||||
const h = harness();
|
const h = harness();
|
||||||
connectToolkit(h, "slack", "Slack");
|
connectToolkit(h, "slack", "Slack");
|
||||||
const grants = new ConnectorGrantsService(h.connection.db);
|
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"]);
|
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);
|
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([]);
|
expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
|||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
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();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user