A toolkit is a named slice of an account's connections (off/read/full) with its own MCP endpoint, scoped workspace (org) or personal (user). Owner-scoped plugin storage + typed CRUD API; resolveRequestScope maps a toolkit to core's RequestScope (off->block, read->read-only, per-toolkit block/require_approval composed with org policies, stricter-wins); URL-routed console page (list + editor). Pure plugin — no core or host changes here.
Unified · Split
| @@ -999,6 +999,38 @@ · 998 unmodified lines | ||
| 999 | 999 | "react", |
| 1000 | 1000 | ], |
| 1001 | 1001 | }, |
| 1002 | + "packages/plugins/toolkits": { | |
| 1003 | + "name": "@executor-js/plugin-toolkits", | |
| 1004 | + "version": "1.5.12", | |
| 1005 | + "dependencies": { | |
| 1006 | + "@effect/atom-react": "catalog:", | |
| 1007 | + "@executor-js/api": "workspace:*", | |
| 1008 | + "@executor-js/sdk": "workspace:*", | |
| 1009 | + }, | |
| 1010 | + "devDependencies": { | |
| 1011 | + "@effect/vitest": "catalog:", | |
| 1012 | + "@executor-js/react": "workspace:*", | |
| 1013 | + "@types/node": "catalog:", | |
| 1014 | + "@types/react": "catalog:", | |
| 1015 | + "bun-types": "catalog:", | |
| 1016 | + "effect": "catalog:", | |
| 1017 | + "react": "catalog:", | |
| 1018 | + "tsup": "catalog:", | |
| 1019 | + "typescript": "catalog:", | |
| 1020 | + "vitest": "catalog:", | |
| 1021 | + }, | |
| 1022 | + "peerDependencies": { | |
| 1023 | + "@effect/atom-react": "catalog:", | |
| 1024 | + "@executor-js/react": "workspace:*", | |
| 1025 | + "effect": "catalog:", | |
| 1026 | + "react": ">=18", | |
| 1027 | + }, | |
| 1028 | + "optionalPeers": [ | |
| 1029 | + "@effect/atom-react", | |
| 1030 | + "@executor-js/react", | |
| 1031 | + "react", | |
| 1032 | + ], | |
| 1033 | + }, | |
| 1002 | 1034 | "packages/plugins/workos-vault": { |
| 1003 | 1035 | "name": "@executor-js/plugin-workos-vault", |
| 1004 | 1036 | "version": "0.0.2", |
| @@ -1589,6 +1621,8 @@ · 584 unmodified lines | ||
| 1589 | 1621 | |
| 1590 | 1622 | "@executor-js/plugin-openapi": ["@executor-js/plugin-openapi@workspace:packages/plugins/openapi"], |
| 1591 | 1623 | |
| 1624 | + "@executor-js/plugin-toolkits": ["@executor-js/plugin-toolkits@workspace:packages/plugins/toolkits"], | |
| 1625 | + | |
| 1592 | 1626 | "@executor-js/plugin-workos-vault": ["@executor-js/plugin-workos-vault@workspace:packages/plugins/workos-vault"], |
| 1593 | 1627 | |
| 1594 | 1628 | "@executor-js/react": ["@executor-js/react@workspace:packages/react"], |
| @@ -0,0 +1 @@ | ||
| 1 | +# @executor-js/plugin-toolkits | |
| @@ -0,0 +1,93 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@executor-js/plugin-toolkits", | |
| 3 | + "version": "1.5.12", | |
| 4 | + "homepage": "https://github.com/RhysSullivan/executor/tree/main/packages/plugins/toolkits", | |
| 5 | + "bugs": { | |
| 6 | + "url": "https://github.com/RhysSullivan/executor/issues" | |
| 7 | + }, | |
| 8 | + "license": "MIT", | |
| 9 | + "repository": { | |
| 10 | + "type": "git", | |
| 11 | + "url": "git+https://github.com/RhysSullivan/executor.git", | |
| 12 | + "directory": "packages/plugins/toolkits" | |
| 13 | + }, | |
| 14 | + "files": [ | |
| 15 | + "dist" | |
| 16 | + ], | |
| 17 | + "type": "module", | |
| 18 | + "exports": { | |
| 19 | + "./server": "./src/server.ts", | |
| 20 | + "./shared": "./src/shared.ts", | |
| 21 | + "./react": "./src/react/index.ts", | |
| 22 | + "./client": "./src/react/plugin-client.tsx" | |
| 23 | + }, | |
| 24 | + "publishConfig": { | |
| 25 | + "access": "public", | |
| 26 | + "exports": { | |
| 27 | + "./server": { | |
| 28 | + "import": { | |
| 29 | + "types": "./dist/server.d.ts", | |
| 30 | + "default": "./dist/server.js" | |
| 31 | + } | |
| 32 | + }, | |
| 33 | + "./shared": { | |
| 34 | + "import": { | |
| 35 | + "types": "./dist/shared.d.ts", | |
| 36 | + "default": "./dist/shared.js" | |
| 37 | + } | |
| 38 | + }, | |
| 39 | + "./react": { | |
| 40 | + "import": { | |
| 41 | + "types": "./dist/react/index.d.ts", | |
| 42 | + "default": "./dist/react/index.js" | |
| 43 | + } | |
| 44 | + }, | |
| 45 | + "./client": { | |
| 46 | + "import": { | |
| 47 | + "types": "./dist/react/plugin-client.d.ts", | |
| 48 | + "default": "./dist/react/plugin-client.js" | |
| 49 | + } | |
| 50 | + } | |
| 51 | + } | |
| 52 | + }, | |
| 53 | + "scripts": { | |
| 54 | + "build": "tsup && (tsc --declaration --emitDeclarationOnly --outDir dist --rootDir src || true)", | |
| 55 | + "typecheck": "tsgo --noEmit", | |
| 56 | + "test": "vitest run", | |
| 57 | + "typecheck:slow": "tsc --noEmit" | |
| 58 | + }, | |
| 59 | + "dependencies": { | |
| 60 | + "@effect/atom-react": "catalog:", | |
| 61 | + "@executor-js/api": "workspace:*", | |
| 62 | + "@executor-js/sdk": "workspace:*" | |
| 63 | + }, | |
| 64 | + "devDependencies": { | |
| 65 | + "@effect/vitest": "catalog:", | |
| 66 | + "@executor-js/react": "workspace:*", | |
| 67 | + "@types/node": "catalog:", | |
| 68 | + "@types/react": "catalog:", | |
| 69 | + "bun-types": "catalog:", | |
| 70 | + "effect": "catalog:", | |
| 71 | + "react": "catalog:", | |
| 72 | + "tsup": "catalog:", | |
| 73 | + "typescript": "catalog:", | |
| 74 | + "vitest": "catalog:" | |
| 75 | + }, | |
| 76 | + "peerDependencies": { | |
| 77 | + "@effect/atom-react": "catalog:", | |
| 78 | + "@executor-js/react": "workspace:*", | |
| 79 | + "effect": "catalog:", | |
| 80 | + "react": ">=18" | |
| 81 | + }, | |
| 82 | + "peerDependenciesMeta": { | |
| 83 | + "@effect/atom-react": { | |
| 84 | + "optional": true | |
| 85 | + }, | |
| 86 | + "@executor-js/react": { | |
| 87 | + "optional": true | |
| 88 | + }, | |
| 89 | + "react": { | |
| 90 | + "optional": true | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
| @@ -0,0 +1,814 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// @executor-js/plugin-toolkits/react — the Toolkits console page. | |
| 3 | +// | |
| 4 | +// A toolkit is a named slice of the caller's connections (each off/read/full) | |
| 5 | +// with its own MCP endpoint; an agent connected to one can't see anything | |
| 6 | +// outside it. List and editor views are URL-routed via `usePluginRoute` — | |
| 7 | +// `/plugins/toolkits/`, `/plugins/toolkits/<id>`, `/plugins/toolkits/new/<scope>`. | |
| 8 | +// The `new/` prefix is reserved for the create flow; toolkit ids are server UUIDs. | |
| 9 | +// | |
| 10 | +// Workspace toolkits draw on org-owned connections; personal toolkits can use | |
| 11 | +// org + the caller's own. The list query returns every visible toolkit and we | |
| 12 | +// split by `scope`. | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | + | |
| 15 | +import { useEffect, useMemo, useState } from "react"; | |
| 16 | +import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 17 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 18 | +import * as Exit from "effect/Exit"; | |
| 19 | +import { usePluginNavigate, usePluginRoute } from "@executor-js/sdk/client"; | |
| 20 | + | |
| 21 | +import { connectionsAllAtom, integrationsAtom } from "@executor-js/react/api/atoms"; | |
| 22 | +import { Button } from "@executor-js/react/components/button"; | |
| 23 | +import { Input } from "@executor-js/react/components/input"; | |
| 24 | +import { Textarea } from "@executor-js/react/components/textarea"; | |
| 25 | +import { Switch } from "@executor-js/react/components/switch"; | |
| 26 | +import { Label } from "@executor-js/react/components/label"; | |
| 27 | +import { Badge } from "@executor-js/react/components/badge"; | |
| 28 | +import { | |
| 29 | + Select, | |
| 30 | + SelectContent, | |
| 31 | + SelectItem, | |
| 32 | + SelectTrigger, | |
| 33 | + SelectValue, | |
| 34 | +} from "@executor-js/react/components/select"; | |
| 35 | +import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | |
| 36 | +import { copyToClipboard } from "@executor-js/react/lib/clipboard"; | |
| 37 | +import { cn } from "@executor-js/react/lib/utils"; | |
| 38 | + | |
| 39 | +import type { | |
| 40 | + ToolkitAccess, | |
| 41 | + ToolkitPolicy, | |
| 42 | + ToolkitPolicyAction, | |
| 43 | + ToolkitScope, | |
| 44 | + ToolkitView, | |
| 45 | +} from "../shared"; | |
| 46 | +import { | |
| 47 | + toolkitsAtom, | |
| 48 | + createToolkit, | |
| 49 | + updateToolkit, | |
| 50 | + removeToolkit, | |
| 51 | + toolkitWriteKeys, | |
| 52 | +} from "./atoms"; | |
| 53 | +import { | |
| 54 | + type FormConn as Conn, | |
| 55 | + accessFromToolkit, | |
| 56 | + connKey, | |
| 57 | + entriesFromAccess, | |
| 58 | + slugify, | |
| 59 | + tiersForScope, | |
| 60 | + uniqueSlug, | |
| 61 | +} from "./form"; | |
| 62 | + | |
| 63 | +// --------------------------------------------------------------------------- | |
| 64 | +// Local shapes — structural views of the core connection/integration rows we | |
| 65 | +// read, so this file doesn't pin a specific API success-type name. | |
| 66 | +// --------------------------------------------------------------------------- | |
| 67 | + | |
| 68 | +interface Integ { | |
| 69 | + readonly slug: string; | |
| 70 | + readonly name: string; | |
| 71 | +} | |
| 72 | + | |
| 73 | +const ACCESS_OPTIONS: ReadonlyArray<{ value: ToolkitAccess; label: string }> = [ | |
| 74 | + { value: "off", label: "Off" }, | |
| 75 | + { value: "read", label: "Read" }, | |
| 76 | + { value: "full", label: "Full" }, | |
| 77 | +]; | |
| 78 | + | |
| 79 | +const ACTION_LABEL: Record<ToolkitPolicyAction, string> = { | |
| 80 | + approve: "Auto-approve", | |
| 81 | + require_approval: "Needs approval", | |
| 82 | + block: "Block", | |
| 83 | +}; | |
| 84 | + | |
| 85 | +// --------------------------------------------------------------------------- | |
| 86 | +// Access segmented control (off / read / full) | |
| 87 | +// --------------------------------------------------------------------------- | |
| 88 | + | |
| 89 | +function AccessSeg(props: { value: ToolkitAccess; onChange: (v: ToolkitAccess) => void }) { | |
| 90 | + return ( | |
| 91 | + <div className="inline-flex shrink-0 gap-0.5 rounded-md border border-input p-0.5"> | |
| 92 | + {ACCESS_OPTIONS.map((o) => ( | |
| 93 | + <Button | |
| 94 | + key={o.value} | |
| 95 | + type="button" | |
| 96 | + size="sm" | |
| 97 | + variant={props.value === o.value ? "secondary" : "ghost"} | |
| 98 | + className="h-6 px-2.5 text-[11px] font-medium" | |
| 99 | + onClick={() => props.onChange(o.value)} | |
| 100 | + > | |
| 101 | + {o.label} | |
| 102 | + </Button> | |
| 103 | + ))} | |
| 104 | + </div> | |
| 105 | + ); | |
| 106 | +} | |
| 107 | + | |
| 108 | +// --------------------------------------------------------------------------- | |
| 109 | +// List | |
| 110 | +// --------------------------------------------------------------------------- | |
| 111 | + | |
| 112 | +function ToolkitCard(props: { | |
| 113 | + toolkit: ToolkitView; | |
| 114 | + integrationName: (slug: string) => string; | |
| 115 | + onOpen: () => void; | |
| 116 | +}) { | |
| 117 | + const active = props.toolkit.connections.filter((c) => c.access !== "off"); | |
| 118 | + const integrations = [...new Set(active.map((c) => c.integration))]; | |
| 119 | + return ( | |
| 120 | + // oxlint-disable-next-line react/forbid-elements -- a card-sized clickable surface; a real <button> is the correct accessible primitive | |
| 121 | + <button | |
| 122 | + type="button" | |
| 123 | + onClick={props.onOpen} | |
| 124 | + className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-ring" | |
| 125 | + > | |
| 126 | + <div className="flex items-center justify-between gap-2"> | |
| 127 | + <span className="text-[15px] font-semibold text-foreground">{props.toolkit.name}</span> | |
| 128 | + <span className="text-[12px] text-muted-foreground"> | |
| 129 | + {active.length} {active.length === 1 ? "connection" : "connections"} | |
| 130 | + </span> | |
| 131 | + </div> | |
| 132 | + <div className="flex flex-wrap gap-1.5"> | |
| 133 | + {integrations.length === 0 ? ( | |
| 134 | + <span className="text-[12px] text-muted-foreground">Empty — nothing granted yet</span> | |
| 135 | + ) : ( | |
| 136 | + integrations.map((slug) => ( | |
| 137 | + <span | |
| 138 | + key={slug} | |
| 139 | + className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-0.5 text-[12px]" | |
| 140 | + > | |
| 141 | + <IntegrationFavicon sourceId={slug} size={14} /> | |
| 142 | + {props.integrationName(slug)} | |
| 143 | + </span> | |
| 144 | + )) | |
| 145 | + )} | |
| 146 | + </div> | |
| 147 | + <span className="font-mono text-[10.5px] text-muted-foreground"> | |
| 148 | + /mcp?toolkit={props.toolkit.slug} | |
| 149 | + </span> | |
| 150 | + </button> | |
| 151 | + ); | |
| 152 | +} | |
| 153 | + | |
| 154 | +function ListSection(props: { | |
| 155 | + title: string; | |
| 156 | + empty: string; | |
| 157 | + toolkits: ReadonlyArray<ToolkitView>; | |
| 158 | + integrationName: (slug: string) => string; | |
| 159 | + onOpen: (id: string) => void; | |
| 160 | + onCreate: () => void; | |
| 161 | +}) { | |
| 162 | + return ( | |
| 163 | + <section className="mt-10"> | |
| 164 | + <div className="mb-3 flex items-center justify-between border-b border-border/50 pb-2"> | |
| 165 | + <h2 className="font-display text-xl tracking-tight text-foreground">{props.title}</h2> | |
| 166 | + <Button type="button" size="sm" onClick={props.onCreate}> | |
| 167 | + New toolkit | |
| 168 | + </Button> | |
| 169 | + </div> | |
| 170 | + {props.toolkits.length === 0 ? ( | |
| 171 | + <div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"> | |
| 172 | + {props.empty} | |
| 173 | + </div> | |
| 174 | + ) : ( | |
| 175 | + <div className="grid gap-3 sm:grid-cols-2"> | |
| 176 | + {props.toolkits.map((tk) => ( | |
| 177 | + <ToolkitCard | |
| 178 | + key={tk.id} | |
| 179 | + toolkit={tk} | |
| 180 | + integrationName={props.integrationName} | |
| 181 | + onOpen={() => props.onOpen(tk.id)} | |
| 182 | + /> | |
| 183 | + ))} | |
| 184 | + </div> | |
| 185 | + )} | |
| 186 | + </section> | |
| 187 | + ); | |
| 188 | +} | |
| 189 | + | |
| 190 | +// --------------------------------------------------------------------------- | |
| 191 | +// Editor | |
| 192 | +// --------------------------------------------------------------------------- | |
| 193 | + | |
| 194 | +interface EditorProps { | |
| 195 | + readonly existing: ToolkitView | null; | |
| 196 | + readonly scope: ToolkitScope; | |
| 197 | + readonly takenSlugs: ReadonlySet<string>; | |
| 198 | + readonly connections: ReadonlyArray<Conn>; | |
| 199 | + readonly integrationName: (slug: string) => string; | |
| 200 | + readonly onBack: () => void; | |
| 201 | + readonly onCreated: () => void; | |
| 202 | + readonly onRemoved: () => void; | |
| 203 | +} | |
| 204 | + | |
| 205 | +function ToolkitEditor(props: EditorProps) { | |
| 206 | + const { existing, scope } = props; | |
| 207 | + | |
| 208 | + const [name, setName] = useState(existing?.name ?? "Untitled"); | |
| 209 | + const [briefing, setBriefing] = useState(existing?.briefing ?? ""); | |
| 210 | + const [inheritOrg, setInheritOrg] = useState(existing?.inheritOrgPolicies ?? true); | |
| 211 | + const [access, setAccess] = useState<Record<string, ToolkitAccess>>( | |
| 212 | + () => accessFromToolkit(existing).access, | |
| 213 | + ); | |
| 214 | + const [notes, setNotes] = useState<Record<string, string>>( | |
| 215 | + () => accessFromToolkit(existing).notes, | |
| 216 | + ); | |
| 217 | + const [policies, setPolicies] = useState<ReadonlyArray<ToolkitPolicy>>( | |
| 218 | + () => existing?.policies.map((p) => ({ ...p })) ?? [], | |
| 219 | + ); | |
| 220 | + | |
| 221 | + const [dirty, setDirty] = useState(false); | |
| 222 | + const [saving, setSaving] = useState(false); | |
| 223 | + const [error, setError] = useState<string | null>(null); | |
| 224 | + const [copied, setCopied] = useState(false); | |
| 225 | + const [origin, setOrigin] = useState(""); | |
| 226 | + useEffect(() => { | |
| 227 | + if (typeof window !== "undefined") setOrigin(window.location.origin); | |
| 228 | + }, []); | |
| 229 | + | |
| 230 | + const doCreate = useAtomSet(createToolkit, { mode: "promiseExit" }); | |
| 231 | + const doUpdate = useAtomSet(updateToolkit, { mode: "promiseExit" }); | |
| 232 | + const doRemove = useAtomSet(removeToolkit, { mode: "promiseExit" }); | |
| 233 | + | |
| 234 | + const mark = () => { | |
| 235 | + setDirty(true); | |
| 236 | + setError(null); | |
| 237 | + }; | |
| 238 | + const setMode = (key: string, v: ToolkitAccess) => { | |
| 239 | + setAccess((m) => ({ ...m, [key]: v })); | |
| 240 | + mark(); | |
| 241 | + }; | |
| 242 | + const setNote = (key: string, v: string) => { | |
| 243 | + setNotes((m) => ({ ...m, [key]: v })); | |
| 244 | + mark(); | |
| 245 | + }; | |
| 246 | + const setPolicy = (i: number, patch: Partial<ToolkitPolicy>) => { | |
| 247 | + setPolicies((ps) => ps.map((p, j) => (j === i ? { ...p, ...patch } : p))); | |
| 248 | + mark(); | |
| 249 | + }; | |
| 250 | + | |
| 251 | + // The connections this toolkit may draw on: workspace = org-owned only; | |
| 252 | + // personal = org + the caller's own. | |
| 253 | + const tiers = useMemo(() => tiersForScope(props.connections, scope), [props.connections, scope]); | |
| 254 | + | |
| 255 | + const buildEntries = () => entriesFromAccess(props.connections, scope, access, notes); | |
| 256 | + | |
| 257 | + const cleanPolicies = (): ReadonlyArray<ToolkitPolicy> => | |
| 258 | + policies.filter((p) => p.pattern.trim()).map((p) => ({ ...p, pattern: p.pattern.trim() })); | |
| 259 | + | |
| 260 | + const handleSave = async () => { | |
| 261 | + setSaving(true); | |
| 262 | + setError(null); | |
| 263 | + const connections = buildEntries(); | |
| 264 | + const cleaned = cleanPolicies(); | |
| 265 | + if (existing) { | |
| 266 | + const exit = await doUpdate({ | |
| 267 | + params: { id: existing.id }, | |
| 268 | + payload: { | |
| 269 | + name: name.trim() || "Untitled", | |
| 270 | + briefing: briefing.trim() ? briefing.trim() : null, | |
| 271 | + inheritOrgPolicies: inheritOrg, | |
| 272 | + connections, | |
| 273 | + policies: cleaned, | |
| 274 | + }, | |
| 275 | + reactivityKeys: toolkitWriteKeys, | |
| 276 | + }); | |
| 277 | + setSaving(false); | |
| 278 | + if (Exit.isFailure(exit)) { | |
| 279 | + setError("Couldn't save changes. Try again."); | |
| 280 | + return; | |
| 281 | + } | |
| 282 | + setDirty(false); | |
| 283 | + return; | |
| 284 | + } | |
| 285 | + const exit = await doCreate({ | |
| 286 | + payload: { | |
| 287 | + slug: uniqueSlug(slugify(name) || "toolkit", props.takenSlugs), | |
| 288 | + name: name.trim() || "Untitled", | |
| 289 | + scope, | |
| 290 | + inheritOrgPolicies: inheritOrg, | |
| 291 | + briefing: briefing.trim() ? briefing.trim() : undefined, | |
| 292 | + connections, | |
| 293 | + policies: cleaned, | |
| 294 | + }, | |
| 295 | + reactivityKeys: toolkitWriteKeys, | |
| 296 | + }); | |
| 297 | + setSaving(false); | |
| 298 | + if (Exit.isFailure(exit)) { | |
| 299 | + setError("Couldn't create the toolkit. Try again."); | |
| 300 | + return; | |
| 301 | + } | |
| 302 | + props.onCreated(); | |
| 303 | + }; | |
| 304 | + | |
| 305 | + const handleDelete = async () => { | |
| 306 | + if (!existing) { | |
| 307 | + props.onBack(); | |
| 308 | + return; | |
| 309 | + } | |
| 310 | + // oxlint-disable-next-line no-alert -- intentional destructive confirmation | |
| 311 | + if (!window.confirm("Delete this toolkit? Connected agents lose access immediately.")) return; | |
| 312 | + const exit = await doRemove({ | |
| 313 | + params: { id: existing.id }, | |
| 314 | + reactivityKeys: toolkitWriteKeys, | |
| 315 | + }); | |
| 316 | + if (Exit.isSuccess(exit)) props.onRemoved(); | |
| 317 | + else setError("Couldn't delete the toolkit. Try again."); | |
| 318 | + }; | |
| 319 | + | |
| 320 | + const slug = existing?.slug ?? (slugify(name) || "toolkit"); | |
| 321 | + const endpoint = `${origin}/mcp?toolkit=${encodeURIComponent(slug)}`; | |
| 322 | + | |
| 323 | + // Live "what the agent sees" — derived from the in-progress form. | |
| 324 | + const previewGroups = useMemo(() => { | |
| 325 | + const byInt = new Map<string, Array<{ name: string; access: ToolkitAccess; note?: string }>>(); | |
| 326 | + for (const tier of tiers) { | |
| 327 | + for (const c of tier.conns) { | |
| 328 | + const key = connKey(c.integration, c.name); | |
| 329 | + const a = access[key] ?? "off"; | |
| 330 | + if (a === "off") continue; | |
| 331 | + const list = byInt.get(c.integration) ?? []; | |
| 332 | + list.push({ | |
| 333 | + name: c.name, | |
| 334 | + access: a, | |
| 335 | + note: notes[key]?.trim() || undefined, | |
| 336 | + }); | |
| 337 | + byInt.set(c.integration, list); | |
| 338 | + } | |
| 339 | + } | |
| 340 | + return [...byInt.entries()]; | |
| 341 | + }, [tiers, access, notes]); | |
| 342 | + const previewCount = previewGroups.reduce((n, [, conns]) => n + conns.length, 0); | |
| 343 | + | |
| 344 | + return ( | |
| 345 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 346 | + <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 347 | + {/* oxlint-disable-next-line react/forbid-elements -- a subtle text breadcrumb; intentionally not the styled design-system Button */} | |
| 348 | + <button | |
| 349 | + type="button" | |
| 350 | + onClick={props.onBack} | |
| 351 | + className="mb-4 text-[12.5px] font-medium text-muted-foreground hover:text-foreground" | |
| 352 | + > | |
| 353 | + ← Toolkits | |
| 354 | + </button> | |
| 355 | + | |
| 356 | + <div className="flex items-start justify-between gap-4"> | |
| 357 | + <div className="min-w-0"> | |
| 358 | + <Input | |
| 359 | + value={name} | |
| 360 | + onChange={(e) => { | |
| 361 | + setName((e.target as HTMLInputElement).value); | |
| 362 | + mark(); | |
| 363 | + }} | |
| 364 | + className="h-auto w-full max-w-[420px] border-transparent bg-transparent px-1 font-display text-3xl tracking-tight text-foreground shadow-none focus-visible:border-input" | |
| 365 | + /> | |
| 366 | + <p className="mt-1 px-1 text-[13px] text-muted-foreground"> | |
| 367 | + {scope === "workspace" | |
| 368 | + ? "Workspace toolkit — shared with your org, draws on workspace connections." | |
| 369 | + : "Personal toolkit — only you, can use workspace and your own connections."} | |
| 370 | + </p> | |
| 371 | + </div> | |
| 372 | + <Button | |
| 373 | + type="button" | |
| 374 | + size="sm" | |
| 375 | + variant="ghost" | |
| 376 | + className="shrink-0 text-destructive/80 hover:text-destructive" | |
| 377 | + onClick={handleDelete} | |
| 378 | + > | |
| 379 | + Delete | |
| 380 | + </Button> | |
| 381 | + </div> | |
| 382 | + | |
| 383 | + <Textarea | |
| 384 | + value={briefing} | |
| 385 | + onChange={(e) => { | |
| 386 | + setBriefing((e.target as HTMLTextAreaElement).value); | |
| 387 | + mark(); | |
| 388 | + }} | |
| 389 | + placeholder="What is this toolkit for? This leads the agent's briefing." | |
| 390 | + className="mt-1 min-h-[2.25rem] resize-none border-transparent bg-transparent px-0 text-sm text-muted-foreground shadow-none focus-visible:border-input focus-visible:bg-card focus-visible:px-3" | |
| 391 | + /> | |
| 392 | + | |
| 393 | + <div className="mt-6 grid grid-cols-1 items-start gap-7 lg:grid-cols-[minmax(0,1fr)_340px]"> | |
| 394 | + {/* Main column */} | |
| 395 | + <div> | |
| 396 | + {/* Access */} | |
| 397 | + <h3 className="mb-2.5 text-[13.5px] font-semibold text-foreground">Access</h3> | |
| 398 | + <div className="overflow-hidden rounded-xl border border-border bg-card"> | |
| 399 | + {tiers.map((tier, ti) => ( | |
| 400 | + <div key={tier.label} className={cn(ti > 0 && "border-t border-border")}> | |
| 401 | + <div className="flex items-center justify-between px-4 pb-1 pt-3"> | |
| 402 | + <span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | |
| 403 | + {tier.label} | |
| 404 | + </span> | |
| 405 | + <span className="text-[11px] text-muted-foreground"> | |
| 406 | + {tier.conns.length} available | |
| 407 | + </span> | |
| 408 | + </div> | |
| 409 | + {tier.conns.length === 0 ? ( | |
| 410 | + <p className="px-4 pb-3 text-[12px] text-muted-foreground"> | |
| 411 | + No connections in this tier yet. | |
| 412 | + </p> | |
| 413 | + ) : ( | |
| 414 | + groupByIntegration(tier.conns).map(([intSlug, conns]) => ( | |
| 415 | + <div key={intSlug} className="px-4 pb-2 pt-1.5"> | |
| 416 | + <div className="flex items-center gap-2 pb-1"> | |
| 417 | + <IntegrationFavicon sourceId={intSlug} size={16} /> | |
| 418 | + <span className="text-[12.5px] font-semibold text-foreground"> | |
| 419 | + {props.integrationName(intSlug)} | |
| 420 | + </span> | |
| 421 | + {conns.length > 1 && ( | |
| 422 | + <span className="ml-auto text-[11px] text-muted-foreground"> | |
| 423 | + {conns.length} accounts | |
| 424 | + </span> | |
| 425 | + )} | |
| 426 | + </div> | |
| 427 | + {conns.map((c) => { | |
| 428 | + const key = connKey(c.integration, c.name); | |
| 429 | + const a = access[key] ?? "off"; | |
| 430 | + return ( | |
| 431 | + <div key={key} data-testid={`tk-conn ${key}`} className="pl-7"> | |
| 432 | + <div className="flex items-center gap-3 py-1"> | |
| 433 | + <div className="min-w-0 flex-1"> | |
| 434 | + <div className="truncate text-[13px] font-semibold text-foreground"> | |
| 435 | + {c.name} | |
| 436 | + </div> | |
| 437 | + {(c.identityLabel || c.description) && ( | |
| 438 | + <div className="truncate text-[11.5px] text-muted-foreground"> | |
| 439 | + {c.identityLabel || c.description} | |
| 440 | + </div> | |
| 441 | + )} | |
| 442 | + </div> | |
| 443 | + <AccessSeg value={a} onChange={(v) => setMode(key, v)} /> | |
| 444 | + </div> | |
| 445 | + {a !== "off" && ( | |
| 446 | + <Input | |
| 447 | + value={notes[key] ?? ""} | |
| 448 | + onChange={(e) => | |
| 449 | + setNote(key, (e.target as HTMLInputElement).value) | |
| 450 | + } | |
| 451 | + placeholder={`Note to the agent about ${c.name} (optional)`} | |
| 452 | + className="mb-1.5 h-7 border-transparent bg-transparent px-0 text-[12px] italic shadow-none focus-visible:border-input" | |
| 453 | + /> | |
| 454 | + )} | |
| 455 | + </div> | |
| 456 | + ); | |
| 457 | + })} | |
| 458 | + </div> | |
| 459 | + )) | |
| 460 | + )} | |
| 461 | + </div> | |
| 462 | + ))} | |
| 463 | + </div> | |
| 464 | + <p className="mt-2.5 text-[12px] text-muted-foreground"> | |
| 465 | + New tools and new connections are included automatically, by mode. | |
| 466 | + </p> | |
| 467 | + | |
| 468 | + {/* Approvals & policies */} | |
| 469 | + <div className="mb-2.5 mt-6 flex items-center justify-between"> | |
| 470 | + <h3 className="text-[13.5px] font-semibold text-foreground"> | |
| 471 | + Approvals & policies | |
| 472 | + </h3> | |
| 473 | + <Label className="flex cursor-pointer items-center gap-2"> | |
| 474 | + <Switch | |
| 475 | + checked={inheritOrg} | |
| 476 | + onCheckedChange={(v) => { | |
| 477 | + setInheritOrg(v); | |
| 478 | + mark(); | |
| 479 | + }} | |
| 480 | + /> | |
| 481 | + <span className="text-[12px] font-medium text-muted-foreground"> | |
| 482 | + Inherit org policies | |
| 483 | + </span> | |
| 484 | + </Label> | |
| 485 | + </div> | |
| 486 | + <div className="overflow-hidden rounded-xl border border-border bg-card"> | |
| 487 | + <div className="px-4 py-3"> | |
| 488 | + <p className="text-[12px] text-muted-foreground"> | |
| 489 | + {inheritOrg | |
| 490 | + ? "Your workspace's policies apply on top of this toolkit. Where an org rule and a toolkit rule overlap, the stricter one wins." | |
| 491 | + : "Workspace guardrails are off — this toolkit is governed only by its own rules below."} | |
| 492 | + </p> | |
| 493 | + </div> | |
| 494 | + <div className="border-t border-border px-4 py-2.5"> | |
| 495 | + <div className="flex items-center gap-2 pb-1"> | |
| 496 | + <span className="text-[12.5px] font-semibold text-foreground">Toolkit rules</span> | |
| 497 | + <span className="ml-auto text-[11px] text-muted-foreground"> | |
| 498 | + {policies.length} {policies.length === 1 ? "rule" : "rules"} | |
| 499 | + </span> | |
| 500 | + </div> | |
| 501 | + {policies.map((p, i) => ( | |
| 502 | + <div key={i} className="flex flex-wrap items-center gap-2 py-1.5"> | |
| 503 | + <Input | |
| 504 | + value={p.pattern} | |
| 505 | + onChange={(e) => | |
| 506 | + setPolicy(i, { | |
| 507 | + pattern: (e.target as HTMLInputElement).value, | |
| 508 | + }) | |
| 509 | + } | |
| 510 | + placeholder="slack.*.post_message" | |
| 511 | + className="h-8 min-w-[190px] flex-1 font-mono text-[12px]" | |
| 512 | + /> | |
| 513 | + <Select | |
| 514 | + value={p.action} | |
| 515 | + onValueChange={(v) => setPolicy(i, { action: v as ToolkitPolicyAction })} | |
| 516 | + > | |
| 517 | + <SelectTrigger className="h-8 w-[150px] text-[12px]"> | |
| 518 | + <SelectValue /> | |
| 519 | + </SelectTrigger> | |
| 520 | + <SelectContent> | |
| 521 | + <SelectItem value="approve">{ACTION_LABEL.approve}</SelectItem> | |
| 522 | + <SelectItem value="require_approval"> | |
| 523 | + {ACTION_LABEL.require_approval} | |
| 524 | + </SelectItem> | |
| 525 | + <SelectItem value="block">{ACTION_LABEL.block}</SelectItem> | |
| 526 | + </SelectContent> | |
| 527 | + </Select> | |
| 528 | + <Button | |
| 529 | + type="button" | |
| 530 | + size="sm" | |
| 531 | + variant="ghost" | |
| 532 | + className="h-8 px-2 text-destructive/70 hover:text-destructive" | |
| 533 | + onClick={() => { | |
| 534 | + setPolicies((ps) => ps.filter((_, j) => j !== i)); | |
| 535 | + mark(); | |
| 536 | + }} | |
| 537 | + > | |
| 538 | + ✕ | |
| 539 | + </Button> | |
| 540 | + </div> | |
| 541 | + ))} | |
| 542 | + <div className="flex items-center gap-3 pt-1"> | |
| 543 | + <Button | |
| 544 | + type="button" | |
| 545 | + size="sm" | |
| 546 | + variant="ghost" | |
| 547 | + onClick={() => { | |
| 548 | + setPolicies((ps) => [...ps, { pattern: "", action: "approve" }]); | |
| 549 | + mark(); | |
| 550 | + }} | |
| 551 | + > | |
| 552 | + + Add rule | |
| 553 | + </Button> | |
| 554 | + <span className="text-[11px] text-muted-foreground"> | |
| 555 | + Block hides matching tools entirely · approve / needs-approval set gating. | |
| 556 | + </span> | |
| 557 | + </div> | |
| 558 | + </div> | |
| 559 | + </div> | |
| 560 | + | |
| 561 | + {/* Connect */} | |
| 562 | + <h3 className="mb-2.5 mt-6 text-[13.5px] font-semibold text-foreground">Connect</h3> | |
| 563 | + <div className="rounded-xl border border-border bg-card p-4"> | |
| 564 | + <div className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | |
| 565 | + MCP endpoint | |
| 566 | + </div> | |
| 567 | + {/* oxlint-disable-next-line react/forbid-elements -- a full-width copy affordance; the design-system Button doesn't fit this row layout */} | |
| 568 | + <button | |
| 569 | + type="button" | |
| 570 | + onClick={async () => { | |
| 571 | + if (await copyToClipboard(endpoint)) { | |
| 572 | + setCopied(true); | |
| 573 | + setTimeout(() => setCopied(false), 1100); | |
| 574 | + } | |
| 575 | + }} | |
| 576 | + className="mt-2 flex w-full items-center justify-between gap-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-left" | |
| 577 | + > | |
| 578 | + <span className="truncate font-mono text-[12px] text-foreground">{endpoint}</span> | |
| 579 | + <span className="shrink-0 text-[11px] text-muted-foreground"> | |
| 580 | + {copied ? "copied" : "copy"} | |
| 581 | + </span> | |
| 582 | + </button> | |
| 583 | + <p className="mt-2.5 text-[11.5px] text-muted-foreground"> | |
| 584 | + Point an agent at this URL with any of your account's API keys — it sees only this | |
| 585 | + toolkit's slice, never the management API. | |
| 586 | + </p> | |
| 587 | + </div> | |
| 588 | + | |
| 589 | + {/* Save bar */} | |
| 590 | + <div className="mt-6 flex items-center gap-3"> | |
| 591 | + <Button | |
| 592 | + type="button" | |
| 593 | + onClick={handleSave} | |
| 594 | + disabled={saving || (!!existing && !dirty)} | |
| 595 | + > | |
| 596 | + {saving ? "Saving…" : existing ? "Save changes" : "Create toolkit"} | |
| 597 | + </Button> | |
| 598 | + {error ? ( | |
| 599 | + <span className="text-[12px] text-destructive">{error}</span> | |
| 600 | + ) : existing && !dirty ? ( | |
| 601 | + <span className="text-[12px] text-muted-foreground">All changes saved</span> | |
| 602 | + ) : null} | |
| 603 | + </div> | |
| 604 | + </div> | |
| 605 | + | |
| 606 | + {/* Preview rail */} | |
| 607 | + <aside className="lg:sticky lg:top-6"> | |
| 608 | + <div className="rounded-xl border border-border bg-card p-4"> | |
| 609 | + <div className="flex items-center justify-between"> | |
| 610 | + <span className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground"> | |
| 611 | + What the agent sees | |
| 612 | + </span> | |
| 613 | + <span className="text-[12px] text-muted-foreground"> | |
| 614 | + {previewCount} {previewCount === 1 ? "connection" : "connections"} | |
| 615 | + </span> | |
| 616 | + </div> | |
| 617 | + {briefing.trim() && ( | |
| 618 | + <p className="mt-3 whitespace-pre-wrap text-[12px] leading-relaxed text-foreground"> | |
| 619 | + {briefing.trim()} | |
| 620 | + </p> | |
| 621 | + )} | |
| 622 | + {previewGroups.length === 0 ? ( | |
| 623 | + <p className="mt-3 text-[12px] text-muted-foreground"> | |
| 624 | + Nothing granted yet — set a connection to Read or Full to give the agent something | |
| 625 | + to work with. | |
| 626 | + </p> | |
| 627 | + ) : ( | |
| 628 | + <div className="mt-3 flex flex-col gap-3"> | |
| 629 | + {previewGroups.map(([intSlug, conns]) => ( | |
| 630 | + <div key={intSlug}> | |
| 631 | + <div className="flex items-center gap-2"> | |
| 632 | + <IntegrationFavicon sourceId={intSlug} size={15} /> | |
| 633 | + <span className="text-[12.5px] font-semibold text-foreground"> | |
| 634 | + {props.integrationName(intSlug)} | |
| 635 | + </span> | |
| 636 | + </div> | |
| 637 | + {conns.map((c) => ( | |
| 638 | + <div key={c.name} className="mt-1 pl-6"> | |
| 639 | + <div className="flex items-center gap-2"> | |
| 640 | + <span className="text-[12px] text-foreground">{c.name}</span> | |
| 641 | + <Badge | |
| 642 | + variant={c.access === "full" ? "default" : "secondary"} | |
| 643 | + className="px-1.5 py-0 text-[10px]" | |
| 644 | + > | |
| 645 | + {c.access === "full" ? "full" : "read-only"} | |
| 646 | + </Badge> | |
| 647 | + </div> | |
| 648 | + {c.note && ( | |
| 649 | + <p className="text-[11px] italic text-muted-foreground">{c.note}</p> | |
| 650 | + )} | |
| 651 | + </div> | |
| 652 | + ))} | |
| 653 | + </div> | |
| 654 | + ))} | |
| 655 | + </div> | |
| 656 | + )} | |
| 657 | + </div> | |
| 658 | + </aside> | |
| 659 | + </div> | |
| 660 | + </div> | |
| 661 | + </div> | |
| 662 | + ); | |
| 663 | +} | |
| 664 | + | |
| 665 | +function groupByIntegration( | |
| 666 | + conns: ReadonlyArray<Conn>, | |
| 667 | +): ReadonlyArray<readonly [string, ReadonlyArray<Conn>]> { | |
| 668 | + const byInt = new Map<string, Conn[]>(); | |
| 669 | + for (const c of conns) { | |
| 670 | + const list = byInt.get(c.integration) ?? []; | |
| 671 | + list.push(c); | |
| 672 | + byInt.set(c.integration, list); | |
| 673 | + } | |
| 674 | + return [...byInt.entries()]; | |
| 675 | +} | |
| 676 | + | |
| 677 | +// --------------------------------------------------------------------------- | |
| 678 | +// Page — list <-> editor switch | |
| 679 | +// --------------------------------------------------------------------------- | |
| 680 | + | |
| 681 | +type View = | |
| 682 | + | { readonly kind: "list" } | |
| 683 | + | { readonly kind: "edit"; readonly id: string } | |
| 684 | + | { readonly kind: "new"; readonly scope: ToolkitScope }; | |
| 685 | + | |
| 686 | +function viewFromSubpath(subpath: string): View { | |
| 687 | + const normalized = subpath === "/" ? "" : subpath; | |
| 688 | + if (normalized === "") return { kind: "list" }; | |
| 689 | + if (normalized === "new/workspace") return { kind: "new", scope: "workspace" }; | |
| 690 | + if (normalized === "new/personal") return { kind: "new", scope: "personal" }; | |
| 691 | + return { kind: "edit", id: normalized }; | |
| 692 | +} | |
| 693 | + | |
| 694 | +export function ToolkitsPage() { | |
| 695 | + const { subpath } = usePluginRoute(); | |
| 696 | + const navigate = usePluginNavigate(); | |
| 697 | + const view = viewFromSubpath(subpath); | |
| 698 | + const toolkitsResult = useAtomValue(toolkitsAtom); | |
| 699 | + const connectionsResult = useAtomValue(connectionsAllAtom); | |
| 700 | + const integrationsResult = useAtomValue(integrationsAtom); | |
| 701 | + | |
| 702 | + const toolkits = AsyncResult.match( | |
| 703 | + toolkitsResult as AsyncResult.AsyncResult<ReadonlyArray<ToolkitView>, unknown>, | |
| 704 | + { | |
| 705 | + onInitial: () => null, | |
| 706 | + onFailure: () => [] as ReadonlyArray<ToolkitView>, | |
| 707 | + onSuccess: ({ value }) => value, | |
| 708 | + }, | |
| 709 | + ); | |
| 710 | + const connections = AsyncResult.match( | |
| 711 | + connectionsResult as AsyncResult.AsyncResult<ReadonlyArray<Conn>, unknown>, | |
| 712 | + { | |
| 713 | + onInitial: () => [] as ReadonlyArray<Conn>, | |
| 714 | + onFailure: () => [] as ReadonlyArray<Conn>, | |
| 715 | + onSuccess: ({ value }) => value, | |
| 716 | + }, | |
| 717 | + ); | |
| 718 | + const integrations = AsyncResult.match( | |
| 719 | + integrationsResult as AsyncResult.AsyncResult<ReadonlyArray<Integ>, unknown>, | |
| 720 | + { | |
| 721 | + onInitial: () => [] as ReadonlyArray<Integ>, | |
| 722 | + onFailure: () => [] as ReadonlyArray<Integ>, | |
| 723 | + onSuccess: ({ value }) => value, | |
| 724 | + }, | |
| 725 | + ); | |
| 726 | + | |
| 727 | + const integrationName = useMemo(() => { | |
| 728 | + const bySlug = new Map(integrations.map((i) => [i.slug, i.name])); | |
| 729 | + return (slug: string) => bySlug.get(slug) ?? slug; | |
| 730 | + }, [integrations]); | |
| 731 | + | |
| 732 | + const takenSlugs = useMemo(() => new Set((toolkits ?? []).map((t) => t.slug)), [toolkits]); | |
| 733 | + | |
| 734 | + if (toolkits === null) { | |
| 735 | + return <div className="px-1 py-8 text-[13px] text-muted-foreground">Loading toolkits…</div>; | |
| 736 | + } | |
| 737 | + | |
| 738 | + if (view.kind === "edit" || view.kind === "new") { | |
| 739 | + const existing = view.kind === "edit" ? (toolkits.find((t) => t.id === view.id) ?? null) : null; | |
| 740 | + // The toolkit was deleted out from under us (or never resolved) — fall back. | |
| 741 | + if (view.kind === "edit" && !existing) { | |
| 742 | + return <ToolkitEditorFallback onBack={() => navigate("")} />; | |
| 743 | + } | |
| 744 | + const scope = view.kind === "new" ? view.scope : (existing as ToolkitView).scope; | |
| 745 | + return ( | |
| 746 | + <ToolkitEditor | |
| 747 | + key={view.kind === "edit" ? view.id : `new-${view.scope}`} | |
| 748 | + existing={existing} | |
| 749 | + scope={scope} | |
| 750 | + takenSlugs={takenSlugs} | |
| 751 | + connections={connections} | |
| 752 | + integrationName={integrationName} | |
| 753 | + onBack={() => navigate("")} | |
| 754 | + onCreated={() => navigate("")} | |
| 755 | + onRemoved={() => navigate("")} | |
| 756 | + /> | |
| 757 | + ); | |
| 758 | + } | |
| 759 | + | |
| 760 | + const workspace = toolkits.filter((t) => t.scope === "workspace"); | |
| 761 | + const personal = toolkits.filter((t) => t.scope === "personal"); | |
| 762 | + | |
| 763 | + return ( | |
| 764 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 765 | + <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 766 | + <header className="mb-2"> | |
| 767 | + <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | |
| 768 | + Toolkits | |
| 769 | + </h1> | |
| 770 | + <p className="mt-1.5 max-w-[640px] text-sm text-muted-foreground"> | |
| 771 | + A toolkit is a slice of your connections with its own MCP endpoint. An agent connected | |
| 772 | + to one can't see anything outside it. | |
| 773 | + </p> | |
| 774 | + </header> | |
| 775 | + <ListSection | |
| 776 | + title="Workspace toolkits" | |
| 777 | + empty="No workspace toolkits yet — these draw on shared workspace connections." | |
| 778 | + toolkits={workspace} | |
| 779 | + integrationName={integrationName} | |
| 780 | + onOpen={(id) => navigate(id)} | |
| 781 | + onCreate={() => navigate("new/workspace")} | |
| 782 | + /> | |
| 783 | + <ListSection | |
| 784 | + title="Personal toolkits" | |
| 785 | + empty="No personal toolkits yet — these can use workspace and personal connections." | |
| 786 | + toolkits={personal} | |
| 787 | + integrationName={integrationName} | |
| 788 | + onOpen={(id) => navigate(id)} | |
| 789 | + onCreate={() => navigate("new/personal")} | |
| 790 | + /> | |
| 791 | + </div> | |
| 792 | + </div> | |
| 793 | + ); | |
| 794 | +} | |
| 795 | + | |
| 796 | +function ToolkitEditorFallback(props: { onBack: () => void }) { | |
| 797 | + return ( | |
| 798 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 799 | + <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 800 | + {/* oxlint-disable-next-line react/forbid-elements -- a subtle text breadcrumb; intentionally not the styled design-system Button */} | |
| 801 | + <button | |
| 802 | + type="button" | |
| 803 | + onClick={props.onBack} | |
| 804 | + className="mb-4 text-[12.5px] font-medium text-muted-foreground hover:text-foreground" | |
| 805 | + > | |
| 806 | + ← Toolkits | |
| 807 | + </button> | |
| 808 | + <p className="text-sm text-muted-foreground">This toolkit no longer exists.</p> | |
| 809 | + </div> | |
| 810 | + </div> | |
| 811 | + ); | |
| 812 | +} | |
| 813 | + | |
| 814 | +export default ToolkitsPage; | |
| @@ -0,0 +1,22 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Query + mutation atoms for the toolkits console page. | |
| 3 | +// | |
| 4 | +// The `list` query returns every toolkit the caller can see (workspace + | |
| 5 | +// personal); the page splits by `scope`. Mutations pass `toolkitWriteKeys` at | |
| 6 | +// the call site so the list refreshes after a create / update / remove. | |
| 7 | +// --------------------------------------------------------------------------- | |
| 8 | + | |
| 9 | +import { ReactivityKey, toolkitWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 10 | + | |
| 11 | +import { ToolkitsClient } from "./client"; | |
| 12 | + | |
| 13 | +export { toolkitWriteKeys }; | |
| 14 | + | |
| 15 | +export const toolkitsAtom = ToolkitsClient.query("toolkits", "list", { | |
| 16 | + timeToLive: "15 seconds", | |
| 17 | + reactivityKeys: [ReactivityKey.toolkits], | |
| 18 | +}); | |
| 19 | + | |
| 20 | +export const createToolkit = ToolkitsClient.mutation("toolkits", "create"); | |
| 21 | +export const updateToolkit = ToolkitsClient.mutation("toolkits", "update"); | |
| 22 | +export const removeToolkit = ToolkitsClient.mutation("toolkits", "remove"); | |
| @@ -0,0 +1,21 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// @executor-js/plugin-toolkits/react — the typed reactive client | |
| 3 | +// | |
| 4 | +// `createPluginAtomClient` builds an AtomHttpApi service keyed to the toolkits | |
| 5 | +// group, reusing the host's active Executor Server Connection for the base URL | |
| 6 | +// and (self-host) authorization header — the same wiring every first-party | |
| 7 | +// plugin client uses. | |
| 8 | +// --------------------------------------------------------------------------- | |
| 9 | + | |
| 10 | +import { createPluginAtomClient } from "@executor-js/sdk/client"; | |
| 11 | +import { | |
| 12 | + getExecutorApiBaseUrl, | |
| 13 | + getExecutorServerAuthorizationHeader, | |
| 14 | +} from "@executor-js/react/api/server-connection"; | |
| 15 | + | |
| 16 | +import { ToolkitsApi } from "../shared"; | |
| 17 | + | |
| 18 | +export const ToolkitsClient = createPluginAtomClient(ToolkitsApi, { | |
| 19 | + baseUrl: getExecutorApiBaseUrl, | |
| 20 | + authorizationHeader: getExecutorServerAuthorizationHeader, | |
| 21 | +}); | |
| @@ -0,0 +1,100 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Pure form <-> wire transforms for the toolkit editor. No React / DOM here so | |
| 3 | +// the bug-prone bits (the access-map ⇄ ToolkitConnectionEntry round-trip, scope | |
| 4 | +// tiering, slug derivation) are unit-testable in isolation. | |
| 5 | +// --------------------------------------------------------------------------- | |
| 6 | + | |
| 7 | +import { IntegrationSlug } from "@executor-js/sdk/shared"; | |
| 8 | + | |
| 9 | +import type { ToolkitAccess, ToolkitConnectionEntry, ToolkitScope, ToolkitView } from "../shared"; | |
| 10 | + | |
| 11 | +/** A structural view of a core connection row — the fields the editor reads. */ | |
| 12 | +export interface FormConn { | |
| 13 | + readonly owner: "org" | "user"; | |
| 14 | + readonly name: string; | |
| 15 | + readonly integration: string; | |
| 16 | + readonly identityLabel?: string | null; | |
| 17 | + readonly description?: string | null; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export interface AccessTier { | |
| 21 | + readonly label: string; | |
| 22 | + readonly conns: ReadonlyArray<FormConn>; | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** Stable key for the per-connection access/notes maps. A space can't appear in | |
| 26 | + * an integration slug, so `"<integration> <connection>"` is unambiguous. */ | |
| 27 | +export const connKey = (integration: string, connection: string): string => | |
| 28 | + `${integration} ${connection}`; | |
| 29 | + | |
| 30 | +export const slugify = (s: string): string => | |
| 31 | + s | |
| 32 | + .toLowerCase() | |
| 33 | + .trim() | |
| 34 | + .replace(/[^a-z0-9]+/g, "-") | |
| 35 | + .replace(/^-+|-+$/g, "") | |
| 36 | + .slice(0, 48); | |
| 37 | + | |
| 38 | +/** A slug not already in `taken`; appends `-2`, `-3`, … on collision. */ | |
| 39 | +export const uniqueSlug = (base: string, taken: ReadonlySet<string>): string => { | |
| 40 | + const root = base || "toolkit"; | |
| 41 | + if (!taken.has(root)) return root; | |
| 42 | + let i = 2; | |
| 43 | + while (taken.has(`${root}-${i}`)) i += 1; | |
| 44 | + return `${root}-${i}`; | |
| 45 | +}; | |
| 46 | + | |
| 47 | +/** Which connections a toolkit may draw on: workspace = org-owned only; personal | |
| 48 | + * = org + the caller's own, split into two tiers. */ | |
| 49 | +export const tiersForScope = ( | |
| 50 | + connections: ReadonlyArray<FormConn>, | |
| 51 | + scope: ToolkitScope, | |
| 52 | +): ReadonlyArray<AccessTier> => { | |
| 53 | + const org = connections.filter((c) => c.owner === "org"); | |
| 54 | + if (scope === "workspace") return [{ label: "Workspace connections", conns: org }]; | |
| 55 | + const user = connections.filter((c) => c.owner === "user"); | |
| 56 | + return [ | |
| 57 | + { label: "Workspace connections", conns: org }, | |
| 58 | + { label: "Personal connections", conns: user }, | |
| 59 | + ]; | |
| 60 | +}; | |
| 61 | + | |
| 62 | +/** Seed the editor's access + notes maps from an existing toolkit. */ | |
| 63 | +export const accessFromToolkit = ( | |
| 64 | + toolkit: ToolkitView | null, | |
| 65 | +): { access: Record<string, ToolkitAccess>; notes: Record<string, string> } => { | |
| 66 | + const access: Record<string, ToolkitAccess> = {}; | |
| 67 | + const notes: Record<string, string> = {}; | |
| 68 | + for (const e of toolkit?.connections ?? []) { | |
| 69 | + const key = connKey(e.integration, e.connection); | |
| 70 | + access[key] = e.access; | |
| 71 | + if (e.note) notes[key] = e.note; | |
| 72 | + } | |
| 73 | + return { access, notes }; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +/** Collapse the editor's access + notes maps back into the wire entries — | |
| 77 | + * only connections set to read/full (off = absent), notes trimmed. */ | |
| 78 | +export const entriesFromAccess = ( | |
| 79 | + connections: ReadonlyArray<FormConn>, | |
| 80 | + scope: ToolkitScope, | |
| 81 | + access: Readonly<Record<string, ToolkitAccess>>, | |
| 82 | + notes: Readonly<Record<string, string>>, | |
| 83 | +): ReadonlyArray<ToolkitConnectionEntry> => { | |
| 84 | + const entries: Array<ToolkitConnectionEntry> = []; | |
| 85 | + for (const tier of tiersForScope(connections, scope)) { | |
| 86 | + for (const c of tier.conns) { | |
| 87 | + const key = connKey(c.integration, c.name); | |
| 88 | + const a = access[key] ?? "off"; | |
| 89 | + if (a === "off") continue; | |
| 90 | + const note = notes[key]?.trim(); | |
| 91 | + entries.push({ | |
| 92 | + integration: IntegrationSlug.make(c.integration), | |
| 93 | + connection: c.name, | |
| 94 | + access: a, | |
| 95 | + ...(note ? { note } : {}), | |
| 96 | + }); | |
| 97 | + } | |
| 98 | + } | |
| 99 | + return entries; | |
| 100 | +}; | |
| @@ -0,0 +1,9 @@ | ||
| 1 | +export { ToolkitsPage, default as ToolkitsPageDefault } from "./ToolkitsPage"; | |
| 2 | +export { ToolkitsClient } from "./client"; | |
| 3 | +export { | |
| 4 | + toolkitsAtom, | |
| 5 | + createToolkit, | |
| 6 | + updateToolkit, | |
| 7 | + removeToolkit, | |
| 8 | + toolkitWriteKeys, | |
| 9 | +} from "./atoms"; | |
| @@ -0,0 +1,26 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// @executor-js/plugin-toolkits/client — the frontend half. | |
| 3 | +// | |
| 4 | +// The Vite plugin resolves `${packageName}/client` for every server plugin | |
| 5 | +// registered in `executor.config.ts` and feeds the default export into | |
| 6 | +// `<ExecutorPluginsProvider>`, so registering `toolkitsPlugin()` server-side is | |
| 7 | +// all it takes for this page (and its "Toolkits" nav entry) to mount. | |
| 8 | +// | |
| 9 | +// Server-only deps (Effect runtime, Node, executor.config) MUST NOT be imported | |
| 10 | +// here — this entry is bundled into the frontend. | |
| 11 | +// --------------------------------------------------------------------------- | |
| 12 | + | |
| 13 | +import { defineClientPlugin } from "@executor-js/sdk/client"; | |
| 14 | + | |
| 15 | +import { ToolkitsPage } from "./ToolkitsPage"; | |
| 16 | + | |
| 17 | +export default defineClientPlugin({ | |
| 18 | + id: "toolkits" as const, | |
| 19 | + pages: [ | |
| 20 | + { | |
| 21 | + path: "/", | |
| 22 | + component: ToolkitsPage, | |
| 23 | + nav: { label: "Toolkits" }, | |
| 24 | + }, | |
| 25 | + ], | |
| 26 | +}); | |
| @@ -0,0 +1,379 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// @executor-js/plugin-toolkits/server | |
| 3 | +// | |
| 4 | +// The server half of the toolkits plugin. A toolkit is a named slice of the | |
| 5 | +// caller's connections, persisted as owner-scoped plugin storage: | |
| 6 | +// - workspace toolkit -> owner "org" (visible to every org member) | |
| 7 | +// - personal toolkit -> owner "user" (visible only to its creator) | |
| 8 | +// The pluginStorage facade is already request-bound to (tenant, subject), so | |
| 9 | +// the org/user owner literal is the entire workspace-vs-personal visibility | |
| 10 | +// story. Slice 1 ships data + CRUD only — no MCP narrowing yet. | |
| 11 | +// | |
| 12 | +// React and other browser-only deps live in `./client` — never here. | |
| 13 | +// --------------------------------------------------------------------------- | |
| 14 | + | |
| 15 | +import { Schema } from "effect"; | |
| 16 | +import { Context, definePlugin, Effect, HttpApiBuilder } from "@executor-js/sdk/core"; | |
| 17 | +import { | |
| 18 | + definePluginStorageCollection, | |
| 19 | + Owner, | |
| 20 | + type PluginCtx, | |
| 21 | + type RequestScope, | |
| 22 | + type StorageFailure, | |
| 23 | +} from "@executor-js/sdk"; | |
| 24 | +import { addGroup, capture } from "@executor-js/api"; | |
| 25 | + | |
| 26 | +import { | |
| 27 | + ToolkitForbidden, | |
| 28 | + ToolkitNotFound, | |
| 29 | + ToolkitsApi, | |
| 30 | + type CreateToolkitPayload, | |
| 31 | + type ToolkitAccess, | |
| 32 | + type ToolkitPolicyAction, | |
| 33 | + type ToolkitScope, | |
| 34 | + type ToolkitView, | |
| 35 | + type UpdateToolkitPayload, | |
| 36 | +} from "./shared"; | |
| 37 | +import { | |
| 38 | + EMPTY_TOOLKIT_SCOPE, | |
| 39 | + toolkitScopeToRequestScope, | |
| 40 | + type ResolvedToolkitScope, | |
| 41 | +} from "./toolkit-scope"; | |
| 42 | + | |
| 43 | +const ORG = Owner.make("org"); | |
| 44 | +const USER = Owner.make("user"); | |
| 45 | + | |
| 46 | +// --------------------------------------------------------------------------- | |
| 47 | +// Storage rows (internal — not the HTTP contract). Keyed by uuid, never by a | |
| 48 | +// user-supplied value (long keyed values hit a fumadb keyed-lookup bug). | |
| 49 | +// --------------------------------------------------------------------------- | |
| 50 | + | |
| 51 | +const ToolkitRow = Schema.Struct({ | |
| 52 | + slug: Schema.String, | |
| 53 | + name: Schema.String, | |
| 54 | + inheritOrgPolicies: Schema.Boolean, | |
| 55 | + briefing: Schema.NullOr(Schema.String), | |
| 56 | +}); | |
| 57 | + | |
| 58 | +const ConnectionRow = Schema.Struct({ | |
| 59 | + toolkitId: Schema.String, | |
| 60 | + integration: Schema.String, | |
| 61 | + connection: Schema.String, | |
| 62 | + access: Schema.String, | |
| 63 | + note: Schema.NullOr(Schema.String), | |
| 64 | +}); | |
| 65 | + | |
| 66 | +const PolicyRow = Schema.Struct({ | |
| 67 | + toolkitId: Schema.String, | |
| 68 | + pattern: Schema.String, | |
| 69 | + action: Schema.String, | |
| 70 | +}); | |
| 71 | + | |
| 72 | +const TOOLKITS = definePluginStorageCollection("toolkits", ToolkitRow, { | |
| 73 | + indexes: ["slug"], | |
| 74 | +}); | |
| 75 | +const CONNECTIONS = definePluginStorageCollection("connections", ConnectionRow, { | |
| 76 | + indexes: ["toolkitId"], | |
| 77 | +}); | |
| 78 | +const POLICIES = definePluginStorageCollection("policies", PolicyRow, { | |
| 79 | + indexes: ["toolkitId"], | |
| 80 | +}); | |
| 81 | + | |
| 82 | +const newId = Effect.sync(() => crypto.randomUUID()); | |
| 83 | +const scopeOfOwner = (owner: Owner): ToolkitScope => (owner === ORG ? "workspace" : "personal"); | |
| 84 | + | |
| 85 | +// --------------------------------------------------------------------------- | |
| 86 | +// Extension — the canonical implementation. Handlers (and later the MCP | |
| 87 | +// contribution) all hit this same code path. | |
| 88 | +// --------------------------------------------------------------------------- | |
| 89 | + | |
| 90 | +type ToolkitRowEntry = { | |
| 91 | + readonly key: string; | |
| 92 | + readonly owner: Owner; | |
| 93 | + readonly data: typeof ToolkitRow.Type; | |
| 94 | +}; | |
| 95 | + | |
| 96 | +const makeToolkitsExtension = (ctx: PluginCtx) => { | |
| 97 | + const toolkits = ctx.pluginStorage.collection(TOOLKITS); | |
| 98 | + const connections = ctx.pluginStorage.collection(CONNECTIONS); | |
| 99 | + const policies = ctx.pluginStorage.collection(POLICIES); | |
| 100 | + | |
| 101 | + const viewFromRow = (row: ToolkitRowEntry) => | |
| 102 | + Effect.gen(function* () { | |
| 103 | + const conns = yield* connections.query({ where: { toolkitId: row.key } }); | |
| 104 | + const pols = yield* policies.query({ where: { toolkitId: row.key } }); | |
| 105 | + return { | |
| 106 | + id: row.key, | |
| 107 | + slug: row.data.slug, | |
| 108 | + name: row.data.name, | |
| 109 | + scope: scopeOfOwner(row.owner), | |
| 110 | + inheritOrgPolicies: row.data.inheritOrgPolicies, | |
| 111 | + briefing: row.data.briefing, | |
| 112 | + connections: conns.map((c) => ({ | |
| 113 | + integration: c.data.integration as ToolkitConnectionIntegration, | |
| 114 | + connection: c.data.connection, | |
| 115 | + access: c.data.access as ToolkitAccess, | |
| 116 | + ...(c.data.note == null ? {} : { note: c.data.note }), | |
| 117 | + })), | |
| 118 | + policies: pols.map((p) => ({ | |
| 119 | + pattern: p.data.pattern, | |
| 120 | + action: p.data.action as ToolkitPolicyAction, | |
| 121 | + })), | |
| 122 | + } satisfies ToolkitView; | |
| 123 | + }); | |
| 124 | + | |
| 125 | + // candidate connections honoring scope: a workspace toolkit may use only | |
| 126 | + // org connections; a personal toolkit may use org + the caller's own. | |
| 127 | + const candidateConnections = (scope: ToolkitScope) => | |
| 128 | + scope === "workspace" ? ctx.connections.list({ owner: ORG }) : ctx.connections.list(); | |
| 129 | + | |
| 130 | + const validate = (scope: ToolkitScope, entries: ReadonlyArray<ToolkitConnectionEntryInput>) => | |
| 131 | + Effect.gen(function* () { | |
| 132 | + const allowed = yield* candidateConnections(scope); | |
| 133 | + const pairs = new Set(allowed.map((c) => `${c.integration}/${c.name}`)); | |
| 134 | + const integrations = new Set(allowed.map((c) => c.integration)); | |
| 135 | + for (const e of entries) { | |
| 136 | + const ok = | |
| 137 | + e.connection === "*" | |
| 138 | + ? integrations.has(e.integration) | |
| 139 | + : pairs.has(`${e.integration}/${e.connection}`); | |
| 140 | + if (!ok) { | |
| 141 | + return yield* new ToolkitForbidden({ | |
| 142 | + reason: `connection ${e.integration}/${e.connection} is not available to a ${scope} toolkit`, | |
| 143 | + }); | |
| 144 | + } | |
| 145 | + } | |
| 146 | + }); | |
| 147 | + | |
| 148 | + const putConnections = ( | |
| 149 | + toolkitId: string, | |
| 150 | + owner: Owner, | |
| 151 | + entries: ReadonlyArray<ToolkitConnectionEntryInput>, | |
| 152 | + ) => | |
| 153 | + Effect.forEach(entries, (e) => | |
| 154 | + newId.pipe( | |
| 155 | + Effect.flatMap((cid) => | |
| 156 | + connections.put({ | |
| 157 | + key: cid, | |
| 158 | + owner, | |
| 159 | + data: { | |
| 160 | + toolkitId, | |
| 161 | + integration: e.integration, | |
| 162 | + connection: e.connection, | |
| 163 | + access: e.access, | |
| 164 | + note: e.note ?? null, | |
| 165 | + }, | |
| 166 | + }), | |
| 167 | + ), | |
| 168 | + ), | |
| 169 | + ); | |
| 170 | + | |
| 171 | + const putPolicies = ( | |
| 172 | + toolkitId: string, | |
| 173 | + owner: Owner, | |
| 174 | + rules: ReadonlyArray<{ readonly pattern: string; readonly action: string }>, | |
| 175 | + ) => | |
| 176 | + Effect.forEach(rules, (r) => | |
| 177 | + newId.pipe( | |
| 178 | + Effect.flatMap((pid) => | |
| 179 | + policies.put({ | |
| 180 | + key: pid, | |
| 181 | + owner, | |
| 182 | + data: { toolkitId, pattern: r.pattern, action: r.action }, | |
| 183 | + }), | |
| 184 | + ), | |
| 185 | + ), | |
| 186 | + ); | |
| 187 | + | |
| 188 | + const create = (input: CreateToolkitPayload) => | |
| 189 | + Effect.gen(function* () { | |
| 190 | + if (input.scope === "personal" && ctx.owner.subject == null) { | |
| 191 | + return yield* new ToolkitForbidden({ | |
| 192 | + reason: "personal toolkits require a signed-in user", | |
| 193 | + }); | |
| 194 | + } | |
| 195 | + const owner = input.scope === "workspace" ? ORG : USER; | |
| 196 | + const entries = input.connections ?? []; | |
| 197 | + yield* validate(input.scope, entries); | |
| 198 | + const id = yield* newId; | |
| 199 | + const data = { | |
| 200 | + slug: input.slug, | |
| 201 | + name: input.name, | |
| 202 | + inheritOrgPolicies: input.inheritOrgPolicies ?? true, | |
| 203 | + briefing: input.briefing ?? null, | |
| 204 | + }; | |
| 205 | + yield* toolkits.put({ key: id, owner, data }); | |
| 206 | + yield* putConnections(id, owner, entries); | |
| 207 | + yield* putPolicies(id, owner, input.policies ?? []); | |
| 208 | + return yield* viewFromRow({ key: id, owner, data }); | |
| 209 | + }); | |
| 210 | + | |
| 211 | + const get = (id: string) => | |
| 212 | + Effect.gen(function* () { | |
| 213 | + const row = yield* toolkits.get({ key: id }); | |
| 214 | + if (row == null) return yield* new ToolkitNotFound({ id }); | |
| 215 | + return yield* viewFromRow(row); | |
| 216 | + }); | |
| 217 | + | |
| 218 | + const list = () => | |
| 219 | + toolkits.list().pipe(Effect.flatMap((rows) => Effect.forEach(rows, viewFromRow))); | |
| 220 | + | |
| 221 | + const update = (id: string, patch: UpdateToolkitPayload) => | |
| 222 | + Effect.gen(function* () { | |
| 223 | + const row = yield* toolkits.get({ key: id }); | |
| 224 | + if (row == null) return yield* new ToolkitNotFound({ id }); | |
| 225 | + const data = { | |
| 226 | + slug: row.data.slug, | |
| 227 | + name: patch.name ?? row.data.name, | |
| 228 | + inheritOrgPolicies: patch.inheritOrgPolicies ?? row.data.inheritOrgPolicies, | |
| 229 | + briefing: patch.briefing === undefined ? row.data.briefing : patch.briefing, | |
| 230 | + }; | |
| 231 | + yield* toolkits.put({ key: id, owner: row.owner, data }); | |
| 232 | + if (patch.connections !== undefined) { | |
| 233 | + yield* validate(scopeOfOwner(row.owner), patch.connections); | |
| 234 | + const existing = yield* connections.query({ where: { toolkitId: id } }); | |
| 235 | + yield* Effect.forEach(existing, (c) => | |
| 236 | + connections.remove({ key: c.key, owner: row.owner }), | |
| 237 | + ); | |
| 238 | + yield* putConnections(id, row.owner, patch.connections); | |
| 239 | + } | |
| 240 | + if (patch.policies !== undefined) { | |
| 241 | + const existing = yield* policies.query({ where: { toolkitId: id } }); | |
| 242 | + yield* Effect.forEach(existing, (p) => policies.remove({ key: p.key, owner: row.owner })); | |
| 243 | + yield* putPolicies(id, row.owner, patch.policies); | |
| 244 | + } | |
| 245 | + return yield* viewFromRow({ key: id, owner: row.owner, data }); | |
| 246 | + }); | |
| 247 | + | |
| 248 | + const remove = (id: string) => | |
| 249 | + Effect.gen(function* () { | |
| 250 | + const row = yield* toolkits.get({ key: id }); | |
| 251 | + if (row == null) return yield* new ToolkitNotFound({ id }); | |
| 252 | + const conns = yield* connections.query({ where: { toolkitId: id } }); | |
| 253 | + yield* Effect.forEach(conns, (c) => connections.remove({ key: c.key, owner: row.owner })); | |
| 254 | + const pols = yield* policies.query({ where: { toolkitId: id } }); | |
| 255 | + yield* Effect.forEach(pols, (p) => policies.remove({ key: p.key, owner: row.owner })); | |
| 256 | + yield* toolkits.remove({ key: id, owner: row.owner }); | |
| 257 | + return { removed: true }; | |
| 258 | + }); | |
| 259 | + | |
| 260 | + // Resolve a selector (slug, then id) to the scope the MCP narrowing seam | |
| 261 | + // applies. Request-scoped, so it only finds toolkits visible to the caller — | |
| 262 | + // a cross-tenant/personal-of-another-user selector returns null (the seam | |
| 263 | + // then fails closed to an empty slice). | |
| 264 | + const resolveScope = ( | |
| 265 | + selector: string, | |
| 266 | + ): Effect.Effect<ResolvedToolkitScope | null, StorageFailure> => | |
| 267 | + Effect.gen(function* () { | |
| 268 | + const bySlug = yield* toolkits.query({ | |
| 269 | + where: { slug: selector }, | |
| 270 | + limit: 1, | |
| 271 | + }); | |
| 272 | + const row = bySlug[0] ?? (yield* toolkits.get({ key: selector })); | |
| 273 | + if (row == null) return null; | |
| 274 | + const conns = yield* connections.query({ where: { toolkitId: row.key } }); | |
| 275 | + const pols = yield* policies.query({ where: { toolkitId: row.key } }); | |
| 276 | + return { | |
| 277 | + entries: conns.map((c) => ({ | |
| 278 | + integration: c.data.integration, | |
| 279 | + connection: c.data.connection, | |
| 280 | + access: c.data.access as ToolkitAccess, | |
| 281 | + })), | |
| 282 | + policies: pols.map((p) => ({ | |
| 283 | + pattern: p.data.pattern, | |
| 284 | + action: p.data.action as ToolkitPolicyAction, | |
| 285 | + })), | |
| 286 | + inheritOrgPolicies: row.data.inheritOrgPolicies, | |
| 287 | + }; | |
| 288 | + }); | |
| 289 | + | |
| 290 | + return { create, get, list, update, remove, resolveScope }; | |
| 291 | +}; | |
| 292 | + | |
| 293 | +// Local aliases so the storage/extension layer stays decoupled from the wire | |
| 294 | +// schema's branded types without re-importing them everywhere. | |
| 295 | +type ToolkitConnectionIntegration = ToolkitView["connections"][number]["integration"]; | |
| 296 | +type ToolkitConnectionEntryInput = { | |
| 297 | + readonly integration: ToolkitConnectionIntegration; | |
| 298 | + readonly connection: string; | |
| 299 | + readonly access: ToolkitAccess; | |
| 300 | + readonly note?: string | undefined; | |
| 301 | +}; | |
| 302 | + | |
| 303 | +type ToolkitsExtension = ReturnType<typeof makeToolkitsExtension>; | |
| 304 | + | |
| 305 | +export class ToolkitsExtensionService extends Context.Service< | |
| 306 | + ToolkitsExtensionService, | |
| 307 | + ToolkitsExtension | |
| 308 | +>()("ToolkitsExtensionService") {} | |
| 309 | + | |
| 310 | +// Build handlers against the core executor API with the toolkits group added, | |
| 311 | +// so the handler layer satisfies `ApiGroup<"executor", "toolkits">` (the marker | |
| 312 | +// the host's composed API requires) — not a standalone single-group bundle. | |
| 313 | +const ExecutorApiWithToolkits = addGroup(ToolkitsApi); | |
| 314 | + | |
| 315 | +const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (h) => | |
| 316 | + h | |
| 317 | + .handle("list", () => | |
| 318 | + capture( | |
| 319 | + Effect.gen(function* () { | |
| 320 | + const ext = yield* ToolkitsExtensionService; | |
| 321 | + return yield* ext.list(); | |
| 322 | + }), | |
| 323 | + ), | |
| 324 | + ) | |
| 325 | + .handle("create", ({ payload }) => | |
| 326 | + capture( | |
| 327 | + Effect.gen(function* () { | |
| 328 | + const ext = yield* ToolkitsExtensionService; | |
| 329 | + return yield* ext.create(payload); | |
| 330 | + }), | |
| 331 | + ), | |
| 332 | + ) | |
| 333 | + .handle("get", ({ params }) => | |
| 334 | + capture( | |
| 335 | + Effect.gen(function* () { | |
| 336 | + const ext = yield* ToolkitsExtensionService; | |
| 337 | + return yield* ext.get(params.id); | |
| 338 | + }), | |
| 339 | + ), | |
| 340 | + ) | |
| 341 | + .handle("update", ({ params, payload }) => | |
| 342 | + capture( | |
| 343 | + Effect.gen(function* () { | |
| 344 | + const ext = yield* ToolkitsExtensionService; | |
| 345 | + return yield* ext.update(params.id, payload); | |
| 346 | + }), | |
| 347 | + ), | |
| 348 | + ) | |
| 349 | + .handle("remove", ({ params }) => | |
| 350 | + capture( | |
| 351 | + Effect.gen(function* () { | |
| 352 | + const ext = yield* ToolkitsExtensionService; | |
| 353 | + return yield* ext.remove(params.id); | |
| 354 | + }), | |
| 355 | + ), | |
| 356 | + ), | |
| 357 | +); | |
| 358 | + | |
| 359 | +const resolveToolkitRequestScope = ( | |
| 360 | + ctx: PluginCtx, | |
| 361 | + selector: string, | |
| 362 | +): Effect.Effect<RequestScope, StorageFailure> => | |
| 363 | + makeToolkitsExtension(ctx) | |
| 364 | + .resolveScope(selector) | |
| 365 | + .pipe(Effect.map((scope) => toolkitScopeToRequestScope(scope ?? EMPTY_TOOLKIT_SCOPE))); | |
| 366 | + | |
| 367 | +export const toolkitsPlugin = definePlugin(() => ({ | |
| 368 | + id: "toolkits" as const, | |
| 369 | + packageName: "@executor-js/plugin-toolkits", | |
| 370 | + storage: () => ({}), | |
| 371 | + pluginStorage: { toolkits: TOOLKITS, connections: CONNECTIONS }, | |
| 372 | + extension: makeToolkitsExtension, | |
| 373 | + resolveRequestScope: resolveToolkitRequestScope, | |
| 374 | + routes: () => ToolkitsApi, | |
| 375 | + handlers: () => ToolkitsHandlers, | |
| 376 | + extensionService: ToolkitsExtensionService, | |
| 377 | +})); | |
| 378 | + | |
| 379 | +export default toolkitsPlugin; | |
| @@ -0,0 +1,137 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// @executor-js/plugin-toolkits/shared | |
| 3 | +// | |
| 4 | +// Schemas + the HttpApiGroup shared between server and client. A "toolkit" is a | |
| 5 | +// named slice of the caller's connections (each off/read/full), scoped to the | |
| 6 | +// workspace (org-owned connections only) or the caller personally (org + own). | |
| 7 | +// | |
| 8 | +// No React or Node imports here — server and client both import this. | |
| 9 | +// --------------------------------------------------------------------------- | |
| 10 | + | |
| 11 | +import { Schema } from "effect"; | |
| 12 | +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | |
| 13 | + | |
| 14 | +import { IntegrationSlug, InternalError } from "@executor-js/sdk/shared"; | |
| 15 | + | |
| 16 | +// off = invisible, read = read-only tools only, full = every tool. | |
| 17 | +export const ToolkitAccess = Schema.Literals(["off", "read", "full"]); | |
| 18 | +export type ToolkitAccess = typeof ToolkitAccess.Type; | |
| 19 | + | |
| 20 | +// workspace = org-owned (visible to every org member); personal = the caller's own. | |
| 21 | +export const ToolkitScope = Schema.Literals(["workspace", "personal"]); | |
| 22 | +export type ToolkitScope = typeof ToolkitScope.Type; | |
| 23 | + | |
| 24 | +// One connection in a toolkit's slice. `connection` is a pinned account name, | |
| 25 | +// or "*" to track every connection of the integration. | |
| 26 | +export const ToolkitConnectionEntry = Schema.Struct({ | |
| 27 | + integration: IntegrationSlug, | |
| 28 | + connection: Schema.String, | |
| 29 | + access: ToolkitAccess, | |
| 30 | + note: Schema.optional(Schema.String), | |
| 31 | +}); | |
| 32 | +export type ToolkitConnectionEntry = typeof ToolkitConnectionEntry.Type; | |
| 33 | + | |
| 34 | +// off/read/full is the per-connection access mode; policies are pattern rules | |
| 35 | +// layered on top. v1 enforces `block` (the tool is excluded); `approve` / | |
| 36 | +// `require_approval` are persisted + briefed (org approval policies still apply). | |
| 37 | +export const ToolkitPolicyAction = Schema.Literals(["approve", "require_approval", "block"]); | |
| 38 | +export type ToolkitPolicyAction = typeof ToolkitPolicyAction.Type; | |
| 39 | + | |
| 40 | +export const ToolkitPolicy = Schema.Struct({ | |
| 41 | + pattern: Schema.String, | |
| 42 | + action: ToolkitPolicyAction, | |
| 43 | +}); | |
| 44 | +export type ToolkitPolicy = typeof ToolkitPolicy.Type; | |
| 45 | + | |
| 46 | +export const ToolkitView = Schema.Struct({ | |
| 47 | + id: Schema.String, | |
| 48 | + slug: Schema.String, | |
| 49 | + name: Schema.String, | |
| 50 | + scope: ToolkitScope, | |
| 51 | + inheritOrgPolicies: Schema.Boolean, | |
| 52 | + briefing: Schema.NullOr(Schema.String), | |
| 53 | + connections: Schema.Array(ToolkitConnectionEntry), | |
| 54 | + policies: Schema.Array(ToolkitPolicy), | |
| 55 | +}); | |
| 56 | +export type ToolkitView = typeof ToolkitView.Type; | |
| 57 | + | |
| 58 | +export const CreateToolkitPayload = Schema.Struct({ | |
| 59 | + slug: Schema.String, | |
| 60 | + name: Schema.String, | |
| 61 | + scope: ToolkitScope, | |
| 62 | + inheritOrgPolicies: Schema.optional(Schema.Boolean), | |
| 63 | + briefing: Schema.optional(Schema.String), | |
| 64 | + connections: Schema.optional(Schema.Array(ToolkitConnectionEntry)), | |
| 65 | + policies: Schema.optional(Schema.Array(ToolkitPolicy)), | |
| 66 | +}); | |
| 67 | +export type CreateToolkitPayload = typeof CreateToolkitPayload.Type; | |
| 68 | + | |
| 69 | +export const UpdateToolkitPayload = Schema.Struct({ | |
| 70 | + name: Schema.optional(Schema.String), | |
| 71 | + briefing: Schema.optional(Schema.NullOr(Schema.String)), | |
| 72 | + inheritOrgPolicies: Schema.optional(Schema.Boolean), | |
| 73 | + connections: Schema.optional(Schema.Array(ToolkitConnectionEntry)), | |
| 74 | + policies: Schema.optional(Schema.Array(ToolkitPolicy)), | |
| 75 | +}); | |
| 76 | +export type UpdateToolkitPayload = typeof UpdateToolkitPayload.Type; | |
| 77 | + | |
| 78 | +// --------------------------------------------------------------------------- | |
| 79 | +// Errors | |
| 80 | +// --------------------------------------------------------------------------- | |
| 81 | + | |
| 82 | +export class ToolkitNotFound extends Schema.TaggedErrorClass<ToolkitNotFound>()("ToolkitNotFound", { | |
| 83 | + id: Schema.String, | |
| 84 | +}) {} | |
| 85 | + | |
| 86 | +export class ToolkitForbidden extends Schema.TaggedErrorClass<ToolkitForbidden>()( | |
| 87 | + "ToolkitForbidden", | |
| 88 | + { | |
| 89 | + reason: Schema.String, | |
| 90 | + }, | |
| 91 | +) {} | |
| 92 | + | |
| 93 | +const NotFound = ToolkitNotFound.annotate({ httpApiStatus: 404 }); | |
| 94 | +const Forbidden = ToolkitForbidden.annotate({ httpApiStatus: 403 }); | |
| 95 | + | |
| 96 | +const IdParams = { id: Schema.String }; | |
| 97 | + | |
| 98 | +// --------------------------------------------------------------------------- | |
| 99 | +// Group | |
| 100 | +// --------------------------------------------------------------------------- | |
| 101 | + | |
| 102 | +export const ToolkitsApi = HttpApiGroup.make("toolkits") | |
| 103 | + .add( | |
| 104 | + HttpApiEndpoint.get("list", "/toolkits", { | |
| 105 | + success: Schema.Array(ToolkitView), | |
| 106 | + error: InternalError, | |
| 107 | + }), | |
| 108 | + ) | |
| 109 | + .add( | |
| 110 | + HttpApiEndpoint.post("create", "/toolkits", { | |
| 111 | + payload: CreateToolkitPayload, | |
| 112 | + success: ToolkitView, | |
| 113 | + error: [InternalError, Forbidden], | |
| 114 | + }), | |
| 115 | + ) | |
| 116 | + .add( | |
| 117 | + HttpApiEndpoint.get("get", "/toolkits/:id", { | |
| 118 | + params: IdParams, | |
| 119 | + success: ToolkitView, | |
| 120 | + error: [InternalError, NotFound], | |
| 121 | + }), | |
| 122 | + ) | |
| 123 | + .add( | |
| 124 | + HttpApiEndpoint.patch("update", "/toolkits/:id", { | |
| 125 | + params: IdParams, | |
| 126 | + payload: UpdateToolkitPayload, | |
| 127 | + success: ToolkitView, | |
| 128 | + error: [InternalError, NotFound, Forbidden], | |
| 129 | + }), | |
| 130 | + ) | |
| 131 | + .add( | |
| 132 | + HttpApiEndpoint.delete("remove", "/toolkits/:id", { | |
| 133 | + params: IdParams, | |
| 134 | + success: Schema.Struct({ removed: Schema.Boolean }), | |
| 135 | + error: [InternalError, NotFound], | |
| 136 | + }), | |
| 137 | + ); | |
| @@ -0,0 +1,116 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// Toolkit scope — pure data mapping from a resolved toolkit slice to core's | |
| 3 | +// vocabulary-neutral `RequestScope`. Core owns enforcement; this module only | |
| 4 | +// derives allow/block/require_approval decisions from toolkit entries and | |
| 5 | +// per-toolkit policy rules. | |
| 6 | +// --------------------------------------------------------------------------- | |
| 7 | + | |
| 8 | +import type { Connection } from "@executor-js/sdk"; | |
| 9 | +import { defaultDecideStaticTool, type RequestScope, type ScopeDecision } from "@executor-js/sdk"; | |
| 10 | + | |
| 11 | +import type { ToolkitAccess, ToolkitPolicyAction } from "./shared"; | |
| 12 | + | |
| 13 | +export interface ToolkitPolicyRule { | |
| 14 | + /** Glob over `<integration>.<connection>.<tool>` — `*` matches one segment, | |
| 15 | + * a trailing `*` matches the rest, and `*` inside a segment globs. */ | |
| 16 | + readonly pattern: string; | |
| 17 | + readonly action: ToolkitPolicyAction; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export interface ToolkitScopeEntry { | |
| 21 | + readonly integration: string; | |
| 22 | + /** A pinned connection name, or "*" to track every connection of the integration. */ | |
| 23 | + readonly connection: string; | |
| 24 | + readonly access: ToolkitAccess; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export interface ResolvedToolkitScope { | |
| 28 | + readonly entries: readonly ToolkitScopeEntry[]; | |
| 29 | + /** Per-toolkit policy rules — `block` excludes tools; `require_approval` | |
| 30 | + * tightens execution via core's stricter-wins merge with org policies. */ | |
| 31 | + readonly policies: readonly ToolkitPolicyRule[]; | |
| 32 | + readonly inheritOrgPolicies: boolean; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** Empty slice — exposes no connection tools. Applied fail-closed when a | |
| 36 | + * selector resolves to no toolkit (unknown or not visible to the caller). */ | |
| 37 | +export const EMPTY_TOOLKIT_SCOPE: ResolvedToolkitScope = { | |
| 38 | + entries: [], | |
| 39 | + policies: [], | |
| 40 | + inheritOrgPolicies: true, | |
| 41 | +}; | |
| 42 | + | |
| 43 | +/** Glob match over dot segments: `*` matches one segment, a trailing bare `*` | |
| 44 | + * matches the rest, and `*` inside a segment globs within it. */ | |
| 45 | +const matchPattern = (pattern: string, target: string): boolean => { | |
| 46 | + const p = pattern.toLowerCase().trim().split("."); | |
| 47 | + const a = target.toLowerCase().split("."); | |
| 48 | + for (let i = 0; i < p.length; i++) { | |
| 49 | + if (p[i] === "*" && i === p.length - 1) return true; | |
| 50 | + if (a[i] === undefined) return false; | |
| 51 | + const re = new RegExp( | |
| 52 | + "^" + p[i].replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$", | |
| 53 | + ); | |
| 54 | + if (!re.test(a[i])) return false; | |
| 55 | + } | |
| 56 | + return p.length === a.length; | |
| 57 | +}; | |
| 58 | + | |
| 59 | +const accessFor = ( | |
| 60 | + scope: ResolvedToolkitScope, | |
| 61 | + integration: string, | |
| 62 | + connection: string, | |
| 63 | +): ToolkitAccess => { | |
| 64 | + for (const e of scope.entries) { | |
| 65 | + if (e.integration === integration && e.connection === connection) return e.access; | |
| 66 | + } | |
| 67 | + for (const e of scope.entries) { | |
| 68 | + if (e.integration === integration && e.connection === "*") return e.access; | |
| 69 | + } | |
| 70 | + return "off"; | |
| 71 | +}; | |
| 72 | + | |
| 73 | +const policyDecisionForTool = ( | |
| 74 | + scope: ResolvedToolkitScope, | |
| 75 | + integration: string, | |
| 76 | + connection: string, | |
| 77 | + name: string, | |
| 78 | +): ScopeDecision | null => { | |
| 79 | + const target = `${integration}.${connection}.${name}`; | |
| 80 | + for (const rule of scope.policies) { | |
| 81 | + if (!matchPattern(rule.pattern, target)) continue; | |
| 82 | + if (rule.action === "block") return "block"; | |
| 83 | + if (rule.action === "require_approval") return "require_approval"; | |
| 84 | + } | |
| 85 | + return null; | |
| 86 | +}; | |
| 87 | + | |
| 88 | +/** Map a resolved toolkit slice to core's `RequestScope` overlay. */ | |
| 89 | +export const toolkitScopeToRequestScope = (scope: ResolvedToolkitScope): RequestScope => { | |
| 90 | + const allowedIntegrations = new Set( | |
| 91 | + scope.entries.filter((e) => e.access !== "off").map((e) => e.integration), | |
| 92 | + ); | |
| 93 | + | |
| 94 | + return { | |
| 95 | + inheritOrgPolicies: scope.inheritOrgPolicies, | |
| 96 | + allowsIntegration: (integration) => allowedIntegrations.has(String(integration.slug)), | |
| 97 | + allowsConnection: (connection: Connection) => | |
| 98 | + accessFor(scope, String(connection.integration), String(connection.name)) !== "off", | |
| 99 | + decideTool: (tool) => { | |
| 100 | + const integration = String(tool.integration); | |
| 101 | + const connection = String(tool.connection); | |
| 102 | + const name = String(tool.name); | |
| 103 | + | |
| 104 | + const fromPolicy = policyDecisionForTool(scope, integration, connection, name); | |
| 105 | + if (fromPolicy === "block") return "block"; | |
| 106 | + | |
| 107 | + const access = accessFor(scope, integration, connection); | |
| 108 | + if (access === "off") return "block"; | |
| 109 | + if (access === "read" && tool.annotations?.readOnly !== true) return "block"; | |
| 110 | + | |
| 111 | + if (fromPolicy === "require_approval") return "require_approval"; | |
| 112 | + return "allow"; | |
| 113 | + }, | |
| 114 | + decideStaticTool: defaultDecideStaticTool, | |
| 115 | + }; | |
| 116 | +}; | |
| @@ -0,0 +1,25 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2022", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "Bundler", | |
| 6 | + "strict": true, | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "jsx": "react-jsx", | |
| 9 | + "lib": ["ES2022", "DOM"], | |
| 10 | + "types": ["bun-types", "node"], | |
| 11 | + "noUnusedLocals": true, | |
| 12 | + "noImplicitOverride": true, | |
| 13 | + "plugins": [ | |
| 14 | + { | |
| 15 | + "name": "@effect/language-service", | |
| 16 | + "ignoreEffectSuggestionsInTscExitCode": true, | |
| 17 | + "ignoreEffectWarningsInTscExitCode": true, | |
| 18 | + "diagnosticSeverity": { | |
| 19 | + "preferSchemaOverJson": "off" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + ] | |
| 23 | + }, | |
| 24 | + "include": ["src/**/*.ts", "src/**/*.tsx"] | |
| 25 | +} | |
| @@ -0,0 +1,13 @@ | ||
| 1 | +import { defineConfig } from "tsup"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + entry: { | |
| 5 | + server: "src/server.ts", | |
| 6 | + shared: "src/shared.ts", | |
| 7 | + }, | |
| 8 | + format: ["esm"], | |
| 9 | + dts: false, | |
| 10 | + sourcemap: true, | |
| 11 | + clean: true, | |
| 12 | + external: [/^@executor-js\//, /^effect/, /^@effect\//], | |
| 13 | +}); | |
| @@ -0,0 +1,8 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { | |
| 5 | + include: ["src/**/*.test.ts"], | |
| 6 | + passWithNoTests: true, | |
| 7 | + }, | |
| 8 | +}); | |