| 1 |
<?php |
| 2 |
/** |
| 3 |
* RELEASE GUARD — AI Tools default-off invariant. |
| 4 |
* |
| 5 |
* AI Tools (function calling) MUST default to ZERO active tools on a fresh |
| 6 |
* install. A site that has never configured the AI Tools section has an empty |
| 7 |
* mxchat_function_calling_tools map, and that MUST resolve to BOTH: |
| 8 |
* (a) 0 enabled tools in available_tools(), AND |
| 9 |
* (b) MxChat_Tool_Registry::is_enabled() === false |
| 10 |
* These are SEPARATE code paths and CAN diverge — see "why both halves" below. |
| 11 |
* |
| 12 |
* Run before releasing mxchat-basic, or after ANY change to |
| 13 |
* class-mxchat-tool-registry.php or the function-calling defaults: |
| 14 |
* |
| 15 |
* wp eval-file wp-content/plugins/mxchat-basic/tests/verify-aitools-default-off.php |
| 16 |
* |
| 17 |
* A build that prints FAIL is NOT releasable. |
| 18 |
* |
| 19 |
* --------------------------------------------------------------------------- |
| 20 |
* Why both halves (plan-mxchat-20260716-71525f) |
| 21 |
* |
| 22 |
* The 3.2.10 regression: resolve_tool_setting() defaulted an unconfigured tool |
| 23 |
* to !$is_cautious (every non-sensitive tool ON), so a fresh install rendered |
| 24 |
* ~10 "active" tools and was one accidental save from turning function calling |
| 25 |
* fully on. The master gate happened to read the raw (empty) map, so the loop |
| 26 |
* was NOT firing — the UI was wrong while the gate was right. That is the proof |
| 27 |
* these two halves diverge: is_enabled() -> count_enabled(enabled_map()) is a |
| 28 |
* different path from the count check -> available_tools($map). This guard |
| 29 |
* previously tested only the second one and printed "PASS ... releasable" |
| 30 |
* having never exercised the half that decides whether the loop runs at all. |
| 31 |
* |
| 32 |
* Why this script does NOT write the option |
| 33 |
* |
| 34 |
* is_enabled() takes NO ARGUMENTS — it reads the live option, so is_enabled(array()) |
| 35 |
* silently ignores the argument and reports on whatever the install happens to |
| 36 |
* have. The obvious way to test it is to empty the real option and restore it, |
| 37 |
* but that makes a release guard into a mutating script (an aborted run could |
| 38 |
* leave a site's tools wiped, and it would be dangerous on prod). enabled_map() |
| 39 |
* reads through get_option(), so a read-time filter substitutes the map for the |
| 40 |
* duration of one call WITHOUT touching the database. Nothing is written; there |
| 41 |
* is nothing to restore; it is safe on any install, including production. |
| 42 |
* |
| 43 |
* Why `pre_option_` and NOT `option_` (plan-mxchat-20260717-bc8bba) |
| 44 |
* |
| 45 |
* get_option() applies `option_{$name}` ONLY when the option EXISTS in the |
| 46 |
* database; an absent option returns early through `default_option_{$name}` |
| 47 |
* and the `option_` filter never fires. On a genuinely fresh install — the |
| 48 |
* exact state this guard exists to assert — an `option_` substitution |
| 49 |
* silently no-ops: half 2 reads the real (absent) option and passes untested, |
| 50 |
* and the positive controls FAIL on a healthy build (the injected map never |
| 51 |
* arrives, so is_enabled() stays false). `pre_option_` fires unconditionally |
| 52 |
* and short-circuits get_option() on any non-false return — an empty array() |
| 53 |
* is not false, so even the empty-map substitution takes. Do NOT "simplify" |
| 54 |
* this back to `option_`; it only appears to work on installs where the |
| 55 |
* option already exists. |
| 56 |
* --------------------------------------------------------------------------- |
| 57 |
*/ |
| 58 |
|
| 59 |
// Never web-accessible: this file lives under wp-content/plugins and would |
| 60 |
// otherwise be reachable by URL. |
| 61 |
if (!defined('ABSPATH')) { |
| 62 |
exit; |
| 63 |
} |
| 64 |
|
| 65 |
if (!class_exists('MxChat_Tool_Registry')) { |
| 66 |
echo "FAIL: MxChat_Tool_Registry not loaded — is mxchat-basic active?\n"; |
| 67 |
return; |
| 68 |
} |
| 69 |
|
| 70 |
$opt = MxChat_Tool_Registry::OPT_TOOLS; |
| 71 |
$pass = true; |
| 72 |
|
| 73 |
/** Run $fn with the tools option filtered to $map. Reads only — no DB write. |
| 74 |
* pre_option_ (NOT option_): it fires even when the option is absent from the |
| 75 |
* DB, which is precisely the fresh-install state under test. See docblock. */ |
| 76 |
$with_map = function (array $map, callable $fn) use ($opt) { |
| 77 |
$sub = function () use ($map) { return $map; }; |
| 78 |
add_filter('pre_option_' . $opt, $sub, 99); |
| 79 |
try { |
| 80 |
return $fn(); |
| 81 |
} finally { |
| 82 |
remove_filter('pre_option_' . $opt, $sub, 99); |
| 83 |
} |
| 84 |
}; |
| 85 |
|
| 86 |
$count_enabled = function (array $tools) { |
| 87 |
return count(array_filter($tools, function ($t) { return !empty($t['enabled']); })); |
| 88 |
}; |
| 89 |
|
| 90 |
echo "Site: " . home_url() . "\n"; |
| 91 |
echo "Live option is READ-ONLY here — the map is substituted via a pre_option_ filter.\n\n"; |
| 92 |
|
| 93 |
/* ---- HALF 1: the empty map must resolve to 0 enabled tools ---- */ |
| 94 |
$fresh = MxChat_Tool_Registry::available_tools(array()); |
| 95 |
$fresh_enabled = $count_enabled($fresh); |
| 96 |
$ok = ($fresh_enabled === 0); |
| 97 |
$pass = $pass && $ok; |
| 98 |
printf("%s empty map -> %d of %d tools enabled (MUST be 0)\n", |
| 99 |
$ok ? 'PASS' : 'FAIL', $fresh_enabled, count($fresh)); |
| 100 |
|
| 101 |
/* ---- HALF 2: the empty map must leave the MASTER GATE off ---- */ |
| 102 |
/* This is the half the guard never tested. is_enabled() reads the live option, |
| 103 |
so the map has to be substituted for real — passing it an argument does nothing. */ |
| 104 |
$gate = $with_map(array(), function () { |
| 105 |
return MxChat_Tool_Registry::is_enabled(); |
| 106 |
}); |
| 107 |
$ok = ($gate === false); |
| 108 |
$pass = $pass && $ok; |
| 109 |
printf("%s empty map -> is_enabled() === %s (MUST be false)\n", |
| 110 |
$ok ? 'PASS' : 'FAIL', var_export($gate, true)); |
| 111 |
|
| 112 |
/* ---- POSITIVE CONTROLS ---- */ |
| 113 |
/* Without these, a hard-coded `return false` / `return 0` would pass everything |
| 114 |
above. A guard that can only ever say "off" is not a guard. */ |
| 115 |
$one = MxChat_Tool_Registry::available_tools(array('mxchat_handle_search_request' => array('enabled' => true))); |
| 116 |
$one_enabled = $count_enabled($one); |
| 117 |
$ok = ($one_enabled === 1); |
| 118 |
$pass = $pass && $ok; |
| 119 |
printf("%s one tool added -> %d enabled (MUST be 1 — positive control)\n", |
| 120 |
$ok ? 'PASS' : 'FAIL', $one_enabled); |
| 121 |
|
| 122 |
$gate_on = $with_map(array('mxchat_handle_search_request' => array('enabled' => true)), function () { |
| 123 |
return MxChat_Tool_Registry::is_enabled(); |
| 124 |
}); |
| 125 |
$ok = ($gate_on === true); |
| 126 |
$pass = $pass && $ok; |
| 127 |
printf("%s one tool added -> is_enabled() === %s (MUST be true — positive control)\n", |
| 128 |
$ok ? 'PASS' : 'FAIL', var_export($gate_on, true)); |
| 129 |
|
| 130 |
/* ---- Proof we never wrote anything ---- */ |
| 131 |
$live = get_option($opt, array()); |
| 132 |
printf("\nLive option untouched: %d tool row(s) still stored.\n", is_array($live) ? count($live) : 0); |
| 133 |
|
| 134 |
echo $pass |
| 135 |
? "PASS: AI Tools default-off invariant holds on BOTH halves — releasable.\n" |
| 136 |
: "FAIL: default-off invariant BROKEN — DO NOT release.\n"; |
| 137 |
|