Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell. | 1 | /** |
| 2 | * Customize's RPC methods (@g1t/contracts workspace-agents.ts, the | |
| 3 | * `marketplaces`, `plugins` and `connectors` calls): marketplaces a | |
| 4 | * workspace reads, what Discover lists, plugins installed and what they | |
| 5 | * brought, and connectors with their sign-ins and grants. The rules are | |
| 6 | * in marketplaces.ts, plugins.ts and connectors.ts; this file reads what | |
| 7 | * they need from identity and repos, checks who may do what (members see, | |
| 8 | * owners change), and writes the audit log. | |
| 9 | */ | |
| 10 | import { type RepoPath, type Result, type User, fail, identityClient, ok, reposClient } from "@g1t/contracts"; | |
| 11 | ||
| 12 | import { type McpServer } from "../../../packages/contracts/src/abilities.ts"; | |
| 13 | import { type McpServerConfig, parseMarketplaceAddress, parsePluginRef } from "../../../packages/contracts/src/claude-plugin.ts"; | |
| 14 | import type { AgentConnector, ConnectorInput, CustomizeBundle, Marketplace, PluginAgentTemplate, PluginDetail, PluginInstall, PluginListing, SkillListing, WorkspaceConnector } from "../../../packages/contracts/src/customize.ts"; | |
| 15 | import type { SkillScope } from "../../../packages/contracts/src/skill-library.ts"; | |
| 16 | import type { SkillFile } from "../../../packages/contracts/src/skill-format.ts"; | |
| 17 | import * as connectors from "./connectors.ts"; | |
| 18 | import type { FetchDeps } from "./customize-fetch.ts"; | |
| 19 | import { definitionOf } from "./store.ts"; | |
| 20 | import * as marketplaces from "./marketplaces.ts"; | |
| 21 | import * as plugins from "./plugins.ts"; | |
| 22 | import { answerListing } from "./installs.ts"; | |
| 23 | import type { SessionEnv } from "./sessions.ts"; | |
| 24 | import { listingRef } from "../../../packages/contracts/src/marketplace.ts"; | |
| 25 | import type { ViewContext } from "./views.ts"; | |
| 26 | ||
| 27 | type CustomizeEnv = SessionEnv & { CONNECTORS_KEY?: string; GITHUB_TOKEN?: string; SITE_URL?: string }; | |
| 28 | ||
| 29 | const env = (ctx: ViewContext): CustomizeEnv => ctx.env as CustomizeEnv; | |
| 30 | ||
| 31 | /** This g1t's host, for reading its own repositories by address. */ | |
| 32 | function hostOf(ctx: ViewContext): string | null { | |
| 33 | try { | |
| 34 | return new URL(env(ctx).SITE_URL ?? "https://g1t.sh").hostname; | |
| 35 | } catch { | |
| 36 | return null; | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | /** Where plugins and marketplaces are read from: GitHub with the token if set, and repositories here the viewer can read. */ | |
| 41 | function fetchDeps(ctx: ViewContext): FetchDeps { | |
| 42 | const repos = reposClient(env(ctx).REPOS); | |
| 43 | return { | |
| 44 | githubToken: env(ctx).GITHUB_TOKEN ?? null, | |
| 45 | repos: { | |
| 46 | repo: async (full) => { | |
| 47 | const [namespace, name] = full.split("/") as [string, string]; | |
| 48 | const found = await repos.get({ namespace, name } as RepoPath, ctx.viewer).catch(() => null); | |
| 49 | return found?.ok ? { id: found.value.id, default_branch: found.value.defaultBranch } : null; | |
| 50 | }, | |
| 51 | listFiles: (repoId, ref) => repos.listFiles(repoId, ref, 10_000), | |
| 52 | blobs: async (repoId, hashes) => (await repos.rawBlobs(repoId, hashes, 2 * 1024 * 1024)).map((blob) => ({ hash: blob.hash, data: blob.data })), | |
| 53 | }, | |
| 54 | }; | |
| 55 | } | |
| 56 | ||
| 57 | async function connectorDeps(ctx: ViewContext): Promise<connectors.Deps> { | |
| 58 | return { key: await connectors.sealingKey(env(ctx).CONNECTORS_KEY), site: (env(ctx).SITE_URL ?? "https://g1t.sh").replace(/\/+$/, "") }; | |
| 59 | } | |
| 60 | ||
| 61 | const owners = (ctx: ViewContext, what: string): Result<null> => (ctx.owner ? ok(null) : fail("forbidden", `Only the workspace's owners ${what}.`)); | |
| 62 | ||
| 63 | type Labels = { agents: Map<string, { handle: string; display_name: string }>; teams: Map<string, string> }; | |
| 64 | ||
| 65 | async function labels(ctx: ViewContext): Promise<Labels> { | |
| 66 | const [agents, teams] = await Promise.all([ | |
| 67 | ctx.db.prepare("SELECT id, handle, display_name FROM agents WHERE workspace_id = ? AND archived_at IS NULL").bind(ctx.workspaceId).all<{ id: string; handle: string; display_name: string }>(), | |
| 68 | identityClient(env(ctx).IDENTITY) | |
| 69 | .listTeams(ctx.viewer, ctx.slug) | |
| 70 | .catch(() => null), | |
| 71 | ]); | |
| 72 | return { agents: new Map(agents.results.map((a) => [a.id, a])), teams: new Map(teams?.ok ? teams.value.map((t) => [t.slug, t.name]) : []) }; | |
| 73 | } | |
| 74 | ||
| 75 | // ── Marketplaces and Discover ──────────────────────────────────────────── | |
| 76 | ||
| 77 | async function loaded(ctx: ViewContext) { | |
| 78 | return marketplaces.loadAll(ctx.db, ctx.workspaceId, fetchDeps(ctx), hostOf(ctx)); | |
| 79 | } | |
| 80 | ||
| 81 | async function marks(ctx: ViewContext): Promise<marketplaces.InstallMarks> { | |
| 82 | return new Map((await plugins.listInstalls(ctx.db, ctx.workspaceId)).map((row) => [row.ref, { enabled: row.enabled === 1 }])); | |
| 83 | } | |
| 84 | ||
| 85 | async function listMarketplaces(ctx: ViewContext): Promise<Result<Marketplace[]>> { | |
| 86 | return ok(marketplaces.marketplacesOut(await loaded(ctx), ctx.owner)); | |
| 87 | } | |
| 88 | ||
| 89 | async function addMarketplace(ctx: ViewContext, address: unknown, name: unknown): Promise<Result<Marketplace>> { | |
| 90 | const may = owners(ctx, "add marketplaces"); | |
| 91 | if (!may.ok) return may; | |
| 92 | const deps = fetchDeps(ctx); | |
| 93 | const parsed = parseMarketplaceAddress(String(address ?? ""), { host: hostOf(ctx) ?? undefined, here: () => false }); | |
| 94 | if (!parsed.ok) return fail("invalid", parsed.message); | |
| 95 | // `workspace/name` that is a repository here is read here; otherwise GitHub. | |
| 96 | let source = parsed.value; | |
| 97 | if (source.source === "github" && !String(address).includes("://") && (await deps.repos!.repo(source.repo))) source = { source: "g1t", repo: source.repo, ...(source.ref ? { ref: source.ref } : {}) }; | |
| 98 | const added = await marketplaces.addMarketplace(ctx.db, ctx.workspaceId, typeof name === "string" ? name : null, source, ctx.viewer.username, deps, hostOf(ctx)); | |
| 99 | if (added.ok) ctx.audit?.("add_marketplace", `customize/marketplaces/${added.value.name}`, `Added the marketplace ${added.value.name} (${added.value.plugins} plugins)`); | |
| 100 | return added; | |
| 101 | } | |
| 102 | ||
| 103 | async function removeMarketplace(ctx: ViewContext, name: unknown): Promise<Result<null>> { | |
| 104 | const may = owners(ctx, "remove marketplaces"); | |
| 105 | if (!may.ok) return may; | |
| 106 | const removed = await marketplaces.removeMarketplace(ctx.db, ctx.workspaceId, String(name ?? "")); | |
| 107 | if (removed.ok) ctx.audit?.("remove_marketplace", `customize/marketplaces/${String(name)}`, `Removed the marketplace ${String(name)}`); | |
| 108 | return removed; | |
| 109 | } | |
| 110 | ||
| 111 | async function refreshMarketplace(ctx: ViewContext, name: unknown): Promise<Result<Marketplace>> { | |
| 112 | const may = owners(ctx, "read marketplaces again"); | |
| 113 | if (!may.ok) return may; | |
| 114 | const entry = await marketplaces.marketplaceByName(ctx.db, ctx.workspaceId, String(name ?? "")); | |
| 115 | if (!entry) return fail("not_found", "The workspace reads no marketplace by that name."); | |
| 116 | const row = await marketplaces.refreshRow(ctx.db, entry, fetchDeps(ctx), hostOf(ctx)); | |
| 117 | return ok(marketplaces.marketplacesOut([{ row, builtin: !!entry.builtin, manifest: marketplaces.cachedManifest(row) }], ctx.owner)[0]!); | |
| 118 | } | |
| 119 | ||
| 120 | export type Discover = { plugins: PluginListing[]; skills: SkillListing[]; marketplaces: Marketplace[] }; | |
| 121 | ||
| 122 | async function discover(ctx: ViewContext): Promise<Result<Discover>> { | |
| 123 | const all = await loaded(ctx); | |
| 124 | const listings = marketplaces.discoverPlugins(all, await marks(ctx), hostOf(ctx)); | |
| 125 | const { results } = await ctx.db.prepare("SELECT plugin, name FROM skills WHERE workspace_id = ? AND plugin IS NOT NULL AND archived_at IS NULL").bind(ctx.workspaceId).all<{ plugin: string; name: string }>(); | |
| 126 | const added = new Set(results.map((r) => `${r.plugin}/${r.name}`)); | |
| 127 | return ok({ plugins: listings, skills: marketplaces.discoverSkills(listings, added), marketplaces: marketplaces.marketplacesOut(all, ctx.owner) }); | |
| 128 | } | |
| 129 | ||
| 130 | // ── Plugins ────────────────────────────────────────────────────────────── | |
| 131 | ||
| 132 | async function listingFor(ctx: ViewContext, ref: unknown) { | |
| 133 | const parsed = parsePluginRef(ref); | |
| 134 | if (!parsed) return fail("invalid", "Name a plugin as name@marketplace."); | |
| 135 | const all = await loaded(ctx); | |
| 136 | const listings = marketplaces.discoverPlugins(all, await marks(ctx), hostOf(ctx)); | |
| 137 | const listing = marketplaces.findListing(listings, ref as string); | |
| 138 | const entry = marketplaces.entryOf(all, parsed); | |
| 139 | if (!listing || !entry) return fail("not_found", `No marketplace the workspace reads lists ${String(ref)}.`); | |
| 140 | const manifest = all.find((l) => l.row.name === parsed.marketplace)?.manifest ?? null; | |
| 141 | return ok({ listing, entry, owner: manifest?.owner.name ?? (parsed.marketplace === "g1t" ? "g1t" : null), listings }); | |
| 142 | } | |
| 143 | ||
| 144 | async function pluginDetail(ctx: ViewContext, ref: unknown): Promise<Result<PluginDetail>> { | |
| 145 | const found = await listingFor(ctx, ref); | |
| 146 | if (!found.ok) return found; | |
| 147 | const { listing, entry, owner } = found.value; | |
| 148 | return ok(await plugins.detail(ctx.db, ctx.workspaceId, listing, entry.entry, { source: entry.source, pluginRoot: entry.pluginRoot, owner }, hostOf(ctx), fetchDeps(ctx))); | |
| 149 | } | |
| 150 | ||
| 151 | async function pluginInstalls(ctx: ViewContext): Promise<Result<PluginInstall[]>> { | |
| 152 | const rows = await plugins.listInstalls(ctx.db, ctx.workspaceId); | |
| 153 | const listings = rows.length ? marketplaces.discoverPlugins(await loaded(ctx), await marks(ctx), hostOf(ctx)) : []; | |
| 154 | return ok(plugins.installsOut(rows, listings)); | |
| 155 | } | |
| 156 | ||
| 157 | async function installPlugin(ctx: ViewContext, ref: unknown): Promise<Result<PluginInstall>> { | |
| 158 | const may = owners(ctx, "install plugins"); | |
| 159 | if (!may.ok) return may; | |
| 160 | const found = await listingFor(ctx, ref); | |
| 161 | if (!found.ok) return found; | |
| 162 | const { listing, entry } = found.value; | |
| 163 | const done = await plugins.install(ctx.db, { workspaceId: ctx.workspaceId, listing, entry: entry.entry, marketplace: { name: listing.marketplace, source: entry.source, pluginRoot: entry.pluginRoot }, host: hostOf(ctx), by: ctx.viewer.username, deps: fetchDeps(ctx) }); | |
| 164 | if (!done.ok) return done; | |
| 165 | await pluginConnectors(ctx, listing, done.value.plugin.servers, done.value.install.id); | |
| 166 | ctx.audit?.("install_plugin", `customize/plugins/${listing.ref}`, `Installed the plugin ${listing.ref}${done.value.install.version ? ` ${done.value.install.version}` : ""} (${done.value.skills.filter((s) => !s.problem).length} skills, ${done.value.plugin.agents.length} agents)`); | |
| 167 | await answerListing(ctx, listingRef("plugin", listing.ref)); | |
| 168 | const fresh = await plugins.installByRef(ctx.db, ctx.workspaceId, listing.ref); | |
| 169 | return ok(fresh ? plugins.installOut(fresh) : done.value.install); | |
| 170 | } | |
| 171 | ||
| 172 | /** A plugin's remote servers become connectors, for an owner to sign in or give a key; one the workspace has from this plugin already is left as it is. */ | |
| 173 | async function pluginConnectors(ctx: ViewContext, listing: PluginListing, servers: McpServerConfig[], installId: string): Promise<void> { | |
| 174 | const deps = await connectorDeps(ctx); | |
| 175 | const have = new Set((await connectors.listConnectors(ctx.db, ctx.workspaceId)).map((row) => JSON.stringify(JSON.parse(row.from_json)))); | |
| 176 | for (const server of servers) { | |
| 177 | if ((server.type !== "http" && server.type !== "sse") || !server.url) continue; | |
| 178 | const from = { kind: "plugin" as const, ref: listing.ref, server: server.name }; | |
| 179 | if (have.has(JSON.stringify(from))) continue; | |
| 180 | const auth = server.variables.length ? "header" : "oauth"; | |
| 181 | const headerName = Object.keys(server.headers)[0] ?? "Authorization"; | |
| 182 | const added = await connectors.addConnector(ctx.db, deps, ctx.workspaceId, { from, auth, header: auth === "header" ? { name: headerName, value: "" } : null }, ctx.viewer.username, { title: `${listing.display_name ?? listing.name}: ${server.name}`, url: server.url }); | |
| 183 | if (!added.ok) await ctx.db.prepare("UPDATE plugin_installs SET problems = json_insert(problems, '$[#]', ?) WHERE id = ?").bind(`${server.name}: ${added.error.message}`, installId).run(); | |
| 184 | } | |
| 185 | } | |
| 186 | ||
| 187 | async function updatePlugin(ctx: ViewContext, ref: unknown): Promise<Result<PluginInstall>> { | |
| 188 | const may = owners(ctx, "update plugins"); | |
| 189 | if (!may.ok) return may; | |
| 190 | const found = await listingFor(ctx, ref); | |
| 191 | if (!found.ok) return found; | |
| 192 | const { listing, entry } = found.value; | |
| 193 | const done = await plugins.update(ctx.db, { workspaceId: ctx.workspaceId, listing, entry: entry.entry, marketplace: { name: listing.marketplace, source: entry.source, pluginRoot: entry.pluginRoot }, host: hostOf(ctx), by: ctx.viewer.username, deps: fetchDeps(ctx) }); | |
| 194 | if (!done.ok) return done; | |
| 195 | if (!done.value.unchanged) { | |
| 196 | await pluginConnectors(ctx, listing, done.value.plugin.servers, done.value.install.id); | |
| 197 | ctx.audit?.("update_plugin", `customize/plugins/${listing.ref}`, `Updated the plugin ${listing.ref}${done.value.install.version ? ` to ${done.value.install.version}` : ""}${done.value.install.sha ? ` (${done.value.install.sha.slice(0, 8)})` : ""}`); | |
| 198 | } | |
| 199 | const fresh = await plugins.installByRef(ctx.db, ctx.workspaceId, listing.ref); | |
| 200 | return ok(fresh ? plugins.installOut(fresh) : done.value.install); | |
| 201 | } | |
| 202 | ||
| 203 | async function setPluginEnabled(ctx: ViewContext, ref: unknown, enabled: unknown): Promise<Result<PluginInstall>> { | |
| 204 | const may = owners(ctx, "switch plugins on and off"); | |
| 205 | if (!may.ok) return may; | |
| 206 | const changed = await plugins.setEnabled(ctx.db, ctx.workspaceId, String(ref ?? ""), enabled === true); | |
| 207 | if (changed.ok) ctx.audit?.(enabled === true ? "enable_plugin" : "disable_plugin", `customize/plugins/${String(ref)}`, `${enabled === true ? "Switched on" : "Switched off"} the plugin ${String(ref)}`); | |
| 208 | return changed; | |
| 209 | } | |
| 210 | ||
| 211 | async function uninstallPlugin(ctx: ViewContext, ref: unknown): Promise<Result<null>> { | |
| 212 | const may = owners(ctx, "uninstall plugins"); | |
| 213 | if (!may.ok) return may; | |
| 214 | const removed = await plugins.uninstall(ctx.db, ctx.workspaceId, String(ref ?? ""), ctx.viewer.username); | |
| 215 | if (!removed.ok) return removed; | |
| 216 | const gone = await connectors.removePluginConnectors(ctx.db, await connectorDeps(ctx), ctx.workspaceId, String(ref)); | |
| 217 | ctx.audit?.("uninstall_plugin", `customize/plugins/${String(ref)}`, `Uninstalled the plugin ${String(ref)} (${removed.value.skills.length} skills, ${gone} connectors)`); | |
| 218 | return ok(null); | |
| 219 | } | |
| 220 | ||
| 221 | async function pluginAgents(ctx: ViewContext): Promise<Result<PluginAgentTemplate[]>> { | |
| 222 | return ok(await plugins.pluginAgents(ctx.db, ctx.workspaceId)); | |
| 223 | } | |
| 224 | ||
| 225 | // ── Connectors ─────────────────────────────────────────────────────────── | |
| 226 | ||
| 227 | async function listConnectors(ctx: ViewContext): Promise<Result<WorkspaceConnector[]>> { | |
| 228 | return ok(await connectors.connectorsOut(ctx.db, ctx.workspaceId, await labels(ctx))); | |
| 229 | } | |
| 230 | ||
| 231 | async function oneConnector(ctx: ViewContext, id: unknown): Promise<Result<WorkspaceConnector>> { | |
| 232 | const all = await connectors.connectorsOut(ctx.db, ctx.workspaceId, await labels(ctx)); | |
| 233 | const found = all.find((c) => c.id === id); | |
| 234 | return found ? ok(found) : fail("not_found", "There is no such connector."); | |
| 235 | } | |
| 236 | ||
| 237 | async function addConnector(ctx: ViewContext, input: unknown): Promise<Result<WorkspaceConnector>> { | |
| 238 | const may = owners(ctx, "add connectors"); | |
| 239 | if (!may.ok) return may; | |
| 240 | const given = (input ?? {}) as Partial<ConnectorInput>; | |
| 241 | if (!given.from || typeof given.from !== "object") return fail("invalid", "Say where the connector comes from."); | |
| 242 | const auth = given.auth === "header" || given.auth === "oauth" ? given.auth : "none"; | |
| 243 | let pluginServer: { title: string; url: string } | null = null; | |
| 244 | if (given.from.kind === "plugin") { | |
| 245 | const install = await plugins.installByRef(ctx.db, ctx.workspaceId, given.from.ref); | |
| 246 | const server = install ? plugins.installOut(install).components.mcp_servers.find((s) => s.name === (given.from as { server: string }).server) : null; | |
| 247 | if (!install || !server?.url) return fail("not_found", "That plugin isn't installed here, or names no such server."); | |
| 248 | pluginServer = { title: `${install.display_name ?? install.name}: ${server.name}`, url: server.url }; | |
| 249 | } | |
| 250 | const added = await connectors.addConnector(ctx.db, await connectorDeps(ctx), ctx.workspaceId, { from: given.from, auth, header: given.header ?? null }, ctx.viewer.username, pluginServer); | |
| 251 | if (!added.ok) return added; | |
| 252 | ctx.audit?.("add_connector", `customize/connectors/${added.value.name}`, `Added the connector ${added.value.title} (${added.value.url})`); | |
| 253 | return oneConnector(ctx, added.value.id); | |
| 254 | } | |
| 255 | ||
| 256 | async function rowFor(ctx: ViewContext, id: unknown): Promise<Result<connectors.ConnectorRow>> { | |
| 257 | const row = await connectors.connectorById(ctx.db, ctx.workspaceId, String(id ?? "")); | |
| 258 | return row ? ok(row) : fail("not_found", "There is no such connector."); | |
| 259 | } | |
| 260 | ||
| 261 | async function setConnectorKey(ctx: ViewContext, id: unknown, headerName: unknown, value: unknown): Promise<Result<WorkspaceConnector>> { | |
| 262 | const may = owners(ctx, "change connectors"); | |
| 263 | if (!may.ok) return may; | |
| 264 | const row = await rowFor(ctx, id); | |
| 265 | if (!row.ok) return row; | |
| 266 | const done = await connectors.setKey(ctx.db, await connectorDeps(ctx), row.value, typeof headerName === "string" ? headerName : null, String(value ?? "")); | |
| 267 | if (!done.ok) return done; | |
| 268 | ctx.audit?.("set_connector_key", `customize/connectors/${row.value.name}`, `Put a key on the connector ${row.value.title}`); | |
| 269 | return oneConnector(ctx, row.value.id); | |
| 270 | } | |
| 271 | ||
| 272 | async function removeConnector(ctx: ViewContext, id: unknown): Promise<Result<null>> { | |
| 273 | const may = owners(ctx, "remove connectors"); | |
| 274 | if (!may.ok) return may; | |
| 275 | const row = await rowFor(ctx, id); | |
| 276 | if (!row.ok) return row; | |
| 277 | const done = await connectors.removeConnector(ctx.db, await connectorDeps(ctx), ctx.workspaceId, row.value.id); | |
| 278 | if (done.ok) ctx.audit?.("remove_connector", `customize/connectors/${row.value.name}`, `Removed the connector ${row.value.title}`); | |
| 279 | return done; | |
| 280 | } | |
| 281 | ||
| 282 | async function refreshConnector(ctx: ViewContext, id: unknown): Promise<Result<WorkspaceConnector>> { | |
| 283 | const may = owners(ctx, "change connectors"); | |
| 284 | if (!may.ok) return may; | |
| 285 | const row = await rowFor(ctx, id); | |
| 286 | if (!row.ok) return row; | |
| 287 | await connectors.refreshTools(ctx.db, await connectorDeps(ctx), row.value); | |
| 288 | return oneConnector(ctx, row.value.id); | |
| 289 | } | |
| 290 | ||
| 291 | async function startSignIn(ctx: ViewContext, id: unknown, redirectUri: unknown): Promise<Result<{ url: string }>> { | |
| 292 | const may = owners(ctx, "sign connectors in"); | |
| 293 | if (!may.ok) return may; | |
| 294 | const row = await rowFor(ctx, id); | |
| 295 | if (!row.ok) return row; | |
| 296 | const redirect = String(redirectUri ?? ""); | |
| 297 | const site = (env(ctx).SITE_URL ?? "https://g1t.sh").replace(/\/+$/, ""); | |
| 298 | if (!redirect.startsWith(`${site}/`)) return fail("invalid", "The sign-in must come back to this g1t."); | |
| 299 | return connectors.startSignIn(ctx.db, await connectorDeps(ctx), row.value, redirect); | |
| 300 | } | |
| 301 | ||
| 302 | async function finishSignIn(ctx: ViewContext, state: unknown, code: unknown): Promise<Result<WorkspaceConnector>> { | |
| 303 | const may = owners(ctx, "sign connectors in"); | |
| 304 | if (!may.ok) return may; | |
| 305 | const id = connectors.connectorOfState(String(state ?? "")); | |
| 306 | if (!id) return fail("invalid", "This sign-in isn't one that was started here."); | |
| 307 | const row = await rowFor(ctx, id); | |
| 308 | if (!row.ok) return row; | |
| 309 | const done = await connectors.finishSignIn(ctx.db, await connectorDeps(ctx), row.value, String(state), String(code ?? ""), ctx.viewer.username); | |
| 310 | if (!done.ok) return done; | |
| 311 | ctx.audit?.("sign_in_connector", `customize/connectors/${row.value.name}`, `Signed the connector ${row.value.title} in (${JSON.parse(done.value.tools || "[]").length} tools)`); | |
| 312 | await answerListing(ctx, listingRef("connector", row.value.name)); | |
| 313 | return oneConnector(ctx, row.value.id); | |
| 314 | } | |
| 315 | ||
| 316 | async function signOut(ctx: ViewContext, id: unknown): Promise<Result<WorkspaceConnector>> { | |
| 317 | const may = owners(ctx, "change connectors"); | |
| 318 | if (!may.ok) return may; | |
| 319 | const row = await rowFor(ctx, id); | |
| 320 | if (!row.ok) return row; | |
| 321 | const done = await connectors.signOut(ctx.db, await connectorDeps(ctx), row.value); | |
| 322 | if (!done.ok) return done; | |
| 323 | ctx.audit?.("sign_out_connector", `customize/connectors/${row.value.name}`, `Signed the connector ${row.value.title} out`); | |
| 324 | return oneConnector(ctx, row.value.id); | |
| 325 | } | |
| 326 | ||
| 327 | /** `target`: an agent's handle or a team's slug, resolved to what grants keep (the agent's id, the team's slug). */ | |
| 328 | async function grantTarget(ctx: ViewContext, scope: SkillScope, target: unknown): Promise<Result<string>> { | |
| 329 | if (scope === "workspace") return ok(""); | |
| 330 | const key = String(target ?? "").trim().replace(/^@/, "").toLowerCase(); | |
| 331 | if (!key) return fail("invalid", "Say which team or agent."); | |
| 332 | if (scope === "agent") { | |
| 333 | const agent = await ctx.db.prepare("SELECT id FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL").bind(ctx.workspaceId, key).first<{ id: string }>(); | |
| 334 | return agent ? ok(agent.id) : fail("not_found", `There is no agent called @${key}.`); | |
| 335 | } | |
| 336 | const teams = await identityClient(env(ctx).IDENTITY) | |
| 337 | .listTeams(ctx.viewer, ctx.slug) | |
| 338 | .catch(() => null); | |
| 339 | return teams?.ok && teams.value.some((t) => t.slug === key) ? ok(key) : fail("not_found", `There is no team called ${key}.`); | |
| 340 | } | |
| 341 | ||
| 342 | async function grantConnector(ctx: ViewContext, id: unknown, scope: unknown, target: unknown): Promise<Result<WorkspaceConnector>> { | |
| 343 | const may = owners(ctx, "give connectors to agents"); | |
| 344 | if (!may.ok) return may; | |
| 345 | const row = await rowFor(ctx, id); | |
| 346 | if (!row.ok) return row; | |
| 347 | const where = scope === "team" || scope === "agent" ? scope : "workspace"; | |
| 348 | const resolved = await grantTarget(ctx, where, target); | |
| 349 | if (!resolved.ok) return resolved; | |
| 350 | const done = await connectors.grant(ctx.db, await connectorDeps(ctx), ctx.workspaceId, row.value.id, where, resolved.value, ctx.viewer.username); | |
| 351 | if (!done.ok) return done; | |
| 352 | ctx.audit?.("grant_connector", `customize/connectors/${row.value.name}`, `Gave the connector ${row.value.title} to ${where === "workspace" ? "every agent" : where === "team" ? `the team ${resolved.value}` : `@${String(target).replace(/^@/, "")}`}`); | |
| 353 | return oneConnector(ctx, row.value.id); | |
| 354 | } | |
| 355 | ||
| 356 | async function revokeConnector(ctx: ViewContext, id: unknown, grantId: unknown): Promise<Result<WorkspaceConnector>> { | |
| 357 | const may = owners(ctx, "change where connectors reach"); | |
| 358 | if (!may.ok) return may; | |
| 359 | const row = await rowFor(ctx, id); | |
| 360 | if (!row.ok) return row; | |
| 361 | const done = await connectors.revoke(ctx.db, ctx.workspaceId, String(grantId ?? "")); | |
| 362 | if (!done.ok) return done; | |
| 363 | ctx.audit?.("revoke_connector", `customize/connectors/${row.value.name}`, `Took the connector ${row.value.title} back from somewhere it reached`); | |
| 364 | return oneConnector(ctx, row.value.id); | |
| 365 | } | |
| 366 | ||
| 367 | async function agentConnectors(ctx: ViewContext, handle: unknown): Promise<Result<AgentConnector[]>> { | |
| 368 | const key = String(handle ?? "").trim().replace(/^@/, "").toLowerCase(); | |
| 369 | const agent = await ctx.db.prepare("SELECT * FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL").bind(ctx.workspaceId, key).first<Parameters<typeof definitionOf>[0]>(); | |
| 370 | if (!agent) return fail("not_found", `There is no agent called @${key}.`); | |
| 371 | const teams = await identityClient(env(ctx).IDENTITY) | |
| 372 | .agentTeams(ctx.slug, agent.id) | |
| 373 | .catch(() => []); | |
| 374 | const reach = await connectors.reaching(ctx.db, ctx.workspaceId, agent.id, teams.map((t) => t.slug)); | |
| 375 | const names = new Map(teams.map((t) => [t.slug, t.name])); | |
| 376 | return ok(connectors.agentConnectorsOut(reach, definitionOf(agent).abilities.mcp_servers ?? [], { teamName: (slug) => names.get(slug) ?? slug })); | |
| 377 | } | |
| 378 | ||
| 379 | // ── The bundle a runner gets ───────────────────────────────────────────── | |
| 380 | ||
| 381 | /** | |
| 382 | * What a job's Claude Code receives: read with the job's agent token (or a | |
| 383 | * workspace token), so what reaches it is what reaches that agent. Secrets | |
| 384 | * are in it: the API hands it to the runner only. | |
| 385 | */ | |
| 386 | async function bundle(ctx: ViewContext): Promise<Result<CustomizeBundle>> { | |
| 387 | const kind = ctx.viewer.kind ?? "user"; | |
| 388 | if (kind !== "agent" && kind !== "workspace" && !ctx.owner) return fail("forbidden", "The bundle is read by a job's agent token."); | |
| 389 | const agent = kind === "agent" ? await ctx.db.prepare("SELECT id FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL").bind(ctx.workspaceId, ctx.viewer.username.toLowerCase()).first<{ id: string }>() : null; | |
| 390 | const teams = agent | |
| 391 | ? await identityClient(env(ctx).IDENTITY) | |
| 392 | .agentTeams(ctx.slug, agent.id) | |
| 393 | .then((t) => t.map((x) => x.slug)) | |
| 394 | .catch(() => []) | |
| 395 | : []; | |
| 396 | const installs = (await plugins.listInstalls(ctx.db, ctx.workspaceId)).filter((row) => row.enabled === 1); | |
| 397 | const skillRows = await ctx.db | |
| 398 | .prepare( | |
| 399 | `SELECT DISTINCT s.id, s.name, s.plugin, a.version FROM skill_attachments a | |
| 400 | JOIN skills s ON s.id = a.skill_id AND s.archived_at IS NULL AND s.status = 'published' | |
| 401 | WHERE a.workspace_id = ?1 AND (a.scope = 'workspace' OR (a.scope = 'agent' AND a.target = ?2) OR (a.scope = 'team' AND a.target IN (SELECT value FROM json_each(?3)))) | |
| 402 | LIMIT 200`, | |
| 403 | ) | |
| 404 | .bind(ctx.workspaceId, agent?.id ?? "", JSON.stringify(teams)) | |
| 405 | .all<{ id: string; name: string; plugin: string | null; version: number }>(); | |
| 406 | const skills: CustomizeBundle["skills"] = []; | |
| 407 | for (const row of skillRows.results) { | |
| 408 | // A plugin's skills reach Claude Code with the plugin itself. | |
| 409 | if (row.plugin) continue; | |
| 410 | const version = await ctx.db.prepare("SELECT skill_md, files FROM skill_versions WHERE skill_id = ? AND version = ?").bind(row.id, row.version).first<{ skill_md: string; files: string }>(); | |
| 411 | if (!version) continue; | |
| 412 | let files: SkillFile[] = []; | |
| 413 | try { | |
| 414 | files = JSON.parse(version.files) as SkillFile[]; | |
| 415 | } catch { | |
| 416 | files = []; | |
| 417 | } | |
| 418 | skills.push({ name: row.name, plugin: null, files: [{ path: "SKILL.md", content: version.skill_md, encoding: "utf8" }, ...files.map((f) => ({ path: f.path, content: f.content, encoding: f.encoding ?? "utf8" }))] }); | |
| 419 | } | |
| 420 | const deps = await connectorDeps(ctx); | |
| 421 | const reach = agent ? await connectors.reaching(ctx.db, ctx.workspaceId, agent.id, teams) : (await connectors.listConnectors(ctx.db, ctx.workspaceId)).map((row) => ({ row, via: "workspace" as const })); | |
| 422 | const out: CustomizeBundle["connectors"] = []; | |
| 423 | for (const { row } of reach) { | |
| 424 | if (connectors.stateOf(row) !== "connected") continue; | |
| 425 | const headers = await connectors.headersFor(ctx.db, deps, row); | |
| 426 | if (headers) out.push({ name: row.name, url: row.url, headers }); | |
| 427 | } | |
| 428 | return ok({ | |
| 429 | plugins: installs.map((row) => ({ ref: row.ref, name: row.name, fetch: plugins.installOut(row).fetch, version: row.version })), | |
| 430 | skills, | |
| 431 | connectors: out, | |
| 432 | }); | |
| 433 | } | |
| 434 | ||
| 435 | // ── Dispatch ───────────────────────────────────────────────────────────── | |
| 436 | ||
| 437 | /** Answers a Customize method, or null when `method` isn't one. */ | |
| 438 | export async function customizeRpc( | |
| 439 | method: string, | |
| 440 | args: any, | |
| 441 | view: <T>(a: { workspace: string; viewer: User | null }, run: (ctx: ViewContext) => Promise<Result<T>>) => Promise<Result<T>>, | |
| 442 | ): Promise<Result<unknown> | null> { | |
| 443 | switch (method) { | |
| 444 | case "marketplaces": | |
| 445 | return view(args, listMarketplaces); | |
| 446 | case "add_marketplace": | |
| 447 | return view(args, (ctx) => addMarketplace(ctx, args.address, args.name)); | |
| 448 | case "remove_marketplace": | |
| 449 | return view(args, (ctx) => removeMarketplace(ctx, args.name)); | |
| 450 | case "refresh_marketplace": | |
| 451 | return view(args, (ctx) => refreshMarketplace(ctx, args.name)); | |
| 452 | case "discover": | |
| 453 | return view(args, discover); | |
| 454 | case "plugin": | |
| 455 | return view(args, (ctx) => pluginDetail(ctx, args.ref)); | |
| 456 | case "plugin_installs": | |
| 457 | return view(args, pluginInstalls); | |
| 458 | case "install_plugin": | |
| 459 | return view(args, (ctx) => installPlugin(ctx, args.ref)); | |
| 460 | case "set_plugin_enabled": | |
| 461 | return view(args, (ctx) => setPluginEnabled(ctx, args.ref, args.enabled)); | |
| 462 | case "uninstall_plugin": | |
| 463 | return view(args, (ctx) => uninstallPlugin(ctx, args.ref)); | |
| 464 | case "update_plugin": | |
| 465 | return view(args, (ctx) => updatePlugin(ctx, args.ref)); | |
| 466 | case "plugin_agents": | |
| 467 | return view(args, pluginAgents); | |
| 468 | case "connectors": | |
| 469 | return view(args, listConnectors); | |
| 470 | case "connector": | |
| 471 | return view(args, (ctx) => oneConnector(ctx, args.id)); | |
| 472 | case "add_connector": | |
| 473 | return view(args, (ctx) => addConnector(ctx, args.input)); | |
| 474 | case "set_connector_key": | |
| 475 | return view(args, (ctx) => setConnectorKey(ctx, args.id, args.header_name, args.value)); | |
| 476 | case "remove_connector": | |
| 477 | return view(args, (ctx) => removeConnector(ctx, args.id)); | |
| 478 | case "refresh_connector": | |
| 479 | return view(args, (ctx) => refreshConnector(ctx, args.id)); | |
| 480 | case "start_connector_sign_in": | |
| 481 | return view(args, (ctx) => startSignIn(ctx, args.id, args.redirect_uri)); | |
| 482 | case "finish_connector_sign_in": | |
| 483 | return view(args, (ctx) => finishSignIn(ctx, args.state, args.code)); | |
| 484 | case "sign_out_connector": | |
| 485 | return view(args, (ctx) => signOut(ctx, args.id)); | |
| 486 | case "grant_connector": | |
| 487 | return view(args, (ctx) => grantConnector(ctx, args.id, args.scope, args.target)); | |
| 488 | case "revoke_connector": | |
| 489 | return view(args, (ctx) => revokeConnector(ctx, args.id, args.grant)); | |
| 490 | case "agent_connectors": | |
| 491 | return view(args, (ctx) => agentConnectors(ctx, args.handle)); | |
| 492 | case "customize_bundle": | |
| 493 | return view(args, bundle); | |
| 494 | default: | |
| 495 | return null; | |
| 496 | } | |
| 497 | } | |
| 498 | ||
| 499 | export type { McpServer }; |
This file's history is long; its oldest lines are credited to the oldest commit read.