File size: 7,650 Bytes
52a9af3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
//! Account/backend-scoped exclusions for bundled plugins replaced by remote plugins.
//! Only plugin IDs are persisted; remote installation and enablement remain server-owned.
// TODO(sites-migration): Remove this module when bundled Sites is retired and rollback support ends.

use super::*;
use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;
use sha2::Sha256;

const BUNDLED_SITES_PLUGIN_ID: &str = "sites@openai-bundled";
const REMOTE_SITES_PLUGIN_ID: &str = "sites@openai-curated-remote";

fn is_remote_sites(plugin: &RemoteInstalledPlugin) -> bool {
    plugin.name == "sites"
        && plugin.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME
        && plugin.id.strip_prefix("plugins~").unwrap_or(&plugin.id)
            == "plugin_connector_1p_689987207de08191979cf68eca2941c6"
}

#[derive(Default, Deserialize, Serialize)]
struct BundledPluginExclusions {
    #[serde(rename = "disabled-bundled-plugin-ids")]
    ids: BTreeSet<String>,
}

fn read_exclusions(path: &Path) -> BundledPluginExclusions {
    std::fs::read(path)
        .ok()
        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
        .unwrap_or_default()
}

impl PluginsManager {
    // Temporary direct-access guard using the same exclusion state as catalog/runtime loading.
    pub(super) fn bundled_sites_is_hidden(
        &self,
        config: &PluginsConfigInput,
        plugin_id: &str,
    ) -> bool {
        plugin_id == BUNDLED_SITES_PLUGIN_ID
            && self.excluded_bundled_plugin_ids(config).contains(plugin_id)
    }

    fn bundled_plugin_exclusion_path(
        &self,
        base_url: &str,
        auth: Option<&CodexAuth>,
    ) -> Option<PathBuf> {
        if self.restriction_product != Some(Product::Codex) {
            return None;
        }
        let auth = auth.filter(|auth| auth.uses_codex_backend())?;
        let account_id = auth.get_account_id().filter(|id| !id.is_empty())?;
        let key = serde_json::to_vec(&(
            base_url,
            account_id,
            auth.get_chatgpt_user_id(),
            auth.is_workspace_account(),
        ))
        .ok()?;
        let digest = Sha256::digest(key);
        Some(
            self.codex_home
                .join("cache/bundled_plugin_exclusions")
                .join(format!("{digest:x}.json")),
        )
    }

    fn remote_sites_bundle_is_loadable(&self) -> bool {
        PluginId::parse(REMOTE_SITES_PLUGIN_ID)
            .ok()
            .is_some_and(|id| self.store.active_plugin_root(&id).is_some())
    }

    pub(super) fn excluded_bundled_plugin_ids(
        &self,
        config: &PluginsConfigInput,
    ) -> BTreeSet<String> {
        if !config.plugins_enabled || !self.remote_global_catalog_active(config) {
            return BTreeSet::new();
        }
        let auth = self.auth_manager.auth_cached();
        let Some(path) =
            self.bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth.as_ref())
        else {
            return BTreeSet::new();
        };
        let mut exclusions = read_exclusions(&path).ids;
        // Preserve the bundled fallback if the replacement's local files are missing.
        if !self.remote_sites_bundle_is_loadable() {
            exclusions.remove(BUNDLED_SITES_PLUGIN_ID);
        }
        exclusions
    }

    // Called only after a current, successful installed-snapshot fetch. Failed requests never
    // erase the last known exclusion; successful absence restores the bundled fallback.
    pub(super) fn update_sites_exclusion(
        &self,
        base_url: &str,
        auth: Option<&CodexAuth>,
        plugins: &[RemoteInstalledPlugin],
    ) -> bool {
        let Some(path) = self.bundled_plugin_exclusion_path(base_url, auth) else {
            return false;
        };
        let mut exclusions = read_exclusions(&path);
        let remote_sites_ready =
            plugins.iter().any(is_remote_sites) && self.remote_sites_bundle_is_loadable();
        let changed = if remote_sites_ready {
            exclusions.ids.insert(BUNDLED_SITES_PLUGIN_ID.to_string())
        } else {
            exclusions.ids.remove(BUNDLED_SITES_PLUGIN_ID)
        };
        if changed {
            let result = serde_json::to_string(&exclusions)
                .map_err(std::io::Error::other)
                .and_then(|contents| codex_utils_path::write_atomically(&path, &contents));
            if let Err(err) = result {
                warn!(error = %err, "failed to persist bundled Sites exclusion");
            }
        }
        changed
    }

    fn sites_migration_needed(
        &self,
        config: &PluginsConfigInput,
        auth: Option<&CodexAuth>,
    ) -> bool {
        if !config.plugins_enabled
            || !self.remote_global_catalog_active(config)
            || self
                .excluded_bundled_plugin_ids(config)
                .contains(BUNDLED_SITES_PLUGIN_ID)
        {
            return false;
        }
        let Some(path) = self.bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth) else {
            return false;
        };
        !self
            .sites_migration_checked_at
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .as_ref()
            .is_some_and(|(checked_path, checked_at)| {
                checked_path == &path && checked_at.elapsed() < Duration::from_secs(60)
            })
    }

    /// Coordinates the first Sites migration at the shared inventory boundary. A persisted
    /// exclusion skips migration on restart without restoring any remote installed metadata.
    pub async fn ensure_sites_migration_ready(
        &self,
        config: &PluginsConfigInput,
        auth: Option<&CodexAuth>,
    ) -> Result<Option<EffectivePluginsChange>, RemoteInstalledPluginBundleSyncError> {
        if !self.sites_migration_needed(config, auth) {
            return Ok(None);
        }
        // Sites is implicitly installed by Plugin Service. Never create an explicit install or
        // overwrite account-wide preferences from bundled config; /installed owns both decisions.
        let plugins = crate::remote::fetch_remote_installed_plugins(
            &remote_plugin_service_config(config),
            auth,
        )
        .await?;
        let remote_sites_available = plugins.iter().any(is_remote_sites);
        // A rollout-off check must not wait behind unrelated startup bundle downloads.
        let _guard = if remote_sites_available {
            let guard = self.acquire_remote_installed_plugin_sync_guard().await?;
            // Another catalog request or background sync may have completed while we waited.
            if !self.sites_migration_needed(config, auth) {
                return Ok(None);
            }
            Some(guard)
        } else {
            None
        };
        *self
            .sites_migration_checked_at
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner) = self
            .bundled_plugin_exclusion_path(&config.chatgpt_base_url, auth)
            .map(|path| (path, Instant::now()));
        if !remote_sites_available {
            return Ok(None);
        }
        let (outcome, changed) =
            Box::pin(self.reconcile_remote_installed_plugins_after_acquiring_gate(config, auth))
                .await?;
        // Preserve materialization details so the existing callback can trust eligible hooks.
        Ok(
            (changed || !outcome.changed_plugins.is_empty()).then_some(EffectivePluginsChange {
                materialized_remote_plugins: outcome.materialized_remote_plugins,
            }),
        )
    }
}