Q105: the Connectors page grants and revokes connector access

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 17:12:59 -04:00
1 parent 558d678ba2
commit 170b977bf8
12 files changed
+119 -11

No files matched your search

+3
View File
@@ -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);
@@ -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. */
+2
View File
@@ -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) => {
+3
View File
@@ -387,6 +387,9 @@ export const zh: Record<string, string> = {
"Account label, e.g. sales": "账号标签,例如 sales",
"Add account": "添加账号",
"limited": "受限",
"Revoke {name}": "撤销 {name}",
"Grant {name} to": "把 {name} 授予",
"Grant to…": "授予给…",
"Refresh models": "刷新模型",
"Region": "区域",
"Rejected": "已驳回",
+5 -2
View File
@@ -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" && (
@@ -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 (
<div key={account.id} style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
@@ -86,11 +92,27 @@ export function ConnectedList({
/>
</span>
</div>
{holders.length > 0 && (
<div className="row" style={{ gap: 6, marginTop: 12, flexWrap: "wrap" }}>
{holders.map((name) => <Pill key={name}>{name}</Pill>)}
</div>
)}
<div className="row" style={{ gap: 6, marginTop: 12, flexWrap: "wrap" }}>
{holders.map((holder) => (
<span key={holder.agentId} className="row" style={{ gap: 2 }}>
<Pill>{holder.text}</Pill>
<IconButton icon="x" size={12} label={t("Revoke {name}", { name: holder.text })} disabled={busy} onClick={() => onRevoke(holder.agentId, account.toolkitSlug)} />
</span>
))}
{account.status === "ACTIVE" && grantable.length > 0 && (
<select
className="input"
style={{ width: 160 }}
value=""
disabled={busy}
aria-label={t("Grant {name} to", { name: `${account.toolkitName} ${account.label}` })}
onChange={(event) => onGrant(event.target.value, account.toolkitSlug, account.label)}
>
<option value="" disabled>{t("Grant to…")}</option>
{grantable.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
</select>
)}
</div>
</div>
);
})}
@@ -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<CatalogSlot>(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<DesktopResult<AppSnapshot>>) => {
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,
};
}
+14
View File
@@ -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;
}
+4
View File
@@ -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<DesktopResult<ConnectComposioToolkitResult>>;
disconnectComposioToolkit(input: DisconnectComposioToolkitInput): Promise<DesktopResult<AppSnapshot>>;
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
grantConnector(input: GrantConnectorInput): Promise<DesktopResult<AppSnapshot>>;
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
subscribe(callback: (event: RendererEvent) => void): () => void;
platform: "win32" | "darwin" | "linux";
+2
View File
@@ -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",
+1 -1
View File
@@ -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,
+25 -2
View File
@@ -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([]);