0 || aafm_agent_call_count() > 0; } /** * Whether the wizard should render on the plugin page for the current request. * * True only for a capable admin who has neither finished it, nor permanently dismissed it, nor * already set the plugin up some other way. A manual close still leaves both flags untouched, so on * a genuinely untouched site the wizard does reopen, which is the point of a first-run screen. * * @return bool */ function aafm_quickconnect_should_render(): bool { return current_user_can( 'manage_options' ) && ! aafm_quickconnect_is_finished() && ! aafm_quickconnect_is_dismissed() && ! aafm_quickconnect_site_looks_configured(); } /** * Native subjects whose read-only abilities make up the wizard's "Read" bundle. * * Deliberately a safe, defensible subset of the full catalog: core site info plus content, media, * and taxonomy READS. It excludes the two native subjects that carry personal data - users and * comments - and every third-party integration (SEO, WooCommerce, ACF, which all declare their own * non-native subjects), so the wizard never turns on a personal-data or integration read. Those are * deferred to the full Abilities and Integrations tabs behind the wizard's "Settings" link. * * @return list */ function aafm_quickconnect_read_subjects(): array { return array( 'content', 'media', 'taxonomies', 'site' ); } /** * The ability names the wizard's "Read content" row turns on: every read-only ability in the * read subjects that exists in the live registry. * * Derived from the registry (not a hardcoded list) so a newly added native read is covered * automatically, and intersected with the live host-gated registry so only real, registered names * are ever written to the enabled-abilities option. * * @return list Ability names, in registry order. */ function aafm_quickconnect_read_abilities(): array { $subjects = aafm_quickconnect_read_subjects(); $out = array(); foreach ( aafm_get_abilities_registry() as $name => $def ) { $subject = isset( $def['subject'] ) ? (string) $def['subject'] : ''; $group = isset( $def['group'] ) ? (string) $def['group'] : ''; // Playbooks stay off until the owner turns them on, so the wizard never enables them. if ( in_array( $name, array( 'aafm/list-playbooks', 'aafm/get-playbook' ), true ) ) { continue; } if ( 'reads' === $group && in_array( $subject, $subjects, true ) ) { $out[] = (string) $name; } } return $out; } /** * The ability names the wizard's optional "Create and edit content" (write) row turns on: content * writes only, with every destructive ability excluded. * * The filter is subject === 'content' AND group === 'writes' AND risk !== 'destructive'. That yields * the create/edit surface (create draft/post/page, update post/page, replace-in-post, create/update * content item, and the content-scoped meta/revision writes) and can never include a trash or delete * ability, because those are the ones marked 'destructive' in the registry. No media, taxonomy, user, * comment, or integration write is in this bundle - all of those live behind the wizard's Settings * link. This is the defensible write subset the sprint locked; the WizardAbilitiesTest pins that no * destructive ability leaks in. * * @return list Ability names, in registry order. */ function aafm_quickconnect_write_abilities(): array { $out = array(); foreach ( aafm_get_abilities_registry() as $name => $def ) { $subject = isset( $def['subject'] ) ? (string) $def['subject'] : ''; $group = isset( $def['group'] ) ? (string) $def['group'] : ''; $risk = isset( $def['risk'] ) ? (string) $def['risk'] : ''; if ( 'content' === $subject && 'writes' === $group && 'destructive' !== $risk ) { $out[] = (string) $name; } } return $out; } /** * Apply the wizard's content-access choice to the enabled-abilities option, and set the site's * read-only mode to match it. * * The Read bundle is always turned on (the wizard's Read row is on by default and cannot be turned * off there). The Write bundle is turned on when $write is true and turned off when it is false, so * a run of the wizard is authoritative for its own two rows. Any other enabled ability the operator * set elsewhere (the full Abilities tab, an integration) is preserved untouched. The result is * intersected with the live registry so no stale name is ever written. * * The mode is what makes the wizard's promise durable. Leaving the write row off used to be a * one-time set of ticks that eroded the moment anyone switched a single write on somewhere else; * setting the mode instead means the read-only posture the operator chose on day one is still the * posture in month six. Turning the write row ON has to clear the mode in the same breath, or the * wizard would enable a write bundle that the floor then refuses to register - a screen saying yes * over a server saying no. * * The mode is set FIRST, before the option is written, for two reasons: the enabled list is read * raw below so the floor cannot subtract the operator's existing writes out of their own option, * and aafm_set_enabled_abilities() refuses a write that is not already stored while the mode is on, * which would otherwise swallow the write bundle this call is trying to enable. * * The return value is whether BOTH the read-only-mode switch and the enabled-abilities write * actually persisted, so the caller can answer the operator honestly instead of reporting success * over a write that silently stuck (see aafm_persist_operator_switch() and * aafm_update_option_verified() for why a stale persistent object cache can do this to either one * independently of the other). The two optional by-reference out params expose which of the two * failed, so the caller can name the actual cause in its error message rather than a generic one. * * @param bool $write Whether the optional write bundle should be enabled. * @param bool|null $read_only_persisted Out param: true when read-only mode reads back as requested. * @param bool|null $abilities_persisted Out param: true when the enabled-abilities write verified. * @param-out bool $read_only_persisted * @param-out bool $abilities_persisted * @return bool True only when both the read-only-mode switch and the enabled-abilities write * actually persisted. */ function aafm_quickconnect_apply_abilities( bool $write, ?bool &$read_only_persisted = null, ?bool &$abilities_persisted = null ): bool { $read_set = aafm_quickconnect_read_abilities(); $write_set = aafm_quickconnect_write_abilities(); $read_only_before = (bool) get_option( 'aafm_read_only_mode', false ); $read_only_persisted = aafm_set_read_only_mode( ! $write ); aafm_log_read_only_switch_change( $read_only_before, ! $write, $read_only_persisted ); // The RAW stored list, not aafm_get_enabled_abilities(): with read-only mode already on from a // previous run, the floored reader would hand back reads only and every write the operator had // chosen elsewhere would be dropped from their own option by a wizard finish. $current = aafm_get_stored_enabled_abilities_raw(); // Start from what is already enabled, then turn the Read bundle on unconditionally. $enabled = array_values( array_unique( array_merge( $current, $read_set ) ) ); if ( $write ) { $enabled = array_values( array_unique( array_merge( $enabled, $write_set ) ) ); } else { // The wizard owns its write row: unticking it removes exactly the write bundle. $enabled = array_values( array_diff( $enabled, $write_set ) ); } // Only persist names that still exist in the full catalog. The host-gated registry would drop // a saved tool whose plugin is inactive right now, the same rule the Abilities tab save keeps. $known = array_keys( aafm_get_abilities_registry_full() ); $enabled = array_values( array_intersect( $enabled, $known ) ); // aafm_set_enabled_abilities() is the single choke point for this option: it strips any // newly-attempted locked name before it persists. The wizard's read/write bundles never // include one by construction (content-subject, non-destructive abilities only), so this is // a defensive pass-through rather than something expected to fire here. // // $enabled_final is the INTENDED set aafm_set_enabled_abilities() always returns, not proof the // database agrees with it - $abilities_persisted (the by-reference out param) is that proof. // The toggle diff is logged exactly like the main save path - "when did this become // reachable, and who made it so" - but only once the write is known to have actually taken; // logging it unconditionally would leave a success-style row for a change a stale persistent // object cache silently swallowed. $current is the raw stored list read above, before any of // this function's writes. $enabled_final = aafm_set_enabled_abilities( $enabled, $abilities_persisted ); if ( $abilities_persisted ) { aafm_log_ability_toggle_diff( $current, $enabled_final ); } else { aafm_log_ability_persist_failure( 'aafm_enabled_abilities', __( 'The enabled abilities selection', 'agent-abilities-for-mcp' ) ); } return $read_only_persisted && $abilities_persisted; } /** * AJAX: enable or disable OAuth from the wizard's connection step. * * This is the ONLY path by which the wizard writes aafm_oauth_enabled, and it runs solely on an * explicit operator action (clicking "Continue" past the connection step). Rendering the wizard * never touches the option, so a new install keeps OAuth off until the operator actively proceeds - * the 1.3.0 off-by-default posture is preserved. Nonce + manage_options gated. * * Dynamic Client Registration is on by default and has its own Settings toggle, so the wizard does * not write it here: turning OAuth on is enough to leave a state a real MCP client can connect to, * because DCR is already on unless the operator deliberately turned it off. This is what fixes the * #90 dead end, where a user who set OAuth up through the wizard (its copy says this "is what lets * ChatGPT, Claude, and Manus connect") ended up connectable in name only because DCR defaulted off. * The response reports the current DCR state so the UI can reflect it, and the wizard never * overrides an operator who chose to switch DCR off. * * @return void */ function aafm_ajax_quickconnect_oauth(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $enabled = ! empty( $_POST['enabled'] ) ? '1' : '0'; if ( ! aafm_update_option_verified( 'aafm_oauth_enabled', $enabled ) ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'Enable OAuth', 'agent-abilities-for-mcp' ) ) ) ); } wp_send_json_success( array( 'aafm_oauth_enabled' => $enabled, // The current DCR toggle state (on by default), reported so the UI can reflect it. 'aafm_oauth_dcr_enabled' => aafm_oauth_dcr_enabled() ? '1' : '0', ) ); } /** * AJAX: complete the wizard. * * Applies the content-access choice (Read always, Write per the posted flag), then records the * permanent completion flag so the wizard does not reopen. Nonce + manage_options gated. Does not * touch OAuth - that is handled by aafm_ajax_quickconnect_oauth() on the connection step, so the * option is only ever written on the operator's explicit connection action. * * @return void */ function aafm_ajax_quickconnect_finish(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified above. $write = ! empty( $_POST['write'] ); aafm_quickconnect_apply_abilities( $write, $read_only_persisted, $abilities_persisted ); // Mirrors aafm_ajax_save_settings(): a wizard finish that reports success while either the // read-only switch or the enabled-abilities write it just applied silently stuck is the same // silent-wrong-answer this exists to close - EITHER can fail independently under a stale // persistent object cache (aafm_quickconnect_apply_abilities()'s docblock). The completion flag // is written only once both checks have passed - written first, it would permanently suppress // the wizard on the next page load even though this exact request is about to report an error, // so a reload would show the site as "finished" over a setup that never actually completed. if ( ! $read_only_persisted ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'Read-only mode', 'agent-abilities-for-mcp' ) ) ) ); } if ( ! $abilities_persisted ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'The enabled abilities selection', 'agent-abilities-for-mcp' ) ) ) ); } // The default access for new connections, posted-only: a finish without the field leaves it alone, // and an unknown value is ignored rather than failing the rest of the finish. // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce verified above; matched against the preset list. $preset_raw = isset( $_POST['preset'] ) ? wp_unslash( $_POST['preset'] ) : null; $preset = is_string( $preset_raw ) && isset( aafm_access_presets()[ $preset_raw ] ) ? aafm_preset_normalize( $preset_raw ) : null; if ( null !== $preset && aafm_default_preset() !== $preset ) { if ( ! aafm_update_option_verified( 'aafm_oauth_default_preset', '' === $preset ? 'custom' : $preset ) ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'The default access for new connections', 'agent-abilities-for-mcp' ) ) ) ); } aafm_log_setting_change( 'aafm/default-preset', __( 'Default access for new connections changed', 'agent-abilities-for-mcp' ) ); } if ( ! aafm_update_option_verified( 'aafm_quickconnect_finished', '1' ) ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'Finishing the wizard', 'agent-abilities-for-mcp' ) ) ) ); } wp_send_json_success( array( 'write' => $write ? 1 : 0, 'enabled_count' => count( aafm_get_enabled_abilities() ), 'oauth_enabled' => aafm_oauth_enabled() ? 1 : 0, // Reported so the success receipt can reflect the real connectable state: OAuth without // DCR is not a state a self-registering MCP client can connect to (issue #90). 'dcr_enabled' => aafm_oauth_dcr_enabled() ? 1 : 0, ) ); } /** * AJAX: permanently dismiss the wizard ("Don't show this again"). * * Sets the dismissed flag so aafm_quickconnect_should_render() returns false from now on, giving an * operator who never wants the wizard a real opt-out. Nonce + manage_options gated. * * @return void */ function aafm_ajax_quickconnect_dismiss(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } if ( ! aafm_update_option_verified( 'aafm_quickconnect_dismissed', '1' ) ) { wp_send_json_error( array( 'message' => aafm_switch_not_persisted_message( __( 'Dismissing the wizard', 'agent-abilities-for-mcp' ) ) ) ); } wp_send_json_success(); } /** * Render the Quick Connect wizard markup on the plugin page. * * Called from aafm_render_admin_page(); it returns immediately unless the wizard is due. The markup * is a WP-admin-native modal (scrim + dialog) styled with the plugin's own admin tokens, carrying * the live endpoint URL, a copy control that stays inert until OAuth is on, the app-password path * (a dedicated agent user + a pre-filled client config), a Read row (on) and an optional Write row * (amber, off), and a success receipt. The behaviour lives in admin.js (#bindQuickConnect). * * @return void */ function aafm_quickconnect_render(): void { if ( ! aafm_quickconnect_should_render() ) { return; } $endpoint = aafm_endpoint_url(); $login = aafm_quickconnect_agent_login(); $snippet = aafm_client_snippet( 'generic', $login ); $profile = admin_url( 'profile.php#application-passwords-section' ); $settings = add_query_arg( array( 'page' => 'agent-abilities-for-mcp', 'tab' => 'abilities', ), admin_url( 'admin.php' ) ); // The shield-with-check brand mark, matched to assets/wp-admin-icon.svg geometry, accent blue. $brandmark = ''; ?>