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 | //! Customize over REST and MCP: what shapes how a workspace's agents work, |
| 2 | //! at `/workspaces/{workspace}/customize/…` and as the MCP `customize` tool. | |
| 3 | //! Marketplaces the workspace reads plugins from, what Discover lists, the | |
| 4 | //! plugins it installed and the agents they brought, and its connectors | |
| 5 | //! (MCP servers connected once, then given to agents), with their keys and | |
| 6 | //! grants. Also the bundle a job's Claude Code loads on a runner. | |
| 7 | //! | |
| 8 | //! The agents service keeps all of it and decides who may do what | |
| 9 | //! (`services/agents/src/customize-rpc.ts`): members see, owners change, | |
| 10 | //! and the bundle is read with a job's agent token or a workspace's. The | |
| 11 | //! bundle holds connectors' secrets: the API hands it on and never keeps | |
| 12 | //! or logs it. OAuth sign-in to a connector needs a browser, so it stays | |
| 13 | //! on the site. | |
| 14 | ||
| 15 | use g1t_contracts::rules::actor_kind; | |
| 16 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 17 | use serde_json::{Value, json}; | |
| 18 | use worker::Result; | |
| 19 | ||
| 20 | use crate::operations::Services; | |
| 21 | ||
| 22 | /// One operation on a workspace's Customize. | |
| 23 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 24 | pub enum CustomizeOp { | |
| 25 | ListMarketplaces, | |
| 26 | AddMarketplace, | |
| 27 | RemoveMarketplace, | |
| 28 | RefreshMarketplace, | |
| 29 | Discover, | |
| 30 | ListPlugins, | |
| 31 | GetPlugin, | |
| 32 | InstallPlugin, | |
| 33 | SetPluginEnabled, | |
| 34 | UninstallPlugin, | |
| 35 | UpdatePlugin, | |
| 36 | ListPluginAgents, | |
| 37 | ListConnectors, | |
| 38 | GetConnector, | |
| 39 | AddConnector, | |
| 40 | SetConnectorKey, | |
| 41 | RemoveConnector, | |
| 42 | RefreshConnector, | |
| 43 | GrantConnector, | |
| 44 | RevokeConnector, | |
| 45 | Bundle, | |
| 46 | } | |
| 47 | ||
| 48 | impl CustomizeOp { | |
| 49 | /// Every one: `Op::ALL` lists each as `Op::Customize(…)`, which a test | |
| 50 | /// checks against this. | |
| 51 | #[cfg(test)] | |
| 52 | pub const ALL: [CustomizeOp; 21] = [ | |
| 53 | CustomizeOp::ListMarketplaces, | |
| 54 | CustomizeOp::AddMarketplace, | |
| 55 | CustomizeOp::RemoveMarketplace, | |
| 56 | CustomizeOp::RefreshMarketplace, | |
| 57 | CustomizeOp::Discover, | |
| 58 | CustomizeOp::ListPlugins, | |
| 59 | CustomizeOp::GetPlugin, | |
| 60 | CustomizeOp::InstallPlugin, | |
| 61 | CustomizeOp::SetPluginEnabled, | |
| 62 | CustomizeOp::UninstallPlugin, | |
| 63 | CustomizeOp::UpdatePlugin, | |
| 64 | CustomizeOp::ListPluginAgents, | |
| 65 | CustomizeOp::ListConnectors, | |
| 66 | CustomizeOp::GetConnector, | |
| 67 | CustomizeOp::AddConnector, | |
| 68 | CustomizeOp::SetConnectorKey, | |
| 69 | CustomizeOp::RemoveConnector, | |
| 70 | CustomizeOp::RefreshConnector, | |
| 71 | CustomizeOp::GrantConnector, | |
| 72 | CustomizeOp::RevokeConnector, | |
| 73 | CustomizeOp::Bundle, | |
| 74 | ]; | |
| 75 | ||
| 76 | pub fn name(self) -> &'static str { | |
| 77 | match self { | |
| 78 | CustomizeOp::ListMarketplaces => "list_marketplaces", | |
| 79 | CustomizeOp::AddMarketplace => "add_marketplace", | |
| 80 | CustomizeOp::RemoveMarketplace => "remove_marketplace", | |
| 81 | CustomizeOp::RefreshMarketplace => "refresh_marketplace", | |
| 82 | CustomizeOp::Discover => "discover", | |
| 83 | CustomizeOp::ListPlugins => "list_plugins", | |
| 84 | CustomizeOp::GetPlugin => "get_plugin", | |
| 85 | CustomizeOp::InstallPlugin => "install_plugin", | |
| 86 | CustomizeOp::SetPluginEnabled => "set_plugin_enabled", | |
| 87 | CustomizeOp::UninstallPlugin => "uninstall_plugin", | |
| 88 | CustomizeOp::UpdatePlugin => "update_plugin", | |
| 89 | CustomizeOp::ListPluginAgents => "list_plugin_agents", | |
| 90 | CustomizeOp::ListConnectors => "list_connectors", | |
| 91 | CustomizeOp::GetConnector => "get_connector", | |
| 92 | CustomizeOp::AddConnector => "add_connector", | |
| 93 | CustomizeOp::SetConnectorKey => "set_connector_key", | |
| 94 | CustomizeOp::RemoveConnector => "remove_connector", | |
| 95 | CustomizeOp::RefreshConnector => "refresh_connector", | |
| 96 | CustomizeOp::GrantConnector => "grant_connector", | |
| 97 | CustomizeOp::RevokeConnector => "revoke_connector", | |
| 98 | CustomizeOp::Bundle => "customize_bundle", | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | /// The agents service method it passes through to. | |
| 103 | fn method(self) -> &'static str { | |
| 104 | match self { | |
| 105 | CustomizeOp::ListMarketplaces => "marketplaces", | |
| 106 | CustomizeOp::AddMarketplace => "add_marketplace", | |
| 107 | CustomizeOp::RemoveMarketplace => "remove_marketplace", | |
| 108 | CustomizeOp::RefreshMarketplace => "refresh_marketplace", | |
| 109 | CustomizeOp::Discover => "discover", | |
| 110 | CustomizeOp::ListPlugins => "plugin_installs", | |
| 111 | CustomizeOp::GetPlugin => "plugin", | |
| 112 | CustomizeOp::InstallPlugin => "install_plugin", | |
| 113 | CustomizeOp::SetPluginEnabled => "set_plugin_enabled", | |
| 114 | CustomizeOp::UninstallPlugin => "uninstall_plugin", | |
| 115 | CustomizeOp::UpdatePlugin => "update_plugin", | |
| 116 | CustomizeOp::ListPluginAgents => "plugin_agents", | |
| 117 | CustomizeOp::ListConnectors => "connectors", | |
| 118 | CustomizeOp::GetConnector => "connector", | |
| 119 | CustomizeOp::AddConnector => "add_connector", | |
| 120 | CustomizeOp::SetConnectorKey => "set_connector_key", | |
| 121 | CustomizeOp::RemoveConnector => "remove_connector", | |
| 122 | CustomizeOp::RefreshConnector => "refresh_connector", | |
| 123 | CustomizeOp::GrantConnector => "grant_connector", | |
| 124 | CustomizeOp::RevokeConnector => "revoke_connector", | |
| 125 | CustomizeOp::Bundle => "customize_bundle", | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | /// For the API reference. | |
| 130 | pub fn title(self) -> &'static str { | |
| 131 | match self { | |
| 132 | CustomizeOp::ListMarketplaces => "List marketplaces", | |
| 133 | CustomizeOp::AddMarketplace => "Add a marketplace", | |
| 134 | CustomizeOp::RemoveMarketplace => "Remove a marketplace", | |
| 135 | CustomizeOp::RefreshMarketplace => "Refresh a marketplace", | |
| 136 | CustomizeOp::Discover => "Discover plugins and skills", | |
| 137 | CustomizeOp::ListPlugins => "List installed plugins", | |
| 138 | CustomizeOp::GetPlugin => "Get a plugin", | |
| 139 | CustomizeOp::InstallPlugin => "Install a plugin", | |
| 140 | CustomizeOp::SetPluginEnabled => "Switch a plugin on or off", | |
| 141 | CustomizeOp::UninstallPlugin => "Uninstall a plugin", | |
| 142 | CustomizeOp::UpdatePlugin => "Update a plugin", | |
| 143 | CustomizeOp::ListPluginAgents => "List plugin agents", | |
| 144 | CustomizeOp::ListConnectors => "List connectors", | |
| 145 | CustomizeOp::GetConnector => "Get a connector", | |
| 146 | CustomizeOp::AddConnector => "Add a connector", | |
| 147 | CustomizeOp::SetConnectorKey => "Set a connector's key", | |
| 148 | CustomizeOp::RemoveConnector => "Remove a connector", | |
| 149 | CustomizeOp::RefreshConnector => "Refresh a connector's tools", | |
| 150 | CustomizeOp::GrantConnector => "Give a connector to agents", | |
| 151 | CustomizeOp::RevokeConnector => "Take a connector back", | |
| 152 | CustomizeOp::Bundle => "Get the runner bundle", | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | pub fn description(self) -> &'static str { | |
| 157 | match self { | |
| 158 | CustomizeOp::ListMarketplaces => "The marketplaces a workspace reads plugins from, in the open plugin format (`.claude-plugin/marketplace.json`): each with its `name` (the `@marketplace` part of a plugin's ref), `source` (`github` with `repo`, `g1t` with `repo`, `git` or `url` with `url`, or `builtin`), `builtin` (every workspace has it and it cannot be removed: claude-plugins-official, anthropic-agent-skills and g1t), `about`, `owner`, how many `plugins` it lists, `fetched_at` and `problem` (why its manifest could not be read, when it could not), who added it and when, and `can_remove`. Members only.", | |
| 159 | CustomizeOp::AddMarketplace => "Add a marketplace by `address`: `owner/repo` on GitHub, `workspace/name` for a repository on this g1t, a git address, or a link to a `marketplace.json`. `name` overrides the one its manifest gives. Its manifest is read at once; a marketplace that cannot be read is refused with `422`, saying why. At most 20 a workspace. Owners only. Recorded in the audit log.", | |
| 160 | CustomizeOp::RemoveMarketplace => "Remove a marketplace by its name (`marketplace`). Plugins installed from it stay installed, but get no updates and cannot be installed again until it is added back. Built-in marketplaces cannot be removed (`409`). Owners only. Recorded in the audit log.", | |
| 161 | CustomizeOp::RefreshMarketplace => "Read a marketplace's manifest again now, by its name (`marketplace`), and return it as list_marketplaces gives it. Marketplaces are refreshed on their own in the background; this is for seeing a change at once. Owners only.", | |
| 162 | CustomizeOp::Discover => "What a workspace can add, from every marketplace it reads: `plugins`, one per listing (`ref` as `name@marketplace`, `display_name`, `description`, `version`, `author`, `category`, `tags`, how g1t would `fetch` it, and `availability`: `available`, `installed` with `enabled`, `unavailable` with `why` for sources g1t cannot read such as npm or a command, or `soon` for g1t's own announced ones), `skills`, one per skill inside those plugins (`ref` as `plugin:skill`, with `added` when the library already has it), and the `marketplaces` themselves. Members only.", | |
| 163 | CustomizeOp::ListPlugins => "The plugins a workspace installed, switched off ones included: each with its `ref` (`name@marketplace`), `name`, `marketplace`, `display_name`, `description`, `version`, the commit it was read at (`sha`), how it is fetched (`fetch`), what it brought (`components`: skills, agents, commands, mcp_servers, and whether it carries hooks, lsp, bin or output_styles, which only Claude Code on a runner uses), `problems` met when installing, `enabled`, who installed it and when, and `update` when the marketplace lists a newer version. Members only.", | |
| 164 | CustomizeOp::GetPlugin => "One plugin's page by `ref` (`name@marketplace`, URL-encoded in a path: `@` as `%40`), installed or not: its marketplace `listing`, its `install` if any, its `components` and `readme` read from its source, its `manifest`, its subagents in full (`agents`) for hiring from, `problems`, and the marketplace it is listed in (`source`). Reads the plugin's files, so it takes a moment. Members only.", | |
| 165 | CustomizeOp::InstallPlugin => "Install a plugin by `ref` (`name@marketplace`) from a marketplace the workspace reads, at the commit its listing names, which it stays pinned to. Its skills and commands join the workspace's library as `plugin:skill`, its subagents become templates to hire from, and the remote MCP servers of its `.mcp.json` become connectors, unconnected until an owner signs in or gives a key; its hooks, LSP servers, output styles and binaries reach Claude Code on runners and never g1t's own agents. A plugin g1t cannot fetch (npm, a command, a git host g1t does not read) is refused with `422`. At most 100 a workspace. Owners only. Recorded in the audit log.", | |
| 166 | CustomizeOp::SetPluginEnabled => "Switch an installed plugin on or off by `ref`: `enabled` false keeps it installed but takes its skills, agents and connectors away from agents and leaves it out of runners' bundles; true brings them back. Owners only. Recorded in the audit log.", | |
| 167 | CustomizeOp::UninstallPlugin => "Uninstall a plugin by `ref`: its skills leave the library, its agent templates go, and the connectors it brought are removed with their secrets. Agents hired from its templates stay. Owners only. Recorded in the audit log.", | |
| 168 | CustomizeOp::UpdatePlugin => "Update an installed plugin by `ref`: read it again at the commit its marketplace now lists and pin it there. Its skills get new versions and keep their attachments, skills it no longer has are archived, its agent templates and components are rewritten, and a server it already brought keeps its connector; new remote servers become connectors. A plugin already at its marketplace's commit comes back unchanged. list_plugins shows `update` when there is one to take. Owners only. Recorded in the audit log.", | |
| 169 | CustomizeOp::ListPluginAgents => "The subagents installed plugins brought, as templates to hire an agent from: each with its `ref` (`plugin:agent`), `plugin`, `marketplace`, `name`, `description`, the system prompt as `prompt`, and its `model`, `skills` and `tools`. Members only.", | |
| 170 | CustomizeOp::ListConnectors => "A workspace's connectors: MCP servers connected once and given to agents. Each has its `id` (`con_…`), `name` (tools are offered to agents as `<name>__<tool>`), `title`, `url`, `category`, `auth` (`none`, `header` or `oauth`), `state` (`connected`, `needs_sign_in`, `needs_key` or `problem`, with `problem` saying why), `from` (the directory, a plugin's server, an address, or one agent's own server), its `tools` as last listed and `checked_at`, `header_name` for a key (the key itself is never returned), who it is `signed_in_as` for OAuth, its `grants` (where it reaches: every agent, a team or one agent), and who added it. g1t's own integrations (GitHub, trackers, alert sources, model providers) are list_integrations. Members only.", | |
| 171 | CustomizeOp::GetConnector => "One connector by `id`, in the shape list_connectors gives. Members only.", | |
| 172 | CustomizeOp::AddConnector => "Add a connector. `from` says where it comes from: `{\"kind\": \"directory\", \"id\"}` for one of the directory's remote MCP servers, `{\"kind\": \"plugin\", \"ref\", \"server\"}` for a server an installed plugin names, or `{\"kind\": \"address\", \"name\", \"url\"}` for any MCP server over HTTP (`name` is 2 to 32 lowercase letters, digits and hyphens). `auth` is `none`, `header` (give `header` with the header's `name` and `value`, kept sealed and never returned) or `oauth` (an owner then signs in on the site: sign-in needs a browser, so it has no route). Its tools are listed as soon as it can be reached. At most 50 a workspace. Owners only. Recorded in the audit log.", | |
| 173 | CustomizeOp::SetConnectorKey => "Put a key on a connector by `id`: `value` is the header's value, kept sealed and never returned, and `header_name` the header it is sent as (the one it had, or Authorization, when left out). Makes the connector use header auth, lists its tools again, and returns it. Owners only. Recorded in the audit log.", | |
| 174 | CustomizeOp::RemoveConnector => "Remove a connector by `id`, with its secrets and grants. Agents that had it lose its tools at their next turn. Owners only. Recorded in the audit log.", | |
| 175 | CustomizeOp::RefreshConnector => "Ask a connector's server for its tools again now, by `id`, and return the connector with its `tools`, `checked_at` and `state` as they are now. Owners only.", | |
| 176 | CustomizeOp::GrantConnector => "Give a connector by `id` to agents: `scope` is `workspace` (every agent), `team` (with `target`, the team's slug) or `agent` (with `target`, the agent's handle). Returns the connector with its `grants`. Owners only. Recorded in the audit log.", | |
| 177 | CustomizeOp::RevokeConnector => "Take a connector back from where one grant reaches, by the connector's `id` and the grant's id (`grant`). Returns the connector with its `grants`. Owners only. Recorded in the audit log.", | |
| 178 | CustomizeOp::Bundle => "What a job's Claude Code loads from the workspace's Customize, read once per job with the job's agent token (or a workspace's token; a person's token is refused): `plugins` to fetch at their pinned commit (`ref`, `name`, `fetch`, `version`; switched-off ones left out), the library `skills` that reach the job's agent as files (each `name` with its `files`: `path`, `content`, `encoding` utf8 or base64; a plugin's skills come with the plugin), and the `connectors` that reach it and are connected, each with its `name`, `url` and the `headers` that sign it in. Secrets are in it: it is for the runner, and g1t never keeps or logs it. See self-hosted runners, \"What a run gets from Customize\".", | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | pub fn input(self) -> Value { | |
| 183 | let workspace = json!({ "type": "string", "description": "The workspace's slug." }); | |
| 184 | let marketplace = json!({ "type": "string", "description": "The marketplace's name, as list_marketplaces gives it." }); | |
| 185 | let plugin_ref = json!({ "type": "string", "description": "The plugin as name@marketplace, such as commit-commands@claude-plugins-official." }); | |
| 186 | let id = json!({ "type": "string", "description": "The connector's id (con_…), from list_connectors." }); | |
| 187 | let (properties, required): (Value, &[&str]) = match self { | |
| 188 | CustomizeOp::ListMarketplaces | |
| 189 | | CustomizeOp::Discover | |
| 190 | | CustomizeOp::ListPlugins | |
| 191 | | CustomizeOp::ListPluginAgents | |
| 192 | | CustomizeOp::ListConnectors | |
| 193 | | CustomizeOp::Bundle => (json!({ "workspace": workspace }), &["workspace"]), | |
| 194 | CustomizeOp::AddMarketplace => ( | |
| 195 | json!({ | |
| 196 | "workspace": workspace, | |
| 197 | "address": { "type": "string", "description": "owner/repo on GitHub, workspace/name for a repository here, a git address, or a link to a marketplace.json." }, | |
| 198 | "name": { "type": "string", "description": "What to call it here. The name its manifest gives when left out." }, | |
| 199 | }), | |
| 200 | &["workspace", "address"], | |
| 201 | ), | |
| 202 | CustomizeOp::RemoveMarketplace | CustomizeOp::RefreshMarketplace => { | |
| 203 | (json!({ "workspace": workspace, "marketplace": marketplace }), &["workspace", "marketplace"]) | |
| 204 | } | |
| 205 | CustomizeOp::GetPlugin | CustomizeOp::InstallPlugin | CustomizeOp::UninstallPlugin | CustomizeOp::UpdatePlugin => { | |
| 206 | (json!({ "workspace": workspace, "ref": plugin_ref }), &["workspace", "ref"]) | |
| 207 | } | |
| 208 | CustomizeOp::SetPluginEnabled => ( | |
| 209 | json!({ | |
| 210 | "workspace": workspace, | |
| 211 | "ref": plugin_ref, | |
| 212 | "enabled": { "type": "boolean", "description": "True to switch it on, false to switch it off." }, | |
| 213 | }), | |
| 214 | &["workspace", "ref", "enabled"], | |
| 215 | ), | |
| 216 | CustomizeOp::GetConnector | CustomizeOp::RemoveConnector | CustomizeOp::RefreshConnector => (json!({ "workspace": workspace, "id": id }), &["workspace", "id"]), | |
| 217 | CustomizeOp::AddConnector => ( | |
| 218 | json!({ | |
| 219 | "workspace": workspace, | |
| 220 | "from": { | |
| 221 | "type": "object", | |
| 222 | "description": "Where it comes from: {\"kind\": \"directory\", \"id\"}, {\"kind\": \"plugin\", \"ref\", \"server\"} or {\"kind\": \"address\", \"name\", \"url\"}.", | |
| 223 | "properties": { | |
| 224 | "kind": { "type": "string", "enum": ["directory", "plugin", "address"] }, | |
| 225 | "id": { "type": "string", "description": "For directory: the directory entry's id." }, | |
| 226 | "ref": { "type": "string", "description": "For plugin: the installed plugin, as name@marketplace." }, | |
| 227 | "server": { "type": "string", "description": "For plugin: the server's name in its .mcp.json." }, | |
| 228 | "name": { "type": "string", "description": "For address: 2 to 32 lowercase letters, digits and hyphens." }, | |
| 229 | "url": { "type": "string", "description": "For address: the MCP server's https address." }, | |
| 230 | }, | |
| 231 | "required": ["kind"], | |
| 232 | }, | |
| 233 | "auth": { "type": "string", "enum": ["none", "header", "oauth"], "description": "How it signs in. none when left out." }, | |
| 234 | "header": { | |
| 235 | "type": ["object", "null"], | |
| 236 | "description": "For header auth: the header's name and value. The value is kept sealed and never returned.", | |
| 237 | "properties": { | |
| 238 | "name": { "type": "string" }, | |
| 239 | "value": { "type": "string" }, | |
| 240 | }, | |
| 241 | "required": ["name", "value"], | |
| 242 | }, | |
| 243 | }), | |
| 244 | &["workspace", "from"], | |
| 245 | ), | |
| 246 | CustomizeOp::SetConnectorKey => ( | |
| 247 | json!({ | |
| 248 | "workspace": workspace, | |
| 249 | "id": id, | |
| 250 | "header_name": { "type": "string", "description": "The header the key is sent as. The one it had, or Authorization, when left out." }, | |
| 251 | "value": { "type": "string", "description": "The key. Kept sealed; never returned." }, | |
| 252 | }), | |
| 253 | &["workspace", "id", "value"], | |
| 254 | ), | |
| 255 | CustomizeOp::GrantConnector => ( | |
| 256 | json!({ | |
| 257 | "workspace": workspace, | |
| 258 | "id": id, | |
| 259 | "scope": { "type": "string", "enum": ["workspace", "team", "agent"], "description": "Every agent, a team's agents, or one agent." }, | |
| 260 | "target": { "type": "string", "description": "The team's slug or the agent's handle; nothing for workspace." }, | |
| 261 | }), | |
| 262 | &["workspace", "id", "scope"], | |
| 263 | ), | |
| 264 | CustomizeOp::RevokeConnector => ( | |
| 265 | json!({ | |
| 266 | "workspace": workspace, | |
| 267 | "id": id, | |
| 268 | "grant": { "type": "string", "description": "The grant's id, from the connector's grants." }, | |
| 269 | }), | |
| 270 | &["workspace", "id", "grant"], | |
| 271 | ), | |
| 272 | }; | |
| 273 | json!({ "type": "object", "properties": properties, "required": required }) | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | fn text(input: &Value, key: &str) -> String { | |
| 278 | input[key].as_str().map(str::trim).unwrap_or_default().to_owned() | |
| 279 | } | |
| 280 | ||
| 281 | /// A boolean as sent: JSON, or a word from a form. | |
| 282 | fn flag(value: &Value) -> Option<bool> { | |
| 283 | match value { | |
| 284 | Value::Bool(flag) => Some(*flag), | |
| 285 | Value::String(word) => match word.trim().to_ascii_lowercase().as_str() { | |
| 286 | "true" | "1" => Some(true), | |
| 287 | "false" | "0" => Some(false), | |
| 288 | _ => None, | |
| 289 | }, | |
| 290 | _ => None, | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | /// What an operation asks the agents service beyond the workspace and the | |
| 295 | /// viewer, read from the input, or why the input will not do. | |
| 296 | fn arguments(op: CustomizeOp, input: &Value) -> std::result::Result<Value, String> { | |
| 297 | let mut args = json!({}); | |
| 298 | let need = |key: &str, what: &str| { | |
| 299 | let value = text(input, key); | |
| 300 | if value.is_empty() { | |
| 301 | Err(what.to_owned()) | |
| 302 | } else { | |
| 303 | Ok(value) | |
| 304 | } | |
| 305 | }; | |
| 306 | match op { | |
| 307 | CustomizeOp::ListMarketplaces | |
| 308 | | CustomizeOp::Discover | |
| 309 | | CustomizeOp::ListPlugins | |
| 310 | | CustomizeOp::ListPluginAgents | |
| 311 | | CustomizeOp::ListConnectors | |
| 312 | | CustomizeOp::Bundle => {} | |
| 313 | CustomizeOp::AddMarketplace => { | |
| 314 | args["address"] = json!(need("address", "Give address: owner/repo, workspace/name, a git address or a link to marketplace.json.")?); | |
| 315 | if let Some(name) = input["name"].as_str().map(str::trim).filter(|name| !name.is_empty()) { | |
| 316 | args["name"] = json!(name); | |
| 317 | } | |
| 318 | } | |
| 319 | CustomizeOp::RemoveMarketplace | CustomizeOp::RefreshMarketplace => { | |
| 320 | args["name"] = json!(need("marketplace", "Name the marketplace.")?); | |
| 321 | } | |
| 322 | CustomizeOp::GetPlugin | CustomizeOp::InstallPlugin | CustomizeOp::UninstallPlugin | CustomizeOp::UpdatePlugin => { | |
| 323 | args["ref"] = json!(need("ref", "Name the plugin as name@marketplace.")?); | |
| 324 | } | |
| 325 | CustomizeOp::SetPluginEnabled => { | |
| 326 | args["ref"] = json!(need("ref", "Name the plugin as name@marketplace.")?); | |
| 327 | args["enabled"] = json!(flag(&input["enabled"]).ok_or("enabled is true or false.")?); | |
| 328 | } | |
| 329 | CustomizeOp::GetConnector | CustomizeOp::RemoveConnector | CustomizeOp::RefreshConnector => { | |
| 330 | args["id"] = json!(need("id", "Name the connector by its id (con_…).")?); | |
| 331 | } | |
| 332 | CustomizeOp::AddConnector => { | |
| 333 | let from = &input["from"]; | |
| 334 | if !matches!(from["kind"].as_str(), Some("directory" | "plugin" | "address")) { | |
| 335 | return Err("from.kind is directory, plugin or address.".to_owned()); | |
| 336 | } | |
| 337 | let auth = match input["auth"].as_str().map(str::trim) { | |
| 338 | None | Some("") | Some("none") => "none", | |
| 339 | Some(auth @ ("header" | "oauth")) => auth, | |
| 340 | Some(_) => return Err("auth is none, header or oauth.".to_owned()), | |
| 341 | }; | |
| 342 | let header = match &input["header"] { | |
| 343 | Value::Null => Value::Null, | |
| 344 | header if header["name"].is_string() && header["value"].is_string() => { | |
| 345 | json!({ "name": header["name"], "value": header["value"] }) | |
| 346 | } | |
| 347 | _ => return Err("header has a name and a value.".to_owned()), | |
| 348 | }; | |
| 349 | if auth == "header" && header.is_null() { | |
| 350 | return Err("Header auth needs header: its name and value.".to_owned()); | |
| 351 | } | |
| 352 | args["input"] = json!({ "from": from, "auth": auth, "header": header }); | |
| 353 | } | |
| 354 | CustomizeOp::SetConnectorKey => { | |
| 355 | args["id"] = json!(need("id", "Name the connector by its id (con_…).")?); | |
| 356 | args["value"] = json!(need("value", "Give value: the key.")?); | |
| 357 | if let Some(name) = input["header_name"].as_str().map(str::trim).filter(|name| !name.is_empty()) { | |
| 358 | args["header_name"] = json!(name); | |
| 359 | } | |
| 360 | } | |
| 361 | CustomizeOp::GrantConnector => { | |
| 362 | args["id"] = json!(need("id", "Name the connector by its id (con_…).")?); | |
| 363 | let scope = text(input, "scope"); | |
| 364 | if !matches!(scope.as_str(), "workspace" | "team" | "agent") { | |
| 365 | return Err("scope is workspace, team or agent.".to_owned()); | |
| 366 | } | |
| 367 | if scope != "workspace" { | |
| 368 | args["target"] = json!(need("target", "Give target: the team's slug or the agent's handle.")?); | |
| 369 | } | |
| 370 | args["scope"] = json!(scope); | |
| 371 | } | |
| 372 | CustomizeOp::RevokeConnector => { | |
| 373 | args["id"] = json!(need("id", "Name the connector by its id (con_…).")?); | |
| 374 | args["grant"] = json!(need("grant", "Name the grant by its id.")?); | |
| 375 | } | |
| 376 | } | |
| 377 | Ok(args) | |
| 378 | } | |
| 379 | ||
| 380 | pub async fn run(op: CustomizeOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 381 | let workspace = text(input, "workspace").to_lowercase(); | |
| 382 | if workspace.is_empty() { | |
| 383 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace.")); | |
| 384 | } | |
| 385 | let actor = viewer.clone().unwrap_or_default(); | |
| 386 | // The bundle carries connectors' secrets: only a run's own token, or | |
| 387 | // the workspace's, reads it, never a person's. The agents service | |
| 388 | // checks this too. | |
| 389 | if op == CustomizeOp::Bundle && actor_kind(&actor) == "person" { | |
| 390 | return Ok(Outcome::fail( | |
| 391 | FailureCode::Forbidden, | |
| 392 | "The bundle is read with a job's agent token or a workspace's token, not a person's.", | |
| 393 | )); | |
| 394 | } | |
| 395 | let mut args = match arguments(op, input) { | |
| 396 | Ok(args) => args, | |
| 397 | Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, &why)), | |
| 398 | }; | |
| 399 | args["workspace"] = json!(workspace); | |
| 400 | args["viewer"] = json!(actor); | |
| 401 | g1t_kit::call(&services.agents, op.method(), &args).await | |
| 402 | } | |
| 403 | ||
| 404 | #[cfg(test)] | |
| 405 | mod tests { | |
| 406 | use super::*; | |
| 407 | use g1t_contracts::scopes::{Level, scope_for}; | |
| 408 | ||
| 409 | #[test] | |
| 410 | fn each_operation_is_described_and_scoped() { | |
| 411 | for op in CustomizeOp::ALL { | |
| 412 | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::Customize(op)), "{}", op.name()); | |
| 413 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 414 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("workspace")), "{}", op.name()); | |
| 415 | let scope = scope_for(op.name()).unwrap(); | |
| 416 | let changes = !matches!( | |
| 417 | op, | |
| 418 | CustomizeOp::ListMarketplaces | |
| 419 | | CustomizeOp::Discover | |
| 420 | | CustomizeOp::ListPlugins | |
| 421 | | CustomizeOp::GetPlugin | |
| 422 | | CustomizeOp::ListPluginAgents | |
| 423 | | CustomizeOp::ListConnectors | |
| 424 | | CustomizeOp::GetConnector | |
| 425 | | CustomizeOp::Bundle | |
| 426 | ); | |
| 427 | let expected = match op { | |
| 428 | CustomizeOp::Bundle => Level::Run, | |
| 429 | _ if changes => Level::Admin, | |
| 430 | _ => Level::Read, | |
| 431 | }; | |
| 432 | assert_eq!(scope.level(), expected, "{}", op.name()); | |
| 433 | } | |
| 434 | assert_eq!(scope_for("customize_bundle").unwrap().as_str(), "agents:run"); | |
| 435 | } | |
| 436 | ||
| 437 | #[test] | |
| 438 | fn inputs_are_checked_before_the_call() { | |
| 439 | let bad = |op, input: Value| arguments(op, &input).unwrap_err(); | |
| 440 | assert!(bad(CustomizeOp::AddMarketplace, json!({})).starts_with("Give address")); | |
| 441 | assert!(bad(CustomizeOp::SetPluginEnabled, json!({ "ref": "a@b" })).contains("enabled")); | |
| 442 | assert!(bad(CustomizeOp::AddConnector, json!({ "from": { "kind": "elsewhere" } })).contains("from.kind")); | |
| 443 | assert!(bad(CustomizeOp::AddConnector, json!({ "from": { "kind": "address", "name": "x", "url": "https://x" }, "auth": "header" })).contains("header")); | |
| 444 | assert!(bad(CustomizeOp::GrantConnector, json!({ "id": "con_1", "scope": "team" })).starts_with("Give target")); | |
| 445 | assert!(bad(CustomizeOp::GrantConnector, json!({ "id": "con_1", "scope": "everyone" })).contains("scope")); | |
| 446 | ||
| 447 | let ok = |op, input: Value| arguments(op, &input).unwrap(); | |
| 448 | assert_eq!(ok(CustomizeOp::SetPluginEnabled, json!({ "ref": "a@b", "enabled": "false" })), json!({ "ref": "a@b", "enabled": false })); | |
| 449 | assert_eq!(ok(CustomizeOp::GrantConnector, json!({ "id": "con_1", "scope": "workspace", "target": "ignored" })), json!({ "id": "con_1", "scope": "workspace" })); | |
| 450 | assert_eq!( | |
| 451 | ok(CustomizeOp::AddConnector, json!({ "from": { "kind": "directory", "id": "linear" } })), | |
| 452 | json!({ "input": { "from": { "kind": "directory", "id": "linear" }, "auth": "none", "header": null } }) | |
| 453 | ); | |
| 454 | assert_eq!( | |
| 455 | ok(CustomizeOp::SetConnectorKey, json!({ "id": "con_1", "value": "k", "header_name": " X-Api-Key " })), | |
| 456 | json!({ "id": "con_1", "value": "k", "header_name": "X-Api-Key" }) | |
| 457 | ); | |
| 458 | assert_eq!(ok(CustomizeOp::GetPlugin, json!({ "ref": "commit-commands@claude-plugins-official" }))["ref"], "commit-commands@claude-plugins-official"); | |
| 459 | // The path names a marketplace as `marketplace`; the agents service as `name`. | |
| 460 | assert_eq!(ok(CustomizeOp::RemoveMarketplace, json!({ "marketplace": "flagon-tools" })), json!({ "name": "flagon-tools" })); | |
| 461 | assert!(bad(CustomizeOp::RefreshMarketplace, json!({ "name": "flagon-tools" })).starts_with("Name the marketplace")); | |
| 462 | } | |
| 463 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.