From defe8f12b19fe0438ea2215673a0ab237e479d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 7 Jul 2026 11:24:28 -0400 Subject: [PATCH] Make the CEO the grant authority: implicit all-access, agent-tool grants, no manual assignment UI --- src/main/app/ipc-handlers.ts | 16 --- src/main/services/agent-skill-tools.ts | 59 ++++++++++ src/main/services/agent-tools.ts | 38 ++++++ .../services/composio/connector-grants.ts | 17 ++- src/main/services/skill-service.ts | 84 +++++++++++--- src/main/services/task-execution-service.ts | 7 +- src/preload/index.ts | 3 - src/renderer/src/pages/ConnectorsPage.tsx | 24 +--- .../src/pages/connectors/ConnectedList.tsx | 99 +++++----------- .../src/pages/settings/SkillsPanel.tsx | 44 +------ src/shared/commands/composio.ts | 7 -- src/shared/desktop-api.ts | 5 - src/shared/ipc.ts | 3 - tests/unit/connector-grants.test.ts | 3 +- tests/unit/skill-grants.test.ts | 108 ++++++++++++++++++ 15 files changed, 327 insertions(+), 190 deletions(-) create mode 100644 src/main/services/agent-skill-tools.ts create mode 100644 tests/unit/skill-grants.test.ts diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index abaa673a..e4784b5e 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -28,7 +28,6 @@ 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 { syncSkillsFromDisk } from "../services/skill-discovery"; import { coalesce } from "./coalesce"; import { normalizeExternalBrowserUrl } from "./external-url"; @@ -257,7 +256,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { syncSkillsFromDisk(deps.connection.db, skills, input.orgId); return null; })); - ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input))); ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input))); ipcMain.handle(IPC_CHANNELS.runSkillTests, (_event, input) => mutate(() => skills.runTests(input))); ipcMain.handle(IPC_CHANNELS.createSocialAccount, (_event, input) => mutate(() => social.createAccount(input))); @@ -327,7 +325,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { }); const composioConnections = new ComposioConnectionService(deps.connection.db); - const connectorGrants = new ConnectorGrantsService(deps.connection.db); ipcMain.handle(IPC_CHANNELS.listConnectorCatalog, async () => { try { @@ -354,19 +351,6 @@ export function registerIpcHandlers(deps: IpcDeps): void { return fail("composio_refresh_failed", error instanceof Error ? error.message : String(error)); } }); - ipcMain.handle(IPC_CHANNELS.grantConnector, (_event, input) => mutate(() => connectorGrants.grant({ - orgId: input.orgId, - agentId: input.agentId, - toolkitSlug: input.toolkitSlug, - grantedByType: "employee", - grantedById: input.employeeId, - }))); - ipcMain.handle(IPC_CHANNELS.revokeConnector, (_event, input) => mutate(() => connectorGrants.revoke({ - orgId: input.orgId, - agentId: input.agentId, - toolkitSlug: input.toolkitSlug, - }))); - ipcMain.handle(IPC_CHANNELS.windowMinimize, () => deps.mainWindow.minimize()); ipcMain.handle(IPC_CHANNELS.windowMaximize, () => deps.mainWindow.isMaximized() ? deps.mainWindow.unmaximize() : deps.mainWindow.maximize()); ipcMain.handle(IPC_CHANNELS.windowClose, () => deps.mainWindow.close()); diff --git a/src/main/services/agent-skill-tools.ts b/src/main/services/agent-skill-tools.ts new file mode 100644 index 00000000..04fc395a --- /dev/null +++ b/src/main/services/agent-skill-tools.ts @@ -0,0 +1,59 @@ +import { requireString } from "./agent-tool-args"; +import type { AgentToolContext, AgentToolDeps } from "./agent-tools"; +import type { JsonRecord } from "./provider-http"; +import { SkillService } from "./skill-service"; + +/** Company skill library visibility for any agent: what exists, who holds it. */ +export function listSkillsTool(deps: AgentToolDeps, ctx: AgentToolContext): string { + const skills = deps.db.prepare( + "select id, name, slug from skill_definitions where org_id = ? and status = 'active' order by slug asc", + ).all(ctx.orgId) as Array<{ id: string; name: string; slug: string }>; + if (skills.length === 0) { + return "The company has no skills yet. The board imports them on the Settings page."; + } + const bindings = deps.db.prepare( + "select skill_id, agent_id from skill_bindings where org_id = ? and enabled = 1", + ).all(ctx.orgId) as Array<{ skill_id: string; agent_id: string }>; + const agentNames = new Map( + (deps.db.prepare("select id, name from agents where org_id = ?").all(ctx.orgId) as Array<{ id: string; name: string }>) + .map((row) => [row.id, row.name]), + ); + const lines = skills.map((skill) => { + const holders = bindings + .filter((binding) => binding.skill_id === skill.id) + .map((binding) => agentNames.get(binding.agent_id)) + .filter(Boolean); + return `- ${skill.name} (${skill.slug}): ${holders.length > 0 ? `granted to ${holders.join(", ")}` : "no grants yet"}`; + }); + return ["Company skills (the CEO carries all of them; teammates need a grant):", ...lines].join("\n"); +} + +/** CEO-only: hand a skill to a teammate, or take it back. */ +export function grantSkillTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord, mode: "grant" | "revoke"): string { + const acting = deps.db.prepare("select role_level from agents where id = ? and org_id = ?") + .get(ctx.agent.id, ctx.orgId) as { role_level: string } | undefined; + if (acting?.role_level !== "c_suite") { + return "ERROR: Only the CEO can manage skill grants. Ask them to run this."; + } + const agentName = requireString(args.agent_name, "agent_name"); + const skillSlug = requireString(args.skill, "skill").trim().toLowerCase(); + const target = deps.db.prepare( + "select id, name from agents where org_id = ? and lower(name) = lower(?) and lifecycle = 'active' limit 1", + ).get(ctx.orgId, agentName) as { id: string; name: string } | undefined; + if (!target) return `ERROR: No active agent named "${agentName}".`; + const skill = deps.db.prepare( + "select id, name from skill_definitions where org_id = ? and slug = ? and status = 'active' limit 1", + ).get(ctx.orgId, skillSlug) as { id: string; name: string } | undefined; + if (!skill) return `ERROR: No skill "${skillSlug}" exists. Run list_skills to see the library.`; + const skillService = new SkillService(deps.db, ""); + if (mode === "grant") { + const revision = deps.db.prepare( + "select id from skill_revisions where skill_id = ? order by published_at desc, id desc limit 1", + ).get(skill.id) as { id: string } | undefined; + if (!revision) return `ERROR: The "${skillSlug}" skill has no published revision.`; + skillService.bind({ orgId: ctx.orgId, agentId: target.id, skillId: skill.id, revisionId: revision.id }); + return `${target.name} now has the ${skill.name} skill; it joins their next run.`; + } + skillService.unbind({ orgId: ctx.orgId, agentId: target.id, skillId: skill.id }); + return `${target.name} no longer has the ${skill.name} skill.`; +} diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index fade945d..857940ef 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -6,6 +6,7 @@ import type { ChatService } from "./chat-service"; import { canonicalMentionHandle } from "../../shared/mention-handles"; import { readMessagesTool } from "./agent-read-messages"; import { grantConnectorTool, listConnectorsTool } from "./agent-connector-tools"; +import { grantSkillTool, listSkillsTool } from "./agent-skill-tools"; import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools"; import { describeSend } from "./agent-message-presence"; import { optionalString, optionalStringArray, requireString } from "./agent-tool-args"; @@ -172,6 +173,37 @@ export const AGENT_TOOLS: ToolDefinition[] = [ required: ["agent_name", "toolkit"], }, }, + { + name: "list_skills", + description: "See the company's skill library and who holds each skill. You carry every skill automatically as CEO; teammates only run with skills you grant them.", + parameters: { type: "object", additionalProperties: false, properties: {}, required: [] }, + }, + { + name: "grant_skill", + description: "CEO only: grant a teammate one of the company's skills so its instructions join their next run. Match the skill to the job.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + agent_name: { type: "string", description: "The teammate receiving the skill." }, + skill: { type: "string", description: "Skill slug from list_skills, e.g. 'release-gate'." }, + }, + required: ["agent_name", "skill"], + }, + }, + { + name: "revoke_skill", + description: "CEO only: withdraw a teammate's skill.", + parameters: { + type: "object", + additionalProperties: false, + properties: { + agent_name: { type: "string", description: "The teammate losing the skill." }, + skill: { type: "string", description: "Skill slug, e.g. 'release-gate'." }, + }, + required: ["agent_name", "skill"], + }, + }, ]; export function dispatchAgentTool( @@ -202,6 +234,12 @@ export function dispatchAgentTool( return grantConnectorTool(deps, ctx, call.arguments, "grant"); case "revoke_connector": return grantConnectorTool(deps, ctx, call.arguments, "revoke"); + case "list_skills": + return listSkillsTool(deps, ctx); + case "grant_skill": + return grantSkillTool(deps, ctx, call.arguments, "grant"); + case "revoke_skill": + return grantSkillTool(deps, ctx, call.arguments, "revoke"); default: return `ERROR: unknown tool "${call.name}".`; } diff --git a/src/main/services/composio/connector-grants.ts b/src/main/services/composio/connector-grants.ts index 446e1962..03dc3118 100644 --- a/src/main/services/composio/connector-grants.ts +++ b/src/main/services/composio/connector-grants.ts @@ -18,9 +18,9 @@ export interface ConnectorGrantRow { /** * Which agents may use which connected toolkits. Connections belong to the - * company (one connected account per toolkit per org); the CEO hands - * individual toolkits to individual agents, and both the tool loadout and - * the execution guard read from these grants. + * 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 { constructor(private readonly db: DatabaseSync) {} @@ -46,8 +46,17 @@ export class ConnectorGrantsService { .run(input.orgId, input.agentId, normalizeSlug(input.toolkitSlug)); } - /** Toolkits this agent may use: granted AND actively connected. */ + /** + * 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[] { + const acting = this.db.prepare("select role_level from agents where id = ? and org_id = ?") + .get(agentId, orgId) as { role_level: string } | undefined; + if (acting?.role_level === "c_suite") { + return this.activeAccounts(orgId).map((account) => account.toolkitSlug); + } const rows = this.db.prepare(` select g.toolkit_slug as slug from agent_connector_grants g diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index b6532202..8cf4c7e8 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -122,25 +122,79 @@ export class SkillService { return id; } - resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string { - const agent = this.db.prepare("select id from agents where id = ? and org_id = ?") - .get(input.agentId, input.orgId) as { id: string } | undefined; + unbind(input: { orgId: string; agentId: string; skillId: string }): void { + const existing = this.db.prepare("select id from skill_bindings where org_id = ? and agent_id = ? and skill_id = ?") + .get(input.orgId, input.agentId, input.skillId) as { id: string } | undefined; + if (!existing) return; + this.db.prepare("delete from skill_bindings where id = ?").run(existing.id); + this.audit.record({ + orgId: input.orgId, + entityType: "skill_binding", + entityId: existing.id, + actorType: "system", + actorId: null, + action: "unbound", + payload: { agentId: input.agentId, skillId: input.skillId }, + }); + } + + /** + * True when a run for this agent would compile at least one skill: the CEO + * carries every active skill by role; other agents need enabled bindings. + */ + hasRuntimeSkills(orgId: string, agentId: string): boolean { + if (this.isCSuite(orgId, agentId)) { + return Boolean(this.db.prepare(` + select sd.id from skill_definitions sd + join skill_revisions sr on sr.skill_id = sd.id + where sd.org_id = ? and sd.status = 'active' + limit 1 + `).get(orgId)); + } + return Boolean(this.db.prepare( + "select id from skill_bindings where org_id = ? and agent_id = ? and enabled = 1 limit 1", + ).get(orgId, agentId)); + } + + private isCSuite(orgId: string, agentId: string): boolean { + const agent = this.db.prepare("select role_level from agents where id = ? and org_id = ?") + .get(agentId, orgId) as { role_level: string } | undefined; if (!agent) throw new Error("Agent was not found."); + return agent.role_level === "c_suite"; + } + + resolveRuntimeSnapshot(input: ResolveAgentSkillsInput): string { + const cSuite = this.isCSuite(input.orgId, input.agentId); const existingSnapshot = this.db.prepare("select id from skill_runtime_snapshots where agent_id = ? and run_id = ?") .get(input.agentId, input.runId) as { id: string } | undefined; if (existingSnapshot) throw new Error("Skill runtime snapshot already exists for this run."); - const rows = this.db.prepare(` - select - sr.id as revision_id, - sr.version_semver, - sr.manifest_json, - sa.object_key - from skill_bindings sb - join skill_revisions sr on sr.id = sb.pinned_revision_id - join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown') - where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1 - order by sb.priority asc, sr.published_at asc - `).all(input.orgId, input.agentId) as unknown as RuntimeSkillRow[]; + const rows = (cSuite + ? this.db.prepare(` + select + sr.id as revision_id, + sr.version_semver, + sr.manifest_json, + sa.object_key + from skill_definitions sd + join skill_revisions sr on sr.id = ( + select id from skill_revisions where skill_id = sd.id order by published_at desc, id desc limit 1 + ) + join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown') + where sd.org_id = ? and sd.status = 'active' + order by sr.published_at asc + `).all(input.orgId) + : this.db.prepare(` + select + sr.id as revision_id, + sr.version_semver, + sr.manifest_json, + sa.object_key + from skill_bindings sb + join skill_revisions sr on sr.id = sb.pinned_revision_id + join skill_artifacts sa on sa.revision_id = sr.id and sa.artifact_type in ('entrypoint', 'markdown') + where sb.org_id = ? and sb.agent_id = ? and sb.enabled = 1 + order by sb.priority asc, sr.published_at asc + `).all(input.orgId, input.agentId)) as unknown as RuntimeSkillRow[]; if (rows.length === 0) throw new Error("Agent has no enabled skills."); const compiledParts: string[] = []; diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index ea198776..5ff8d86e 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -428,12 +428,7 @@ export class TaskExecutionService { } private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string): SkillRuntimeSnapshotRow | null { - const hasEnabledSkill = this.db.prepare(` - select id from skill_bindings - where org_id = ? and agent_id = ? and enabled = 1 - limit 1 - `).get(orgId, agentId); - if (!hasEnabledSkill) return null; + if (!this.skills.hasRuntimeSkills(orgId, agentId)) return null; const snapshotId = this.skills.resolveRuntimeSnapshot({ orgId, agentId, runId }); const snapshot = this.db.prepare(` select id, compiled_prompt, tools_manifest, source_revision_ids diff --git a/src/preload/index.ts b/src/preload/index.ts index 0b32fe07..a4ea247d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -57,7 +57,6 @@ const api: DesktopApi = { cancelProviderResponse: (input) => invoke(IPC_CHANNELS.cancelProviderResponse, input), importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input), syncSkillsFromDisk: (input) => invoke(IPC_CHANNELS.syncSkillsFromDisk, input), - bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input), resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input), runSkillTests: (input) => invoke(IPC_CHANNELS.runSkillTests, input), createSocialAccount: (input) => invoke(IPC_CHANNELS.createSocialAccount, input), @@ -86,8 +85,6 @@ const api: DesktopApi = { listConnectorCatalog: () => invoke(IPC_CHANNELS.listConnectorCatalog), connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input), refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input), - grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input), - revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input), search: (input) => invoke(IPC_CHANNELS.search, input), subscribe: (callback) => { const listener = (_event: Electron.IpcRendererEvent, payload: RendererEvent) => callback(payload); diff --git a/src/renderer/src/pages/ConnectorsPage.tsx b/src/renderer/src/pages/ConnectorsPage.tsx index b46660a8..c91142b1 100644 --- a/src/renderer/src/pages/ConnectorsPage.tsx +++ b/src/renderer/src/pages/ConnectorsPage.tsx @@ -1,19 +1,18 @@ import { useMemo } from "react"; import { Button } from "../shared/ui"; import type { PageProps } from "./types"; -import { activeOrgId, requireActiveEmployee } from "./types"; +import { activeOrgId } from "./types"; import { ConnectedList } from "./connectors/ConnectedList"; import { ToolkitCatalog } from "./connectors/ToolkitCatalog"; import { useConnectorCatalog } from "./connectors/useConnectorCatalog"; /** - * The company's connector control room: what is connected, who may use it, - * and the configured catalog to connect more. Connections are org-level; the - * grant chips decide which agents carry each toolkit's tools. + * The company's connector control room: what is connected, who holds it, and + * the configured catalog to connect more. Connections are org-level; the CEO + * decides which agents carry each toolkit's tools. */ export function ConnectorsPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); - const employee = requireActiveEmployee(snapshot); const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions); const accounts = useMemo( @@ -40,12 +39,6 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { return map; }, [catalog]); - const grant = (agentId: string, toolkitSlug: string) => - void actions.run(() => window.agentCompany.grantConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id })); - - const revoke = (agentId: string, toolkitSlug: string) => - void actions.run(() => window.agentCompany.revokeConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id })); - return (

Connectors

@@ -65,14 +58,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
)} - + {catalog.phase === "error" && (
diff --git a/src/renderer/src/pages/connectors/ConnectedList.tsx b/src/renderer/src/pages/connectors/ConnectedList.tsx index b6be8aa9..a48e51c7 100644 --- a/src/renderer/src/pages/connectors/ConnectedList.tsx +++ b/src/renderer/src/pages/connectors/ConnectedList.tsx @@ -14,95 +14,52 @@ const STATUS_TONE: Record = { /** * The company's connected toolkits, each with the roster of agents the CEO - * or the board has granted access to. Toggling a chip grants or revokes. + * has granted access to. Grants are the CEO's call — made with its + * grant_connector tool — so the roster here is read-only. */ export function ConnectedList({ accounts, grants, agents, logoBySlug, - onGrant, - onRevoke, }: { accounts: ConnectorAccountRecord[]; grants: ConnectorGrantRecord[]; agents: AgentRecord[]; logoBySlug: Map; - onGrant: (agentId: string, toolkitSlug: string) => void; - onRevoke: (agentId: string, toolkitSlug: string) => void; }) { if (accounts.length === 0) return null; + const agentNames = new Map(agents.map((agent) => [agent.id, agent.name])); return (
Connected
- {accounts.map((account) => ( - grant.toolkitSlug === account.toolkitSlug).map((grant) => grant.agentId))} - agents={agents} - logoUrl={logoBySlug.get(account.toolkitSlug) ?? null} - onGrant={onGrant} - onRevoke={onRevoke} - /> - ))} + {accounts.map((account) => { + const holders = grants + .filter((grant) => grant.toolkitSlug === account.toolkitSlug) + .map((grant) => agentNames.get(grant.agentId)) + .filter((name): name is string => Boolean(name)); + return ( +
+
+ + {account.toolkitName} + {account.status.charAt(0) + account.status.slice(1).toLowerCase()} +
+ {holders.length > 0 && ( +
+ {holders.map((name) => {name})} +
+ )} +
+ ); + })}
); } - -function ConnectedRow({ - account, - grantedAgentIds, - agents, - logoUrl, - onGrant, - onRevoke, -}: { - account: ConnectorAccountRecord; - grantedAgentIds: Set; - agents: AgentRecord[]; - logoUrl: string | null; - onGrant: (agentId: string, toolkitSlug: string) => void; - onRevoke: (agentId: string, toolkitSlug: string) => void; -}) { - const active = account.status === "ACTIVE"; - return ( -
-
- - {account.toolkitName} - {account.status.charAt(0) + account.status.slice(1).toLowerCase()} -
- {active && agents.length > 0 && ( -
- {agents.map((agent) => { - const granted = grantedAgentIds.has(agent.id); - return ( - - ); - })} -
- )} -
- ); -} diff --git a/src/renderer/src/pages/settings/SkillsPanel.tsx b/src/renderer/src/pages/settings/SkillsPanel.tsx index 9afcd113..a715ee3e 100644 --- a/src/renderer/src/pages/settings/SkillsPanel.tsx +++ b/src/renderer/src/pages/settings/SkillsPanel.tsx @@ -39,12 +39,6 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents, })).then((result) => { if (result) setImportOpen(false); }); }; - const bind = (skill: SkillDefinitionRecord, agentId: string) => { - const latest = revisions.find((revision) => revision.skillId === skill.id); - if (!latest) return; - void actions.run(() => window.agentCompany.bindSkill({ orgId, agentId, skillId: skill.id, revisionId: latest.id })); - }; - const runTests = (revisionId: string) => { void actions.run(() => window.agentCompany.runSkillTests({ orgId, revisionId })); }; @@ -61,9 +55,9 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
-
Skill
Version
Tests
Bindings
Actions
+
Skill
Version
Tests
Held by
Actions
{skills.map((skill, index) => { const last = index === skills.length - 1; @@ -74,7 +68,7 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
{skill.name}
@@ -87,9 +81,8 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents, {agentName(binding.agentId, agents)} ))}
-
+
- bind(skill, agentId)} disabled={!latest} />
); @@ -104,35 +97,6 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents, ); } -function BindAgent({ - skill: _skill, - agents, - onBind, - disabled, -}: { - skill: SkillDefinitionRecord; - agents: AgentRecord[]; - onBind: (agentId: string) => void; - disabled: boolean; -}) { - if (agents.length === 0) return null; - return ( - - ); -} - function agentName(agentId: string, agents: AgentRecord[]): string { return agents.find((agent) => agent.id === agentId)?.name ?? agentId; } diff --git a/src/shared/commands/composio.ts b/src/shared/commands/composio.ts index 17af3b9d..ea668edc 100644 --- a/src/shared/commands/composio.ts +++ b/src/shared/commands/composio.ts @@ -19,10 +19,3 @@ export interface ConnectComposioToolkitResult { export interface RefreshComposioAccountsInput { orgId: string; } - -export interface ConnectorGrantInput { - orgId: string; - agentId: string; - toolkitSlug: string; - employeeId: string; -} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 3b1fcfe6..72bca067 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -2,7 +2,6 @@ import type { DesktopResult } from "./result"; import type { AppSnapshot, SearchResultRecord } from "./domain"; import type { ProviderModelEntry } from "./provider-models"; import type { - BindSkillInput, BootstrapOnboardingInput, BootstrapOnboardingSummary, CancelProviderResponseInput, @@ -67,7 +66,6 @@ import type { ConnectComposioToolkitInput, ConnectComposioToolkitResult, ConnectorCatalogEntry, - ConnectorGrantInput, RefreshComposioAccountsInput, SearchInput, SendMessageInput, @@ -153,7 +151,6 @@ export interface DesktopApi { cancelProviderResponse(input: CancelProviderResponseInput): Promise>; importSkillDirectory(input: ImportSkillDirectoryInput): Promise>; syncSkillsFromDisk(input: SyncSkillsFromDiskInput): Promise>; - bindSkill(input: BindSkillInput): Promise>; resolveAgentSkills(input: ResolveAgentSkillsInput): Promise>; runSkillTests(input: RunSkillTestsInput): Promise>; createSocialAccount(input: CreateSocialAccountInput): Promise>; @@ -182,8 +179,6 @@ export interface DesktopApi { listConnectorCatalog(): Promise>; connectComposioToolkit(input: ConnectComposioToolkitInput): Promise>; refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise>; - grantConnector(input: ConnectorGrantInput): Promise>; - revokeConnector(input: ConnectorGrantInput): 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 a85485cc..b5aae24c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -34,7 +34,6 @@ export const IPC_CHANNELS = { cancelProviderResponse: "agentcompany:cancel-provider-response", importSkillDirectory: "agentcompany:import-skill-directory", syncSkillsFromDisk: "agentcompany:sync-skills-from-disk", - bindSkill: "agentcompany:bind-skill", resolveAgentSkills: "agentcompany:resolve-agent-skills", runSkillTests: "agentcompany:run-skill-tests", createSocialAccount: "agentcompany:create-social-account", @@ -78,8 +77,6 @@ export const IPC_CHANNELS = { listConnectorCatalog: "agentcompany:list-connector-catalog", connectComposioToolkit: "agentcompany:connect-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/connector-grants.test.ts b/tests/unit/connector-grants.test.ts index 7c5fb77f..b8c2cc4e 100644 --- a/tests/unit/connector-grants.test.ts +++ b/tests/unit/connector-grants.test.ts @@ -83,7 +83,8 @@ describe("connector grants", () => { const grants = new ConnectorGrantsService(h.connection.db); expect(grants.toolkitsForAgent(h.orgId, h.danaId)).toEqual(["github"]); - expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual([]); + // The CEO holds every active connection by role, without a grant row. + expect(grants.toolkitsForAgent(h.orgId, h.ceoId)).toEqual(["github"]); const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_connectors", {})); expect(listing).toContain("GitHub (github): granted to Dana"); diff --git a/tests/unit/skill-grants.test.ts b/tests/unit/skill-grants.test.ts new file mode 100644 index 00000000..31ba64bd --- /dev/null +++ b/tests/unit/skill-grants.test.ts @@ -0,0 +1,108 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { SkillService } from "../../src/main/services/skill-service"; +import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; + +function call(name: string, args: Record) { + return { id: `call_${randomUUID()}`, name, arguments: args }; +} + +interface Harness { + connection: DatabaseConnection; + orgId: string; + ceoId: string; + danaId: string; + ceoCtx: AgentToolContext; + danaCtx: AgentToolContext; + deps: { db: DatabaseConnection["db"]; organization: OrganizationService; chat: ChatService }; +} + +function harness(): Harness { + const connection = new DatabaseConnection(join(tmpdir(), `ac-skill-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let ceoId = ""; + let danaId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", 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 = organization.createAgent({ + orgId, name: "CEO", role: "CEO", department: "Executive", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, roleLevel: "c_suite", + }); + danaId = organization.createAgent({ + orgId, name: "Dana", role: "Engineer", department: "Engineering", + modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 1000, managerAgentId: ceoId, + }); + }); + const context = (agentId: string): AgentToolContext => ({ + orgId, + agent: { id: agentId, modelProviderId: null, modelId: null }, + taskProjectId: null, + taskGoalId: null, + workspace: null, + }); + return { + connection, + orgId, + ceoId, + danaId, + ceoCtx: context(ceoId), + danaCtx: context(danaId), + deps: { db: connection.db, organization, chat: new ChatService(connection.db) }, + }; +} + +function importSkill(h: Harness, name: string, slug: string): void { + const now = new Date().toISOString(); + h.connection.db.prepare(` + insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at) + values (?, ?, ?, ?, 'organization', 'active', ?) + `).run(`skl_${slug}`, h.orgId, slug, name, now); + h.connection.db.prepare(` + insert into skill_revisions(id, skill_id, version_semver, manifest_json, source_type, source_ref, published_at, immutable_checksum) + values (?, ?, '1.0.0', '{}', 'local_directory', '/tmp', ?, 'checksum') + `).run(`skr_${slug}`, `skl_${slug}`, now); +} + +describe("skill grants", () => { + test("the CEO grants and revokes skills for teammates", () => { + const h = harness(); + importSkill(h, "Release Gate", "release-gate"); + + const granted = dispatchAgentTool(h.deps, h.ceoCtx, call("grant_skill", { agent_name: "Dana", skill: "release-gate" })); + expect(granted).toContain("Dana now has the Release Gate skill"); + + const listing = dispatchAgentTool(h.deps, h.danaCtx, call("list_skills", {})); + expect(listing).toContain("Release Gate (release-gate): granted to Dana"); + + const skills = new SkillService(h.connection.db, ""); + expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(true); + + const revoked = dispatchAgentTool(h.deps, h.ceoCtx, call("revoke_skill", { agent_name: "Dana", skill: "release-gate" })); + expect(revoked).toContain("no longer has the Release Gate skill"); + expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(false); + }); + + test("staff cannot hand out skills", () => { + const h = harness(); + importSkill(h, "Release Gate", "release-gate"); + const denied = dispatchAgentTool(h.deps, h.danaCtx, call("grant_skill", { agent_name: "Dana", skill: "release-gate" })); + expect(denied).toContain("ERROR"); + expect(new SkillService(h.connection.db, "").hasRuntimeSkills(h.orgId, h.danaId)).toBe(false); + }); + + test("the CEO carries every active skill by role, without bindings", () => { + const h = harness(); + const skills = new SkillService(h.connection.db, ""); + expect(skills.hasRuntimeSkills(h.orgId, h.ceoId)).toBe(false); + importSkill(h, "Release Gate", "release-gate"); + expect(skills.hasRuntimeSkills(h.orgId, h.ceoId)).toBe(true); + expect(skills.hasRuntimeSkills(h.orgId, h.danaId)).toBe(false); + }); +});