Expose workspace plugin publish capability (#35254)

## What changed

- Add nullable `canPublishToWorkspace` metadata to plugin share contexts and `plugin/share/save` responses.
- Preserve the remote capability through catalog parsing and app-server responses so clients can decide whether to offer workspace-directory publishing.
- Document that clients should fail closed when the capability is unavailable.

## Testing

- Cover remote catalog parsing, share-save response propagation, and protocol serialization.

GitOrigin-RevId: b0346b3b330169838f800dfdf21dfa5db1a47f4d
This commit is contained in:
thomas
2026-07-24 22:14:12 +00:00
committed by copyberry
parent 1811b67a84
commit 32329b289d
20 changed files with 114 additions and 3 deletions
@@ -14595,6 +14595,13 @@
},
"PluginShareContext": {
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"creatorAccountUserId": {
"type": [
"string",
@@ -14797,6 +14804,13 @@
"PluginShareSaveResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"remotePluginId": {
"type": "string"
},
@@ -10937,6 +10937,13 @@
},
"PluginShareContext": {
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"creatorAccountUserId": {
"type": [
"string",
@@ -11139,6 +11146,13 @@
"PluginShareSaveResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"remotePluginId": {
"type": "string"
},
@@ -259,6 +259,13 @@
},
"PluginShareContext": {
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"creatorAccountUserId": {
"type": [
"string",
@@ -259,6 +259,13 @@
},
"PluginShareContext": {
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"creatorAccountUserId": {
"type": [
"string",
@@ -409,6 +409,13 @@
},
"PluginShareContext": {
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"creatorAccountUserId": {
"type": [
"string",
@@ -194,6 +194,13 @@
},
"PluginShareContext": {
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"creatorAccountUserId": {
"type": [
"string",
@@ -1,6 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"canPublishToWorkspace": {
"default": null,
"type": [
"boolean",
"null"
]
},
"remotePluginId": {
"type": "string"
},
@@ -8,4 +8,4 @@ export type PluginShareContext = { remotePluginId: string,
/**
* Version of the remote shared plugin release when available.
*/
remoteVersion: string | null, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array<PluginSharePrincipal> | null, };
remoteVersion: string | null, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array<PluginSharePrincipal> | null, canPublishToWorkspace: boolean | null, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginShareSaveResponse = { remotePluginId: string, shareUrl: string, };
export type PluginShareSaveResponse = { remotePluginId: string, shareUrl: string, canPublishToWorkspace: boolean | null, };
@@ -254,6 +254,8 @@ pub struct PluginShareSaveParams {
pub struct PluginShareSaveResponse {
pub remote_plugin_id: String,
pub share_url: String,
#[serde(default)]
pub can_publish_to_workspace: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -662,6 +664,8 @@ pub struct PluginShareContext {
pub creator_account_user_id: Option<String>,
pub creator_name: Option<String>,
pub share_principals: Option<Vec<PluginSharePrincipal>>,
#[serde(default)]
pub can_publish_to_workspace: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -3701,11 +3701,13 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
serde_json::to_value(PluginShareSaveResponse {
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
share_url: String::new(),
can_publish_to_workspace: Some(true),
})
.unwrap(),
json!({
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"shareUrl": "",
"canPublishToWorkspace": true,
}),
);
+1 -1
View File
@@ -220,7 +220,7 @@ Example with notification opt-out:
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Every `PluginSummary` returned by plugin list, installed, read, and share-list methods includes `mustShowInstallationInterstitial`: remote service values preserve `true` or `false`, while local plugins and remote responses that omit the policy return `null`. Clients should fail closed when the value is `null`. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. Set `forceRefetch: true` to bypass TTL-backed remote catalog caches for the requested marketplaces and wait for fresh data; cache entries are replaced only after a successful fetch. When local marketplaces are included, the request also waits for configured plugin caches to reconcile before marketplace summaries are returned. At app-server startup, existing cached catalogs remain available to `plugin/list` while they refresh in the background. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Remote rows include nullable `installPolicySource`; local rows return `null`. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details can include scheduled task summaries from the catalog; `scheduledTasks: null` means the metadata is unavailable, while an empty array means the catalog found no scheduled tasks. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Remote skill interfaces expose `iconSmallUrl` and `iconLargeUrl` when the catalog supplies icon URLs. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details can include scheduled task summaries from the catalog; `scheduledTasks: null` means the metadata is unavailable, while an empty array means the catalog found no scheduled tasks. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. For owned workspace plugins, `summary.shareContext.canPublishToWorkspace` reports whether the current user may add the plugin to the workspace directory; `plugin/share/save` returns the same capability after creating or updating a share, and clients should fail closed when either value is `null`. Remote skill interfaces expose `iconSmallUrl` and `iconLargeUrl` when the catalog supplies icon URLs. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/installed` — read installed connector runtime state from the last committed snapshot, optionally refreshing it first.
@@ -157,6 +157,7 @@ fn share_context_for_source(
creator_account_user_id: None,
creator_name: None,
share_principals: None,
can_publish_to_workspace: None,
}),
MarketplacePluginSource::Git { .. } | MarketplacePluginSource::Npm { .. } => None,
}
@@ -1054,6 +1055,8 @@ impl PluginRequestProcessor {
Some(remote_plugin_share_context_to_info(remote_share_context))
} else {
let remote_version = remote_share_context.remote_version;
let can_publish_to_workspace =
remote_share_context.can_publish_to_workspace;
let remote_plugin_id = context.remote_plugin_id.clone();
warn!(
remote_plugin_id = %remote_plugin_id,
@@ -1061,6 +1064,7 @@ impl PluginRequestProcessor {
);
Some(PluginShareContext {
remote_version,
can_publish_to_workspace,
..context
})
}
@@ -1288,6 +1292,7 @@ impl PluginRequestProcessor {
Ok(PluginShareSaveResponse {
remote_plugin_id,
share_url: result.share_url.unwrap_or_default(),
can_publish_to_workspace: result.can_publish_to_workspace,
})
}
@@ -2211,6 +2216,7 @@ fn remote_plugin_share_context_to_info(
.map(plugin_share_principal_from_remote)
.collect()
}),
can_publish_to_workspace: context.can_publish_to_workspace,
}
}
@@ -95,6 +95,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"plugin_id": "plugins_123",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"can_publish_to_workspace": true,
})))
.expect(1)
.mount(&server)
@@ -123,6 +124,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
PluginShareSaveResponse {
remote_plugin_id: "plugins_123".to_string(),
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
can_publish_to_workspace: Some(true),
}
);
@@ -278,6 +280,7 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> {
PluginShareSaveResponse {
remote_plugin_id: "plugins_123".to_string(),
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
can_publish_to_workspace: None,
}
);
Ok(())
@@ -1361,6 +1364,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value {
"name": "demo-plugin",
"scope": "WORKSPACE",
"discoverability": "PRIVATE",
"can_publish_to_workspace": true,
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"share_principals": [
{
@@ -1454,6 +1458,7 @@ fn expected_share_context(plugin_id: &str) -> PluginShareContext {
name: "Reader".to_string(),
},
]),
can_publish_to_workspace: Some(true),
}
}
+4
View File
@@ -245,6 +245,7 @@ pub struct RemotePluginShareContext {
pub creator_account_user_id: Option<String>,
pub creator_name: Option<String>,
pub share_principals: Option<Vec<RemotePluginSharePrincipal>>,
pub can_publish_to_workspace: Option<bool>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -628,6 +629,8 @@ struct RemotePluginDirectoryItem {
share_url: Option<String>,
#[serde(default)]
share_principals: Option<Vec<RemotePluginDirectorySharePrincipal>>,
#[serde(default)]
can_publish_to_workspace: Option<bool>,
installation_policy: PluginInstallPolicy,
installation_policy_source: Option<RemotePluginInstallPolicySource>,
#[serde(default)]
@@ -1690,6 +1693,7 @@ fn remote_plugin_share_context(
})
.collect()
}),
can_publish_to_workspace: plugin.can_publish_to_workspace,
}))
}
}
@@ -25,6 +25,7 @@ pub use checkout::checkout_remote_plugin_share;
pub struct RemotePluginShareSaveResult {
pub remote_plugin_id: String,
pub share_url: Option<String>,
pub can_publish_to_workspace: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
@@ -123,6 +124,8 @@ struct RemoteWorkspacePluginCreateRequest {
struct RemoteWorkspacePluginCreateResponse {
plugin_id: String,
share_url: Option<String>,
#[serde(default)]
can_publish_to_workspace: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -201,6 +204,7 @@ pub async fn save_remote_plugin_share(
Ok(RemotePluginShareSaveResult {
remote_plugin_id: response.plugin_id,
share_url: response.share_url,
can_publish_to_workspace: response.can_publish_to_workspace,
})
}
@@ -230,6 +230,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"plugin_id": "plugins_123",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
"can_publish_to_workspace": true,
})))
.expect(1)
.mount(&server)
@@ -258,6 +259,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() {
RemotePluginShareSaveResult {
remote_plugin_id: "plugins_123".to_string(),
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
can_publish_to_workspace: Some(true),
}
);
assert_eq!(
@@ -435,6 +437,7 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
RemotePluginShareSaveResult {
remote_plugin_id: "plugins_123".to_string(),
share_url: None,
can_publish_to_workspace: None,
}
);
}
@@ -659,6 +662,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
name: "Reader".to_string(),
},
]),
can_publish_to_workspace: None,
}),
installed: false,
enabled: false,
@@ -700,6 +704,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
name: "Editor".to_string(),
},
]),
can_publish_to_workspace: None,
}),
installed: true,
enabled: true,
+15
View File
@@ -241,6 +241,20 @@ fn scheduled_task_metadata_distinguishes_unavailable_from_empty() {
assert_eq!(with_empty_metadata.scheduled_tasks, Some(Vec::new()));
}
#[test]
fn workspace_share_context_preserves_publish_capability() {
let mut plugin = directory_plugin("plugin-workspace", "workspace plugin");
plugin.scope = RemotePluginScope::Workspace;
plugin.discoverability = Some(RemotePluginShareDiscoverability::Private);
plugin.can_publish_to_workspace = Some(true);
let context = remote_plugin_share_context(&plugin)
.expect("workspace plugin should be valid")
.expect("workspace plugin should have share context");
assert_eq!(context.can_publish_to_workspace, Some(true));
}
fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem {
RemotePluginDirectoryItem {
id: id.to_string(),
@@ -251,6 +265,7 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem {
creator_name: None,
share_url: None,
share_principals: None,
can_publish_to_workspace: None,
installation_policy: PluginInstallPolicy::Available,
installation_policy_source: None,
must_show_installation_interstitial: None,
+1
View File
@@ -156,6 +156,7 @@ mod tests {
creator_account_user_id: None,
creator_name: Some("Test User".to_string()),
share_principals: None,
can_publish_to_workspace: None,
}),
..plugin_summary(name)
}
@@ -1045,6 +1045,7 @@ async fn plugin_detail_popup_shows_local_share_context_as_read_only_snapshot() {
creator_account_user_id: None,
creator_name: Some("Test User".to_string()),
share_principals: None,
can_publish_to_workspace: None,
}),
..plugins_test_summary(
"plugin-docs",
@@ -1326,6 +1327,7 @@ async fn plugins_popup_remote_detail_tracks_physical_and_policy_install_state()
creator_account_user_id: None,
creator_name: None,
share_principals: None,
can_publish_to_workspace: None,
}),
..plugins_test_summary(
"plugin-docs",