Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.10% covered (warning)
83.10%
59 / 71
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_AI_Settings
86.76% covered (warning)
86.76%
59 / 68
91.67% covered (success)
91.67%
11 / 12
34.37
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 register_settings
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 add_sync_options_whitelist
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 apply_master_gates
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 should_enforce_ai_controls
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 is_ai_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 host_allows_ai
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_master_enabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_master_forced_off
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 set_master_enabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 is_feature_enabled
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 is_ai_seo_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Jetpack AI feature settings.
4 *
5 * Central registry for the Jetpack AI master switch and per-feature toggles,
6 * implementing the layered AI gate contract:
7 *
8 *   1. the host allows AI            — WP_AI_SUPPORT, via wp_supports_ai()
9 *   2. the plan includes AI          — connection + plan checks (owned by each feature)
10 *   3. AI is on for the whole site   — the jetpack_ai_enabled option (master switch)
11 *   4. the feature's own switch      — per-feature options surfaced on the AI settings page
12 *
13 * Gates 1 and 3 are enforced by is_ai_enabled(), which plugin load points call
14 * instead of applying `jetpack_ai_enabled` directly: the gates AND in after the
15 * filter chain, so no later-priority callback can override them. The gates also
16 * ride the filter itself for package consumers that cannot reference this class.
17 * Gate 4 options are registered here and consulted at each feature's registration
18 * or enqueue point — a disabled feature must stop loading, not just hide.
19 *
20 * @package automattic/jetpack
21 */
22
23use Automattic\Jetpack\Modules;
24use Automattic\Jetpack\Status\Host;
25
26if ( ! defined( 'ABSPATH' ) ) {
27    exit( 0 );
28}
29
30// All consumers require this canonical file once. A class_exists() guard here
31// would be true on the first load because PHP registers unconditional classes
32// before executing the file, returning before the self-initialization below.
33
34/**
35 * Registers the Jetpack AI master switch and per-feature toggle options, and
36 * enforces the host (WP_AI_SUPPORT) and master gates on the AI filters.
37 */
38class Jetpack_AI_Settings {
39
40    /**
41     * Master switch option. Named after the pre-existing `jetpack_ai_enabled`
42     * filter it backs, following the `reader_chat` option/filter precedent.
43     *
44     * @var string
45     */
46    const MASTER_OPTION = 'jetpack_ai_enabled';
47
48    /**
49     * Slug of the `ai` module that acts as the site-wide master switch off
50     * WordPress.com Simple (self-hosted and Atomic), where modules run.
51     *
52     * @var string
53     */
54    const AI_MODULE = 'ai';
55
56    /**
57     * Feature key => option name for every toggle on the AI settings page.
58     *
59     * `ai_search` reuses an option owned by the Search surface; the rest are
60     * registered by this class. The automatic-generation option is deliberately
61     * absent: the Traffic page and the SEO dashboard own it.
62     *
63     * @var array
64     */
65    const FEATURE_OPTIONS = array(
66        'writing_assistant' => 'jetpack_ai_writing_assistant_enabled',
67        'image_editor'      => 'jetpack_ai_image_editor_enabled',
68        'feature_clip'      => 'jetpack_ai_feature_clip_enabled',
69        'ai_seo'            => 'jetpack_ai_seo_enabled',
70        'ai_search'         => 'jetpack_search_ai_answers_enabled',
71    );
72
73    /**
74     * Option defaults. The reused Search option keeps its established opt-in
75     * default; the new per-feature toggles default to on.
76     *
77     * @var array
78     */
79    const FEATURE_DEFAULTS = array(
80        'writing_assistant' => true,
81        'image_editor'      => true,
82        'feature_clip'      => true,
83        'ai_seo'            => true,
84        'ai_search'         => false,
85    );
86
87    /**
88     * Feature keys whose options this class registers and syncs (the reused
89     * Search option is registered by its owning surface).
90     *
91     * @var array
92     */
93    const OWNED_FEATURES = array( 'writing_assistant', 'image_editor', 'feature_clip', 'ai_seo' );
94
95    /**
96     * Whether init() has already run.
97     *
98     * @var bool
99     */
100    private static $initialized = false;
101
102    /**
103     * Hook everything up. Must run on every request (front-end, editor, REST):
104     * the filters attached here gate feature loading.
105     *
106     * @return void
107     */
108    public static function init() {
109        if ( self::$initialized ) {
110            return;
111        }
112        self::$initialized = true;
113
114        add_action( 'init', array( __CLASS__, 'register_settings' ) );
115        add_filter( 'jetpack_sync_options_whitelist', array( __CLASS__, 'add_sync_options_whitelist' ) );
116
117        // Plugin call sites use is_ai_enabled(), which applies gates 1 (host) and
118        // 3 (master) after the filter chain. This in-chain registration stays for
119        // the package consumers that cannot reference this plugin class
120        // (external-media, my-jetpack): there the gates keep their pre-helper,
121        // priority-10 behavior.
122        add_filter( 'jetpack_ai_enabled', array( __CLASS__, 'apply_master_gates' ) );
123
124        // AI surfaces that do not flow through jetpack_ai_enabled.
125        add_filter( 'jetpack_search_ai_answers_enabled', array( __CLASS__, 'apply_master_gates' ) );
126        add_filter( 'jetpack_ai_sidebar_enabled', array( __CLASS__, 'apply_master_gates' ) );
127        add_filter( 'jetpack_ai_seo_enabled', array( __CLASS__, 'apply_master_gates' ) );
128    }
129
130    /**
131     * Register the master switch and the per-feature options this class owns.
132     *
133     * @return void
134     */
135    public static function register_settings() {
136        $show_in_rest = ! ( new Host() )->is_wpcom_simple();
137
138        $options = array(
139            self::MASTER_OPTION                        => __( 'Whether Jetpack AI is enabled on this site.', 'jetpack' ),
140            self::FEATURE_OPTIONS['writing_assistant'] => __( 'Whether the Jetpack AI writing assistant is enabled.', 'jetpack' ),
141            self::FEATURE_OPTIONS['image_editor']      => __( 'Whether the Jetpack AI image editor is enabled.', 'jetpack' ),
142            self::FEATURE_OPTIONS['feature_clip']      => __( 'Whether Jetpack AI video clip generation is enabled.', 'jetpack' ),
143            self::FEATURE_OPTIONS['ai_seo']            => __( 'Whether the Jetpack AI SEO features are enabled.', 'jetpack' ),
144        );
145
146        // These settings do not belong to Settings > General. A separate group
147        // prevents options.php from clearing values whose fields are absent from
148        // the General form.
149        foreach ( $options as $option => $description ) {
150            register_setting(
151                'jetpack_ai',
152                $option,
153                array(
154                    'type'              => 'boolean',
155                    'description'       => $description,
156                    'sanitize_callback' => 'rest_sanitize_boolean',
157                    // The master option is never exposed over core settings REST:
158                    // off-Simple the `ai` module is the master and the option only
159                    // holds the legacy pre-module opt-out (a core-REST write would
160                    // clobber it without touching the real master); on Simple the
161                    // dedicated feature-settings endpoint is the writable surface.
162                    'show_in_rest'      => self::MASTER_OPTION === $option ? false : $show_in_rest,
163                    'default'           => true,
164                )
165            );
166        }
167    }
168
169    /**
170     * Add the per-feature AI options to Jetpack Sync's option whitelist.
171     *
172     * Atomic and self-hosted sites write these locally; syncing them lets
173     * WordPress.com (Calypso, the multi-site dashboard) read toggle state and
174     * is the prerequisite for mirroring the dashboard AI toggle later.
175     *
176     * The master switch is deliberately absent: off-Simple the `ai` module is
177     * the master, and module state already reaches WordPress.com through the
178     * synced `active_modules` callable — syncing the option as well would add
179     * a second, driftable source of truth for the same bit.
180     *
181     * @param array $options Option names allowed to sync.
182     * @return array Updated option names.
183     */
184    public static function add_sync_options_whitelist( $options ) {
185        $options = (array) $options;
186        foreach ( self::OWNED_FEATURES as $feature ) {
187            $options[] = self::FEATURE_OPTIONS[ $feature ];
188        }
189        return array_values( array_unique( $options ) );
190    }
191
192    /**
193     * Fold the host (gate 1) and master switch (gate 3) into an AI enabled filter.
194     *
195     * Restrictive-only on purpose: `jetpack_ai_enabled` is applied with different
196     * defaults at different call sites (Jetpack_AI_Helper passes false on plain
197     * self-hosted sites; the editor extension hub passes true), so this callback
198     * may only ever turn a yes into a no — returning the option value directly
199     * would flip self-hosted defaults to enabled.
200     *
201     * @param bool $enabled The value the call site computed so far.
202     * @return bool
203     */
204    public static function apply_master_gates( $enabled ) {
205        return (bool) $enabled
206            && self::host_allows_ai()
207            && ( ! self::should_enforce_ai_controls() || self::is_master_enabled() );
208    }
209
210    /**
211     * Whether the AI controls — the master switch and the toggles this class owns
212     * — take effect here. Simple keeps its existing option contract, self-hosted
213     * sites use the Jetpack controls, and Atomic remains limited to internal testing.
214     *
215     * @return bool
216     */
217    private static function should_enforce_ai_controls() {
218        $host = new Host();
219        if ( $host->is_wpcom_simple() ) {
220            return true;
221        }
222
223        return ! $host->is_woa_site()
224            || ( function_exists( 'jetpack_is_internal_testing_environment' ) && jetpack_is_internal_testing_environment() );
225    }
226
227    /**
228     * Whether Jetpack AI is enabled on this site, with the host (gate 1) and
229     * master switch (gate 3) as final, non-overridable checks.
230     *
231     * Runs the `jetpack_ai_enabled` filter with the call site's default — the
232     * chain may still enable or disable as before — then ANDs the host and
233     * master gates after it, so no late-priority callback can turn AI back on
234     * once either gate says no. Plugin call sites use this helper; the filter
235     * registration in init() stays for the package consumers that cannot
236     * reference this class.
237     *
238     * @since 16.2
239     *
240     * @param bool $default The call site's computed default. Defaults differ
241     *                      between call sites — see apply_master_gates().
242     * @return bool
243     */
244    public static function is_ai_enabled( $default = true ) {
245        /**
246         * Filter whether the AI features are enabled in the Jetpack plugin.
247         *
248         * @since 11.8
249         *
250         * @param bool $default Are AI features enabled? The default varies by call site.
251         */
252        $enabled = (bool) apply_filters( 'jetpack_ai_enabled', $default );
253
254        return self::apply_master_gates( $enabled );
255    }
256
257    /**
258     * Gate 1: whether the host allows AI at all.
259     *
260     * Defers to core's wp_supports_ai(), which is backed by the WP_AI_SUPPORT
261     * constant and its own filter. This is a server-owner decision: when it is
262     * off, no AI settings should be shown and no upgrade should ever be offered.
263     *
264     * @return bool
265     */
266    public static function host_allows_ai() {
267        return wp_supports_ai();
268    }
269
270    /**
271     * Gate 3: whether the site-wide AI master switch is on.
272     *
273     * The master lives in a different place depending on the platform. On
274     * WordPress.com Simple no Jetpack modules run, so the `jetpack_ai_enabled`
275     * option is the master. Everywhere else (self-hosted and Atomic) the `ai`
276     * module is the real master switch, toggled through the standard Jetpack
277     * module machinery; there the option only carries the legacy pre-module
278     * value the one-time opt-out migration reads, and is never written again.
279     *
280     * @return bool
281     */
282    public static function is_master_enabled() {
283        if ( ( new Host() )->is_wpcom_simple() ) {
284            return (bool) get_option( self::MASTER_OPTION, true );
285        }
286
287        return ( new Modules() )->is_active( self::AI_MODULE );
288    }
289
290    /**
291     * Whether a filter, such as a module allowlist, keeps the `ai` module off,
292     * so no switch on this site can turn it on. Always false on WordPress.com
293     * Simple, which runs no modules.
294     *
295     * @return bool
296     */
297    public static function is_master_forced_off() {
298        if ( ( new Host() )->is_wpcom_simple() ) {
299            return false;
300        }
301
302        if ( self::is_master_enabled() ) {
303            return false;
304        }
305
306        // Removed from the available list by `jetpack_get_available_modules`.
307        if ( ! in_array( self::AI_MODULE, ( new Modules() )->get_available(), true ) ) {
308            return true;
309        }
310
311        // Forced off through `option_jetpack_active_modules` or `jetpack_active_modules`.
312        return class_exists( 'Jetpack_Modules_Overrides' )
313            && 'inactive' === Jetpack_Modules_Overrides::instance()->get_module_override( self::AI_MODULE );
314    }
315
316    /**
317     * Set the site-wide AI master switch, writing to whichever store backs it on
318     * this platform (see {@see self::is_master_enabled()}).
319     *
320     * On WordPress.com Simple the `jetpack_ai_enabled` option is the master, so
321     * we update it. Off-Simple the `ai` module is the master, so we activate or
322     * deactivate it. The no-exit / no-redirect arguments are passed to
323     * `Modules::update_status()` so this is safe to call outside a request that
324     * expects to terminate (REST handlers, migrations, CLI).
325     *
326     * @param bool $enabled Whether AI should be enabled site-wide.
327     * @return void
328     */
329    public static function set_master_enabled( bool $enabled ) {
330        if ( ( new Host() )->is_wpcom_simple() ) {
331            update_option( self::MASTER_OPTION, $enabled );
332            return;
333        }
334
335        // The module alone is the master off-Simple. The option is deliberately NOT
336        // written here: WordPress.com derives the master state from the synced
337        // `active_modules` callable, and the stored option must keep its legacy
338        // pre-module value so Jetpack::reconcile_ai_master_optout() can read an
339        // explicit opt-out on sites that upgrade later.
340        ( new Modules() )->update_status( self::AI_MODULE, $enabled, false, false );
341    }
342
343    /**
344     * Gate 4: whether an individual feature's switch is on.
345     *
346     * Checks only the feature's own toggle — callers remain responsible for the
347     * outer gates (most already consult the jetpack_ai_enabled filter, which
348     * carries host + master). Only the matching option is read: a code-level
349     * override belongs on the option itself, through core's own option filters.
350     *
351     * Not {@see self::is_ai_seo_enabled()}, which is this check for the `ai_seo`
352     * key plus its filter and the site-wide gates. Use that one at load points.
353     *
354     * @param string $feature Feature key (see FEATURE_OPTIONS).
355     * @return bool False for unknown features.
356     */
357    public static function is_feature_enabled( $feature ) {
358        if ( ! isset( self::FEATURE_OPTIONS[ $feature ] ) ) {
359            return false;
360        }
361
362        // The toggles this class owns stay on wherever they do not apply: Simple keeps
363        // the existing wp.com settings contract, while Atomic keeps them hidden.
364        // The reused Search option has its own settings surface, so it always honors
365        // its stored value.
366        if ( in_array( $feature, self::OWNED_FEATURES, true )
367            && ( ( new Host() )->is_wpcom_simple() || ! self::should_enforce_ai_controls() ) ) {
368            return true;
369        }
370
371        $option = self::FEATURE_OPTIONS[ $feature ];
372
373        return (bool) get_option( $option, self::FEATURE_DEFAULTS[ $feature ] );
374    }
375
376    /**
377     * Whether the AI SEO feature (metadata generation, manual and automatic)
378     * is effectively enabled: its own toggle (gate 4) through the filter, with
379     * the host and master gates ANDed after the chain so no late-priority
380     * callback can turn the feature back on — same finality as is_ai_enabled().
381     *
382     * Not {@see self::is_feature_enabled()} with `ai_seo`, which is the stored
383     * toggle alone. This is the one load points and payloads should read.
384     *
385     * @since 16.2
386     *
387     * @return bool
388     */
389    public static function is_ai_seo_enabled() {
390        /**
391         * Filter whether the Jetpack AI SEO feature is enabled.
392         *
393         * @since 16.2
394         *
395         * @param bool $enabled Whether the SEO feature toggle is on.
396         */
397        $enabled = (bool) apply_filters( 'jetpack_ai_seo_enabled', self::is_feature_enabled( 'ai_seo' ) );
398
399        return self::apply_master_gates( $enabled );
400    }
401}
402
403// Self-initialize on load. The consuming AI extension files require this file
404// directly (__DIR__-relative) because on WordPress.com Simple the plugin's
405// extension files load through wpcom's own loader and load-jetpack.php never
406// runs. This keeps filter registration identical in both bootstrap paths.
407Jetpack_AI_Settings::init();