Skip to content
463 linesCodeBlameRaw
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
15use g1t_contracts::rules::actor_kind;
16use g1t_contracts::{FailureCode, Outcome, Viewer};
17use serde_json::{Value, json};
18use worker::Result;
19
20use crate::operations::Services;
21
22/// One operation on a workspace's Customize.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub 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
48impl 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
277fn 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.
282fn 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.
296fn 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
380pub 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)]
405mod 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}