Skip to content

Commit

Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9

# Conflicts: # apps/web/app/lib/front-door-limits.test.ts # apps/web/app/lib/front-door-limits.ts

Claudeagentcommitted Parents2f1d134bba76b3Browse files
284 files+2831−13268 files not counted0/284 viewed
+1−0
1+* @syntaqx
+463−0
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+}
+1−0
1212 mod billing;
1313 mod blobs;
1414 mod checks;
15+mod customize;
1516 mod deployments;
1617 mod deploy_keys;
1718 mod limits;
+1−1
1111 const SUPPORTED_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
1212
1313 const INSTRUCTIONS: &str = "g1t is a git forge where people and agents work through issues and pull requests. Repositories are named \"owner/name\"; issues and pull requests in one share a sequence of numbers.
14−Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, security, webhook, access, workspace, account, notifications. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs.
14+Tools are resources, each with an `action`: search, repository, issue, pull_request, agent, plan, memory, workflow, secret, security, webhook, access, workspace, customize, account, notifications. The `action` field lists each action and the fields it needs. You see only what your token's scopes allow; a refusal names the scope it needs.
1515 Find a repository: account whoami lists your workspaces; repository list or search finds one.
1616 Work on an issue: issue get (read it and the pull requests already made for it), memory recall, then pull_request create with the issue's number: you get a draft with its own fork to clone and push to. Record your reasoning with pull_request record_session as you go, push, then pull_request ready with a summary. Watch `overlaps` and `behind` on pull_request get, and its checks there: `statuses` from the repository's workflows and `required_checks`, which must pass before it merges. If one fails, read why with workflow get_run and job_logs, push a fix, and the checks run again.
1717 Hand work to g1t's agent: agent delegate opens an issue and starts it in one step; agent assign starts it on an existing issue. Each costs the workspace money.
+43−0
99
1010 use crate::about::AboutOp;
1111 use crate::run_artifacts::ArtifactsOp;
12+use crate::customize::CustomizeOp;
1213 use crate::deploy_keys::DeployKeysOp;
1314 use crate::mirrors::MirrorsOp;
1415 use crate::deployments::DeploymentsOp;
284285 ],
285286 ),
286287 (
288+ "Security advisories",
289+ "What a repository says about vulnerabilities in what it ships: advisories drafted, published and withdrawn by its maintainers, and the vulnerability reports people send them privately when the repository takes them.",
290+ &[
291+ Op::Security(SecurityOp::ListAdvisories),
292+ Op::Security(SecurityOp::CreateAdvisory),
293+ Op::Security(SecurityOp::GetAdvisory),
294+ Op::Security(SecurityOp::UpdateAdvisory),
295+ Op::Security(SecurityOp::SetAdvisoryState),
296+ Op::Security(SecurityOp::ListReports),
297+ Op::Security(SecurityOp::ReportVulnerability),
298+ Op::Security(SecurityOp::ReviewReport),
299+ ],
300+ ),
301+ (
287302 "Rules",
288303 "Rulesets: what may happen to a repository's branches and tags and what a pull request needs before it merges, for a repository or across a workspace; the rules that hold for one branch; and how they judged each push and merge, with insights.",
289304 &[
550565 ],
551566 ),
552567 (
568+ "Customize",
569+ "What shapes how a workspace's agents work, in the formats Claude Code reads: the marketplaces it reads plugins from, what Discover lists, the plugins it installed (their skills, subagents and remote MCP servers reach g1t's agents; the rest reaches Claude Code on a runner), and its connectors: MCP servers connected once, then given to every agent, a team or one agent. The bundle is what a job's Claude Code loads on a runner.",
570+ &[
571+ Op::Customize(CustomizeOp::ListMarketplaces),
572+ Op::Customize(CustomizeOp::AddMarketplace),
573+ Op::Customize(CustomizeOp::RemoveMarketplace),
574+ Op::Customize(CustomizeOp::RefreshMarketplace),
575+ Op::Customize(CustomizeOp::Discover),
576+ Op::Customize(CustomizeOp::ListPlugins),
577+ Op::Customize(CustomizeOp::GetPlugin),
578+ Op::Customize(CustomizeOp::InstallPlugin),
579+ Op::Customize(CustomizeOp::SetPluginEnabled),
580+ Op::Customize(CustomizeOp::UninstallPlugin),
581+ Op::Customize(CustomizeOp::UpdatePlugin),
582+ Op::Customize(CustomizeOp::ListPluginAgents),
583+ Op::Customize(CustomizeOp::ListConnectors),
584+ Op::Customize(CustomizeOp::GetConnector),
585+ Op::Customize(CustomizeOp::AddConnector),
586+ Op::Customize(CustomizeOp::SetConnectorKey),
587+ Op::Customize(CustomizeOp::RemoveConnector),
588+ Op::Customize(CustomizeOp::RefreshConnector),
589+ Op::Customize(CustomizeOp::GrantConnector),
590+ Op::Customize(CustomizeOp::RevokeConnector),
591+ Op::Customize(CustomizeOp::Bundle),
592+ ],
593+ ),
594+ (
553595 "Artifacts",
554596 "A workspace's docs, slides, designs and dashboards (Artifacts mode): listing and searching the ones you can open, reading and editing their content in Markdown, making, moving, trashing and restoring them, their versions, and who can open them. Each answers for your own role on it. Not workflow runs' artifacts, which are under Actions. Slides, designs and dashboards answer that they are not here yet.",
555597 &[
794836 Op::Mirrors(op) => op.title(),
795837 Op::Packages(op) => op.title(),
796838 Op::Folios(op) => op.title(),
839+ Op::Customize(op) => op.title(),
797840 }
798841 }
799842
+43−2
2929 use crate::checks::ChecksOp;
3030 use crate::about::AboutOp;
3131 use crate::run_artifacts::ArtifactsOp;
32+use crate::customize::CustomizeOp;
3233 use crate::deploy_keys::DeployKeysOp;
3334 use crate::mirrors::MirrorsOp;
3435 use crate::deployments::DeploymentsOp;
338339 /// Artifacts mode's docs, slides, designs and dashboards, kept by the
339340 /// artifacts service: folios.rs.
340341 Folios(FoliosOp),
342+ /// A workspace's Customize: marketplaces, plugins, connectors and the
343+ /// bundle a runner loads, kept by the agents service: customize.rs.
344+ Customize(CustomizeOp),
341345 }
342346
343347 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
707711 }
708712
709713 impl Op {
710− pub const ALL: [Op; 351] = [
714+ pub const ALL: [Op; 380] = [
711715 Op::Whoami,
712716 Op::GetWorkspace,
713717 Op::CreateWorkspace,
936940 Op::Security(SecurityOp::GetWorkspaceSettings),
937941 Op::Security(SecurityOp::UpdateWorkspaceSettings),
938942 Op::Security(SecurityOp::GetOverview),
943+ Op::Security(SecurityOp::ListAdvisories),
944+ Op::Security(SecurityOp::GetAdvisory),
945+ Op::Security(SecurityOp::CreateAdvisory),
946+ Op::Security(SecurityOp::UpdateAdvisory),
947+ Op::Security(SecurityOp::SetAdvisoryState),
948+ Op::Security(SecurityOp::ListReports),
949+ Op::Security(SecurityOp::ReportVulnerability),
950+ Op::Security(SecurityOp::ReviewReport),
939951 Op::Rules(RulesOp::ListRepoRulesets),
940952 Op::Rules(RulesOp::GetRepoRuleset),
941953 Op::Rules(RulesOp::CreateRepoRuleset),
9951007 Op::DeployKeys(DeployKeysOp::GetDeployKey),
9961008 Op::DeployKeys(DeployKeysOp::CreateDeployKey),
9971009 Op::DeployKeys(DeployKeysOp::DeleteDeployKey),
1010+ Op::Customize(CustomizeOp::ListMarketplaces),
1011+ Op::Customize(CustomizeOp::AddMarketplace),
1012+ Op::Customize(CustomizeOp::RemoveMarketplace),
1013+ Op::Customize(CustomizeOp::RefreshMarketplace),
1014+ Op::Customize(CustomizeOp::Discover),
1015+ Op::Customize(CustomizeOp::ListPlugins),
1016+ Op::Customize(CustomizeOp::GetPlugin),
1017+ Op::Customize(CustomizeOp::InstallPlugin),
1018+ Op::Customize(CustomizeOp::SetPluginEnabled),
1019+ Op::Customize(CustomizeOp::UninstallPlugin),
1020+ Op::Customize(CustomizeOp::UpdatePlugin),
1021+ Op::Customize(CustomizeOp::ListPluginAgents),
1022+ Op::Customize(CustomizeOp::ListConnectors),
1023+ Op::Customize(CustomizeOp::GetConnector),
1024+ Op::Customize(CustomizeOp::AddConnector),
1025+ Op::Customize(CustomizeOp::SetConnectorKey),
1026+ Op::Customize(CustomizeOp::RemoveConnector),
1027+ Op::Customize(CustomizeOp::RefreshConnector),
1028+ Op::Customize(CustomizeOp::GrantConnector),
1029+ Op::Customize(CustomizeOp::RevokeConnector),
1030+ Op::Customize(CustomizeOp::Bundle),
9981031 Op::Mirrors(MirrorsOp::GetMirror),
9991032 Op::Mirrors(MirrorsOp::GetHandBackPlan),
10001033 Op::Mirrors(MirrorsOp::TakeOver),
12771310 Op::Mirrors(op) => op.name(),
12781311 Op::Packages(op) => op.name(),
12791312 Op::Folios(op) => op.name(),
1313+ Op::Customize(op) => op.name(),
12801314 }
12811315 }
12821316
18491883 Op::Mirrors(op) => op.description(),
18501884 Op::Packages(op) => op.description(),
18511885 Op::Folios(op) => op.description(),
1886+ Op::Customize(op) => op.description(),
18521887 }
18531888 }
18541889
33423377 Op::Mirrors(op) => op.input(),
33433378 Op::Packages(op) => op.input(),
33443379 Op::Folios(op) => op.input(),
3380+ Op::Customize(op) => op.input(),
33453381 }
33463382 }
33473383
34203456 if let Op::Folios(_) = self {
34213457 return false;
34223458 }
3459+ // So does its Customize.
3460+ if let Op::Customize(_) = self {
3461+ return false;
3462+ }
34233463 if let Op::About(op) = self {
34243464 return op.needs_repo();
34253465 }
46944734 }
46954735 }
46964736 let status = current.as_ref().map(|pull| pull.status);
4697− let mut answer = current.map(|pull| serde_json::to_value(pull)).transpose()?;
4737+ let mut answer = current.map(serde_json::to_value).transpose()?;
46984738 if wanted.as_deref() == Some("open") && status == Some(PullStatus::Closed) {
46994739 match pass(work, "reopen_pull", &pull_action()).await? {
47004740 Outcome::Ok(pull) => answer = Some(pull),
56975737 Op::Mirrors(op) => crate::mirrors::run(op, services, viewer, input).await,
56985738 Op::Packages(op) => crate::packages::run(op, services, viewer, input).await,
56995739 Op::Folios(op) => crate::folios::run(op, services, viewer, input).await,
5740+ Op::Customize(op) => crate::customize::run(op, services, viewer, input).await,
57005741 Op::ReopenSecurityAlert => {
57015742 let changed: Outcome<AlertChange> = call(
57025743 &services.security,
+1280−2
A large diff (1,306 lines) is not shown until you ask for it.
+38−1
44
55 use crate::about::AboutOp;
66 use crate::run_artifacts::ArtifactsOp;
7+use crate::customize::CustomizeOp;
78 use crate::deploy_keys::DeployKeysOp;
89 use crate::mirrors::MirrorsOp;
910 use crate::deployments::DeploymentsOp;
255256 route("GET", "/workspaces/:workspace/security/settings", Op::Security(SecurityOp::GetWorkspaceSettings), &[]),
256257 route("PATCH", "/workspaces/:workspace/security/settings", Op::Security(SecurityOp::UpdateWorkspaceSettings), &[]),
257258 route("GET", "/workspaces/:workspace/security/overview", Op::Security(SecurityOp::GetOverview), &[("days", "days")]),
259+ // Reporting: a repository's advisories, and the vulnerability reports sent to its maintainers.
260+ route("GET", "/repos/:owner/:name/security/advisories", Op::Security(SecurityOp::ListAdvisories), &[("state", "state")]),
261+ route("POST", "/repos/:owner/:name/security/advisories", Op::Security(SecurityOp::CreateAdvisory), &[]),
262+ route("GET", "/repos/:owner/:name/security/advisories/:id", Op::Security(SecurityOp::GetAdvisory), &[]),
263+ route("PATCH", "/repos/:owner/:name/security/advisories/:id", Op::Security(SecurityOp::UpdateAdvisory), &[]),
264+ route("POST", "/repos/:owner/:name/security/advisories/:id/state", Op::Security(SecurityOp::SetAdvisoryState), &[]),
265+ route("GET", "/repos/:owner/:name/security/reports", Op::Security(SecurityOp::ListReports), &[("state", "state")]),
266+ route("POST", "/repos/:owner/:name/security/reports", Op::Security(SecurityOp::ReportVulnerability), &[]),
267+ route("PATCH", "/repos/:owner/:name/security/reports/:id", Op::Security(SecurityOp::ReviewReport), &[]),
258268 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
259269 route(
260270 "GET",
651661 Op::TestIntegration,
652662 &[],
653663 ),
664+ // Customize: marketplaces, Discover, plugins and connectors. A plugin's
665+ // ref is `name@marketplace`, URL-encoded (`@` as `%40`).
666+ route("GET", "/workspaces/:workspace/customize/marketplaces", Op::Customize(CustomizeOp::ListMarketplaces), &[]),
667+ route("POST", "/workspaces/:workspace/customize/marketplaces", Op::Customize(CustomizeOp::AddMarketplace), &[]),
668+ route("DELETE", "/workspaces/:workspace/customize/marketplaces/:marketplace", Op::Customize(CustomizeOp::RemoveMarketplace), &[]),
669+ route("POST", "/workspaces/:workspace/customize/marketplaces/:marketplace/refresh", Op::Customize(CustomizeOp::RefreshMarketplace), &[]),
670+ route("GET", "/workspaces/:workspace/customize/discover", Op::Customize(CustomizeOp::Discover), &[]),
671+ route("GET", "/workspaces/:workspace/customize/plugins", Op::Customize(CustomizeOp::ListPlugins), &[]),
672+ route("POST", "/workspaces/:workspace/customize/plugins", Op::Customize(CustomizeOp::InstallPlugin), &[]),
673+ route("GET", "/workspaces/:workspace/customize/plugins/:ref", Op::Customize(CustomizeOp::GetPlugin), &[]),
674+ route("PATCH", "/workspaces/:workspace/customize/plugins/:ref", Op::Customize(CustomizeOp::SetPluginEnabled), &[]),
675+ route("DELETE", "/workspaces/:workspace/customize/plugins/:ref", Op::Customize(CustomizeOp::UninstallPlugin), &[]),
676+ route("POST", "/workspaces/:workspace/customize/plugins/:ref/update", Op::Customize(CustomizeOp::UpdatePlugin), &[]),
677+ route("GET", "/workspaces/:workspace/customize/agents", Op::Customize(CustomizeOp::ListPluginAgents), &[]),
678+ route("GET", "/workspaces/:workspace/customize/connectors", Op::Customize(CustomizeOp::ListConnectors), &[]),
679+ route("POST", "/workspaces/:workspace/customize/connectors", Op::Customize(CustomizeOp::AddConnector), &[]),
680+ route("GET", "/workspaces/:workspace/customize/connectors/:id", Op::Customize(CustomizeOp::GetConnector), &[]),
681+ route("PATCH", "/workspaces/:workspace/customize/connectors/:id", Op::Customize(CustomizeOp::SetConnectorKey), &[]),
682+ route("DELETE", "/workspaces/:workspace/customize/connectors/:id", Op::Customize(CustomizeOp::RemoveConnector), &[]),
683+ route("POST", "/workspaces/:workspace/customize/connectors/:id/refresh", Op::Customize(CustomizeOp::RefreshConnector), &[]),
684+ route("POST", "/workspaces/:workspace/customize/connectors/:id/grants", Op::Customize(CustomizeOp::GrantConnector), &[]),
685+ route("DELETE", "/workspaces/:workspace/customize/connectors/:id/grants/:grant", Op::Customize(CustomizeOp::RevokeConnector), &[]),
686+ route("GET", "/workspaces/:workspace/customize/bundle", Op::Customize(CustomizeOp::Bundle), &[]),
654687 route(
655688 "GET",
656689 "/repos/:owner/:name/actions/workflows",
9951028 /// Whether the route answers success with `204` and no body, as
9961029 /// GitHub's address for the same does. MCP still answers `true`.
9971030 pub fn no_content(&self) -> bool {
998− self.op == Op::DeleteComment
1031+ matches!(
1032+ self.op,
1033+ Op::DeleteComment
1034+ | Op::Customize(CustomizeOp::RemoveMarketplace | CustomizeOp::UninstallPlugin | CustomizeOp::RemoveConnector)
1035+ )
9991036 }
10001037
10011038 /// The names of the route's path parameters, in order.
+212−3
1212 //! need the g1t plan, which Security and quality comes with (a 402 says so); this module
1313 //! reads the input and gives each answer its public shape.
1414
15+use g1t_contracts::advisories::{
16+ Advisory, AdvisoryInput, CreateAdvisoryArgs, GetAdvisoryArgs, ListAdvisoriesArgs, ListReportsArgs, ReportVulnerabilityArgs, ReviewReportArgs,
17+ SetAdvisoryStateArgs, UpdateAdvisoryArgs,
18+};
1519 use g1t_contracts::repos::RepoPath;
1620 use g1t_contracts::security::{AlertChange, AlertState, DismissArgs, DismissReason, ReopenArgs, SecretFinding, SecurityOverview, Vulnerability};
1721 use g1t_contracts::security_suite::*;
5761 GetWorkspaceSettings,
5862 UpdateWorkspaceSettings,
5963 GetOverview,
64+ ListAdvisories,
65+ GetAdvisory,
66+ CreateAdvisory,
67+ UpdateAdvisory,
68+ SetAdvisoryState,
69+ ListReports,
70+ ReportVulnerability,
71+ ReviewReport,
6072 }
6173
6274 impl SecurityOp {
6375 /// Every one: `Op::ALL` lists each as `Op::Security(…)`, which a test
6476 /// checks against this.
6577 #[cfg(test)]
66− pub const ALL: [SecurityOp; 31] = [
78+ pub const ALL: [SecurityOp; 39] = [
6779 SecurityOp::ListSecretAlerts,
6880 SecurityOp::GetSecretAlert,
6981 SecurityOp::UpdateSecretAlert,
95107 SecurityOp::GetWorkspaceSettings,
96108 SecurityOp::UpdateWorkspaceSettings,
97109 SecurityOp::GetOverview,
110+ SecurityOp::ListAdvisories,
111+ SecurityOp::GetAdvisory,
112+ SecurityOp::CreateAdvisory,
113+ SecurityOp::UpdateAdvisory,
114+ SecurityOp::SetAdvisoryState,
115+ SecurityOp::ListReports,
116+ SecurityOp::ReportVulnerability,
117+ SecurityOp::ReviewReport,
98118 ];
99119
100120 pub fn name(self) -> &'static str {
130150 SecurityOp::GetWorkspaceSettings => "get_workspace_security_settings",
131151 SecurityOp::UpdateWorkspaceSettings => "update_workspace_security_settings",
132152 SecurityOp::GetOverview => "get_security_overview",
153+ SecurityOp::ListAdvisories => "list_security_advisories",
154+ SecurityOp::GetAdvisory => "get_security_advisory",
155+ SecurityOp::CreateAdvisory => "create_security_advisory",
156+ SecurityOp::UpdateAdvisory => "update_security_advisory",
157+ SecurityOp::SetAdvisoryState => "set_security_advisory_state",
158+ SecurityOp::ListReports => "list_vulnerability_reports",
159+ SecurityOp::ReportVulnerability => "report_vulnerability",
160+ SecurityOp::ReviewReport => "review_vulnerability_report",
133161 }
134162 }
135163
167195 SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings",
168196 SecurityOp::UpdateWorkspaceSettings => "Update a workspace's security settings",
169197 SecurityOp::GetOverview => "Get the security overview",
198+ SecurityOp::ListAdvisories => "List security advisories",
199+ SecurityOp::GetAdvisory => "Get a security advisory",
200+ SecurityOp::CreateAdvisory => "Create a security advisory",
201+ SecurityOp::UpdateAdvisory => "Update a security advisory",
202+ SecurityOp::SetAdvisoryState => "Publish or withdraw a security advisory",
203+ SecurityOp::ListReports => "List vulnerability reports",
204+ SecurityOp::ReportVulnerability => "Report a vulnerability privately",
205+ SecurityOp::ReviewReport => "Accept or decline a vulnerability report",
170206 }
171207 }
172208
198234 SecurityOp::GetDependencyGraph => "Get the dependency graph: every package the lockfiles on the default branch resolve, per lockfile, with whether it is direct or transitive (where the lockfile says), for development, its license when recorded, its package URL and its open vulnerability alerts.",
199235 SecurityOp::GetSbom => "Export the dependency graph as an SPDX 2.3 JSON document, in sbom. Every package is named by its package URL.",
200236 SecurityOp::CompareDependencies => "Compare the dependencies at two commits, branches or tags (basehead, as base...head): what was added and removed per lockfile, with the known vulnerabilities of what was added and whether it passes the repository's dependency review policy. Needs the g1t plan on a private repository.",
201− SecurityOp::GetSettings => "Get a repository's security settings: when the Code scanning check fails, dependency review and its policy, its workspace's settings, and whether the paid features are on for it.",
202− SecurityOp::UpdateSettings => "Change a repository's security settings: code_scanning_gate (none, errors, critical, high, medium or any), dependency_review, review_fail_on (critical, high, medium, low or none), review_deny_licenses (SPDX ids) and review_comment. Takes the Admin role, or a security manager of the workspace. Require the Code scanning and Dependency review checks in branch protection to gate merges on them.",
237+ SecurityOp::GetSettings => "Get a repository's security settings: when the Code scanning check fails, dependency review and its policy, whether secrets in the history open alerts, whether it takes private vulnerability reports, its workspace's settings, and whether the paid features are on for it.",
238+ SecurityOp::UpdateSettings => "Change a repository's security settings: code_scanning_gate (none, errors, critical, high, medium or any), dependency_review, review_fail_on (critical, high, medium, low or none), review_deny_licenses (SPDX ids), review_comment, history_alerts (whether secrets found in the history open alerts; push protection stays on) and private_vulnerability_reporting. Takes the Admin role, or a security manager of the workspace. Require the Code scanning and Dependency review checks in branch protection to gate merges on them.",
203239 SecurityOp::GetWorkspaceSettings => "Get a workspace's security settings (delegated bypass, validity checks) and whether it has Security and quality, which comes with the g1t plan.",
204240 SecurityOp::UpdateWorkspaceSettings => "Turn delegated bypass and validity checks on or off for a workspace. Owners only.",
205241 SecurityOp::GetOverview => "Get a workspace's security overview: open alerts by type and severity, how many opened and closed in the last days (7 to 90, 30 by default), a daily trend, and for each repository which features are on and what is open, most in need first. Private repositories count only on the g1t plan.",
242+ SecurityOp::ListAdvisories => "List a repository's security advisories, newest first: what it says about vulnerabilities in what it ships. Anyone who can see the repository sees the published ones; those who see its findings (Write, or a security manager) see drafts and withdrawn ones too. Filter by state (draft, published, withdrawn).",
243+ SecurityOp::GetAdvisory => "Get one security advisory by its adv_… id or its G1TSA-… id, with the same visibility as the list.",
244+ SecurityOp::CreateAdvisory => "Create a draft security advisory: title, description (Markdown), severity (critical, high, medium or low), and optionally cvss_vector and cvss_score, cve_id, the package affected (ecosystem, package, affected_versions, patched_versions) and credits. Admin on the repository, or a security manager of its workspace. Nobody is told of a draft.",
245+ SecurityOp::UpdateAdvisory => "Change an advisory's fields, in any state; what is not given stays as it is. A published advisory's change is visible at once. Admin, or a security manager.",
246+ SecurityOp::SetAdvisoryState => "Publish a draft (state published), withdraw a published advisory (state withdrawn), or publish a withdrawn one again. Publishing needs a description, tells webhooks (security_advisory.published) and the repository's watchers who chose security alerts, and makes the advisory visible to anyone who can see the repository. Admin, or a security manager.",
247+ SecurityOp::ListReports => "List the vulnerability reports people sent the repository's maintainers privately, new ones first. Those who see findings (Write, or a security manager). Filter by state (new, accepted, declined). Reporters never see this list.",
248+ SecurityOp::ReportVulnerability => "Report a vulnerability privately to a repository's maintainers: title, description and, if you like, severity. Needs private vulnerability reporting on for the repository. Anyone who can see the repository may report; on a public one you may report without a token by giving an email the maintainers can answer at. At most 5 reports a day per reporter and repository. The workspace's owners and security managers are told at once. You see only your own report.",
249+ SecurityOp::ReviewReport => "Accept a report (decision accept), which makes a draft advisory from it crediting the reporter, or decline it (decision decline), with an optional note. Admin, or a security manager.",
206250 }
207251 }
208252
248292 properties
249293 };
250294 let workspace = || json!({ "type": "string", "description": "The workspace's slug, e.g. \"flagon-io\"." });
295+ let advisory_fields = |mut properties: Value| {
296+ properties["title"] = json!({ "type": "string", "description": "One line: what the vulnerability is. At most 200 characters." });
297+ properties["description"] = json!({ "type": "string", "description": "Markdown: what is affected, how it is exploited, what to do. At most 20,000 characters." });
298+ properties["severity"] = json!({ "type": "string", "enum": ["critical", "high", "medium", "low"], "description": "How bad it is." });
299+ properties["cvss_vector"] = json!({ "type": "string", "description": "A CVSS vector string, when scored: CVSS:3.1/AV:N/…" });
300+ properties["cvss_score"] = json!({ "type": "number", "description": "The CVSS base score, 0 to 10." });
301+ properties["cve_id"] = json!({ "type": "string", "description": "CVE-2026-12345, when one was issued elsewhere." });
302+ properties["ecosystem"] = json!({ "type": "string", "description": "How the affected package is distributed: npm, crates.io, Go, PyPI, or how the project ships." });
303+ properties["package"] = json!({ "type": "string", "description": "The package affected." });
304+ properties["affected_versions"] = json!({ "type": "string", "description": "The versions affected, as a range: >= 1.2.0, < 1.4.1" });
305+ properties["patched_versions"] = json!({ "type": "string", "description": "The versions that fix it: >= 1.4.1" });
306+ properties["credits"] = json!({ "type": "array", "items": { "type": "string" }, "description": "Who found or fixed it: usernames or names, up to 20." });
307+ properties
308+ };
309+ let advisory_id = || json!({ "type": "string", "description": "The advisory's id: adv_…, or its G1TSA-… id." });
251310 let (properties, required): (Value, &[&str]) = match self {
252311 SecurityOp::ListSecretAlerts => (
253312 either(json!({
394453 "review_fail_on": { "type": "string", "enum": ["critical", "high", "medium", "low", "none"], "description": "The lowest severity of a known vulnerability in an added package that fails the review." },
395454 "review_deny_licenses": { "type": "array", "items": { "type": "string" }, "description": "SPDX license ids an added package may not have." },
396455 "review_comment": { "type": "boolean", "description": "Whether the review comments its summary on the pull request." },
456+ "history_alerts": { "type": "boolean", "description": "Whether secrets found in the history, and in pushes too large to scan first, open alerts and tell people. Push protection stays on either way." },
457+ "private_vulnerability_reporting": { "type": "boolean", "description": "Whether anyone who can see the repository may report a vulnerability privately to its maintainers." },
397458 }),
398459 &["repo"],
399460 ),
410471 json!({ "workspace": workspace(), "days": { "type": "integer", "description": "Days of trend, 7 to 90. Default 30." } }),
411472 &["workspace"],
412473 ),
474+ SecurityOp::ListAdvisories => (
475+ json!({ "repo": repo(), "state": { "type": "string", "enum": ["draft", "published", "withdrawn"], "description": "Only advisories in this state." } }),
476+ &["repo"],
477+ ),
478+ SecurityOp::GetAdvisory => (json!({ "repo": repo(), "id": advisory_id() }), &["repo", "id"]),
479+ SecurityOp::CreateAdvisory => (advisory_fields(json!({ "repo": repo() })), &["repo", "title", "severity"]),
480+ SecurityOp::UpdateAdvisory => (advisory_fields(json!({ "repo": repo(), "id": advisory_id() })), &["repo", "id"]),
481+ SecurityOp::SetAdvisoryState => (
482+ json!({
483+ "repo": repo(),
484+ "id": advisory_id(),
485+ "state": { "type": "string", "enum": ["published", "withdrawn"], "description": "published to publish (or publish again), withdrawn to withdraw." },
486+ }),
487+ &["repo", "id", "state"],
488+ ),
489+ SecurityOp::ListReports => (
490+ json!({ "repo": repo(), "state": { "type": "string", "enum": ["new", "accepted", "declined"], "description": "Only reports in this state." } }),
491+ &["repo"],
492+ ),
493+ SecurityOp::ReportVulnerability => (
494+ json!({
495+ "repo": repo(),
496+ "title": { "type": "string", "description": "One line: what you found. At most 200 characters." },
497+ "description": { "type": "string", "description": "What is affected and how to reproduce it. At most 20,000 characters." },
498+ "severity": { "type": "string", "enum": ["critical", "high", "medium", "low"], "description": "How bad you think it is, if you like." },
499+ "email": { "type": "string", "description": "Without a token: where the maintainers can answer you." },
500+ }),
501+ &["repo", "title", "description"],
502+ ),
503+ SecurityOp::ReviewReport => (
504+ json!({
505+ "repo": repo(),
506+ "id": { "type": "string", "description": "The report's id: vrp_…" },
507+ "decision": { "type": "string", "enum": ["accept", "decline"], "description": "accept makes a draft advisory from it; decline closes it." },
508+ "note": { "type": "string", "description": "A note for the record, in a sentence. At most 1,000 characters." },
509+ }),
510+ &["repo", "id", "decision"],
511+ ),
413512 };
414513 let mut schema = json!({ "type": "object", "properties": properties });
415514 if !required.is_empty() {
601700 crate::operations::repo_path(input)
602701 }
603702
703+/// An advisory's fields as given, over `current` for what is not.
704+pub(crate) fn advisory_input(input: &Value, current: &AdvisoryInput) -> AdvisoryInput {
705+ let given = |key: &str, now: &Option<String>| if input.get(key).is_some() { text(input, key) } else { now.clone() };
706+ AdvisoryInput {
707+ title: text(input, "title").unwrap_or_else(|| current.title.clone()),
708+ description: input["description"].as_str().map(str::to_owned).unwrap_or_else(|| current.description.clone()),
709+ severity: text(input, "severity").map(|severity| severity.to_lowercase()).unwrap_or_else(|| current.severity.clone()),
710+ cvss_vector: given("cvss_vector", &current.cvss_vector),
711+ cvss_score: if input.get("cvss_score").is_some() { input["cvss_score"].as_f64() } else { current.cvss_score },
712+ cve_id: given("cve_id", &current.cve_id),
713+ ecosystem: given("ecosystem", &current.ecosystem),
714+ package: given("package", &current.package),
715+ affected_versions: given("affected_versions", &current.affected_versions),
716+ patched_versions: given("patched_versions", &current.patched_versions),
717+ credits: if input["credits"].is_array() { strings(input, "credits") } else { current.credits.clone() },
718+ }
719+}
720+
721+/// An advisory as it stands, as input to change it from.
722+fn advisory_as_input(advisory: &Advisory) -> AdvisoryInput {
723+ AdvisoryInput {
724+ title: advisory.title.clone(),
725+ description: advisory.description.clone(),
726+ severity: advisory.severity.clone(),
727+ cvss_vector: advisory.cvss_vector.clone(),
728+ cvss_score: advisory.cvss_score,
729+ cve_id: advisory.cve_id.clone(),
730+ ecosystem: advisory.ecosystem.clone(),
731+ package: advisory.package.clone(),
732+ affected_versions: advisory.affected_versions.clone(),
733+ patched_versions: advisory.patched_versions.clone(),
734+ credits: advisory.credits.clone(),
735+ }
736+}
737+
604738 pub async fn run(op: SecurityOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> {
605739 let actor = || viewer.clone().unwrap_or_default();
606740 let repo = path_of(input);
9451079 if let Some(on) = flag(input, "review_comment") {
9461080 settings.review_comment = on;
9471081 }
1082+ if let Some(on) = flag(input, "history_alerts") {
1083+ settings.history_alerts = on;
1084+ }
1085+ if let Some(on) = flag(input, "private_vulnerability_reporting") {
1086+ settings.private_vulnerability_reporting = on;
1087+ }
9481088 pass(services, "set_security_settings", &SetSecuritySettingsArgs { actor: actor(), repo, settings }).await
9491089 }
1090+ SecurityOp::ListAdvisories => {
1091+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1092+ let state = match one_of(input, "state", &["draft", "published", "withdrawn"]) {
1093+ Ok(state) => state,
1094+ Err(message) => return failed(FailureCode::Invalid, &message),
1095+ };
1096+ pass(services, "list_advisories", &ListAdvisoriesArgs { viewer: viewer.clone(), repo, state }).await
1097+ }
1098+ SecurityOp::GetAdvisory => {
1099+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1100+ pass(services, "get_advisory", &GetAdvisoryArgs { viewer: viewer.clone(), repo, id: text(input, "id").unwrap_or_default() }).await
1101+ }
1102+ SecurityOp::CreateAdvisory => {
1103+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1104+ let advisory = advisory_input(input, &AdvisoryInput::default());
1105+ pass(services, "create_advisory", &CreateAdvisoryArgs { actor: actor(), repo, advisory }).await
1106+ }
1107+ SecurityOp::UpdateAdvisory => {
1108+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1109+ let id = text(input, "id").unwrap_or_default();
1110+ // What is not given stays as it is.
1111+ let current: Outcome<Advisory> = call(services, "get_advisory", &GetAdvisoryArgs { viewer: viewer.clone(), repo: repo.clone(), id: id.clone() }).await?;
1112+ let current = match current {
1113+ Outcome::Ok(advisory) => advisory,
1114+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1115+ };
1116+ let advisory = advisory_input(input, &advisory_as_input(&current));
1117+ pass(services, "update_advisory", &UpdateAdvisoryArgs { actor: actor(), repo, id, advisory }).await
1118+ }
1119+ SecurityOp::SetAdvisoryState => {
1120+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1121+ let state = match one_of(input, "state", &["published", "withdrawn"]) {
1122+ Ok(Some(state)) => state,
1123+ Ok(None) => return failed(FailureCode::Invalid, "state is published or withdrawn."),
1124+ Err(message) => return failed(FailureCode::Invalid, &message),
1125+ };
1126+ pass(services, "set_advisory_state", &SetAdvisoryStateArgs { actor: actor(), repo, id: text(input, "id").unwrap_or_default(), state }).await
1127+ }
1128+ SecurityOp::ListReports => {
1129+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1130+ let state = match one_of(input, "state", &["new", "accepted", "declined"]) {
1131+ Ok(state) => state,
1132+ Err(message) => return failed(FailureCode::Invalid, &message),
1133+ };
1134+ pass(services, "list_vulnerability_reports", &ListReportsArgs { viewer: viewer.clone(), repo, state }).await
1135+ }
1136+ SecurityOp::ReportVulnerability => {
1137+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1138+ let args = ReportVulnerabilityArgs {
1139+ viewer: viewer.clone(),
1140+ repo,
1141+ title: text(input, "title").unwrap_or_default(),
1142+ description: text(input, "description").unwrap_or_default(),
1143+ severity: text(input, "severity").map(|severity| severity.to_lowercase()),
1144+ email: text(input, "email"),
1145+ };
1146+ pass(services, "report_vulnerability", &args).await
1147+ }
1148+ SecurityOp::ReviewReport => {
1149+ let Some(repo) = repo else { return failed(FailureCode::Invalid, &need_repo()) };
1150+ let args = ReviewReportArgs {
1151+ actor: actor(),
1152+ repo,
1153+ id: text(input, "id").unwrap_or_default(),
1154+ decision: text(input, "decision").unwrap_or_default().to_lowercase(),
1155+ note: text(input, "note").unwrap_or_default(),
1156+ };
1157+ pass(services, "review_vulnerability_report", &args).await
1158+ }
9501159 SecurityOp::GetWorkspaceSettings => {
9511160 let Some(workspace) = workspace else { return failed(FailureCode::Invalid, "Give the workspace.") };
9521161 pass(services, "workspace_security_settings", &WorkspaceSecuritySettingsArgs { viewer: viewer.clone(), workspace }).await
+41−2
2020
2121 use crate::about::AboutOp;
2222 use crate::run_artifacts::ArtifactsOp;
23+use crate::customize::CustomizeOp;
2324 use crate::deploy_keys::DeployKeysOp;
2425 use crate::mirrors::MirrorsOp;
2526 use crate::deployments::DeploymentsOp;
433434 ],
434435 },
435436 Tool {
437+ name: "customize",
438+ title: "Customize",
439+ description: "What shapes how a workspace's agents work, in the formats Claude Code reads: the marketplaces it reads plugins from (`.claude-plugin/marketplace.json`), what Discover lists, the plugins it installed as `name@marketplace` (their skills join the library as `plugin:skill`, their subagents become templates, their remote MCP servers become connectors), and its connectors: MCP servers connected once with a key or a sign-in, then given to every agent, a team or one agent. Members read; owners change. `bundle` is what a job's Claude Code loads on a runner, for the job's own token.",
440+ default_action: None,
441+ actions: &[
442+ a("list_marketplaces", Op::Customize(CustomizeOp::ListMarketplaces), "The marketplaces it reads plugins from"),
443+ a("add_marketplace", Op::Customize(CustomizeOp::AddMarketplace), "Add one by owner/repo, workspace/name, a git address or a marketplace.json link"),
444+ a("remove_marketplace", Op::Customize(CustomizeOp::RemoveMarketplace), "Remove one; its installed plugins stay"),
445+ a("refresh_marketplace", Op::Customize(CustomizeOp::RefreshMarketplace), "Read its manifest again now"),
446+ a("discover", Op::Customize(CustomizeOp::Discover), "Every plugin and skill it can add, with availability"),
447+ a("list_plugins", Op::Customize(CustomizeOp::ListPlugins), "Installed plugins: pinned commit, components, updates"),
448+ a("get_plugin", Op::Customize(CustomizeOp::GetPlugin), "One plugin's listing, install, components, README and agents"),
449+ a("install_plugin", Op::Customize(CustomizeOp::InstallPlugin), "Install one at its listed commit"),
450+ a("set_plugin_enabled", Op::Customize(CustomizeOp::SetPluginEnabled), "Switch an installed one on or off"),
451+ a("uninstall_plugin", Op::Customize(CustomizeOp::UninstallPlugin), "Uninstall one, with its skills, templates and connectors"),
452+ a("update_plugin", Op::Customize(CustomizeOp::UpdatePlugin), "Read one again at its marketplace's current commit"),
453+ a("list_plugin_agents", Op::Customize(CustomizeOp::ListPluginAgents), "Subagents plugins brought, as templates to hire from"),
454+ a("list_connectors", Op::Customize(CustomizeOp::ListConnectors), "MCP servers connected to the workspace, with state, tools and grants"),
455+ a("get_connector", Op::Customize(CustomizeOp::GetConnector), "One connector"),
456+ a("add_connector", Op::Customize(CustomizeOp::AddConnector), "Add one from the directory, a plugin's server or an address"),
457+ a("set_connector_key", Op::Customize(CustomizeOp::SetConnectorKey), "Put a key on one; never returned"),
458+ a("remove_connector", Op::Customize(CustomizeOp::RemoveConnector), "Remove one with its secrets and grants"),
459+ a("refresh_connector", Op::Customize(CustomizeOp::RefreshConnector), "List its tools again now"),
460+ a("grant_connector", Op::Customize(CustomizeOp::GrantConnector), "Give it to every agent, a team or one agent"),
461+ a("revoke_connector", Op::Customize(CustomizeOp::RevokeConnector), "Take one grant back"),
462+ a("bundle", Op::Customize(CustomizeOp::Bundle), "What a job's Claude Code loads: plugins, library skills, connectors with headers"),
463+ ],
464+ },
465+ Tool {
436466 name: "billing",
437467 title: "Billing",
438468 description: "A workspace's billing: its usage by product, project and day, its budget (the monthly spend limit, alerts and whether usage pauses at it), its AI credit, its invoices, and its AI Gateway requests. Amounts are whole millionths of a dollar (`_micros`), or cents (`_cents`) where named. Members read it; changing the budget and buying credit are for owners, as people, and never for g1t's agents.",
451481 Tool {
452482 name: "security",
453483 title: "Security",
454− description: "A repository's security: secret scanning alerts and push protection bypasses, custom secret patterns, code scanning alerts and SARIF uploads, vulnerability alerts, the dependency graph and its SBOM, dependency review, settings, and a workspace's overview. Fix an alert with g1t. Findings are shown to those who can change the code only. Give `repo` (owner/name), or `workspace` for lists across one.",
484+ description: "A repository's security: secret scanning alerts and push protection bypasses, custom secret patterns, code scanning alerts and SARIF uploads, vulnerability alerts, the dependency graph and its SBOM, dependency review, settings, a workspace's overview, security advisories and private vulnerability reports. Fix an alert with g1t. Findings are shown to those who can change the code only; published advisories to anyone who can see the repository. Give `repo` (owner/name), or `workspace` for lists across one.",
455485 default_action: Some("secret_alerts"),
456486 actions: &[
457487 a("secret_alerts", Op::Security(SecurityOp::ListSecretAlerts), "Secret scanning alerts; by state, secret_type, validity, bypassed"),
485515 a("workspace_settings", Op::Security(SecurityOp::GetWorkspaceSettings), "A workspace's delegated bypass and validity checks"),
486516 a("update_workspace_settings", Op::Security(SecurityOp::UpdateWorkspaceSettings), "Turn delegated bypass or validity checks on or off"),
487517 a("overview", Op::Security(SecurityOp::GetOverview), "A workspace's alerts, trends and coverage"),
518+ a("advisories", Op::Security(SecurityOp::ListAdvisories), "A repository's security advisories; by state"),
519+ a("advisory", Op::Security(SecurityOp::GetAdvisory), "One advisory by adv_… or G1TSA-… id"),
520+ a("create_advisory", Op::Security(SecurityOp::CreateAdvisory), "Draft a security advisory"),
521+ a("update_advisory", Op::Security(SecurityOp::UpdateAdvisory), "Change an advisory's fields"),
522+ a("set_advisory_state", Op::Security(SecurityOp::SetAdvisoryState), "Publish or withdraw an advisory"),
523+ a("reports", Op::Security(SecurityOp::ListReports), "Vulnerability reports sent to the maintainers; by state"),
524+ a("report_vulnerability", Op::Security(SecurityOp::ReportVulnerability), "Report a vulnerability privately to a repository's maintainers"),
525+ a("review_report", Op::Security(SecurityOp::ReviewReport), "Accept a report as a draft advisory, or decline it"),
488526 ],
489527 },
490528 Tool {
594632 | Op::DeleteRunnerGroup
595633 | Op::UpdateRunnerSettings
596634 | Op::Folios(FoliosOp::SetAccess | FoliosOp::Purge)
635+ | Op::Customize(CustomizeOp::RemoveMarketplace | CustomizeOp::UninstallPlugin | CustomizeOp::RemoveConnector)
597636 )
598637 }
599638
844883 assert!(tool.action(default).is_some(), "{}", tool.name);
845884 }
846885 }
847− assert!(TOOLS.len() <= 19, "{} tools", TOOLS.len());
886+ assert!(TOOLS.len() <= 20, "{} tools", TOOLS.len());
848887 }
849888
850889 #[test]
+13−2
11 // The launcher's work: ask the app for its server, check that it answers,
2−// go there. `?change` opens the choice of server instead. Everything it
2+// go there, as the desktop app (`?app=desktop`, which takes someone signed
3+// out to the sign-in page rather than the site's landing page). `?change` opens the choice of server instead. Everything it
34 // asks is a command in src/bridge.rs, answered only to this page.
45 (function () {
56 var sections = { connecting: byId("connecting"), offline: byId("offline"), choose: byId("choose") };
2930 }
3031 }
3132
33+ function appUrl(server) {
34+ try {
35+ var url = new URL(server);
36+ url.searchParams.set("app", "desktop");
37+ return url.href;
38+ } catch (error) {
39+ return server;
40+ }
41+ }
42+
3243 function connect(server) {
3344 show("connecting");
3445 byId("connecting-text").textContent = "Connecting to " + hostOf(server) + "…";
3546 invoke("reachable", { url: server })
3647 .then(function (ok) {
37− if (ok) location.replace(server);
48+ if (ok) location.replace(appUrl(server));
3849 else offline(server);
3950 })
4051 .catch(function () {
+227−0
1+/* The settings window, in the launcher's colours (launcher.css): rows of
2+ settings under small headings, each with its switch on the right. */
3+:root {
4+ --bg: #0f0f11;
5+ --fg: #ededf0;
6+ --muted: #9a9aa6;
7+ --line: #26262c;
8+ --surface: #17171b;
9+ --accent: #b6a8ff;
10+ --accent-ink: #15112e;
11+ --error: #ff8a8a;
12+ color-scheme: dark;
13+}
14+* {
15+ box-sizing: border-box;
16+}
17+html,
18+body {
19+ margin: 0;
20+}
21+body {
22+ background: var(--bg);
23+ color: var(--fg);
24+ font: 14px/1.45 system-ui, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
25+ -webkit-font-smoothing: antialiased;
26+ user-select: none;
27+ cursor: default;
28+}
29+/* g1t's own title bar, when the window has no system frame: fixed at the top, 36px tall. */
30+.titlebar {
31+ position: fixed;
32+ inset: 0 0 auto 0;
33+ z-index: 10;
34+ height: 36px;
35+ display: flex;
36+ align-items: center;
37+ justify-content: space-between;
38+ padding-left: 14px;
39+ background: #050507;
40+ color: var(--muted);
41+ font-size: 12px;
42+ font-weight: 500;
43+}
44+.titlebar[hidden] {
45+ display: none;
46+}
47+.mac .titlebar {
48+ padding-left: 78px;
49+}
50+.mac .titlebar .caption {
51+ display: none;
52+}
53+.titlebar .caption {
54+ all: unset;
55+ display: inline-flex;
56+ align-items: center;
57+ justify-content: center;
58+ width: 46px;
59+ height: 36px;
60+}
61+.titlebar .caption:hover {
62+ background: #c42b1c;
63+ color: #fff;
64+}
65+/* Under it, the page scrolls on its own, so its scrollbar starts below the bar. */
66+body.framed .page {
67+ position: fixed;
68+ inset: 36px 0 0 0;
69+ overflow-y: auto;
70+}
71+main {
72+ max-width: 36rem;
73+ margin: 0 auto;
74+ padding: 1.5rem 1.25rem 1.25rem;
75+}
76+h1 {
77+ font-size: 1.25rem;
78+ font-weight: 600;
79+ margin: 0 0 0.25rem;
80+}
81+h2 {
82+ font-size: 0.75rem;
83+ font-weight: 600;
84+ letter-spacing: 0.06em;
85+ text-transform: uppercase;
86+ color: var(--muted);
87+ margin: 0 0 0.25rem;
88+}
89+p {
90+ margin: 0;
91+}
92+.lede {
93+ margin-bottom: 1.5rem;
94+}
95+.muted {
96+ color: var(--muted);
97+}
98+.small {
99+ font-size: 0.8125rem;
100+}
101+section {
102+ margin-bottom: 1.5rem;
103+}
104+.setting {
105+ display: flex;
106+ align-items: center;
107+ justify-content: space-between;
108+ gap: 1rem;
109+ padding: 0.75rem 0;
110+ border-bottom: 1px solid var(--line);
111+}
112+section .setting:last-child {
113+ border-bottom: 0;
114+}
115+.setting > span {
116+ display: flex;
117+ flex-direction: column;
118+ gap: 0.125rem;
119+ min-width: 0;
120+}
121+.setting strong {
122+ font-weight: 500;
123+ overflow-wrap: anywhere;
124+}
125+.setting .muted {
126+ font-size: 0.8125rem;
127+}
128+.setting.off {
129+ opacity: 0.5;
130+}
131+input[type="checkbox"][role="switch"] {
132+ appearance: none;
133+ flex: none;
134+ width: 36px;
135+ height: 20px;
136+ margin: 0;
137+ border-radius: 999px;
138+ background: var(--line);
139+ position: relative;
140+ transition: background 0.15s;
141+}
142+input[type="checkbox"][role="switch"]::after {
143+ content: "";
144+ position: absolute;
145+ top: 2px;
146+ left: 2px;
147+ width: 16px;
148+ height: 16px;
149+ border-radius: 50%;
150+ background: var(--fg);
151+ transition: transform 0.15s;
152+}
153+input[type="checkbox"][role="switch"]:checked {
154+ background: var(--accent);
155+}
156+input[type="checkbox"][role="switch"]:checked::after {
157+ transform: translateX(16px);
158+ background: var(--accent-ink);
159+}
160+input:disabled {
161+ opacity: 0.5;
162+}
163+@media (prefers-reduced-motion: reduce) {
164+ input[type="checkbox"][role="switch"],
165+ input[type="checkbox"][role="switch"]::after {
166+ transition: none;
167+ }
168+}
169+button,
170+select {
171+ flex: none;
172+ font: inherit;
173+ font-size: 0.8125rem;
174+ font-weight: 500;
175+ padding: 0.375rem 0.75rem;
176+ border-radius: 8px;
177+ border: 1px solid var(--line);
178+ background: var(--surface);
179+ color: var(--fg);
180+ cursor: default;
181+}
182+button:hover,
183+select:hover {
184+ border-color: #3a3a44;
185+}
186+.setting > .buttons {
187+ flex-direction: row;
188+ flex: none;
189+ gap: 0.5rem;
190+}
191+button.primary {
192+ background: var(--accent);
193+ border-color: var(--accent);
194+ color: var(--accent-ink);
195+}
196+button.primary:hover {
197+ filter: brightness(1.06);
198+}
199+button[hidden] {
200+ display: none;
201+}
202+button:disabled {
203+ opacity: 0.6;
204+}
205+button:focus-visible,
206+select:focus-visible,
207+input:focus-visible,
208+a:focus-visible {
209+ outline: 2px solid var(--accent);
210+ outline-offset: 2px;
211+}
212+a {
213+ color: var(--muted);
214+ text-decoration: underline;
215+ text-underline-offset: 3px;
216+}
217+a:hover {
218+ color: var(--fg);
219+}
220+.error {
221+ color: var(--error);
222+ font-size: 0.875rem;
223+ margin-bottom: 1rem;
224+}
225+.foot {
226+ text-align: center;
227+}
+122−0
1+<!doctype html>
2+<!--
3+ The desktop app's settings, in a window of their own: opened from the
4+ menu, the tray, or the site's account menu (src/settings.rs). Each
5+ change is kept as soon as it is made; there is nothing to save.
6+-->
7+<html lang="en">
8+ <head>
9+ <meta charset="utf-8" />
10+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11+ <meta name="color-scheme" content="dark" />
12+ <title>g1t settings</title>
13+ <link rel="stylesheet" href="settings.css" />
14+ </head>
15+ <body>
16+ <!-- Its title bar, when g1t draws its own (src/frame.rs): drag it to move the window. -->
17+ <header class="titlebar" id="titlebar" hidden>
18+ <span>Settings</span>
19+ <button type="button" id="close-window" class="caption" aria-label="Close" title="Close" tabindex="-1">
20+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" aria-hidden="true">
21+ <line x1="4" y1="4" x2="12" y2="12" />
22+ <line x1="12" y1="4" x2="4" y2="12" />
23+ </svg>
24+ </button>
25+ </header>
26+ <div class="page">
27+ <main>
28+ <h1>Settings</h1>
29+ <p class="muted lede">For the desktop app on this computer. Your account's settings, notifications included, are on g1t itself.</p>
30+
31+ <section aria-labelledby="general-heading">
32+ <h2 id="general-heading">Starting and closing</h2>
33+ <label class="setting">
34+ <span><strong>Open g1t when you sign in</strong><span class="muted" id="open-at-login-note">g1t starts with your computer.</span></span>
35+ <input type="checkbox" role="switch" id="openAtLogin" />
36+ </label>
37+ <label class="setting">
38+ <span><strong>Start in the tray</strong><span class="muted" id="start-hidden-note">Opened at sign-in, g1t waits in the tray without opening its window.</span></span>
39+ <input type="checkbox" role="switch" id="startHidden" />
40+ </label>
41+ <label class="setting">
42+ <span><strong>Keep g1t running when the window closes</strong><span class="muted">It stays in the tray for notifications. Turned off, closing the window quits.</span></span>
43+ <input type="checkbox" role="switch" id="closeToTray" />
44+ </label>
45+ </section>
46+
47+ <section aria-labelledby="notifications-heading">
48+ <h2 id="notifications-heading">Notifications</h2>
49+ <label class="setting">
50+ <span><strong>Show notifications</strong><span class="muted">From your system, for what needs you while g1t is behind another window. Which things notify you is set on g1t.</span></span>
51+ <input type="checkbox" role="switch" id="notifications" />
52+ </label>
53+ <label class="setting">
54+ <span><strong id="flash-label">Flash the taskbar icon</strong><span class="muted">When a notification arrives and the window is not in front.</span></span>
55+ <input type="checkbox" role="switch" id="flash" />
56+ </label>
57+ <label class="setting">
58+ <span><strong>Show a badge on the icon</strong><span class="muted">How many things are waiting for you.</span></span>
59+ <input type="checkbox" role="switch" id="badge" />
60+ </label>
61+ </section>
62+
63+ <section aria-labelledby="window-heading">
64+ <h2 id="window-heading">Window</h2>
65+ <label class="setting">
66+ <span
67+ ><strong>Use the system's title bar</strong
68+ ><span class="muted" id="system-title-bar-note">In place of g1t's own, with the menu bar under it.</span></span
69+ >
70+ <input type="checkbox" role="switch" id="systemTitleBar" />
71+ </label>
72+ <div class="setting" id="restart-row" hidden>
73+ <span><strong>Restart to change the title bar</strong><span class="muted">g1t opens again where you were.</span></span>
74+ <button type="button" id="restart-app" class="primary">Restart now</button>
75+ </div>
76+ <label class="setting">
77+ <span><strong>Zoom</strong><span class="muted" id="zoom-note">The View menu changes it too.</span></span>
78+ <select id="zoom">
79+ <option value="0.67">67%</option>
80+ <option value="0.75">75%</option>
81+ <option value="0.8">80%</option>
82+ <option value="0.9">90%</option>
83+ <option value="1">100%</option>
84+ <option value="1.1">110%</option>
85+ <option value="1.25">125%</option>
86+ <option value="1.5">150%</option>
87+ <option value="1.75">175%</option>
88+ <option value="2">200%</option>
89+ </select>
90+ </label>
91+ </section>
92+
93+ <section aria-labelledby="updates-heading">
94+ <h2 id="updates-heading">Updates</h2>
95+ <label class="setting">
96+ <span><strong>Download updates by themselves</strong><span class="muted">Each one installs when you restart or quit g1t, never in the middle of something.</span></span>
97+ <input type="checkbox" role="switch" id="autoUpdate" />
98+ </label>
99+ <div class="setting" id="updates-row">
100+ <span><strong id="version">g1t</strong><span class="muted" id="update-status" aria-live="polite">&nbsp;</span></span>
101+ <span class="buttons">
102+ <button type="button" id="restart" class="primary" hidden>Restart now</button>
103+ <button type="button" id="check">Check for updates</button>
104+ </span>
105+ </div>
106+ </section>
107+
108+ <section aria-labelledby="server-heading">
109+ <h2 id="server-heading">Server</h2>
110+ <div class="setting">
111+ <span><strong id="server">g1t.sh</strong><span class="muted">The g1t this app opens. Change it if you run g1t yourself.</span></span>
112+ <button type="button" id="change-server">Change…</button>
113+ </div>
114+ </section>
115+
116+ <p class="error" id="error" role="alert" hidden></p>
117+ <p class="muted small foot"><a href="https://docs.g1t.sh/guides/desktop/">About the desktop app</a></p>
118+ </main>
119+ </div>
120+ <script src="settings.js"></script>
121+ </body>
122+</html>
+209−0
1+// The settings window's work: show what the app has, and keep each change
2+// the moment it is made. Everything it asks is a command in
3+// src/bridge.rs, answered only to this window.
4+(function () {
5+ var SWITCHES = ["startHidden", "closeToTray", "notifications", "flash", "badge", "systemTitleBar", "autoUpdate"];
6+ var current = null;
7+
8+ function byId(id) {
9+ return document.getElementById(id);
10+ }
11+
12+ function invoke(command, args) {
13+ var core = window.__TAURI__ && window.__TAURI__.core;
14+ if (core && typeof core.invoke === "function") return core.invoke(command, args || {});
15+ return Promise.reject(new Error("This page belongs inside the g1t desktop app."));
16+ }
17+
18+ function fail(error) {
19+ var box = byId("error");
20+ box.textContent = typeof error === "string" ? error : (error && error.message) || "That could not be changed.";
21+ box.hidden = false;
22+ }
23+
24+ function hostOf(server) {
25+ try {
26+ return new URL(server).host;
27+ } catch (error) {
28+ return server;
29+ }
30+ }
31+
32+ // What changes between Windows, macOS and Linux.
33+ function platform(name) {
34+ byId("flash-label").textContent =
35+ name === "macos" ? "Bounce the dock icon" : name === "windows" ? "Flash the taskbar icon" : "Ask for attention";
36+ // macOS keeps its menu bar at the top of the screen, whichever title bar the window has.
37+ if (name === "macos") byId("system-title-bar-note").textContent = "In place of g1t's own, which sits under the window's buttons.";
38+ if (name === "macos") byId("zoom-note").textContent = "The View menu changes it too: ⌘+, ⌘− and ⌘0.";
39+ else byId("zoom-note").textContent = "The View menu changes it too: Ctrl+=, Ctrl+− and Ctrl+0.";
40+ }
41+
42+ function render(view) {
43+ current = view;
44+ byId("error").hidden = true;
45+ byId("openAtLogin").checked = !!view.openAtLogin;
46+ SWITCHES.forEach(function (key) {
47+ byId(key).checked = !!view[key];
48+ });
49+ // Starting in the tray only means something when g1t starts at sign-in.
50+ byId("startHidden").disabled = !view.openAtLogin;
51+ byId("startHidden").closest(".setting").classList.toggle("off", !view.openAtLogin);
52+ // The title bar changes when g1t restarts.
53+ byId("restart-row").hidden = !view.titleBarChanged;
54+ var zoom = byId("zoom");
55+ zoom.value = String(view.zoom);
56+ if (zoom.value === "") zoom.value = "1";
57+ byId("version").textContent = "g1t " + view.version;
58+ if (!checking) updateStatus(view);
59+ byId("server").textContent = hostOf(view.server);
60+ }
61+
62+ function settingsOf() {
63+ var settings = {};
64+ SWITCHES.forEach(function (key) {
65+ settings[key] = byId(key).checked;
66+ });
67+ settings.zoom = Number(byId("zoom").value) || 1;
68+ return settings;
69+ }
70+
71+ function save() {
72+ invoke("save_settings", { settings: settingsOf() }).then(render, function (error) {
73+ fail(error);
74+ if (current) render(current);
75+ });
76+ }
77+
78+ SWITCHES.forEach(function (key) {
79+ byId(key).addEventListener("change", save);
80+ });
81+ byId("zoom").addEventListener("change", save);
82+ byId("openAtLogin").addEventListener("change", function (event) {
83+ invoke("set_open_at_login", { on: event.target.checked }).then(render, function (error) {
84+ fail(error);
85+ if (current) render(current);
86+ });
87+ });
88+
89+ // Updates: where this copy stands, said in the window, so a check from
90+ // the menu or the tray is answered here and cannot be missed.
91+ var checking = false;
92+
93+ function when(ms) {
94+ var date = new Date(ms);
95+ var today = new Date().toDateString() === date.toDateString();
96+ var time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
97+ return today ? "today at " + time : date.toLocaleDateString([], { month: "short", day: "numeric" }) + " at " + time;
98+ }
99+
100+ function ready(version) {
101+ byId("update-status").textContent = "Version " + version + " is downloaded and installs when g1t restarts.";
102+ byId("restart").hidden = false;
103+ }
104+
105+ function updateStatus(view) {
106+ if (view.updateReady) return ready(view.updateReady);
107+ byId("restart").hidden = true;
108+ byId("update-status").textContent = view.checkedAt
109+ ? "Up to date. Last checked " + when(view.checkedAt) + "."
110+ : view.autoUpdate
111+ ? "Not checked yet since g1t started; it checks on its own shortly."
112+ : "Checking on its own is off.";
113+ }
114+
115+ function check() {
116+ if (checking) return;
117+ checking = true;
118+ var button = byId("check");
119+ var status = byId("update-status");
120+ button.disabled = true;
121+ status.textContent = "Checking, and downloading any new version…";
122+ invoke("check_for_updates")
123+ .then(function (answer) {
124+ if (answer.indexOf("ready:") === 0) ready(answer.slice(6));
125+ else status.textContent = "Up to date. Last checked " + when(Date.now()) + ".";
126+ })
127+ .catch(function (error) {
128+ var text = typeof error === "string" ? error : "";
129+ status.textContent = /sending request|connect|dns|timed out/i.test(text) || !text
130+ ? "Could not reach g1t.sh to check. Check your connection and try again."
131+ : "Could not check: " + text;
132+ })
133+ .then(function () {
134+ checking = false;
135+ button.disabled = false;
136+ });
137+ }
138+
139+ byId("check").addEventListener("click", check);
140+ byId("restart").addEventListener("click", function () {
141+ byId("restart").disabled = true;
142+ byId("update-status").textContent = "Installing…";
143+ invoke("restart_to_update").catch(function (error) {
144+ byId("restart").disabled = false;
145+ fail(error);
146+ });
147+ });
148+
149+ // The menu's or tray's "Check for updates…" with this window open, and a
150+ // download finishing in the background (src/settings.rs).
151+ var events = window.__TAURI__ && window.__TAURI__.event;
152+ if (events && typeof events.listen === "function") {
153+ events.listen("g1t-check-updates", check).catch(function () {});
154+ events
155+ .listen("g1t-update-ready", function (event) {
156+ if (!checking && typeof event.payload === "string") ready(event.payload);
157+ })
158+ .catch(function () {});
159+ }
160+
161+ byId("restart-app").addEventListener("click", function () {
162+ byId("restart-app").disabled = true;
163+ invoke("restart").catch(function (error) {
164+ byId("restart-app").disabled = false;
165+ fail(error);
166+ });
167+ });
168+
169+ byId("change-server").addEventListener("click", function () {
170+ invoke("change_server").catch(fail);
171+ });
172+
173+ // Links out, in the system's browser (src/bridge.js does the same on the site).
174+ document.addEventListener("click", function (event) {
175+ var anchor = event.target && event.target.closest ? event.target.closest("a[href]") : null;
176+ if (!anchor) return;
177+ event.preventDefault();
178+ invoke("open_url", { url: anchor.href }).catch(function () {});
179+ });
180+
181+ // g1t's own title bar, when the window has no system frame.
182+ function frame(view) {
183+ if (!view.customFrame) return;
184+ if (view.platform === "macos") document.documentElement.classList.add("mac");
185+ var bar = byId("titlebar");
186+ bar.hidden = false;
187+ document.body.classList.add("framed");
188+ bar.addEventListener("mousedown", function (event) {
189+ if (event.button !== 0 || event.target.closest("button")) return;
190+ invoke("window_control", { action: "drag" }).catch(function () {});
191+ });
192+ byId("close-window").addEventListener("click", function () {
193+ invoke("window_control", { action: "close" }).catch(function () {});
194+ });
195+ }
196+
197+ invoke("get_settings")
198+ .then(function (view) {
199+ frame(view);
200+ platform(view.platform);
201+ render(view);
202+ // Opened by "Check for updates…" in a menu.
203+ if (new URLSearchParams(location.search).has("check")) {
204+ byId("updates-row").scrollIntoView({ block: "center" });
205+ check();
206+ }
207+ })
208+ .catch(fail);
209+})();
+114−0
1+/* The title bar, in the colours of the page under it (titlebar.js sets
2+ them): g1t's rail on g1t's pages, light or dark. 36px tall. */
3+:root {
4+ --bg: #050507;
5+ --fg: #ededef;
6+ --muted: #a0a0a8;
7+ --accent: #b6a8ff;
8+ --hover: rgba(237, 237, 239, 0.1);
9+ --pill: rgba(237, 237, 239, 0.09);
10+ --pill-hover: rgba(237, 237, 239, 0.14);
11+ --pill-edge: rgba(237, 237, 239, 0.08);
12+}
13+* {
14+ box-sizing: border-box;
15+}
16+html,
17+body {
18+ height: 100%;
19+ margin: 0;
20+ overflow: hidden;
21+ background: var(--bg);
22+}
23+.bar {
24+ height: 100%;
25+ display: flex;
26+ align-items: center;
27+ gap: 2px;
28+ padding-left: 6px;
29+ color: var(--muted);
30+ font: 500 12px/1 system-ui, "Segoe UI", -apple-system, Roboto, sans-serif;
31+ user-select: none;
32+ -webkit-user-select: none;
33+ cursor: default;
34+}
35+.mac .bar {
36+ padding-left: 78px;
37+}
38+.side {
39+ display: flex;
40+ align-items: center;
41+ gap: 2px;
42+ flex: 1 1 0;
43+ min-width: 0;
44+}
45+.end {
46+ justify-content: flex-end;
47+}
48+.middle {
49+ flex: 0 1 560px;
50+ min-width: 0;
51+ display: flex;
52+ justify-content: center;
53+}
54+button {
55+ all: unset;
56+ box-sizing: border-box;
57+ display: inline-flex;
58+ align-items: center;
59+ justify-content: center;
60+ color: inherit;
61+}
62+.icon {
63+ width: 28px;
64+ height: 26px;
65+ border-radius: 6px;
66+}
67+.icon:hover {
68+ background: var(--hover);
69+ color: var(--fg);
70+}
71+.icon:disabled {
72+ opacity: 0.35;
73+ background: none;
74+ color: inherit;
75+}
76+.search {
77+ width: 100%;
78+ height: 24px;
79+ gap: 8px;
80+ padding: 0 10px;
81+ justify-content: flex-start;
82+ border-radius: 6px;
83+ background: var(--pill);
84+ box-shadow: inset 0 0 0 1px var(--pill-edge);
85+}
86+.search:hover {
87+ background: var(--pill-hover);
88+ color: var(--fg);
89+}
90+.search span,
91+.title {
92+ overflow: hidden;
93+ text-overflow: ellipsis;
94+ white-space: nowrap;
95+}
96+.title {
97+ padding: 0 8px;
98+}
99+.caption {
100+ width: 46px;
101+ height: 36px;
102+}
103+.caption:hover {
104+ background: var(--hover);
105+ color: var(--fg);
106+}
107+.caption.close:hover {
108+ background: #c42b1c;
109+ color: #fff;
110+}
111+button:focus-visible {
112+ outline: 2px solid var(--accent);
113+ outline-offset: -2px;
114+}
+23−0
1+<!doctype html>
2+<!--
3+ The title bar: a page of its own, across the top of the main window,
4+ above the site's (src/frame.rs). It shows what the site's page reports
5+ (its title, its search, whether there is a way back, its colours), and
6+ its buttons act on the window and on that page through the app.
7+-->
8+<html lang="en">
9+ <head>
10+ <meta charset="utf-8" />
11+ <meta name="viewport" content="width=device-width, initial-scale=1" />
12+ <title>g1t</title>
13+ <link rel="stylesheet" href="titlebar.css" />
14+ </head>
15+ <body>
16+ <div class="bar" id="bar" role="toolbar" aria-label="Window">
17+ <div class="side" id="left"></div>
18+ <div class="middle" id="middle"></div>
19+ <div class="side end" id="right"></div>
20+ </div>
21+ <script src="titlebar.js"></script>
22+ </body>
23+</html>
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…