Q105: the Connectors page grants and revokes connector access
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
558d678ba2
commit
170b977bf8
12 files changed
+119
-11
No files matched your search
@@ -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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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": "已驳回",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Reference in new issue
Block a user