From 170b977bf8205fc2f63ac9216533c5d4a3344ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 17:12:59 -0400 Subject: [PATCH] Q105: the Connectors page grants and revokes connector access Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-handlers.ts | 3 ++ .../services/composio/connector-grants.ts | 14 ++++++++ src/preload/index.ts | 2 ++ src/renderer/src/i18n/zh.ts | 3 ++ src/renderer/src/pages/ConnectorsPage.tsx | 7 ++-- .../src/pages/connectors/ConnectedList.tsx | 34 +++++++++++++++---- .../pages/connectors/useConnectorCatalog.ts | 18 ++++++++++ src/shared/commands/composio.ts | 14 ++++++++ src/shared/desktop-api.ts | 4 +++ src/shared/ipc.ts | 2 ++ tests/unit/agent-instructions.test.ts | 2 +- tests/unit/connector-grants.test.ts | 27 +++++++++++++-- 12 files changed, 119 insertions(+), 11 deletions(-) diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 34e75050..82b5e326 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -28,6 +28,7 @@ import { AutomationService } from "../services/automation-service"; import { MeetingService } from "../services/meeting-service"; import { SprintService } from "../services/sprint-service"; import { ComposioConnectionService } from "../services/composio/composio-connection-service"; +import { ConnectorGrantsService } from "../services/composio/connector-grants"; import { IntegrationKeyStore } from "../services/integration-key-store"; import { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills"; import { coalesce } from "./coalesce"; @@ -415,6 +416,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { notifyDataChanged(); } }); + ipcMain.handle(IPC_CHANNELS.grantConnector, (_event, input) => mutate(() => new ConnectorGrantsService(deps.connection.db).grantFromBoard(input))); + ipcMain.handle(IPC_CHANNELS.revokeConnector, (_event, input) => mutate(() => new ConnectorGrantsService(deps.connection.db).revoke(input))); ipcMain.handle(IPC_CHANNELS.refreshComposioAccounts, async (_event, input) => { try { await composioConnections.refreshAccounts(input.orgId); diff --git a/src/main/services/composio/connector-grants.ts b/src/main/services/composio/connector-grants.ts index 4daedab4..6fe9f6b4 100644 --- a/src/main/services/composio/connector-grants.ts +++ b/src/main/services/composio/connector-grants.ts @@ -40,6 +40,9 @@ export class ConnectorGrantsService { const agent = this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'") .get(input.agentId, input.orgId); if (!agent) throw new Error("Grant target must be an active agent in this organization."); + if (input.grantedByType === "employee" && !this.db.prepare("select id from employees where id = ? and org_id = ?").get(input.grantedById, input.orgId)) { + throw new Error("A grant by an employee must name an employee of this organization."); + } const slug = normalizeSlug(input.toolkitSlug); const label = this.chooseAccount(input.orgId, slug, input.accountLabel).label; const actions = input.limits.actions === null ? null : JSON.stringify(normalizeActionNames(input.limits.actions)); @@ -54,12 +57,23 @@ export class ConnectorGrantsService { granted_by_type = excluded.granted_by_type, granted_by_id = excluded.granted_by_id `).run(createId("cgr"), input.orgId, input.agentId, slug, label, actions, fixed, input.grantedByType, input.grantedById, new Date().toISOString()); + console.info(`[connectors] ${input.grantedByType} ${input.grantedById} granted ${slug}/${label} to ${input.agentId}`); return label; } + /** A board grant from the Connectors page: whole access for an agent holding none of the toolkit, never replacing a grant the CEO shaped. */ + grantFromBoard(input: { orgId: string; agentId: string; toolkitSlug: string; accountLabel: string; employeeId: string }): string { + const slug = normalizeSlug(input.toolkitSlug); + const held = this.db.prepare("select account_label from agent_connector_grants where org_id = ? and agent_id = ? and toolkit_slug = ?") + .get(input.orgId, input.agentId, slug) as { account_label: string } | undefined; + if (held) throw new Error(`This agent already holds ${slug} through its "${held.account_label}" account; revoke that grant first.`); + return this.grant({ orgId: input.orgId, agentId: input.agentId, toolkitSlug: slug, accountLabel: input.accountLabel, limits: NO_LIMITS, grantedByType: "employee", grantedById: input.employeeId }); + } + revoke(input: { orgId: string; agentId: string; toolkitSlug: string }): void { this.db.prepare("delete from agent_connector_grants where org_id = ? and agent_id = ? and toolkit_slug = ?") .run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug)); + console.info(`[connectors] revoked ${normalizeSlug(input.toolkitSlug)} from ${input.agentId}`); } /** Toolkits this agent may use: every active one for the CEO; for others, those whose granted account is active. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 9213cc28..d6923453 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -92,6 +92,8 @@ const api: DesktopApi = { listConnectorCatalog: () => invoke(IPC_CHANNELS.listConnectorCatalog), connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input), disconnectComposioToolkit: (input) => invoke(IPC_CHANNELS.disconnectComposioToolkit, input), + grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input), + revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), search: (input) => invoke(IPC_CHANNELS.search, input), subscribe: (callback) => { diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index ce7ece1d..fb4619aa 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -387,6 +387,9 @@ export const zh: Record = { "Account label, e.g. sales": "账号标签,例如 sales", "Add account": "添加账号", "limited": "受限", + "Revoke {name}": "撤销 {name}", + "Grant {name} to": "把 {name} 授予", + "Grant to…": "授予给…", "Refresh models": "刷新模型", "Region": "区域", "Rejected": "已驳回", diff --git a/src/renderer/src/pages/ConnectorsPage.tsx b/src/renderer/src/pages/ConnectorsPage.tsx index 12dab85f..94d1eb1b 100644 --- a/src/renderer/src/pages/ConnectorsPage.tsx +++ b/src/renderer/src/pages/ConnectorsPage.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { Button } from "../shared/ui"; import { t } from "../i18n/i18n"; import type { PageProps } from "./types"; -import { activeOrgId } from "./types"; +import { activeOrgId, requireActiveEmployee } from "./types"; import { ConnectedList } from "./connectors/ConnectedList"; import { ToolkitCatalog } from "./connectors/ToolkitCatalog"; import { useConnectorCatalog } from "./connectors/useConnectorCatalog"; @@ -15,7 +15,8 @@ import { DEFAULT_CONNECTOR_ACCOUNT_LABEL } from "../../../shared/commands"; */ export function ConnectorsPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); - const { catalog, busySlug, busyAction, connectError, connect, disconnect, retry } = useConnectorCatalog(orgId, actions); + const employee = requireActiveEmployee(snapshot); + const { catalog, busySlug, busyAction, connectError, connect, disconnect, grant, revoke, retry } = useConnectorCatalog(orgId, actions); const accounts = useMemo( () => snapshot.connectorAccounts.filter((account) => account.orgId === orgId), @@ -69,6 +70,8 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { busyAction={busyAction} onConnect={connect} onDelete={disconnect} + onGrant={(agentId, toolkitSlug, accountLabel) => grant(agentId, toolkitSlug, accountLabel, employee.id)} + onRevoke={revoke} /> {catalog.phase === "error" && ( diff --git a/src/renderer/src/pages/connectors/ConnectedList.tsx b/src/renderer/src/pages/connectors/ConnectedList.tsx index af7909de..402ca9ea 100644 --- a/src/renderer/src/pages/connectors/ConnectedList.tsx +++ b/src/renderer/src/pages/connectors/ConnectedList.tsx @@ -29,6 +29,8 @@ export function ConnectedList({ busyAction, onConnect, onDelete, + onGrant, + onRevoke, }: { accounts: ConnectorAccountRecord[]; grants: ConnectorGrantRecord[]; @@ -38,6 +40,8 @@ export function ConnectedList({ busyAction: "connect" | "disconnect" | null; onConnect: (toolkitSlug: string, label: string) => void; onDelete: (toolkitSlug: string, label: string) => void; + onGrant: (agentId: string, toolkitSlug: string, label: string) => void; + onRevoke: (agentId: string, toolkitSlug: string) => void; }) { const [addSlug, setAddSlug] = useState(""); const [addLabel, setAddLabel] = useState(""); @@ -58,8 +62,10 @@ export function ConnectedList({ .filter((grant) => grant.toolkitSlug === account.toolkitSlug && grant.accountLabel === account.label) .flatMap((grant) => { const name = agentNames.get(grant.agentId); - return name ? [grant.allowedActions || grant.fixedArguments ? `${name} · ${t("limited")}` : name] : []; + return name ? [{ agentId: grant.agentId, text: grant.allowedActions || grant.fixedArguments ? `${name} · ${t("limited")}` : name }] : []; }); + // One account per toolkit per agent: only agents holding none of this toolkit can be granted here; the CEO holds all by role. + const grantable = agents.filter((agent) => agent.roleLevel !== "c_suite" && !grants.some((grant) => grant.agentId === agent.id && grant.toolkitSlug === account.toolkitSlug)); const busy = busySlug !== null; return (
@@ -86,11 +92,27 @@ export function ConnectedList({ />
- {holders.length > 0 && ( -
- {holders.map((name) => {name})} -
- )} +
+ {holders.map((holder) => ( + + {holder.text} + onRevoke(holder.agentId, account.toolkitSlug)} /> + + ))} + {account.status === "ACTIVE" && grantable.length > 0 && ( + + )} +
); })} diff --git a/src/renderer/src/pages/connectors/useConnectorCatalog.ts b/src/renderer/src/pages/connectors/useConnectorCatalog.ts index 865934e5..ae98f7c5 100644 --- a/src/renderer/src/pages/connectors/useConnectorCatalog.ts +++ b/src/renderer/src/pages/connectors/useConnectorCatalog.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { ConnectorCatalogEntry } from "../../../../shared/commands"; import type { SnapshotActions } from "../../app/useSnapshot"; +import type { DesktopResult } from "../../../../shared/result"; +import type { AppSnapshot } from "../../../../shared/domain"; export type CatalogState = | { phase: "loading" } @@ -24,6 +26,8 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { connectError: string | null; connect: (toolkitSlug: string, label: string) => void; disconnect: (toolkitSlug: string, label: string) => void; + grant: (agentId: string, toolkitSlug: string, accountLabel: string, employeeId: string) => void; + revoke: (agentId: string, toolkitSlug: string) => void; retry: () => void; } { const [catalogSlot, setCatalogSlot] = useState(null); @@ -118,6 +122,18 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { .finally(() => setBusy(null)); }, [actions, orgId]); + // A refused grant or revoke is shown on the page, not dropped. + const runGrantChange = useCallback((change: () => Promise>) => { + setConnectError(null); + void actions.run(change).catch((error: unknown) => setConnectError(error instanceof Error ? error.message : String(error))); + }, [actions]); + const grant = useCallback((agentId: string, toolkitSlug: string, accountLabel: string, employeeId: string) => { + runGrantChange(() => window.agentCompany.grantConnector({ orgId, agentId, toolkitSlug, accountLabel, employeeId })); + }, [runGrantChange, orgId]); + const revoke = useCallback((agentId: string, toolkitSlug: string) => { + runGrantChange(() => window.agentCompany.revokeConnector({ orgId, agentId, toolkitSlug })); + }, [runGrantChange, orgId]); + return { catalog, busySlug: busy?.slug ?? null, @@ -125,6 +141,8 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { connectError, connect, disconnect, + grant, + revoke, retry: load, }; } diff --git a/src/shared/commands/composio.ts b/src/shared/commands/composio.ts index 46666173..b2f758c4 100644 --- a/src/shared/commands/composio.ts +++ b/src/shared/commands/composio.ts @@ -27,6 +27,20 @@ export interface DisconnectComposioToolkitInput { label: string; } +export interface GrantConnectorInput { + orgId: string; + agentId: string; + toolkitSlug: string; + accountLabel: string; + employeeId: string; +} + +export interface RevokeConnectorInput { + orgId: string; + agentId: string; + toolkitSlug: string; +} + export interface RefreshComposioAccountsInput { orgId: string; } diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index e4685d03..03df8d99 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -73,6 +73,8 @@ import type { ConnectComposioToolkitResult, ConnectorCatalogEntry, DisconnectComposioToolkitInput, + GrantConnectorInput, + RevokeConnectorInput, RefreshComposioAccountsInput, SearchInput, SendMessageInput, @@ -200,6 +202,8 @@ export interface DesktopApi { connectComposioToolkit(input: ConnectComposioToolkitInput): Promise>; disconnectComposioToolkit(input: DisconnectComposioToolkitInput): Promise>; refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise>; + grantConnector(input: GrantConnectorInput): Promise>; + revokeConnector(input: RevokeConnectorInput): Promise>; search(input: SearchInput): Promise>; subscribe(callback: (event: RendererEvent) => void): () => void; platform: "win32" | "darwin" | "linux"; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index cacb2f35..887dfb44 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -85,6 +85,8 @@ export const IPC_CHANNELS = { connectComposioToolkit: "agentcompany:connect-composio-toolkit", disconnectComposioToolkit: "agentcompany:disconnect-composio-toolkit", refreshComposioAccounts: "agentcompany:refresh-composio-accounts", + grantConnector: "agentcompany:grant-connector", + revokeConnector: "agentcompany:revoke-connector", search: "agentcompany:search", rendererEvent: "agentcompany:renderer-event", windowMinimize: "agentcompany:window-minimize", diff --git a/tests/unit/agent-instructions.test.ts b/tests/unit/agent-instructions.test.ts index 11c928be..6ac1ed3d 100644 --- a/tests/unit/agent-instructions.test.ts +++ b/tests/unit/agent-instructions.test.ts @@ -158,7 +158,7 @@ describe("agent instruction wiring", () => { values ('ca_github', ?, 'github', 'GitHub', 'main', 'ac_github', 'ACTIVE', ?, ?) `).run(orgId, now, now); new ConnectorGrantsService(connection.db).grant({ - orgId, agentId, toolkitSlug: "github", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: null, + orgId, agentId, toolkitSlug: "github", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id, }); taskId = organization.createTask({ orgId, title: "Ship it", description: "d", status: TASK_STATUS.IN_PROGRESS, diff --git a/tests/unit/connector-grants.test.ts b/tests/unit/connector-grants.test.ts index 8bd8d8b0..84d57cf3 100644 --- a/tests/unit/connector-grants.test.ts +++ b/tests/unit/connector-grants.test.ts @@ -17,6 +17,7 @@ interface Harness { orgId: string; ceoId: string; danaId: string; + employeeId: string; ceoCtx: AgentToolContext; danaCtx: AgentToolContext; deps: { db: DatabaseConnection["db"]; organization: OrganizationService; chat: ChatService }; @@ -52,6 +53,7 @@ function harness(): Harness { orgId, ceoId, danaId, + employeeId: (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id, ceoCtx: context(ceoId), danaCtx: context(danaId), deps: { db: connection.db, organization, chat: new ChatService(connection.db) }, @@ -71,7 +73,7 @@ describe("connector grants", () => { const h = harness(); const grants = new ConnectorGrantsService(h.connection.db); expect(() => grants.grant({ - orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: null, + orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: h.employeeId, })).toThrow(/No active connection/); }); @@ -145,6 +147,27 @@ describe("connector grants", () => { expect(new ConnectorGrantsService(h.connection.db).limitsForAgent(h.orgId, h.danaId, "gmail")).toEqual({ actions: null, fixedArguments: null }); }); + test("the board grants and revokes as a named employee of the company", () => { + const h = harness(); + connectToolkit(h, "github", "GitHub"); + const grants = new ConnectorGrantsService(h.connection.db); + const grant = (grantedById: string | null) => grants.grant({ + orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: "main", limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById, + }); + expect(() => grant(null)).toThrow("A grant by an employee must name an employee of this organization."); + expect(() => grant("emp_elsewhere")).toThrow("A grant by an employee must name an employee of this organization."); + expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]); + grant(h.employeeId); + expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["github"]); + grants.revoke({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github" }); + expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]); + // The page never replaces a grant the CEO shaped, even from a stale selection. + grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: null, limits: { actions: ["GITHUB_LIST_ISSUES"], fixedArguments: null }, grantedByType: "agent", grantedById: h.ceoId }); + expect(() => grants.grantFromBoard({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "github", accountLabel: "main", employeeId: h.employeeId })) + .toThrow('This agent already holds github through its "main" account; revoke that grant first.'); + expect(grants.limitsForAgent(h.orgId, h.danaId, "github")).toEqual({ actions: ["GITHUB_LIST_ISSUES"], fixedArguments: null }); + }); + test("staff cannot hand out connector access", () => { const h = harness(); connectToolkit(h, "github", "GitHub"); @@ -157,7 +180,7 @@ describe("connector grants", () => { const h = harness(); connectToolkit(h, "slack", "Slack"); const grants = new ConnectorGrantsService(h.connection.db); - grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "slack", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: null }); + grants.grant({ orgId: h.orgId, agentId: h.danaId, toolkitSlug: "slack", accountLabel: null, limits: { actions: null, fixedArguments: null }, grantedByType: "employee", grantedById: h.employeeId }); expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["slack"]); h.connection.db.prepare("update connector_accounts set status = 'EXPIRED' where org_id = ? and toolkit_slug = 'slack'").run(h.orgId); expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual([]);