Q100: reconnecting keeps the working connector account until the new one is authorized
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f8731cbac
commit
736c45c0ee
9 files changed
+194
-30
No files matched your search
@@ -0,0 +1,6 @@
|
||||
export const connectorPendingAccountSchema = `
|
||||
alter table connector_accounts add column pending_account_id text;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (82, 'connector_pending_account', datetime('now'));
|
||||
`;
|
||||
@@ -80,6 +80,7 @@ import { requestErrorCodesSchema } from "./migrations/078-request-error-codes";
|
||||
import { eraseRetiredCredentialsSchema } from "./migrations/079-erase-retired-credentials";
|
||||
import { runCapabilityWarningsSchema } from "./migrations/080-run-capability-warnings";
|
||||
import { connectorExecutionsSchema } from "./migrations/081-connector-executions";
|
||||
import { connectorPendingAccountSchema } from "./migrations/082-connector-pending-account";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -169,6 +170,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 79, name: "erase_retired_credentials", sql: eraseRetiredCredentialsSchema },
|
||||
{ version: 80, name: "run_capability_warnings", sql: runCapabilityWarningsSchema },
|
||||
{ version: 81, name: "connector_executions", sql: connectorExecutionsSchema },
|
||||
{ version: 82, name: "connector_pending_account", sql: connectorPendingAccountSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ConnectorCatalogEntry, ConnectComposioToolkitResult } from "../../../shared/commands";
|
||||
import { ComposioClient } from "./composio-client";
|
||||
import { ComposioClient, type ComposioConnectedAccount } from "./composio-client";
|
||||
import { IntegrationKeyStore } from "../integration-key-store";
|
||||
import { getComposioUserId } from "./composio-tool-bridge";
|
||||
import { connectorDisplayName, connectorLogoUrl } from "./connector-brands";
|
||||
import { normalizeSlug } from "./connector-grants";
|
||||
|
||||
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||
const WAITING_STATUSES = new Set(["INITIALIZING", "INITIATED"]);
|
||||
|
||||
let catalogCache: { fetchedAt: number; keys: string; entries: ConnectorCatalogEntry[] } | null = null;
|
||||
|
||||
@@ -34,15 +35,7 @@ export class ComposioConnectionService {
|
||||
return this.catalog(this.requireClient());
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or restarts) the company's connection for a toolkit: initiates a
|
||||
* connected account against the toolkit's configured auth config via the
|
||||
* link flow and records the pending account locally as INITIALIZING — the
|
||||
* poll that follows adopts Composio's real status. Any previous account
|
||||
* for the toolkit is deleted first so reconnecting replaces instead of
|
||||
* accumulating accounts in Composio. The returned redirect URL completes
|
||||
* authorization (or API-key entry) in the browser.
|
||||
*/
|
||||
/** Starts a toolkit's connection; a reconnect waits beside an active account until refresh finds it authorized. */
|
||||
connectToolkit(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
|
||||
return this.serialize(orgId, () => this.connectToolkitNow(orgId, toolkitSlug));
|
||||
}
|
||||
@@ -61,15 +54,22 @@ export class ComposioConnectionService {
|
||||
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.`);
|
||||
const previous = this.db.prepare(
|
||||
"select id from connector_accounts where org_id = ? and toolkit_slug = ?",
|
||||
).get(orgId, slug) as { id: string } | undefined;
|
||||
if (previous) await client.deleteConnectedAccount(previous.id);
|
||||
"select id, status, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ?",
|
||||
).get(orgId, slug) as { id: string; status: string; pending_account_id: string | null } | undefined;
|
||||
const account = await client.createConnectedAccount({
|
||||
authConfigId: entry.authConfigId,
|
||||
userId: getComposioUserId(this.db, orgId),
|
||||
});
|
||||
if (!account.id) throw new Error("Composio did not return a connected account id.");
|
||||
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.
|
||||
if (previous?.status === "ACTIVE") {
|
||||
this.db.prepare("update connector_accounts set pending_account_id = ?, updated_at = ? where org_id = ? and toolkit_slug = ?")
|
||||
.run(account.id, now, orgId, slug);
|
||||
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);
|
||||
return { accountId: account.id, redirectUrl: account.redirectUrl };
|
||||
}
|
||||
this.db.prepare(`
|
||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -78,17 +78,15 @@ export class ComposioConnectionService {
|
||||
toolkit_name = excluded.toolkit_name,
|
||||
auth_config_id = excluded.auth_config_id,
|
||||
status = excluded.status,
|
||||
pending_account_id = null,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(account.id, orgId, slug, entry.name, entry.authConfigId, "INITIALIZING", now, now);
|
||||
if (previous?.pending_account_id) await client.deleteConnectedAccount(previous.pending_account_id);
|
||||
if (previous) await client.deleteConnectedAccount(previous.id);
|
||||
return { accountId: account.id, redirectUrl: account.redirectUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls account statuses from Composio and updates the local mirror, in
|
||||
* both directions: statuses adopt Composio's truth, and local rows whose
|
||||
* account no longer exists in Composio are removed along with their
|
||||
* grants — deleting an account in the dashboard cleans up here too.
|
||||
*/
|
||||
/** Mirrors Composio: tracked accounts take its status, gone ones are removed with their grants, untracked toolkits are adopted. */
|
||||
refreshAccounts(orgId: string): Promise<void> {
|
||||
return this.serialize(orgId, () => this.refreshAccountsNow(orgId));
|
||||
}
|
||||
@@ -98,13 +96,26 @@ export class ComposioConnectionService {
|
||||
const accounts = await client.listConnectedAccounts(getComposioUserId(this.db, orgId));
|
||||
const now = new Date().toISOString();
|
||||
const liveIds = new Set(accounts.map((account) => account.id));
|
||||
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 }>;
|
||||
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 = ?")
|
||||
.all(orgId) as Array<{ id: string; toolkit_slug: string; pending_account_id: string | null }>;
|
||||
for (const row of localRows) {
|
||||
if (liveIds.has(row.id)) continue;
|
||||
this.removeLocalAccount(orgId, row.toolkit_slug);
|
||||
}
|
||||
const tracked = new Map(localRows.filter((row) => liveIds.has(row.id)).map((row) => [row.toolkit_slug, row]));
|
||||
for (const account of accounts) {
|
||||
if (!account.toolkitSlug) continue;
|
||||
const row = tracked.get(account.toolkitSlug);
|
||||
// A toolkit's row follows only its own account; others (a waiting reconnect, an orphan) never replace it.
|
||||
if (row && row.id !== account.id) continue;
|
||||
tracked.set(account.toolkitSlug, { id: account.id, toolkit_slug: account.toolkitSlug, pending_account_id: null });
|
||||
this.db.prepare(`
|
||||
insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(org_id, toolkit_slug) do update set
|
||||
id = excluded.id,
|
||||
auth_config_id = excluded.auth_config_id,
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at
|
||||
@@ -119,12 +130,27 @@ export class ComposioConnectionService {
|
||||
now,
|
||||
);
|
||||
}
|
||||
const localRows = this.db.prepare("select id, toolkit_slug from connector_accounts where org_id = ?")
|
||||
.all(orgId) as Array<{ id: string; toolkit_slug: string }>;
|
||||
for (const row of localRows) {
|
||||
if (liveIds.has(row.id)) continue;
|
||||
this.removeLocalAccount(orgId, row.toolkit_slug);
|
||||
}
|
||||
|
||||
/** A waiting reconnect replaces the old account once active (or once the old one is gone); one that failed or vanished is dropped and the old account stays. */
|
||||
private async settleReconnect(
|
||||
client: ComposioClient,
|
||||
row: { id: string; toolkit_slug: string; pending_account_id: string },
|
||||
pending: ComposioConnectedAccount | undefined,
|
||||
oldIsLive: boolean,
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
if (pending && (pending.status === "ACTIVE" || !oldIsLive)) {
|
||||
if (oldIsLive) await client.deleteConnectedAccount(row.id);
|
||||
this.db.prepare("update connector_accounts set id = ?, auth_config_id = ?, status = ?, pending_account_id = null, updated_at = ? where id = ?")
|
||||
.run(pending.id, pending.authConfigId, pending.status, now, row.id);
|
||||
console.info(`[connectors] ${row.toolkit_slug} reconnect ${pending.id} replaced ${row.id}`);
|
||||
return;
|
||||
}
|
||||
if (pending && WAITING_STATUSES.has(pending.status)) return;
|
||||
if (pending) await client.deleteConnectedAccount(pending.id);
|
||||
this.db.prepare("update connector_accounts set pending_account_id = null, updated_at = ? where id = ?").run(now, row.id);
|
||||
console.warn(`[connectors] ${row.toolkit_slug} reconnect ${row.pending_account_id} ended ${pending?.status ?? "missing"}; ${row.id} stays in use`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,10 +165,16 @@ export class ComposioConnectionService {
|
||||
private async disconnectToolkitNow(orgId: string, toolkitSlug: string): Promise<void> {
|
||||
const slug = normalizeSlug(toolkitSlug);
|
||||
const row = this.db.prepare(
|
||||
"select id from connector_accounts where org_id = ? and toolkit_slug = ?",
|
||||
).get(orgId, slug) as { id: string } | undefined;
|
||||
"select id, pending_account_id from connector_accounts where org_id = ? and toolkit_slug = ?",
|
||||
).get(orgId, slug) as { id: string; pending_account_id: string | null } | undefined;
|
||||
if (!row) return;
|
||||
await this.requireClient().deleteConnectedAccount(row.id);
|
||||
const client = this.requireClient();
|
||||
// Every account Composio holds for the toolkit goes, so an orphan from a failed delete cannot be adopted back.
|
||||
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))) {
|
||||
if (account.toolkitSlug === slug) ids.add(account.id);
|
||||
}
|
||||
for (const id of ids) await client.deleteConnectedAccount(id);
|
||||
this.removeLocalAccount(orgId, slug);
|
||||
}
|
||||
|
||||
|
||||
@@ -382,6 +382,7 @@ export const zh: Record<string, string> = {
|
||||
"Recent messages": "最近消息",
|
||||
"Recently decided": "最近已决",
|
||||
"Reconnect": "重新连接",
|
||||
"Reconnect waiting for authorization": "重新连接等待授权",
|
||||
"Refresh models": "刷新模型",
|
||||
"Region": "区域",
|
||||
"Rejected": "已驳回",
|
||||
|
||||
@@ -61,6 +61,7 @@ export function ConnectedList({
|
||||
/>
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
|
||||
<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>}
|
||||
<span className="row" style={{ gap: 6, marginLeft: "auto" }}>
|
||||
<Button size="small" disabled={busy} onClick={() => onReconnect(account.toolkitSlug)}>
|
||||
{busySlug === account.toolkitSlug && busyAction === "connect" ? t("Opening…") : t("Reconnect")}
|
||||
|
||||
@@ -83,7 +83,7 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
||||
const account = data?.connectorAccounts.find(
|
||||
(item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug,
|
||||
);
|
||||
if (account?.status === "ACTIVE") return;
|
||||
if (account?.status === "ACTIVE" && !account.pendingAccountId) return;
|
||||
poll(attempt + 1);
|
||||
});
|
||||
}, CONNECT_POLL_INTERVAL_MS);
|
||||
|
||||
@@ -453,6 +453,8 @@ export interface ConnectorAccountRecord {
|
||||
toolkitName: string;
|
||||
authConfigId: string;
|
||||
status: string;
|
||||
/** A reconnect waiting for authorization; the current account keeps working until it is active. */
|
||||
pendingAccountId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
type RemoteAccount = { id: string; toolkitSlug: string; status: string; authConfigId: string; createdAt: string };
|
||||
const remote = new Map<string, RemoteAccount>();
|
||||
let created = 0;
|
||||
let failCreate = false;
|
||||
const failDelete = new Set<string>();
|
||||
|
||||
vi.mock("../../src/main/services/composio/composio-client", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
ComposioClient: class {
|
||||
async listAuthConfigs() {
|
||||
return [{ id: "ac_gh", toolkitSlug: "github", authScheme: "OAUTH2", status: "ENABLED" }];
|
||||
}
|
||||
async createConnectedAccount(input: { authConfigId: string }) {
|
||||
if (failCreate) throw new Error("Composio is down");
|
||||
const id = `ca_${++created}`;
|
||||
remote.set(id, { id, toolkitSlug: "github", status: "INITIATED", authConfigId: input.authConfigId, createdAt: new Date().toISOString() });
|
||||
return { id, redirectUrl: `https://connect.example/${id}` };
|
||||
}
|
||||
async deleteConnectedAccount(id: string) {
|
||||
if (failDelete.has(id)) throw new Error(`Composio could not delete ${id}`);
|
||||
remote.delete(id);
|
||||
}
|
||||
async listConnectedAccounts() {
|
||||
return [...remote.values()];
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { ComposioConnectionService } = await import("../../src/main/services/composio/composio-connection-service");
|
||||
const { DatabaseConnection } = await import("../../src/main/database/connection");
|
||||
const { OrganizationService } = await import("../../src/main/services/organization-service");
|
||||
const { IntegrationKeyStore } = await import("../../src/main/services/integration-key-store");
|
||||
const { ConnectorGrantsService } = await import("../../src/main/services/composio/connector-grants");
|
||||
|
||||
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };
|
||||
|
||||
/** A company whose GitHub connection is authorized and granted to its CEO. */
|
||||
async function connectedCompany() {
|
||||
remote.clear();
|
||||
failCreate = false;
|
||||
failDelete.clear();
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-reconnect-${randomUUID()}.db`));
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = 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 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");
|
||||
remote.get(first.accountId)!.status = "ACTIVE";
|
||||
await service.refreshAccounts(orgId);
|
||||
new ConnectorGrantsService(connection.db).grant({ orgId, agentId: ceoId, toolkitSlug: "github", 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 };
|
||||
}
|
||||
|
||||
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");
|
||||
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);
|
||||
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: again.accountId });
|
||||
remote.get(again.accountId)!.status = "EXPIRED";
|
||||
await h.service.refreshAccounts(h.orgId);
|
||||
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: null });
|
||||
expect([...remote.keys()]).toEqual([h.oldId]);
|
||||
expect(h.grants()).toBe(1);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
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");
|
||||
remote.get(again.accountId)!.status = "ACTIVE";
|
||||
await h.service.refreshAccounts(h.orgId);
|
||||
expect(h.account()).toEqual({ id: again.accountId, status: "ACTIVE", pending: null });
|
||||
expect([...remote.keys()]).toEqual([again.accountId]);
|
||||
expect(h.grants()).toBe(1);
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
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");
|
||||
failDelete.add(first.accountId);
|
||||
await expect(h.service.connectToolkit(h.orgId, "github")).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");
|
||||
expect(remote.size).toBe(0);
|
||||
await h.service.refreshAccounts(h.orgId);
|
||||
expect(h.account()).toBeUndefined();
|
||||
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");
|
||||
expect(h.account()).toEqual({ id: h.oldId, status: "ACTIVE", pending: null });
|
||||
expect([...remote.keys()]).toEqual([h.oldId]);
|
||||
h.connection.close();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
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]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user