fix: settings compare against the sanitized value, fail via WriteFailed

- isStored() compares the stored option with sanitize_option() of the new value
  (core esc_html()s blogname/blogdescription): setting "Müller & Söhne" again
  no longer looks like a failed write; lit/wp-settings-get and previous return
  those two decoded, as the user would type them.
- The change log runs as FollowUp (warning on failure); a failed write throws
  WriteFailed (write_failed / partial_write with keys), partial writes are
  logged with result partial.
- permalink_structure needs a real %tag% (WordPress' own /%[^/%]+%/ rule).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 01:26:11 +02:00
co-authored by Claude Opus 5.5
parent d67a1d4fb3
commit ff0da9f406
4 changed files with 170 additions and 48 deletions
@@ -39,9 +39,17 @@ final class SettingsWhitelist
private const INTEGER_KEYS = ['page_on_front', 'page_for_posts', 'posts_per_page', 'site_icon'];
/** Stored esc_html()'d by sanitize_option(); read back as the user would type them. */
private const HTML_ESCAPED_KEYS = ['blogname', 'blogdescription'];
/** The value as a client should see and send it (not WordPress' storage form). */
public function current(string $key): mixed
{
$value = get_option($key);
if (in_array($key, self::HTML_ESCAPED_KEYS, true)) {
return wp_specialchars_decode((string) $value, ENT_QUOTES);
}
if ($key === 'WPLANG') {
return $value === '' || $value === false ? 'en_US' : (string) $value;
}
@@ -201,10 +209,11 @@ final class SettingsWhitelist
return null;
}
// WordPress' own rule for a structure tag (wp-admin/options-permalink.php): /%[^\/%]+%/.
if (
is_string($value)
&& str_starts_with($value, '/')
&& str_contains($value, '%')
&& preg_match('/%[^\/%]+%/', $value) === 1
&& mb_strlen($value) <= 200
&& preg_match('/\s/', $value) !== 1
) {
@@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\WordPress\Settings;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use RuntimeException;
use Throwable;
@@ -126,8 +128,10 @@ final class WpSettingsUpdateAbility implements Ability
* previous values so the call stays all-or-nothing.
*
* @param array<string, mixed> $settings
* @return array<string, mixed>
* @throws WriteFailed When a write fails (after restoring what it could).
*/
private function write(array $settings): array|WP_Error
private function write(array $settings): array
{
$previous = [];
foreach ($settings as $key => $value) {
@@ -146,7 +150,7 @@ final class WpSettingsUpdateAbility implements Ability
$changed[] = $key;
}
} catch (Throwable $e) {
return $this->fail($changed, $previous, $e);
$this->fail($changed, $previous, $e);
}
if ($changed === []) {
@@ -155,15 +159,26 @@ final class WpSettingsUpdateAbility implements Ability
->toArray();
}
// The settings are saved – a failure of the change log from here on is a warning only.
$summary = 'Einstellungen geändert: ' . implode(', ', $changed);
$logId = $this->log->record($this->name(), 'option', implode(',', $changed), $summary);
$warnings = [];
$logId = FollowUp::run(
fn () => $this->log->record($this->name(), 'option', implode(',', $changed), $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; die Einstellungen sind gespeichert.',
$warnings
);
$result = ToolResult::ok($summary . '.');
foreach ($changed as $key) {
$result->changed($key, 'option', 'updated', SettingsWhitelist::SETTINGS[$key][2]);
}
return $result->logId($logId)->with('previous', JsonMap::of($previous))->toArray();
return $result
->logId(is_int($logId) ? $logId : null)
->warnings($warnings)
->with('previous', JsonMap::of($previous))
->toArray();
}
private function writeOne(string $key, mixed $value): void
@@ -185,26 +200,32 @@ final class WpSettingsUpdateAbility implements Ability
}
}
/** Whether option $key already holds $normalized, compared like WordPress compares scalars. */
/**
* Whether option $key already holds $normalized in the form WordPress stores it:
* update_option() runs sanitize_option() first (blogname/blogdescription are esc_html()'d),
* so the stored value is compared with the sanitized one, like WordPress compares scalars.
*/
private function isStored(string $key, mixed $normalized): bool
{
$stored = get_option($key);
if (is_scalar($stored) && is_scalar($normalized)) {
return (string) $stored === (string) $normalized;
$expected = sanitize_option($key, $normalized);
if (is_scalar($stored) && is_scalar($expected)) {
return (string) $stored === (string) $expected;
}
return $stored == $normalized; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
return $stored == $expected; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
}
/**
* A write failed; restores the keys already written to their previous values and
* reports write_failed (fully restored) or partial_write (some keys could not be
* restored – the client is told which, so it can check and correct them).
* A write failed; restores the keys already written to their previous values and throws
* WriteFailed: write_failed (fully restored) or partial_write (some keys could not be
* restored – the client is told which, so it can check and correct them; logged as partial).
*
* @param list<string> $changed Keys written so far, in write order.
* @param array<string, mixed> $previous Their values before this call.
* @throws WriteFailed Always.
*/
private function fail(array $changed, array $previous, Throwable $cause): WP_Error
private function fail(array $changed, array $previous, Throwable $cause): never
{
$stuck = [];
foreach (array_reverse($changed) as $key) {
@@ -215,23 +236,27 @@ final class WpSettingsUpdateAbility implements Ability
}
}
error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
sprintf('[wp-lit-mcp] wp-settings-update failed: %s', $cause)
);
$reason = 'Eine Einstellung konnte nicht gespeichert werden.';
if ($stuck === []) {
return Errors::writeFailed('Eine Einstellung konnte nicht gespeichert werden.');
throw new WriteFailed($reason, true, null, null, $cause);
}
return Errors::partialWrite(
'Eine Einstellung konnte nicht gespeichert werden.',
$this->log->recordPartial(
$this->name(),
'option',
implode(',', $stuck),
'Einstellungen nur teilweise zurückgenommen: ' . implode(', ', $stuck)
);
throw new WriteFailed(
$reason,
false,
null,
null,
sprintf(
'Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.',
implode(', ', $stuck)
),
'Aktuelle Werte über lit-wp-settings-get prüfen und ggf. erneut auf die gewünschten Werte setzen.'
$cause,
$cause->getMessage() . '; not restored: ' . implode(', ', $stuck),
sprintf('Möglicherweise auf dem neuen, nicht vollständigen Stand: %s.', implode(', ', $stuck)),
'Aktuelle Werte über lit/wp-settings-get prüfen und ggf. erneut auf die gewünschten Werte setzen.',
['keys' => $stuck]
);
}