From 3c0d19022528738d0280d8c86ad3048e69963112 Mon Sep 17 00:00:00 2001 From: Wppilot Date: Tue, 29 Sep 2026 23:18:39 +0500 Subject: [PATCH 01/23] WIP: add Runtime\unclaimed() for moved kits, stopped at usage limit (unreviewed, not tested) Kit runtime API 1.2: unclaimed() lets a kit stand aside for an ability name that Pro 1.10.0 (or a richer Pro version) registered first. Free phpunit and the kit gates passed on this change alone; none of the five moved kits exist yet. Co-Authored-By: Claude Opus 5.5 --- includes/kits/_runtime/runtime.php | 24 +++++++++++++++++++++++- tests/Unit/Kits/KitRuntimeTest.php | 5 +++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/includes/kits/_runtime/runtime.php b/includes/kits/_runtime/runtime.php index 959de61..4ab9481 100644 --- a/includes/kits/_runtime/runtime.php +++ b/includes/kits/_runtime/runtime.php @@ -26,8 +26,9 @@ * than loading code written against a different contract. * * 1.1 added require_profile() and the ProfileGate host interface. + * 1.2 added unclaimed(), for kits that carry an ability another plugin may already register. */ -const API_VERSION = '1.1'; +const API_VERSION = '1.2'; require_once __DIR__ . '/host.php'; require_once __DIR__ . '/ledger/post-partial.php'; @@ -61,6 +62,27 @@ function has_host(): bool } } +/** + * Whether this copy may register an ability under this name: false when something registered it + * first. + * + * Some kits carry abilities an older release of a companion plugin still registers itself under + * the same name — WPPilot 1.16.0 took the WooCommerce reads, the backup and security status + * reads, the basic per-post SEO and form reads and the scheduled audits from Pro 1.10.0, which + * keeps registering its own copies until its next release, and a richer Pro version of a write + * keeps its name on purpose. Registering a name twice is refused by core with a doing_it_wrong + * notice on every request, so the kit checks first and stands aside: the first registration + * wins, and on a WPPilot site that is Pro's, which loads its integrations at + * wp_abilities_api_init priority 10, ahead of the kit loader's 20. + * + * A kit that stands aside must also leave the name's ledger capture alone, since the ability that + * runs is not its own: call Ledger::capture_for() only inside the same check. + */ +function unclaimed(string $ability_name): bool +{ + return !wp_has_ability($ability_name); +} + /** * Whether a kit's `runtime` constraint accepts this runtime: same major, and a minor at least * the one it names. `^1.0` accepts 1.0 through 1.x. diff --git a/tests/Unit/Kits/KitRuntimeTest.php b/tests/Unit/Kits/KitRuntimeTest.php index d5be7b6..6e9a8b9 100644 --- a/tests/Unit/Kits/KitRuntimeTest.php +++ b/tests/Unit/Kits/KitRuntimeTest.php @@ -54,8 +54,9 @@ public static function constraints(): array { return [ 'same major, older minor' => ['^1.0', true], - 'same major and minor' => ['^1.1', true], - 'newer minor than the runtime' => ['^1.2', false], + 'same major, the previous minor' => ['^1.1', true], + 'same major and minor' => ['^1.2', true], + 'newer minor than the runtime' => ['^1.3', false], 'other major' => ['^2.0', false], 'no caret' => ['1.0', false], 'garbage' => ['latest', false], From b0dc46a1d20ecb6f3cd3a3a754633df47c6485fb Mon Sep 17 00:00:00 2001 From: Wppilot Date: Wed, 30 Sep 2026 03:24:54 +0500 Subject: [PATCH 02/23] Match generated client configs to the vendors' current docs Every client below was re-checked against its vendor's documentation on 2026-09-30; the source URL is cited next to each builder. - Kimi: target Kimi Code CLI (~/.kimi-code/mcp.json, /mcp-config, /mcp-config login) instead of the archived Python kimi-cli. - GitHub Copilot: emit Copilot CLI's format (mcpServers + tools, at ~/.copilot/mcp-config.json or .github/mcp.json); .github/copilot/mcp.json is undocumented and the CLI rejects "servers". Note that VS Code uses the VS Code tab and the cloud agent cannot use OAuth. - Devin Desktop: list ~/.config/devin/mcp_config.json with the Windsurf path. - Zed: native url/headers and url-only OAuth; drop "source": "custom". - Kilo Code: kilo.jsonc under "mcp" (OpenCode's shape). - OpenClaw: mcp.servers with transport "streamable-http". - Claude Desktop token entry: run through mcp-remote, since the config file is for local servers. - Native OAuth for Zed, Cline, Kilo, OpenCode, Amazon Q/Kiro, OpenClaw; OAuth tabs for Kimi, Qwen, ZCode, Factory Droid, OpenClaw (Gemini CLI locally). - Manus accepts an access token. - Fix the Claude Code "needs authentication" note, the Antigravity IDE hint and the Roo Code migration targets. Shared config paths now live in one helper per client, and ClientConfigsTest pins each shape. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.txt | 4 + includes/admin/connect-methods.php | 258 ++++++++++++++++--- includes/admin/connect/client-configs.php | 188 +++++++++++--- includes/admin/connect/oauth-panel.php | 12 +- includes/admin/connect/token-configs.php | 290 +++++++++++++++++----- includes/admin/connect/web-apps.php | 2 +- includes/clients.php | 169 +++++++++---- readme.txt | 4 +- tests/Unit/ClientConfigsTest.php | 285 +++++++++++++++++++++ tests/doubles/connect-environment.php | 21 ++ 10 files changed, 1052 insertions(+), 181 deletions(-) create mode 100644 tests/Unit/ClientConfigsTest.php create mode 100644 tests/doubles/connect-environment.php diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 336f5cd..d556646 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,7 @@ +Unreleased +* Fixed: generated client configuration that no longer matched the clients' own documentation (each re-checked against the vendor's docs on 2026-09-30, with the source cited in the code). Kimi now targets Kimi Code CLI (~/.kimi-code/mcp.json, /mcp-config, and /mcp-config login for OAuth) instead of the archived Python kimi-cli. GitHub Copilot snippets are for Copilot CLI (~/.copilot/mcp-config.json or .github/mcp.json, "mcpServers" with "tools"), since .github/copilot/mcp.json is not a documented location and Copilot CLI rejects the "servers" key; the notes say Copilot Chat in VS Code uses the VS Code tab and the cloud agent cannot use OAuth. Devin Desktop lists ~/.config/devin/mcp_config.json alongside the old Windsurf path. Kilo Code entries go in kilo.jsonc under "mcp". OpenClaw's token entry uses mcp.servers with transport "streamable-http" (it fell back to SSE before). Zed connects natively instead of through mcp-remote, without the retired "source" field. Claude Desktop's access-token entry runs through the mcp-remote bridge, because claude_desktop_config.json is for local servers. The Claude Code note no longer says /mcp shows a working token as "needs authentication", the Antigravity IDE hint names the IDE's own menu, and Roo Code points to Cline or ZooCode. +* New: native OAuth entries for Zed, Cline, Kilo Code, OpenCode, Amazon Q / Kiro and OpenClaw, which now run the sign-in themselves (only Roo Code still uses the mcp-remote bridge on a public site), and OAuth tabs for Kimi Code CLI, Qwen Code, ZCode, Factory Droid and OpenClaw, which had none (Gemini CLI gets one on a local site; its public-site entry is still to come). Manus can take an access token, as its documentation lists Bearer tokens for custom servers. + v1.15.1 - 2026/09/29 * Fixed: with Wordfence active, every MCP call made with an Application Password returned 401 and nothing said why. Wordfence switches Application Passwords off by default (its "Disable WordPress application passwords" setting), and the Connection Doctor, Diagnostics and the Connect screen only said "a plugin or code". They now name Wordfence and the exact setting to uncheck, name any other plugin that switches Application Passwords off by the file its filter lives in, and point to OAuth or an access token, which do not need Application Passwords. * Fixed: change records showed email addresses in full. wppilot/get-change, wppilot/export-changes, the Changes screen and its download redacted secrets by key name only, so an address typed into an excerpt or a comment came back as written, and get-change returned before-images with no redaction at all. Email addresses in the input, the result, the before-image and the undo verification are now masked when a record is read (j***@e***.com), and before-images get the key-name redaction too. What is stored is unchanged, so undo still restores the exact original. diff --git a/includes/admin/connect-methods.php b/includes/admin/connect-methods.php index 24fabf3..9f0b991 100644 --- a/includes/admin/connect-methods.php +++ b/includes/admin/connect-methods.php @@ -465,14 +465,165 @@ function wppilot_build_oauth_web_ui_configs(string $mcp_url, string $mcp_name): function wppilot_build_oauth_public_configs(string $mcp_url, string $mcp_name): array { $bridge = wppilot_build_oauth_bridge_configs($mcp_url, $mcp_name, []); - $tail = ['cline', 'roo-code', 'amazon-q', 'zed', 'kilo-code', 'opencode']; + // Only Roo Code is left on the bridge: every other client that used to be + // here now documents its own OAuth flow (see wppilot_build_oauth_native_more()). + $tail = ['roo-code']; return array_merge( array_intersect_key($bridge, array_flip($tail)), wppilot_build_oauth_native_configs($mcp_url, $mcp_name), + wppilot_build_oauth_native_more($mcp_url, $mcp_name), + wppilot_build_oauth_native_agents($mcp_url, $mcp_name), ); } +/** + * Native OAuth entries for the clients whose own documentation now describes a + * browser sign-in against a remote URL. Each gets the bare remote shape its + * docs show — no header, so the client discovers this site's OAuth metadata — + * plus the one command that starts the sign-in where the client needs one. + * Sources, all checked 2026-09-30, are on each entry. + * + * @return array> + */ +function wppilot_build_oauth_native_more(string $mcp_url, string $mcp_name): array +{ + $opts = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES; + $opencode = (string) json_encode(['mcp' => [$mcp_name => ['type' => 'remote', 'url' => $mcp_url]]], $opts); + + return [ + // https://zed.dev/docs/ai/mcp — no Authorization header means Zed runs + // the standard MCP OAuth flow. + 'zed' => wppilot_oauth_code_entry( + (string) json_encode(['context_servers' => [$mcp_name => ['url' => $mcp_url]]], $opts), + wppilot_oauth_add_to('settings.json'), + wppilot_zed_paths(), + ), + // https://docs.cline.bot/mcp/mcp-overview — omitting `type` means SSE. + 'cline' => wppilot_oauth_code_entry( + wppilot_oauth_json('mcpServers', $mcp_name, ['type' => 'streamableHttp', 'url' => $mcp_url]), + wppilot_oauth_add_to('cline_mcp_settings.json'), + [ + __('Via UI', domain: 'wppilot') => __( + 'Cline sidebar → MCP Servers → Configure MCP Servers', + domain: 'wppilot', + ), + 'Cline CLI' => '~/.cline/mcp.json', + ], + ), + // https://kilo.ai/docs/automate/mcp/using-in-kilo-code + 'kilo-code' => wppilot_oauth_code_entry( + $opencode, + wppilot_oauth_add_to('kilo.jsonc'), + wppilot_kilo_code_paths(), + ), + // https://opencode.ai/docs/mcp-servers/ — `opencode mcp auth ` + // starts the sign-in by hand if the automatic one did not. + 'opencode' => wppilot_oauth_code_entry( + $opencode, + wppilot_oauth_add_to('opencode.json'), + [ + __('Project', domain: 'wppilot') => 'opencode.json', + __('Global', domain: 'wppilot') => '~/.config/opencode/opencode.json', + ], + ), + // https://kiro.dev/docs/mcp/configuration/ and + // https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/mcp-ide.html + 'amazon-q' => wppilot_oauth_code_entry( + wppilot_oauth_json('mcpServers', $mcp_name, ['url' => $mcp_url]), + wppilot_oauth_add_to('mcp.json'), + wppilot_amazon_q_kiro_paths(), + ), + ]; +} + +/** + * The terminal agents in the same position as wppilot_build_oauth_native_more(): each documents + * its own OAuth sign-in, and each starts it differently, which is why the entries differ in kind. + * + * @return array> + */ +function wppilot_build_oauth_native_agents(string $mcp_url, string $mcp_name): array +{ + $sq = static fn(string $v): string => "'" . str_replace(search: "'", replace: "'\\''", subject: $v) . "'"; + + return [ + // https://docs.openclaw.ai/cli/mcp/transports + 'openclaw' => [ + 'kind' => 'code', + 'code' => implode("\n", [ + 'openclaw mcp set ' . $sq($mcp_name) . ' ' . $sq((string) json_encode([ + 'url' => $mcp_url, + 'transport' => 'streamable-http', + 'auth' => 'oauth', + ], JSON_UNESCAPED_SLASHES)), + 'openclaw mcp login ' . $sq($mcp_name), + ]), + 'hint' => __( + 'Run both commands. The login prints an authorization URL — approve it in the browser and OpenClaw finishes the exchange.', + domain: 'wppilot', + ), + 'paths' => [], + 'isShell' => true, + ], + // https://moonshotai.github.io/kimi-code/en/customization/mcp.html + 'kimi-cli' => wppilot_oauth_code_entry( + wppilot_oauth_json('mcpServers', $mcp_name, ['url' => $mcp_url]), + wppilot_oauth_add_to('mcp.json'), + wppilot_kimi_code_paths(), + sprintf( + /* translators: %s: sign-in command in tags */ + __('To sign in, start a new Kimi Code session and run %s.', domain: 'wppilot'), + '/mcp-config login ' . esc_html($mcp_name) . '', + ), + ), + // https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/ — + // client id and endpoints are optional with dynamic registration. + 'qwen-code' => wppilot_oauth_code_entry( + wppilot_oauth_json('mcpServers', $mcp_name, ['httpUrl' => $mcp_url, 'oauth' => ['enabled' => true]]), + wppilot_oauth_add_to('settings.json'), + [ + __('Global', domain: 'wppilot') => '~/.qwen/settings.json', + __('Project', domain: 'wppilot') => '.qwen/settings.json', + ], + ), + // https://zcode.z.ai/en/docs/mcp-services — the manager's form, then an + // "Open authorization" button on the server's row. + 'zcode' => [ + 'kind' => 'code', + 'code' => '', + 'hint' => '', + 'paths' => [], + 'isShell' => false, + 'steps' => [ + [ + 'title' => __('Add the server', domain: 'wppilot'), + 'body' => __( + 'In ZCode open Settings, MCP Servers, and click New MCP Server. Choose HTTP as the type, name it, and paste this URL. Enable OAuth for it.', + domain: 'wppilot', + ), + 'copy' => $mcp_url, + ], + [ + 'title' => __('Authorize', domain: 'wppilot'), + 'body' => __( + 'An Open authorization button appears on the server\'s row. Click it, approve in the browser, and ZCode reconnects and loads the tools.', + domain: 'wppilot', + ), + ], + ], + ], + // https://docs.factory.ai/cli/configuration/mcp + 'factory-droid' => [ + 'kind' => 'code', + 'code' => 'droid mcp add ' . $sq($mcp_name) . ' ' . $sq($mcp_url) . ' --type http', + 'hint' => __('Run in your terminal, then open /mcp inside Droid and complete the browser sign-in.', domain: 'wppilot'), + 'paths' => [], + 'isShell' => true, + ], + ]; +} + /** * Manual walkthrough for the Claude connector clients, which add remote MCP servers from the * Connectors UI rather than a config file. The server name keeps the placeholder that the page @@ -562,6 +713,32 @@ function wppilot_oauth_codex_bridge_cli_note(string $mcp_name, string $mcp_url, return sprintf(__('Prefer the terminal? Run %s.', domain: 'wppilot'), '' . $cmd . ''); } +/** + * Note under the Copilot snippets: which Copilot surface the file is for, and the one surface that + * cannot use this method at all. HTML, like the Codex notes; the name is the client-side placeholder. + */ +function wppilot_oauth_copilot_bridge_note(): string +{ + return __( + 'This is Copilot CLI\'s file. Copilot Chat in VS Code uses the VS Code configuration instead, and the Copilot cloud agent cannot use OAuth servers at all — give it an access token.', + domain: 'wppilot', + ); +} + +/** + * The native-OAuth Copilot note: the bridge note plus the command that restarts Copilot CLI's own + * sign-in. + */ +function wppilot_oauth_copilot_note(string $mcp_name): string +{ + return sprintf( + /* translators: 1: which Copilot surfaces this is for, 2: /mcp auth command in tags */ + __('%1$s To sign in again later, run %2$s.', domain: 'wppilot'), + wppilot_oauth_copilot_bridge_note(), + '/mcp auth ' . esc_html($mcp_name) . '', + ); +} + /** * Antigravity CLI and IDE share the same current remote MCP schema and config locations. * @@ -672,20 +849,23 @@ function wppilot_build_oauth_native_configs(string $mcp_url, string $mcp_name): ), ], ), + // Copilot CLI: `mcpServers`, `type: "http"`, required `tools`; it runs + // the OAuth flow itself and /mcp auth restarts it. Sources, + // checked 2026-09-30: + // https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference + // https://docs.github.com/en/copilot/concepts/agents/cloud-agent/mcp-and-cloud-agent 'github-copilot' => wppilot_oauth_code_entry( - wppilot_oauth_json('servers', $mcp_name, ['type' => 'http', 'url' => $mcp_url]), - wppilot_oauth_add_to('mcp.json'), - [__('Project', domain: 'wppilot') => '.github/copilot/mcp.json'], + wppilot_oauth_json('mcpServers', $mcp_name, ['type' => 'http', 'url' => $mcp_url, 'tools' => ['*']]), + wppilot_oauth_add_to('mcp-config.json'), + wppilot_copilot_cli_paths(), + wppilot_oauth_copilot_note($mcp_name), ), 'antigravity-cli' => $antigravity_entry, 'antigravity-ide' => $antigravity_entry, 'windsurf' => wppilot_oauth_code_entry( wppilot_oauth_json('mcpServers', $mcp_name, ['serverUrl' => $mcp_url]), wppilot_oauth_add_to('mcp_config.json'), - [ - 'macOS / Linux' => '~/.codeium/windsurf/mcp_config.json', - 'Windows' => '%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json', - ], + wppilot_devin_desktop_paths(), ), ]; } @@ -724,15 +904,17 @@ function wppilot_build_oauth_bridge_special(string $mcp_url, string $mcp_name, a { $opts = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES; - $zed_json = (string) json_encode([ - 'context_servers' => [$mcp_name => array_merge(['source' => 'custom', 'enabled' => true], $server)], - ], $opts); + $zed_json = (string) json_encode(['context_servers' => [$mcp_name => $server]], $opts); $opencode_server = ['type' => 'local', 'command' => ['npx', '-y', 'mcp-remote', $mcp_url]]; if ($env !== []) { $opencode_server['environment'] = $env; } $opencode_json = (string) json_encode(['mcp' => [$mcp_name => $opencode_server]], $opts); + $copilot_json = (string) json_encode([ + 'mcpServers' => [$mcp_name => array_merge(['type' => 'local'], $server, ['tools' => ['*']])], + ], $opts); + $openclaw_json = (string) json_encode(['mcp' => ['servers' => [$mcp_name => $server]]], $opts); return [ 'claude-code' => [ @@ -755,6 +937,22 @@ function wppilot_build_oauth_bridge_special(string $mcp_url, string $mcp_name, a __('Project', domain: 'wppilot') => 'opencode.json', __('Global', domain: 'wppilot') => '~/.config/opencode/opencode.json', ]), + 'kilo-code' => wppilot_oauth_code_entry( + $opencode_json, + wppilot_oauth_add_to('kilo.jsonc'), + wppilot_kilo_code_paths(), + ), + 'github-copilot' => wppilot_oauth_code_entry( + $copilot_json, + wppilot_oauth_add_to('mcp-config.json'), + wppilot_copilot_cli_paths(), + wppilot_oauth_copilot_bridge_note(), + ), + 'openclaw' => wppilot_oauth_code_entry( + $openclaw_json, + wppilot_oauth_add_to('openclaw.json'), + wppilot_openclaw_paths(), + ), ]; } @@ -787,8 +985,14 @@ function wppilot_build_oauth_bridge_standard(string $mcp_servers_json, string $s __('Global', domain: 'wppilot') => '~/.cursor/mcp.json', __('Project', domain: 'wppilot') => '.cursor/mcp.json', ]), - 'kimi-cli' => wppilot_oauth_code_entry($mcp_servers_json, wppilot_oauth_add_to('mcp.json'), [ - __('Global', domain: 'wppilot') => '~/.kimi/mcp.json', + 'kimi-cli' => wppilot_oauth_code_entry( + $mcp_servers_json, + wppilot_oauth_add_to('mcp.json'), + wppilot_kimi_code_paths(), + ), + 'factory-droid' => wppilot_oauth_code_entry($mcp_servers_json, wppilot_oauth_add_to('mcp.json'), [ + __('Global', domain: 'wppilot') => '~/.factory/mcp.json', + __('Project', domain: 'wppilot') => '.factory/mcp.json', ]), 'qwen-code' => wppilot_oauth_code_entry($mcp_servers_json, wppilot_oauth_add_to('settings.json'), [ __('Global', domain: 'wppilot') => '~/.qwen/settings.json', @@ -810,13 +1014,11 @@ function wppilot_build_oauth_bridge_standard(string $mcp_servers_json, string $s domain: 'wppilot', ), ]), - 'github-copilot' => wppilot_oauth_code_entry($servers_json, wppilot_oauth_add_to('mcp.json'), [ - __('Project', domain: 'wppilot') => '.github/copilot/mcp.json', - ]), - 'windsurf' => wppilot_oauth_code_entry($mcp_servers_json, wppilot_oauth_add_to('mcp_config.json'), [ - 'macOS / Linux' => '~/.codeium/windsurf/mcp_config.json', - 'Windows' => '%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json', - ]), + 'windsurf' => wppilot_oauth_code_entry( + $mcp_servers_json, + wppilot_oauth_add_to('mcp_config.json'), + wppilot_devin_desktop_paths(), + ), 'cline' => wppilot_oauth_code_entry($mcp_servers_json, wppilot_oauth_add_to('cline_mcp_settings.json'), [ __('Via UI', domain: 'wppilot') => __( 'Cline sidebar → MCP Servers → Configure MCP Servers', @@ -830,16 +1032,10 @@ function wppilot_build_oauth_bridge_standard(string $mcp_servers_json, string $s domain: 'wppilot', ), ]), - 'amazon-q' => wppilot_oauth_code_entry($mcp_servers_json, wppilot_oauth_add_to('mcp.json'), [ - __('Global', domain: 'wppilot') => '~/.aws/amazonq/mcp.json', - __('Project', domain: 'wppilot') => '.amazonq/mcp.json', - ]), - 'kilo-code' => wppilot_oauth_code_entry($mcp_servers_json, wppilot_oauth_add_to('mcp.json'), [ - __('Project', domain: 'wppilot') => '.kilocode/mcp.json', - __('Via UI', domain: 'wppilot') => __( - 'Kilo Code sidebar → MCP Servers → Configure MCP Servers', - domain: 'wppilot', - ), - ]), + 'amazon-q' => wppilot_oauth_code_entry( + $mcp_servers_json, + wppilot_oauth_add_to('mcp.json'), + wppilot_amazon_q_kiro_paths(), + ), ]; } diff --git a/includes/admin/connect/client-configs.php b/includes/admin/connect/client-configs.php index 802bda1..7e7b356 100644 --- a/includes/admin/connect/client-configs.php +++ b/includes/admin/connect/client-configs.php @@ -106,6 +106,113 @@ function wppilot_build_npx_server(string $rest_url, string $username, string $di ]; } +/** + * Config-file locations shared by the three connection methods. + * + * Kept in one place because each method's builder used to restate them, and a + * vendor moving a file then needed three identical edits — the ones below had + * drifted into being wrong in all three at once. Each carries the vendor page + * it was checked against. + */ + +/** + * Devin Desktop (formerly Windsurf). Devin's MCP page gives the ~/.config/devin + * path; its transition FAQ says the app still reads the legacy Windsurf/Codeium + * locations, so a Windsurf install that has not updated keeps working there. + * Sources, checked 2026-09-30: https://docs.devin.ai/desktop/cascade/mcp and + * https://docs.devin.ai/desktop/devin-desktop-faq + * + * @return array + */ +function wppilot_devin_desktop_paths(): array +{ + return [ + 'Devin Desktop — macOS / Linux' => '~/.config/devin/mcp_config.json', + 'Devin Desktop — Windows' => '%APPDATA%\\devin\\mcp_config.json', + __('Windsurf (before the rename)', domain: 'wppilot') => '~/.codeium/windsurf/mcp_config.json', + ]; +} + +/** + * Copilot CLI. Source, checked 2026-09-30: + * https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers + * + * @return array + */ +function wppilot_copilot_cli_paths(): array +{ + return [ + __('User', domain: 'wppilot') => '~/.copilot/mcp-config.json', + __('Project (committed)', domain: 'wppilot') => '.github/mcp.json', + __('Project (local)', domain: 'wppilot') => '.mcp.json', + ]; +} + +/** + * Kimi Code CLI. Source, checked 2026-09-30: + * https://moonshotai.github.io/kimi-code/en/customization/mcp.html + * + * @return array + */ +function wppilot_kimi_code_paths(): array +{ + return [ + __('Global', domain: 'wppilot') => '~/.kimi-code/mcp.json', + __('Project', domain: 'wppilot') => '.kimi-code/mcp.json', + ]; +} + +/** + * Kilo Code. Source, checked 2026-09-30: + * https://kilo.ai/docs/automate/mcp/using-in-kilo-code + * + * @return array + */ +function wppilot_kilo_code_paths(): array +{ + return [ + __('Global', domain: 'wppilot') => '~/.config/kilo/kilo.jsonc', + __('Project', domain: 'wppilot') => 'kilo.jsonc', + ]; +} + +/** + * Kiro, then the Amazon Q Developer files it replaces. Sources, checked + * 2026-09-30: https://kiro.dev/docs/mcp/configuration/ and + * https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/mcp-ide.html + * + * @return array + */ +function wppilot_amazon_q_kiro_paths(): array +{ + return [ + 'Kiro — ' . __('Global', domain: 'wppilot') => '~/.kiro/settings/mcp.json', + 'Kiro — ' . __('Workspace', domain: 'wppilot') => '.kiro/settings/mcp.json', + 'Amazon Q — ' . __('Global', domain: 'wppilot') => '~/.aws/amazonq/mcp.json', + 'Amazon Q — ' . __('Project', domain: 'wppilot') => '.amazonq/mcp.json', + ]; +} + +/** + * Zed. The settings file is opened with the `zed: open settings file` action. + * + * @return array + */ +function wppilot_zed_paths(): array +{ + return ['macOS / Linux' => '~/.config/zed/settings.json']; +} + +/** + * OpenClaw. Source, checked 2026-09-30: https://docs.openclaw.ai/gateway/configuration + * + * @return array + */ +function wppilot_openclaw_paths(): array +{ + return [__('Global', domain: 'wppilot') => '~/.openclaw/openclaw.json']; +} + /** * Build the MCPB bundle manifest (manifest.json contents) for this site. * @@ -161,19 +268,22 @@ function wppilot_build_mcpb_manifest( ]; } -/** @param array $npx_server */ +/** + * Zed local server: `command`, `args`, `env` under `context_servers`. The + * `source: "custom"` marker is no longer in Zed's documentation. + * Source, checked 2026-09-30: https://zed.dev/docs/ai/mcp + * + * @param array $npx_server + */ function wppilot_build_zed_json(string $mcp_name, array $npx_server, int $opts): string { - return (string) json_encode([ - 'context_servers' => [ - $mcp_name => array_merge([ - 'source' => 'custom', - 'enabled' => true, - ], $npx_server), - ], - ], $opts); + return (string) json_encode(['context_servers' => [$mcp_name => $npx_server]], $opts); } +/** + * OpenCode local server. Kilo Code uses the identical `mcp` / `local` shape in + * kilo.jsonc, so it shares this builder. + */ function wppilot_build_opencode_json( string $mcp_name, string $rest_url, @@ -336,6 +446,34 @@ function wppilot_build_configs(string $rest_url, string $username, string $displ ], 'isShell' => false, ], + 'kilo-code' => [ + 'code' => wppilot_build_opencode_json($mcp_name, $rest_url, $username, $display_password, $opts), + 'hint' => sprintf($add_to, 'kilo.jsonc'), + 'paths' => wppilot_kilo_code_paths(), + 'isShell' => false, + ], + // Copilot CLI marks a stdio server `type: "local"` and requires `tools`. + 'github-copilot' => [ + 'code' => (string) json_encode([ + 'mcpServers' => [$mcp_name => array_merge(['type' => 'local'], $npx_server, ['tools' => ['*']])], + ], $opts), + 'hint' => sprintf( + /* translators: %s: config file name wrapped in tags */ + __( + 'For Copilot CLI: add to %s. Copilot Chat in VS Code reads the VS Code configuration instead — use the VS Code tab.', + domain: 'wppilot', + ), + 'mcp-config.json', + ), + 'paths' => wppilot_copilot_cli_paths(), + 'isShell' => false, + ], + 'openclaw' => [ + 'code' => (string) json_encode(['mcp' => ['servers' => [$mcp_name => $npx_server]]], $opts), + 'hint' => sprintf($add_to, 'openclaw.json'), + 'paths' => wppilot_openclaw_paths(), + 'isShell' => false, + ], ]; return array_merge( @@ -398,10 +536,7 @@ function wppilot_build_standard_configs(string $mcp_servers_json, string $vscode 'windsurf' => [ 'code' => $mcp_servers_json, 'hint' => sprintf($add_to, 'mcp_config.json'), - 'paths' => [ - 'macOS / Linux' => '~/.codeium/windsurf/mcp_config.json', - 'Windows' => '%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json', - ], + 'paths' => wppilot_devin_desktop_paths(), 'isShell' => false, ], 'cline' => [ @@ -427,33 +562,10 @@ function wppilot_build_standard_configs(string $mcp_servers_json, string $vscode ], 'isShell' => false, ], - 'kilo-code' => [ - 'code' => $mcp_servers_json, - 'hint' => sprintf($add_to, 'mcp.json'), - 'paths' => [ - __('Project', domain: 'wppilot') => '.kilocode/mcp.json', - __('Via UI', domain: 'wppilot') => __( - 'Kilo Code sidebar → MCP Servers → Configure MCP Servers', - domain: 'wppilot', - ), - ], - 'isShell' => false, - ], - 'github-copilot' => [ - 'code' => $vscode_servers_json, - 'hint' => sprintf($add_to, 'mcp.json'), - 'paths' => [ - __('Project', domain: 'wppilot') => '.github/copilot/mcp.json', - ], - 'isShell' => false, - ], 'amazon-q' => [ 'code' => $mcp_servers_json, 'hint' => sprintf($add_to, 'mcp.json'), - 'paths' => [ - __('Global', domain: 'wppilot') => '~/.aws/amazonq/mcp.json', - __('Project', domain: 'wppilot') => '.amazonq/mcp.json', - ], + 'paths' => wppilot_amazon_q_kiro_paths(), 'isShell' => false, ], 'antigravity-cli' => [ @@ -496,7 +608,7 @@ function wppilot_build_cli_agent_configs(string $mcp_servers_json): array 'kimi-cli' => [ 'code' => $mcp_servers_json, 'hint' => sprintf($add_to, 'mcp.json'), - 'paths' => [__('Global', domain: 'wppilot') => '~/.kimi/mcp.json'], + 'paths' => wppilot_kimi_code_paths(), 'isShell' => false, ], // Qwen Code and Gemini CLI put MCP servers in settings.json. The diff --git a/includes/admin/connect/oauth-panel.php b/includes/admin/connect/oauth-panel.php index 20e72da..db01cee 100644 --- a/includes/admin/connect/oauth-panel.php +++ b/includes/admin/connect/oauth-panel.php @@ -96,13 +96,21 @@ function wppilot_render_oauth_config_section(string $rest_url): void 'cursor' => 'Cursor', 'vscode' => 'VS Code', 'github-copilot' => 'GitHub Copilot', - 'windsurf' => 'Windsurf', + 'windsurf' => 'Devin Desktop (Windsurf)', 'cline' => 'Cline', 'roo-code' => 'Roo Code', - 'amazon-q' => 'Amazon Q', + 'amazon-q' => 'Amazon Q / Kiro', 'zed' => 'Zed', 'kilo-code' => 'Kilo Code', 'opencode' => 'OpenCode', + // These have had OAuth entries in the local (bridge) set for a while but + // no tab to reach them, so the method was unreachable for them. + 'factory-droid' => 'Factory Droid', + 'kimi-cli' => 'Kimi Code CLI', + 'qwen-code' => 'Qwen Code', + 'gemini-cli' => 'Gemini CLI', + 'zcode' => 'ZCode (GLM)', + 'openclaw' => 'OpenClaw', ]; ?>

diff --git a/includes/admin/connect/token-configs.php b/includes/admin/connect/token-configs.php index 0de9bf0..5105886 100644 --- a/includes/admin/connect/token-configs.php +++ b/includes/admin/connect/token-configs.php @@ -9,14 +9,17 @@ * Configuration page: generated client configuration for the Access token method. * * One credential, one URL, one header — so unlike the application-password - * method there is no npx bridge here and nothing to install. What differs - * between clients is only where the header goes, and the field names disagree - * more than they should: VS Code nests servers under `servers`, everyone else - * under `mcpServers`; Antigravity and Devin Desktop call the URL `serverUrl`; - * Cline spells the transport `streamableHttp` where Kilo spells it - * `streamable-http`; Codex uses TOML with `http_headers`. Each builder below - * writes the shape that client actually parses, verified against its own - * documentation rather than assumed from the others. + * method there is nothing to install for almost every client. The exception is + * Claude Desktop, whose config file is documented for local stdio servers only, + * so the header rides on the mcp-remote bridge there. What differs between + * clients is mostly where the header goes, and the field names disagree more + * than they should: VS Code nests servers under `servers`, most others under + * `mcpServers`, OpenCode and Kilo under `mcp`, OpenClaw under `mcp.servers`; + * Antigravity and Devin Desktop call the URL `serverUrl`; Cline spells the + * transport `streamableHttp` where Roo spells it `streamable-http` and OpenClaw + * wants `transport: "streamable-http"`; Codex uses TOML with `http_headers`. + * Each builder below writes the shape that client actually parses, verified + * against its own documentation rather than assumed from the others. * * Every builder returns a string; nothing in this file echoes, so the same value * can be rendered, copied, or handed to JavaScript. @@ -159,34 +162,32 @@ function wppilot_token_droid_cmd(string $name, string $url, string $token): stri } /** - * Zed speaks stdio only, so the token rides on an mcp-remote bridge. + * Zed connects to a remote server natively: `url` plus `headers` under + * `context_servers`. The old `source: "custom"` marker is gone from its docs, + * and with a header set Zed skips its own OAuth prompt. * - * This is the one client here that still needs Node — not a WPPilot limitation: - * Zed has no native Streamable HTTP transport, and its own documentation points - * at the same bridge. + * Source, checked 2026-09-30: https://zed.dev/docs/ai/mcp */ function wppilot_token_zed_json(string $name, string $url, string $token): string { return wppilot_token_json([ 'context_servers' => [ $name => [ - 'source' => 'custom', - 'enabled' => true, - 'command' => 'npx', - 'args' => [ - '-y', - 'mcp-remote', - $url, - '--header', - 'Authorization: ' . wppilot_token_auth_header($token), - ], + 'url' => $url, + 'headers' => ['Authorization' => wppilot_token_auth_header($token)], ], ], ]); } /** - * OpenCode names the remote transport `remote` and the URL `url`. + * OpenCode and Kilo Code share a format: servers under `mcp`, the remote + * transport named `remote`. `oauth: false` is what both document for a server + * authenticated by a header — it stops either client starting a browser sign-in + * because this site also advertises OAuth metadata. + * + * Sources, checked 2026-09-30: https://opencode.ai/docs/mcp-servers/ and + * https://kilo.ai/docs/automate/mcp/using-in-kilo-code */ function wppilot_token_opencode_json(string $name, string $url, string $token): string { @@ -196,12 +197,108 @@ function wppilot_token_opencode_json(string $name, string $url, string $token): 'type' => 'remote', 'url' => $url, 'enabled' => true, + 'oauth' => false, + 'headers' => ['Authorization' => wppilot_token_auth_header($token)], + ], + ], + ]); +} + +/** + * Claude Desktop's config file is documented for local stdio servers; remote + * servers are added as custom connectors, which connect from Anthropic's cloud. + * So a token for the desktop app goes through the mcp-remote bridge, which runs + * locally and therefore also reaches a site that is not on the public internet. + * + * The header travels as `Authorization:${AUTH_HEADER}` with the value in env: + * mcp-remote's README warns that Claude Desktop on Windows does not escape spaces + * inside `args`, which would split "Bearer " in two. + * + * Sources, checked 2026-09-30: + * https://modelcontextprotocol.io/docs/develop/connect-local-servers + * https://modelcontextprotocol.io/docs/develop/connect-remote-servers + * https://github.com/geelen/mcp-remote (README, "--header") + */ +function wppilot_token_claude_desktop_json(string $name, string $url, string $token): string +{ + $env = ['AUTH_HEADER' => wppilot_token_auth_header($token)]; + if (wppilot_likely_self_signed_https()) { + $env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; + } + + return wppilot_token_json([ + 'mcpServers' => [ + $name => [ + 'command' => 'npx', + 'args' => ['-y', 'mcp-remote', $url, '--header', 'Authorization:${AUTH_HEADER}'], + 'env' => $env, + ], + ], + ]); +} + +/** + * Copilot CLI: `mcpServers`, `type: "http"`, and a required `tools` list. It + * rejects the `servers` key VS Code uses. + * + * Sources, checked 2026-09-30: + * https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers + * https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference + */ +function wppilot_token_copilot_cli_json(string $name, string $url, string $token): string +{ + return wppilot_token_json([ + 'mcpServers' => [ + $name => [ + 'type' => 'http', + 'url' => $url, + 'headers' => ['Authorization' => wppilot_token_auth_header($token)], + 'tools' => ['*'], + ], + ], + ]); +} + +/** + * Kimi Code CLI: an entry with `url` and no `transport` is Streamable HTTP; + * static headers go in `headers`. + * + * Source, checked 2026-09-30: + * https://moonshotai.github.io/kimi-code/en/customization/mcp.html + */ +function wppilot_token_kimi_json(string $name, string $url, string $token): string +{ + return wppilot_token_json([ + 'mcpServers' => [ + $name => [ + 'url' => $url, 'headers' => ['Authorization' => wppilot_token_auth_header($token)], ], ], ]); } +/** + * OpenClaw reads `mcp.servers` from ~/.openclaw/openclaw.json. Without + * `transport: "streamable-http"` a remote entry is treated as SSE. + * + * Source, checked 2026-09-30: https://docs.openclaw.ai/cli/mcp/transports + */ +function wppilot_token_openclaw_json(string $name, string $url, string $token): string +{ + return wppilot_token_json([ + 'mcp' => [ + 'servers' => [ + $name => [ + 'url' => $url, + 'transport' => 'streamable-http', + 'headers' => ['Authorization' => wppilot_token_auth_header($token)], + ], + ], + ], + ]); +} + /** * Claude's Messages API MCP connector: the reason this credential exists. * @@ -346,7 +443,11 @@ function wppilot_token_ui_entry(array $steps, string $hint): array * service-account credential is wanted rather than a sign-in per person. * * ChatGPT is absent: developer mode offers OAuth or no authentication and has no - * header field, so a token has nowhere to go there. + * header field, so a token has nowhere to go there. Manus is present because its + * custom-MCP documentation lists a Bearer token among the credentials a custom + * server can take (https://manus.im/docs/integrations/custom-mcp, checked + * 2026-09-30); that page does not name the dialog's fields, so the steps stay + * general about them. * * @return array, isShell: bool, steps: list}> */ @@ -421,6 +522,28 @@ function wppilot_build_token_web_ui_configs(string $url, string $token): array domain: 'wppilot', ), ), + 'manus' => wppilot_token_ui_entry( + [ + __( + 'In Manus, open Settings and add a custom MCP server (Connectors, then Custom MCP, in current versions).', + domain: 'wppilot', + ), + __('Paste the server URL:', domain: 'wppilot'), + $url, + __('For authentication choose the Bearer token option and paste the token:', domain: 'wppilot'), + $token, + __( + 'If the form asks for a request header instead, name it Authorization and give it this value:', + domain: 'wppilot', + ), + $header, + __('Save. Manus checks it can reach the server and lists the tools it found.', domain: 'wppilot'), + ], + __( + 'Manus connects from its own servers, so this site must be reachable over public HTTPS.', + domain: 'wppilot', + ), + ), ]; } @@ -428,11 +551,11 @@ function wppilot_build_token_web_ui_configs(string $url, string $token): array * Every access-token config snippet, keyed the same way as the client registry so * the tab strip can be built from wppilot_selectable_clients(). * - * The hosted web UIs that can store a fixed Authorization header — claude.ai, - * Mistral Le Chat, Perplexity — appear here as click-through steps rather than a - * snippet, because that is how a token is entered there. ChatGPT, Manus and the - * Codex desktop app are absent on purpose: their connector dialogs offer OAuth or - * no authentication and have no header field, so a token has nowhere to go, and + * The hosted web UIs that can store a fixed credential — claude.ai, Mistral Le + * Chat, Perplexity, Manus — appear here as click-through steps rather than a + * snippet, because that is how a token is entered there. ChatGPT and the Codex + * desktop app are absent on purpose: their connector dialogs offer OAuth or no + * authentication and have no header field, so a token has nowhere to go, and * showing one would be the page inventing a route that does not exist. * * @return array, isShell: bool}> @@ -446,15 +569,26 @@ function wppilot_build_token_configs(string $url, string $name, string $token): [ 'claude-code' => wppilot_token_entry( wppilot_token_claude_code_cmd($name, $url, $token), + // A server whose Authorization header you configured is never + // flagged "needs authentication": a good token shows connected, a + // bad one "failed" with the HTTP status. Source, checked + // 2026-09-30: https://code.claude.com/docs/en/mcp __( - 'Run in your terminal, then restart Claude Code. The /mcp screen may still list this server as needing authentication — that is a known display bug when a server also advertises OAuth; the header authenticates every call regardless, and the tools will work.', + 'Run in your terminal, then restart Claude Code. Run /mcp to check: the server should read connected. If it reads failed, the detail shows the HTTP status — a 401 means the token is wrong or revoked.', domain: 'wppilot', ), is_shell: true, ), 'claude-desktop' => wppilot_token_entry( - $standard, - sprintf($add_to, 'claude_desktop_config.json'), + wppilot_token_claude_desktop_json($name, $url, $token), + sprintf( + /* translators: %s: config file name wrapped in tags */ + __( + 'Add to %s, then fully quit and reopen Claude Desktop. This file runs local servers only, so the token goes through the mcp-remote bridge (needs Node.js). On a public HTTPS site you can instead add a custom connector under Settings, Connectors — see the Claude (web) steps.', + domain: 'wppilot', + ), + 'claude_desktop_config.json', + ), [ 'macOS' => '~/Library/Application Support/Claude/claude_desktop_config.json', 'Windows' => '%APPDATA%\\Claude\\claude_desktop_config.json', @@ -496,17 +630,16 @@ function wppilot_build_token_configs(string $url, string $name, string $token): ], ), 'github-copilot' => wppilot_token_entry( - wppilot_token_json([ - 'servers' => [ - $name => [ - 'type' => 'http', - 'url' => $url, - 'headers' => ['Authorization' => wppilot_token_auth_header($token)], - ], - ], - ]), - __('Copilot reads the VS Code MCP configuration.', domain: 'wppilot'), - [__('Project', domain: 'wppilot') => '.github/copilot/mcp.json'], + wppilot_token_copilot_cli_json($name, $url, $token), + sprintf( + /* translators: %s: config file name wrapped in tags */ + __( + 'For Copilot CLI: add to %s, or run copilot mcp add. Copilot Chat in VS Code reads the VS Code configuration instead — use the VS Code tab.', + domain: 'wppilot', + ), + 'mcp-config.json', + ), + wppilot_copilot_cli_paths(), ), 'factory-droid' => wppilot_token_entry( wppilot_token_droid_cmd($name, $url, $token), @@ -521,15 +654,12 @@ function wppilot_build_token_configs(string $url, string $name, string $token): sprintf( /* translators: %s: config file name wrapped in tags */ __( - 'Add to %s. Devin Desktop still reads the Windsurf path, and expects "serverUrl".', + 'Add to %s — the Cascade panel\'s … menu, then Open MCP config file, opens the one your install reads. Remote servers use "serverUrl".', domain: 'wppilot', ), 'mcp_config.json', ), - [ - 'macOS / Linux' => '~/.codeium/windsurf/mcp_config.json', - 'Windows' => '%USERPROFILE%\\.codeium\\windsurf\\mcp_config.json', - ], + wppilot_devin_desktop_paths(), ), 'antigravity-cli' => wppilot_token_entry( wppilot_token_server_url_json($name, $url, $token), @@ -548,7 +678,13 @@ function wppilot_build_token_configs(string $url, string $name, string $token): ), 'antigravity-ide' => wppilot_token_entry( wppilot_token_server_url_json($name, $url, $token), - __('Settings, then Customizations, then Open MCP Config.', domain: 'wppilot'), + // "Settings, Customizations" is the Antigravity 2.0 desktop app; + // the IDE opens the file from the agent side panel. Source, + // checked 2026-09-30: https://antigravity.google/docs/mcp + __( + 'Click … at the top of the agent side panel, choose MCP Servers, then Manage MCP Servers and View raw config. Antigravity expects "serverUrl".', + domain: 'wppilot', + ), [ __('Global', domain: 'wppilot') => '~/.gemini/config/mcp_config.json', __('Workspace', domain: 'wppilot') => '.agents/mcp_config.json', @@ -584,20 +720,37 @@ function wppilot_build_token_vscode_family_configs(string $url, string $name, st 'roo-code' => wppilot_token_entry( wppilot_token_mcp_servers_json($name, $url, $token, type: 'streamable-http'), __( - 'Roo Code spells the transport "streamable-http", with a hyphen. The extension was discontinued in May 2026 — for a new setup, use Cline.', + 'Roo Code spells the transport "streamable-http", with a hyphen. The extension was discontinued in May 2026 — for a new setup, use Cline or ZooCode.', domain: 'wppilot', ), [__('Project', domain: 'wppilot') => '.roo/mcp.json'], ), 'kilo-code' => wppilot_token_entry( - wppilot_token_mcp_servers_json($name, $url, $token, type: 'streamable-http'), - __('Kilo Code spells the transport "streamable-http", with a hyphen.', domain: 'wppilot'), - [__('Project', domain: 'wppilot') => '.kilocode/mcp.json'], + wppilot_token_opencode_json($name, $url, $token), + sprintf( + /* translators: %s: config file name wrapped in tags */ + __( + 'Add to %s, or use Settings, Agent Behaviour, MCP Servers. Kilo names the remote transport "remote"; "oauth": false stops it offering a sign-in the header makes unnecessary.', + domain: 'wppilot', + ), + 'kilo.jsonc', + ), + wppilot_kilo_code_paths(), + ), + // Kiro's documented remote shape is plain `url` + `headers`; Amazon Q + // Developer reads the same mcpServers file format. + 'amazon-q' => wppilot_token_entry( + wppilot_token_json([ + 'mcpServers' => [ + $name => [ + 'url' => $url, + 'headers' => ['Authorization' => wppilot_token_auth_header($token)], + ], + ], + ]), + sprintf($add_to, 'mcp.json'), + wppilot_amazon_q_kiro_paths(), ), - 'amazon-q' => wppilot_token_entry($standard, sprintf($add_to, 'mcp.json'), [ - __('Global', domain: 'wppilot') => '~/.aws/amazonq/mcp.json', - __('Project', domain: 'wppilot') => '.amazonq/mcp.json', - ]), 'opencode' => wppilot_token_entry( wppilot_token_opencode_json($name, $url, $token), sprintf($add_to, 'opencode.json'), @@ -611,24 +764,24 @@ function wppilot_build_token_vscode_family_configs(string $url, string $name, st sprintf( /* translators: %s: config file name wrapped in tags */ __( - 'Add to %s. Zed has no HTTP transport, so this one still needs Node for the bridge.', + 'Add to %s (zed: open settings file), or use Settings, AI, MCP Servers, Add Remote Server. Zed connects directly — no Node or bridge needed.', domain: 'wppilot', ), 'settings.json', ), - ['macOS / Linux' => '~/.config/zed/settings.json'], + wppilot_zed_paths(), ), 'kimi-cli' => wppilot_token_entry( - $standard, + wppilot_token_kimi_json($name, $url, $token), sprintf( /* translators: %s: config file name wrapped in tags */ __( - 'Add to %s, or run the equivalent kimi mcp add command. Restart afterwards; no sign-in step follows.', + 'Add to %s, or add it with /mcp-config inside Kimi Code. New sessions pick it up; no sign-in step follows.', domain: 'wppilot', ), 'mcp.json', ), - [__('Global', domain: 'wppilot') => '~/.kimi/mcp.json'], + wppilot_kimi_code_paths(), ), 'qwen-code' => wppilot_token_entry( wppilot_token_http_url_json($name, $url, $token), @@ -659,8 +812,17 @@ function wppilot_build_token_vscode_family_configs(string $url, string $name, st domain: 'wppilot', )), 'openclaw' => wppilot_token_entry( - wppilot_token_mcp_servers_json($name, $url, $token), - __('Add to your OpenClaw MCP configuration and restart the agent.', domain: 'wppilot'), + wppilot_token_openclaw_json($name, $url, $token), + sprintf( + /* translators: 1: config file name, 2: command, both wrapped in tags */ + __( + 'Merge into %1$s, or save the inner server object with %2$s. Keep "transport": "streamable-http" — without it OpenClaw falls back to SSE.', + domain: 'wppilot', + ), + 'openclaw.json', + 'openclaw mcp set', + ), + wppilot_openclaw_paths(), ), ]; } diff --git a/includes/admin/connect/web-apps.php b/includes/admin/connect/web-apps.php index 5f2e671..acb603c 100644 --- a/includes/admin/connect/web-apps.php +++ b/includes/admin/connect/web-apps.php @@ -50,7 +50,7 @@ function wppilot_web_apps(): array 'oauth' => 'mistral', 'bearer_steps' => 'mistral-lechat', ], - 'manus' => ['label' => 'Manus', 'oauth' => 'manus', 'bearer_steps' => null], + 'manus' => ['label' => 'Manus', 'oauth' => 'manus', 'bearer_steps' => 'manus'], ]; } diff --git a/includes/clients.php b/includes/clients.php index 37622f7..d39209e 100644 --- a/includes/clients.php +++ b/includes/clients.php @@ -32,9 +32,9 @@ * password, access token — are actually available, so the Connect screen * offers each client the ones that work rather than a generic list the user * has to filter themselves. A client is listed for `token` only where it can - * send a static Authorization header — including three of the hosted web UIs, - * which now store one per connector. ChatGPT, Manus and the Codex desktop app - * have nowhere to put one and stay OAuth-only. + * send a static Authorization header — including four of the hosted web UIs, + * which now store one per connector. ChatGPT and the Codex desktop app have + * nowhere to put one and stay OAuth-only. * * The registry is split by that OAuth route rather than listed flat, so no entry * has to restate it and no entry can contradict the group it sits in. A client @@ -61,7 +61,7 @@ function wppilot_clients_native_oauth(): array 'match' => ['claude-code', 'claude code'], 'methods' => ['oauth', 'token', 'password'], 'note' => __( - 'Add the server, then run /mcp inside Claude Code to complete the browser sign-in. With an access token there is no sign-in step, but /mcp still reports the server as needing authentication — a display bug that does not stop the tools working.', + 'Add the server, then run /mcp inside Claude Code to complete the browser sign-in. With an access token there is no sign-in step: /mcp shows the server as connected, or as failed with the HTTP status if the token is wrong.', domain: 'wppilot', ), ], @@ -92,12 +92,12 @@ function wppilot_clients_native_oauth(): array function wppilot_clients_web_ui(): array { return [ - // Not all OAuth-only any more: claude.ai, Le Chat and Perplexity each - // store a fixed Authorization header per connector, which is what makes - // an access token usable from a browser at all. ChatGPT's developer mode - // offers OAuth or no authentication and has no header field, and Manus - // and the Codex app take OAuth client credentials rather than a header, - // so those three stay OAuth-only. + // Not all OAuth-only any more: claude.ai, Le Chat, Perplexity and Manus + // each accept a fixed credential per connector, which is what makes an + // access token usable from a browser at all. ChatGPT's developer mode + // offers OAuth or no authentication and has no header field, and the + // Codex app takes OAuth client credentials rather than a header, so those + // two stay OAuth-only. 'claude-web' => [ 'label' => __('Claude (web)', domain: 'wppilot'), 'match' => ['claude-web'], @@ -130,10 +130,14 @@ function wppilot_clients_web_ui(): array domain: 'wppilot', ), ], + // Manus's own custom-MCP documentation lists "API key, Bearer token, or + // other credentials" as the authentication a custom server can use + // (https://manus.im/docs/integrations/custom-mcp, checked 2026-09-30), so + // the access token is offered alongside OAuth. 'manus' => [ 'label' => 'Manus', 'match' => ['manus'], - 'methods' => ['oauth'], + 'methods' => ['oauth', 'token'], 'note' => __( 'Settings, Connectors, Add connectors, Custom MCP, Direct configuration. Manus connects from its own servers, so this site must be reachable over public HTTPS.', domain: 'wppilot', @@ -206,13 +210,21 @@ function wppilot_clients_native_oauth_editors(): array 'methods' => ['oauth', 'token', 'password'], 'note' => __('Add the server with /mcp inside Droid, or put it in ~/.factory/mcp.json.', domain: 'wppilot'), ], - // Kimi Code CLI takes a whole server in one command, like Claude Code, - // and stores it in a standard mcpServers file. + // Kimi Code CLI is the Node.js rewrite that replaced the Python kimi-cli. + // It reads ~/.kimi-code/mcp.json (per project .kimi-code/mcp.json), edits + // servers through /mcp-config in the TUI, and signs in to an OAuth server + // with /mcp-config login . `kimi migrate` carries a legacy + // ~/.kimi/mcp.json across. Source, checked 2026-09-30: + // https://moonshotai.github.io/kimi-code/en/customization/mcp.html and + // https://moonshotai.github.io/kimi-code/en/guides/migration.html 'kimi-cli' => [ 'label' => __('Kimi Code CLI', domain: 'wppilot'), 'match' => ['kimi', 'kimi-cli', 'kimi code'], 'methods' => ['oauth', 'token', 'password'], - 'note' => __('Servers live in ~/.kimi/mcp.json, or add one with kimi mcp add.', domain: 'wppilot'), + 'note' => __( + 'Servers live in ~/.kimi-code/mcp.json, or add one with /mcp-config inside Kimi Code. Still on the legacy Python kimi-cli? Run kimi migrate after installing Kimi Code.', + domain: 'wppilot', + ), ], // Qwen Code and Gemini CLI share a lineage and a quirk: a remote server's // URL goes in `httpUrl`, not `url`. A snippet copied from any other @@ -238,11 +250,22 @@ function wppilot_clients_native_oauth_editors(): array domain: 'wppilot', ), ], + // The snippets are for Copilot CLI, which reads ~/.copilot/mcp-config.json + // or a repository's .mcp.json / .github/mcp.json under `mcpServers` and + // rejects VS Code's `servers` key. Copilot Chat in VS Code uses the VS Code + // configuration, and the cloud agent cannot use OAuth servers. Sources, + // checked 2026-09-30: + // https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers + // https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference + // https://docs.github.com/en/copilot/concepts/agents/cloud-agent/mcp-and-cloud-agent 'github-copilot' => [ 'label' => 'GitHub Copilot', 'match' => ['github copilot', 'copilot'], 'methods' => ['oauth', 'token', 'password'], - 'note' => __('Uses the VS Code MCP configuration.', domain: 'wppilot'), + 'note' => __( + 'These snippets are for Copilot CLI. Copilot Chat in VS Code uses the VS Code configuration — use the VS Code tab. The Copilot cloud agent cannot use OAuth servers; give it an access token instead.', + domain: 'wppilot', + ), ], 'antigravity-cli' => [ 'label' => 'Antigravity CLI', @@ -255,7 +278,7 @@ function wppilot_clients_native_oauth_editors(): array 'match' => ['antigravity-ide', 'antigravity ide', 'antigravity'], 'methods' => ['oauth', 'token', 'password'], 'note' => __( - 'Open MCP Servers in the IDE, add the server, then complete the browser sign-in.', + 'Click … at the top of the agent side panel, choose MCP Servers, then Manage MCP Servers and View raw config.', domain: 'wppilot', ), ], @@ -263,15 +286,16 @@ function wppilot_clients_native_oauth_editors(): array } /** - * Clients that reach OAuth through the mcp-remote wrapper. + * Editors and agents that used to reach OAuth through the mcp-remote wrapper and + * now document a sign-in of their own. * - * Either the client has no OAuth flow of its own, or it has not been verified to - * have one. The wrapper performs the exchange out of process and speaks stdio to - * the client, so the route works regardless. + * Split from the list above to keep either function readable; they share the + * native route. Moved out of the proxied group on 2026-09-30 after each vendor's + * page was re-checked (cited per entry). * * @return array> */ -function wppilot_clients_proxied_oauth(): array +function wppilot_clients_native_oauth_extensions(): array { return [ // Cognition rebranded Windsurf to Devin Desktop on 2 June 2026 and @@ -287,63 +311,88 @@ function wppilot_clients_proxied_oauth(): array // connections. Renaming it would orphan all of those. Only what the // rebrand actually changed — the display name and the identifiers the // client reports — moves. + // Devin's MCP page documents ~/.config/devin/mcp_config.json (Windows + // %APPDATA%\devin\mcp_config.json) and native OAuth on every transport; + // its transition FAQ says the app reads the legacy Windsurf/Codeium paths + // too. Sources, checked 2026-09-30: + // https://docs.devin.ai/desktop/cascade/mcp + // https://docs.devin.ai/desktop/devin-desktop-faq 'windsurf' => [ 'label' => 'Devin Desktop (Windsurf)', 'match' => ['devin-desktop', 'devin desktop', 'devin', 'windsurf', 'codeium'], 'methods' => ['oauth', 'token', 'password'], 'note' => __( - 'MCP configuration still lives at the Windsurf path, and remote servers use "serverUrl" rather than "url".', + 'Open the MCP config from the Cascade panel: the … menu, then Open MCP config file. Remote servers use "serverUrl" rather than "url".', domain: 'wppilot', ), ], + // Zed connects to remote servers natively (`url` + `headers`) and runs + // the MCP OAuth flow when no Authorization header is set, so no bridge. + // Source, checked 2026-09-30: https://zed.dev/docs/ai/mcp 'zed' => [ 'label' => 'Zed', 'match' => ['zed'], 'methods' => ['oauth', 'token', 'password'], - 'note' => __('Configured as a context server.', domain: 'wppilot'), + 'note' => __( + 'Settings, AI, MCP Servers, Add Server, then Add Remote Server. Without an Authorization header Zed runs the OAuth sign-in itself.', + domain: 'wppilot', + ), ], + // Cline supports OAuth for remote MCP servers (CHANGELOG: "Support + // pre-registered OAuth clients for remote MCP servers"), and a remote + // entry needs "type": "streamableHttp" or it falls back to SSE. + // Sources, checked 2026-09-30: https://docs.cline.bot/mcp/mcp-overview + // https://github.com/cline/cline/blob/main/CHANGELOG.md 'cline' => [ 'label' => 'Cline', 'match' => ['cline'], 'methods' => ['oauth', 'token', 'password'], 'note' => '', ], - // Self-hosted Node agent with native MCP client support since early - // 2026. It runs on the operator's own machine or server, so the - // mcp-remote proxy this group uses is available to it. + // Self-hosted agent. Servers live under `mcp.servers` in + // ~/.openclaw/openclaw.json; a remote one needs + // "transport": "streamable-http" (omitted, OpenClaw uses SSE), and + // "auth": "oauth" plus `openclaw mcp login ` for OAuth. Sources, + // checked 2026-09-30: https://docs.openclaw.ai/cli/mcp/transports + // https://docs.openclaw.ai/gateway/configuration 'openclaw' => [ 'label' => 'OpenClaw', 'match' => ['openclaw', 'open-claw'], 'methods' => ['oauth', 'token', 'password'], - 'note' => __('Add the server to your OpenClaw MCP configuration and restart the agent.', domain: 'wppilot'), - ], - // The extension was discontinued on 15 May 2026 and its repository - // archived, with Cline named as the migration target. It stays in the - // registry because installs already in the field keep working and keep - // identifying themselves — dropping the entry would turn a live - // connection into an unlabelled row — but the note says so, since anyone - // reading this screen to set up a new client should not start here. - 'roo-code' => [ - 'label' => 'Roo Code', - 'match' => ['roo-code', 'roo code', 'roocode'], - 'methods' => ['oauth', 'token', 'password'], 'note' => __( - 'Discontinued in May 2026. Existing installs still connect; for a new setup use Cline.', + 'Servers live under mcp.servers in ~/.openclaw/openclaw.json, or save one with openclaw mcp set.', domain: 'wppilot', ), ], + // Kilo Code moved MCP servers into kilo.jsonc under the top-level `mcp` + // key (OpenCode's shape: `local` / `remote`), and starts OAuth itself + // for a remote server that asks for it. Source, checked 2026-09-30: + // https://kilo.ai/docs/automate/mcp/using-in-kilo-code 'kilo-code' => [ 'label' => 'Kilo Code', 'match' => ['kilo-code', 'kilo code', 'kilocode'], 'methods' => ['oauth', 'token', 'password'], - 'note' => '', + 'note' => __( + 'Settings, Agent Behaviour, MCP Servers — or edit kilo.jsonc, where servers sit under "mcp".', + domain: 'wppilot', + ), ], + // Amazon Q Developer in the IDE opens a browser to authorize an HTTP + // server that asks for it, and Kiro, its successor, runs the OAuth flow + // natively from ~/.kiro/settings/mcp.json. Sources, checked 2026-09-30: + // https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/mcp-ide.html + // https://kiro.dev/docs/mcp/configuration/ 'amazon-q' => [ - 'label' => 'Amazon Q', - 'match' => ['amazon-q', 'amazon q', 'amazonq'], + 'label' => 'Amazon Q / Kiro', + 'match' => ['amazon-q', 'amazon q', 'amazonq', 'kiro'], 'methods' => ['oauth', 'token', 'password'], - 'note' => '', + 'note' => __( + 'Kiro reads ~/.kiro/settings/mcp.json. Amazon Q Developer reads ~/.aws/amazonq/mcp.json, or add the server from its MCP configuration screen.', + domain: 'wppilot', + ), ], + // OpenCode detects a 401 and runs the OAuth flow for a remote server + // itself. Source, checked 2026-09-30: https://opencode.ai/docs/mcp-servers/ 'opencode' => [ 'label' => 'OpenCode', 'match' => ['opencode'], @@ -353,6 +402,39 @@ function wppilot_clients_proxied_oauth(): array ]; } +/** + * Clients that reach OAuth through the mcp-remote wrapper. + * + * Either the client has no OAuth flow of its own, or it has not been verified to + * have one. The wrapper performs the exchange out of process and speaks stdio to + * the client, so the route works regardless. + * + * @return array> + */ +function wppilot_clients_proxied_oauth(): array +{ + return [ + // The extension was discontinued on 15 May 2026 and its repository + // archived; the README names ZooCode (a community fork) and Cline as the + // alternatives (https://github.com/RooCodeInc/Roo-Code, checked + // 2026-09-30). No native OAuth flow has been verified for it, so it + // stays in the proxied group. It stays in the + // registry because installs already in the field keep working and keep + // identifying themselves — dropping the entry would turn a live + // connection into an unlabelled row — but the note says so, since anyone + // reading this screen to set up a new client should not start here. + 'roo-code' => [ + 'label' => 'Roo Code', + 'match' => ['roo-code', 'roo code', 'roocode'], + 'methods' => ['oauth', 'token', 'password'], + 'note' => __( + 'Discontinued in May 2026. Existing installs still connect; for a new setup use Cline or ZooCode.', + domain: 'wppilot', + ), + ], + ]; +} + /** * Not clients but connection shapes. * @@ -416,6 +498,7 @@ function wppilot_clients(): array wppilot_clients_with_oauth_route(wppilot_clients_native_oauth(), oauth: 'native'), wppilot_clients_with_oauth_route(wppilot_clients_web_ui(), oauth: 'native'), wppilot_clients_with_oauth_route(wppilot_clients_native_oauth_editors(), oauth: 'native'), + wppilot_clients_with_oauth_route(wppilot_clients_native_oauth_extensions(), oauth: 'native'), wppilot_clients_with_oauth_route(wppilot_clients_proxied_oauth(), oauth: 'proxy'), wppilot_client_proxy_shapes(), ); diff --git a/readme.txt b/readme.txt index abecdef..1f66454 100644 --- a/readme.txt +++ b/readme.txt @@ -54,11 +54,11 @@ Either way, WPPilot generates the exact configuration for your client and shows **Editors and CLIs:** Claude Code, Claude Desktop, Codex CLI, the Codex desktop app, Cursor, VS Code, GitHub Copilot, Factory Droid, Antigravity CLI, Antigravity IDE, Devin Desktop (formerly Windsurf), Zed, Cline, Roo Code, Kilo Code, Amazon Q, OpenCode, OpenClaw, Kimi Code CLI, Qwen Code, Gemini CLI and ZCode (GLM). -**Web apps**, each with its own walkthrough: Claude on the web, ChatGPT, Perplexity, Mistral Le Chat and Manus. Every one adds this site from its own settings screen, and WPPilot tells you which credential that app accepts — three of the five can take an access token instead of signing in. +**Web apps**, each with its own walkthrough: Claude on the web, ChatGPT, Perplexity, Mistral Le Chat and Manus. Every one adds this site from its own settings screen, and WPPilot tells you which credential that app accepts — four of the five can take an access token instead of signing in. **Anything else that speaks HTTP:** the Claude Messages API MCP connector, the OpenAI Responses API, and plain curl. -Every client's snippet is written in the shape that client actually parses. The field names disagree more than they should — VS Code nests servers under "servers", Antigravity and Devin Desktop want "serverUrl", Qwen Code and Gemini CLI want "httpUrl", Cline spells the transport "streamableHttp" where Kilo spells it "streamable-http", Codex uses TOML — and a snippet copied from the wrong client's documentation often parses cleanly and then connects to nothing. +Every client's snippet is written in the shape that client actually parses. The field names disagree more than they should — VS Code nests servers under "servers", Antigravity and Devin Desktop want "serverUrl", Qwen Code and Gemini CLI want "httpUrl", Cline spells the transport "streamableHttp" where Roo Code spells it "streamable-http" and OpenClaw wants a "transport" field, OpenCode and Kilo Code nest servers under "mcp", Codex uses TOML — and a snippet copied from the wrong client's documentation often parses cleanly and then connects to nothing. = Or let your AI set it up = diff --git a/tests/Unit/ClientConfigsTest.php b/tests/Unit/ClientConfigsTest.php new file mode 100644 index 0000000..45c442d --- /dev/null +++ b/tests/Unit/ClientConfigsTest.php @@ -0,0 +1,285 @@ + +// SPDX-License-Identifier: GPL-2.0-or-later + +declare(strict_types=1); + +namespace WPPilot\Tests\Unit; + +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\TestCase; + +/** + * The generated client configuration on the Connect screen. + * + * Every snippet here is pasted into a file some other program parses, so the + * promises are about shape: the key each client documents, the transport + * spelling it accepts, the file it actually reads. The per-client expectations + * follow the vendor pages cited in the builders (checked 2026-09-30); when a + * vendor changes its format, the builder and the assertion here change together. + * + * Each test runs in its own process and loads the builders in setUp(), which + * PHPUnit runs in the child only: loading them defines + * wppilot_oauth_transport_allowed(), and the connection doctor's tests rely on + * that function being absent (OAuth "not available") in the shared process. + */ +#[RunTestsInSeparateProcesses] +final class ClientConfigsTest extends TestCase +{ + private const URL = 'https://example.test/wp-json/mcp/wppilot'; + private const NAME = 'wppilot-example'; + private const TOKEN = 'wpp_test-token'; + + protected function setUp(): void + { + require_once dirname(__DIR__) . '/doubles/connect-environment.php'; + require_once dirname(__DIR__, 2) . '/includes/clients.php'; + require_once dirname(__DIR__, 2) . '/includes/admin/connect-methods.php'; + require_once dirname(__DIR__, 2) . '/includes/admin/connect/client-configs.php'; + require_once dirname(__DIR__, 2) . '/includes/admin/connect/token-configs.php'; + require_once dirname(__DIR__, 2) . '/includes/admin/connect/web-apps.php'; + } + + /** @return array */ + private static function json(string $code): array + { + $decoded = json_decode($code, true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($decoded); + + return $decoded; + } + + /** @return array> */ + private static function token(): array + { + return wppilot_build_token_configs(self::URL, self::NAME, self::TOKEN); + } + + /** @return array> */ + private static function oauthPublic(): array + { + return wppilot_build_oauth_public_configs(self::URL, self::NAME); + } + + /** @return array> */ + private static function oauthBridge(): array + { + return wppilot_build_oauth_bridge_configs(self::URL, self::NAME, []); + } + + /** @return array> */ + private static function password(): array + { + return wppilot_build_configs(self::URL, 'admin', 'abcd efgh', self::NAME); + } + + /** + * Keys of the selectable clients that offer a method. + * + * @return list + */ + private static function clientsOffering(string $method): array + { + $keys = []; + foreach (wppilot_selectable_clients() as $key => $client) { + if (in_array($method, (array) ($client['methods'] ?? []), true)) { + $keys[] = (string) $key; + } + } + + return $keys; + } + + public function test_every_client_offering_a_method_has_an_entry_for_it(): void + { + $password = self::password(); + foreach (self::clientsOffering('password') as $key) { + self::assertArrayHasKey($key, $password, "{$key} offers an application password but has no snippet"); + } + + $token = self::token(); + foreach (self::clientsOffering('token') as $key) { + self::assertArrayHasKey($key, $token, "{$key} offers an access token but has no snippet"); + } + + // OAuth: the Claude web target is keyed claude-ai in the OAuth panel, and + // the Codex app and Gemini CLI have no public-site entry yet (reported + // separately rather than papered over here). + $public = self::oauthPublic() + wppilot_build_oauth_web_ui_configs(self::URL, self::NAME); + foreach (array_diff(self::clientsOffering('oauth'), ['claude-web', 'codex-app', 'gemini-cli']) as $key) { + self::assertArrayHasKey($key, $public, "{$key} offers OAuth but has no public-site entry"); + } + + $web = array_keys(wppilot_clients_web_ui()); + foreach (array_diff(self::clientsOffering('oauth'), $web) as $key) { + self::assertArrayHasKey($key, self::oauthBridge(), "{$key} offers OAuth but has no local-site entry"); + } + } + + public function test_every_json_snippet_parses(): void + { + $sets = ['password' => self::password(), 'token' => self::token(), 'oauth-public' => self::oauthPublic(), 'oauth-bridge' => self::oauthBridge()]; + foreach ($sets as $set => $configs) { + foreach ($configs as $key => $entry) { + $code = (string) ($entry['code'] ?? ''); + if ($code === '' || ($entry['isShell'] ?? false) === true || str_starts_with($code, '[mcp_servers.')) { + continue; + } + self::assertNotNull(json_decode($code, true), "{$set}/{$key} is not valid JSON"); + } + } + } + + public function test_verified_clients_run_oauth_natively_and_only_roo_code_keeps_the_bridge(): void + { + $clients = wppilot_clients(); + foreach (['windsurf', 'zed', 'cline', 'openclaw', 'kilo-code', 'amazon-q', 'opencode'] as $key) { + self::assertSame('native', $clients[$key]['oauth'], $key); + } + self::assertSame('proxy', $clients['roo-code']['oauth']); + + $public = self::oauthPublic(); + foreach (['zed', 'cline', 'kilo-code', 'opencode', 'amazon-q', 'openclaw', 'kimi-cli', 'qwen-code', 'factory-droid'] as $key) { + self::assertStringNotContainsString('mcp-remote', (string) $public[$key]['code'], $key); + } + self::assertStringContainsString('mcp-remote', (string) $public['roo-code']['code']); + } + + public function test_kimi_targets_kimi_code_not_the_archived_python_cli(): void + { + $token = self::token()['kimi-cli']; + $server = self::json($token['code'])['mcpServers'][self::NAME]; + self::assertSame(['url' => self::URL, 'headers' => ['Authorization' => 'Bearer ' . self::TOKEN]], $server); + + foreach ([$token, self::password()['kimi-cli'], self::oauthPublic()['kimi-cli'], self::oauthBridge()['kimi-cli']] as $entry) { + self::assertContains('~/.kimi-code/mcp.json', $entry['paths']); + self::assertNotContains('~/.kimi/mcp.json', $entry['paths']); + } + self::assertStringNotContainsString('kimi mcp add', $token['hint']); + self::assertStringContainsString('/mcp-config login', (string) self::oauthPublic()['kimi-cli']['note']); + } + + public function test_copilot_snippets_use_the_copilot_cli_format(): void + { + $entries = [self::token()['github-copilot'], self::password()['github-copilot'], self::oauthPublic()['github-copilot'], self::oauthBridge()['github-copilot']]; + foreach ($entries as $entry) { + $config = self::json($entry['code']); + self::assertArrayNotHasKey('servers', $config, 'Copilot CLI rejects the servers key'); + self::assertSame(['*'], $config['mcpServers'][self::NAME]['tools']); + self::assertContains('~/.copilot/mcp-config.json', $entry['paths']); + self::assertNotContains('.github/copilot/mcp.json', $entry['paths']); + } + + self::assertSame('http', self::json(self::token()['github-copilot']['code'])['mcpServers'][self::NAME]['type']); + self::assertSame('local', self::json(self::password()['github-copilot']['code'])['mcpServers'][self::NAME]['type']); + self::assertStringContainsString('cloud agent cannot use OAuth', (string) self::oauthPublic()['github-copilot']['note']); + } + + public function test_devin_desktop_lists_its_current_path_and_the_windsurf_one(): void + { + foreach ([self::token()['windsurf'], self::password()['windsurf'], self::oauthPublic()['windsurf'], self::oauthBridge()['windsurf']] as $entry) { + self::assertContains('~/.config/devin/mcp_config.json', $entry['paths']); + self::assertContains('%APPDATA%\\devin\\mcp_config.json', $entry['paths']); + self::assertContains('~/.codeium/windsurf/mcp_config.json', $entry['paths']); + } + self::assertSame(self::URL, self::json(self::token()['windsurf']['code'])['mcpServers'][self::NAME]['serverUrl']); + } + + public function test_zed_connects_natively_without_the_deprecated_source_marker(): void + { + self::assertSame( + ['url' => self::URL, 'headers' => ['Authorization' => 'Bearer ' . self::TOKEN]], + self::json(self::token()['zed']['code'])['context_servers'][self::NAME], + ); + self::assertSame(['url' => self::URL], self::json(self::oauthPublic()['zed']['code'])['context_servers'][self::NAME]); + + foreach ([self::password()['zed'], self::oauthBridge()['zed']] as $entry) { + $server = self::json($entry['code'])['context_servers'][self::NAME]; + self::assertArrayNotHasKey('source', $server); + self::assertSame('npx', $server['command']); + } + } + + public function test_cline_opencode_and_kilo_use_their_documented_remote_shapes(): void + { + self::assertSame( + ['type' => 'streamableHttp', 'url' => self::URL], + self::json(self::oauthPublic()['cline']['code'])['mcpServers'][self::NAME], + ); + self::assertSame('streamableHttp', self::json(self::token()['cline']['code'])['mcpServers'][self::NAME]['type']); + + foreach (['opencode', 'kilo-code'] as $key) { + $server = self::json(self::token()[$key]['code'])['mcp'][self::NAME]; + self::assertSame('remote', $server['type']); + self::assertFalse($server['oauth'], 'a header-authenticated server should not start a sign-in'); + self::assertSame(['type' => 'remote', 'url' => self::URL], self::json(self::oauthPublic()[$key]['code'])['mcp'][self::NAME]); + self::assertSame('local', self::json(self::password()[$key]['code'])['mcp'][self::NAME]['type']); + } + + foreach ([self::token()['kilo-code'], self::password()['kilo-code'], self::oauthPublic()['kilo-code'], self::oauthBridge()['kilo-code']] as $entry) { + self::assertContains('~/.config/kilo/kilo.jsonc', $entry['paths']); + self::assertNotContains('.kilocode/mcp.json', $entry['paths']); + } + } + + public function test_openclaw_uses_mcp_servers_and_streamable_http(): void + { + $server = self::json(self::token()['openclaw']['code'])['mcp']['servers'][self::NAME]; + self::assertSame('streamable-http', $server['transport']); + self::assertSame(self::URL, $server['url']); + self::assertSame('Bearer ' . self::TOKEN, $server['headers']['Authorization']); + + $oauth = self::oauthPublic()['openclaw']; + self::assertTrue($oauth['isShell']); + self::assertStringContainsString('"transport":"streamable-http"', $oauth['code']); + self::assertStringContainsString('"auth":"oauth"', $oauth['code']); + self::assertStringContainsString('openclaw mcp login', $oauth['code']); + + self::assertSame('npx', self::json(self::password()['openclaw']['code'])['mcp']['servers'][self::NAME]['command']); + } + + public function test_claude_desktop_token_entry_is_a_local_bridge_not_a_remote_server(): void + { + $server = self::json(self::token()['claude-desktop']['code'])['mcpServers'][self::NAME]; + self::assertArrayNotHasKey('url', $server); + self::assertArrayNotHasKey('type', $server); + self::assertSame('npx', $server['command']); + self::assertSame(['-y', 'mcp-remote', self::URL, '--header', 'Authorization:${AUTH_HEADER}'], $server['args']); + self::assertSame('Bearer ' . self::TOKEN, $server['env']['AUTH_HEADER']); + foreach ($server['args'] as $arg) { + self::assertStringNotContainsString(' ', $arg, 'Claude Desktop on Windows splits args on spaces'); + } + } + + public function test_wording_fixes(): void + { + $token = self::token(); + self::assertStringNotContainsString('needing authentication', $token['claude-code']['hint']); + self::assertStringNotContainsString('display bug', (string) wppilot_clients()['claude-code']['note']); + self::assertStringNotContainsString('Customizations', $token['antigravity-ide']['hint']); + self::assertStringContainsString('ZooCode', $token['roo-code']['hint']); + self::assertStringContainsString('ZooCode', (string) wppilot_clients()['roo-code']['note']); + } + + public function test_oauth_entries_exist_for_qwen_zcode_and_droid(): void + { + $public = self::oauthPublic(); + self::assertSame( + ['httpUrl' => self::URL, 'oauth' => ['enabled' => true]], + self::json($public['qwen-code']['code'])['mcpServers'][self::NAME], + ); + self::assertNotEmpty($public['zcode']['steps']); + self::assertSame(self::URL, $public['zcode']['steps'][0]['copy']); + self::assertSame("droid mcp add 'wppilot-example' '" . self::URL . "' --type http", $public['factory-droid']['code']); + } + + public function test_manus_takes_an_access_token(): void + { + self::assertContains('token', wppilot_clients()['manus']['methods']); + $steps = wppilot_build_token_web_ui_configs(self::URL, self::TOKEN)['manus']['steps']; + self::assertContains(self::URL, $steps); + self::assertContains(self::TOKEN, $steps); + self::assertSame('manus', wppilot_web_apps()['manus']['bearer_steps'] ?? null); + } +} diff --git a/tests/doubles/connect-environment.php b/tests/doubles/connect-environment.php new file mode 100644 index 0000000..f743ef2 --- /dev/null +++ b/tests/doubles/connect-environment.php @@ -0,0 +1,21 @@ + +// SPDX-License-Identifier: GPL-2.0-or-later + +declare(strict_types=1); + +/** + * The one environment.php answer the Connect-screen builders read. + * + * environment.php itself cannot load in the suite (the WordPress doubles already + * declare wppilot_is_enabled()), and the builders only ask whether to add the + * self-signed-certificate bypass. The tests describe a site with a trusted + * certificate, so the answer is no. + */ +if (!function_exists('wppilot_likely_self_signed_https')) { + function wppilot_likely_self_signed_https(): bool + { + return false; + } +} From abeb5e6333b3f3d26fa2c262ee03f3c0d92de90c Mon Sep 17 00:00:00 2001 From: Wppilot Date: Wed, 30 Sep 2026 03:28:00 +0500 Subject: [PATCH 03/23] Withhold cache-plugin credential option names in the change history Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.txt | 1 + includes/change-log.php | 5 ++++- tests/Unit/ChangeOutputRedactionTest.php | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index d556646..e69c82a 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,6 @@ Unreleased * Fixed: generated client configuration that no longer matched the clients' own documentation (each re-checked against the vendor's docs on 2026-09-30, with the source cited in the code). Kimi now targets Kimi Code CLI (~/.kimi-code/mcp.json, /mcp-config, and /mcp-config login for OAuth) instead of the archived Python kimi-cli. GitHub Copilot snippets are for Copilot CLI (~/.copilot/mcp-config.json or .github/mcp.json, "mcpServers" with "tools"), since .github/copilot/mcp.json is not a documented location and Copilot CLI rejects the "servers" key; the notes say Copilot Chat in VS Code uses the VS Code tab and the cloud agent cannot use OAuth. Devin Desktop lists ~/.config/devin/mcp_config.json alongside the old Windsurf path. Kilo Code entries go in kilo.jsonc under "mcp". OpenClaw's token entry uses mcp.servers with transport "streamable-http" (it fell back to SSE before). Zed connects natively instead of through mcp-remote, without the retired "source" field. Claude Desktop's access-token entry runs through the mcp-remote bridge, because claude_desktop_config.json is for local servers. The Claude Code note no longer says /mcp shows a working token as "needs authentication", the Antigravity IDE hint names the IDE's own menu, and Roo Code points to Cline or ZooCode. +* Fixed: the change history hid fewer credentials than it should. Option names such as LiteSpeed's object-pswd and cdn-cloudflare_key, NitroPack's siteId, Autoptimize's ccss_key and any *_pass, *_pwd or *_auth key were shown in plain text in get-change, list-changes and the Changes screen; they are now withheld like passwords and tokens. * New: native OAuth entries for Zed, Cline, Kilo Code, OpenCode, Amazon Q / Kiro and OpenClaw, which now run the sign-in themselves (only Roo Code still uses the mcp-remote bridge on a public site), and OAuth tabs for Kimi Code CLI, Qwen Code, ZCode, Factory Droid and OpenClaw, which had none (Gemini CLI gets one on a local site; its public-site entry is still to come). Manus can take an access token, as its documentation lists Bearer tokens for custom servers. v1.15.1 - 2026/09/29 diff --git a/includes/change-log.php b/includes/change-log.php index b31b93e..a5b2c55 100644 --- a/includes/change-log.php +++ b/includes/change-log.php @@ -2297,7 +2297,10 @@ function wppilot_change_key_is_sensitive(string $key): bool { return ( preg_match( - '/password|passwd|secret|token|authorization|api[_-]?key|private[_-]?key|license|cookie|credential|php[_-]?code|source[_-]?code/', + // Whole-word pass/pwd/pswd/auth (so "bypass" and "author" stay visible), plus the + // credential option names cache plugins use: LiteSpeed object-pswd and + // cdn-cloudflare_key, NitroPack siteId, Autoptimize ccss_key. + '/password|passwd|pswd|secret|token|authoriz|api[_-]?key|private[_-]?key|access[_-]?key|cloudflare[_-]?key|ccss[_-]?key|license|cookie|credential|php[_-]?code|source[_-]?code|(^|[^a-z])(pass|pwd|auth)([^a-z]|$)|siteid$/', strtolower($key), ) === 1 ); diff --git a/tests/Unit/ChangeOutputRedactionTest.php b/tests/Unit/ChangeOutputRedactionTest.php index 0ce6e50..ba1d508 100644 --- a/tests/Unit/ChangeOutputRedactionTest.php +++ b/tests/Unit/ChangeOutputRedactionTest.php @@ -28,6 +28,29 @@ public static function emails(): iterable yield 'no address' => ['Just text with an @ sign', 'Just text with an @ sign']; } + /** @return iterable */ + public static function keyNames(): iterable + { + yield 'litespeed object password' => ['litespeed.conf.object-pswd', true]; + yield 'litespeed cloudflare key' => ['litespeed.conf.cdn-cloudflare_key', true]; + yield 'nitropack site id' => ['nitropack-siteId', true]; + yield 'autoptimize ccss key' => ['autoptimize_ccss_key', true]; + yield 'smtp pass' => ['smtp_pass', true]; + yield 'basic auth' => ['basic-auth', true]; + yield 'api key' => ['stripe_api_key', true]; + yield 'cookie settings still match' => ['cache_bypass_cookies_enabled', true]; + yield 'bypass alone' => ['bypass_logged_in', false]; + yield 'author stays visible' => ['post_author', false]; + yield 'meta key stays visible' => ['meta_key', false]; + yield 'keywords stay visible' => ['_yoast_wpseo_focuskw_keywords', false]; + } + + #[DataProvider('keyNames')] + public function testSensitiveKeyNames(string $key, bool $sensitive): void + { + self::assertSame($sensitive, \wppilot_change_key_is_sensitive($key), $key); + } + #[DataProvider('emails')] public function testEmailsAreMasked(string $input, string $expected): void { From 5ccb183d893eeabd6e26ed08b3d1446129601abd Mon Sep 17 00:00:00 2001 From: Wppilot Date: Wed, 30 Sep 2026 03:31:21 +0500 Subject: [PATCH 04/23] Undo or redo everything one agent session did Ledger rows written inside an agent session now carry its id (the hashed Mcp-Session-Id, one stdio process, or one credential and client until 30 minutes idle) and a digest of the target's state right after the write. wppilot/undo-session undoes a session newest first through the verified rollback, refusing the whole run when something else changed a target since, stopping at the first change that fails verification, and never skipping an irreversible change silently (allow_partial to proceed past them). Every undo keeps the state it replaced, and wppilot/redo-session puts it back oldest first under the same checks. wppilot/list-sessions lists sessions and their undo/redo plan. The Changes screen lists recent sessions and gets Undo session / Redo session buttons. Also stops recording a row for the legacy MCP execute tool itself: it ran the target ability, which records its own row, so every legacy call (reads included) left an extra "No supported before-image" row that would have made every legacy session look irreversible. Ledger table schema 2 adds session_id and redo_data. Co-Authored-By: Claude Opus 5.5 --- includes/abilities/bootstrap.php | 1 + includes/abilities/change-log.php | 3 + includes/abilities/sessions.php | 110 ++ includes/admin/changes/assets/changes.css | 27 + includes/admin/changes/changes.php | 91 +- includes/admin/changes/templates/detail.php | 10 + includes/admin/changes/templates/list.php | 129 ++ includes/agent-identities.php | 6 +- includes/agent-sessions.php | 143 +++ includes/change-log-table.php | 44 +- includes/change-log.php | 96 +- includes/change-sessions.php | 1277 +++++++++++++++++++ includes/design/gate.php | 2 + includes/troubleshoot/bootstrap.php | 7 +- tests/Unit/SessionUndoTest.php | 371 ++++++ tests/bootstrap.php | 3 + wppilot.php | 2 + 17 files changed, 2289 insertions(+), 33 deletions(-) create mode 100644 includes/abilities/sessions.php create mode 100644 includes/agent-sessions.php create mode 100644 includes/change-sessions.php create mode 100644 tests/Unit/SessionUndoTest.php diff --git a/includes/abilities/bootstrap.php b/includes/abilities/bootstrap.php index 4ce0267..733d3cf 100644 --- a/includes/abilities/bootstrap.php +++ b/includes/abilities/bootstrap.php @@ -145,6 +145,7 @@ function wppilot_register_builtin_abilities(): void require_once $dir . 'discover-abilities.php'; require_once $dir . 'agent-context.php'; require_once $dir . 'change-log.php'; + require_once $dir . 'sessions.php'; require_once $dir . 'diagnostics.php'; require_once $dir . 'preview.php'; wppilot_load_wordpress_abilities(); diff --git a/includes/abilities/change-log.php b/includes/abilities/change-log.php index 0609c9b..c45bf23 100644 --- a/includes/abilities/change-log.php +++ b/includes/abilities/change-log.php @@ -102,6 +102,9 @@ function wppilot_change_public_summary(array $entry): array 'duration_ms' => (float) ($entry['duration_ms'] ?? 0), 'user' => $entry['user'] ?? [], 'agent' => $entry['agent'] ?? [], + // The agent session the write belongs to, for wppilot/undo-session. Empty for writes no + // agent made and for rows recorded before 1.16.0. + 'session_id' => (string) ($entry['session'] ?? ''), 'reversible' => ($rollback['reversible'] ?? false) === true, 'rollback_reason' => (string) ($rollback['reason'] ?? ''), 'rolled_back' => ($entry['rolled_back'] ?? false) === true, diff --git a/includes/abilities/sessions.php b/includes/abilities/sessions.php new file mode 100644 index 0000000..14c9a02 --- /dev/null +++ b/includes/abilities/sessions.php @@ -0,0 +1,110 @@ + +// SPDX-License-Identifier: GPL-2.0-or-later + +declare(strict_types=1); + +if (!defined('ABSPATH')) { + exit(); +} + +/* + * Session undo and redo over MCP. The work is in includes/change-sessions.php; these are its + * typed entry points. Undo and redo are destructive (they overwrite the current state of every + * target the session touched), so they go through the same confirmation contract as any other + * destructive ability, and a read-only safety profile refuses them. + */ + +wp_register_ability('wppilot/list-sessions', [ + 'label' => __('List Agent Sessions', domain: 'wppilot'), + 'description' => __( + 'Lists agent sessions recorded in the WPPilot change ledger, most recently active first: one per MCP connection (or per stdio process, or per agent credential and client until it has been idle for 30 minutes). Each shows the agent, the time span, and how many of its changes can be undone, are undone, or cannot be undone. current_session_id is the session this call belongs to. Pass session_id to get that session\'s changes and what undo-session and redo-session would do right now: conflicts (targets changed by something else since), changes that cannot be undone or redone, and whether a run is ready.', + domain: 'wppilot', + ), + 'category' => 'changes', + 'input_schema' => [ + 'type' => 'object', + 'default' => [], + 'properties' => [ + 'session_id' => ['type' => 'string', 'maxLength' => 64, 'description' => 'A session to show in full, with its undo and redo plan.'], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20], + ], + 'additionalProperties' => false, + ], + 'output_schema' => ['type' => 'object'], + 'execute_callback' => static function (array $input): array|WP_Error { + $current = wppilot_current_session_id(); + $session = trim((string) ($input['session_id'] ?? '')); + if ($session !== '') { + $detail = wppilot_session_detail($session, (int) ($input['limit'] ?? 100)); + return $detail instanceof WP_Error ? $detail : array_merge(['current_session_id' => $current], $detail); + } + $sessions = wppilot_list_change_sessions((int) ($input['limit'] ?? 20)); + return ['current_session_id' => $current, 'sessions' => $sessions, 'count' => count($sessions)]; + }, + 'permission_callback' => 'wppilot_permission_callback', + 'meta' => [ + 'show_in_rest' => true, + 'mcp' => ['public' => true], + 'annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true], + ], +]); + +$wppilot_session_run_schema = [ + 'type' => 'object', + 'properties' => [ + 'session_id' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 64, 'description' => 'From wppilot/list-sessions.'], + 'allow_partial' => [ + 'type' => 'boolean', + 'default' => false, + 'description' => 'Go ahead when some of the session\'s changes cannot be handled; they are listed in the result and left as they are. Without it, such a session is refused and nothing is changed.', + ], + ], + 'required' => ['session_id'], + 'additionalProperties' => false, +]; + +wp_register_ability('wppilot/undo-session', [ + 'label' => __('Undo Agent Session', domain: 'wppilot'), + 'description' => __( + 'Undoes every change one agent session made, newest first, each restored and verified against its before-image. Before touching anything it checks that no target was changed by something else after the session wrote to it; if one was, nothing is changed and the conflicts are listed. It stops at the first change that fails verification and reports what was undone, what failed and what was not attempted. Changes that cannot be undone are listed, never skipped silently: the run is refused unless allow_partial is true. Check wppilot/list-sessions with the session_id first to see the plan. Every undo can be put back with wppilot/redo-session. Requires explicit confirmation.', + domain: 'wppilot', + ), + 'category' => 'changes', + 'input_schema' => $wppilot_session_run_schema, + 'output_schema' => ['type' => 'object'], + 'execute_callback' => static fn(array $input): array|WP_Error => wppilot_undo_session( + (string) $input['session_id'], + allow_partial: ($input['allow_partial'] ?? false) === true, + ), + 'permission_callback' => 'wppilot_permission_callback', + 'meta' => [ + 'show_in_rest' => true, + 'mcp' => ['public' => true], + 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => false], + ], +]); + +wp_register_ability('wppilot/redo-session', [ + 'label' => __('Redo Agent Session', domain: 'wppilot'), + 'description' => __( + 'Puts back the changes of one agent session that were undone, oldest first, each verified against the state recorded when the change was first made. Refuses without changing anything when a target was changed by something else after the undo, and stops at the first change that fails verification. Undone changes that cannot be redone (a created term or comment the undo deleted, a target too large to copy) are listed; the run is refused unless allow_partial is true. Requires explicit confirmation.', + domain: 'wppilot', + ), + 'category' => 'changes', + 'input_schema' => $wppilot_session_run_schema, + 'output_schema' => ['type' => 'object'], + 'execute_callback' => static fn(array $input): array|WP_Error => wppilot_redo_session( + (string) $input['session_id'], + allow_partial: ($input['allow_partial'] ?? false) === true, + ), + 'permission_callback' => 'wppilot_permission_callback', + 'meta' => [ + 'show_in_rest' => true, + 'mcp' => ['public' => true], + 'annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => false], + ], +]); + +unset($wppilot_session_run_schema); diff --git a/includes/admin/changes/assets/changes.css b/includes/admin/changes/assets/changes.css index 615ffa7..272aa27 100644 --- a/includes/admin/changes/assets/changes.css +++ b/includes/admin/changes/assets/changes.css @@ -113,3 +113,30 @@ justify-content: flex-start; } } + +.wppilot-changes__session, +.wppilot-changes__sessions { + margin: 0 0 1rem; + padding: 0.75rem 1rem; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 6px; +} + +.wppilot-changes__sessions h2 { + margin-top: 0; + font-size: 14px; +} + +.wppilot-changes__session-facts { + margin: 0 0 0.75rem; +} + +.wppilot-changes__session-conflict { + color: #b32d2e; +} + +.wppilot-changes__session form { + display: flex; + gap: 0.5rem; + align-items: center; +} diff --git a/includes/admin/changes/changes.php b/includes/admin/changes/changes.php index 16a23fc..f266ff9 100644 --- a/includes/admin/changes/changes.php +++ b/includes/admin/changes/changes.php @@ -34,7 +34,7 @@ const EXPORT_PAGE = 200; /** The query-string keys the screen and its downloads accept as filters. */ -const FILTER_KEYS = ['kind', 'ability', 'user_id', 'agent', 'group', 'status', 'since', 'until']; +const FILTER_KEYS = ['kind', 'ability', 'user_id', 'agent', 'group', 'session', 'status', 'since', 'until']; function current_user_can_manage(): bool { @@ -72,6 +72,8 @@ function register_post_handlers(): void add_action('admin_post_wppilot_changes_undo_group', __NAMESPACE__ . '\\handle_undo_group'); add_action('admin_post_wppilot_changes_undo_matching', __NAMESPACE__ . '\\handle_undo_matching'); add_action('admin_post_wppilot_changes_export', __NAMESPACE__ . '\\handle_export'); + add_action('admin_post_wppilot_changes_undo_session', __NAMESPACE__ . '\\handle_undo_session'); + add_action('admin_post_wppilot_changes_redo_session', __NAMESPACE__ . '\\handle_redo_session'); } function render_page(): void @@ -175,6 +177,93 @@ function handle_undo_matching(): void redirect_with_notice(summary_notice_type($result), summary_message($result), $filters); } +/** + * Undo or redo one agent session from the screen. The same run the MCP abilities perform: planned + * first, refused whole on a conflict, stopped at the first change that does not verify. + */ +function handle_undo_session(): void +{ + require_capability_and_nonce('wppilot_changes_undo_session'); + handle_session_run('undo'); +} + +function handle_redo_session(): void +{ + require_capability_and_nonce('wppilot_changes_redo_session'); + handle_session_run('redo'); +} + +function handle_session_run(string $operation): void +{ + $session = posted('session'); + $allow_partial = posted('allow_partial') === '1'; + $result = $operation === 'undo' + ? \wppilot_undo_session($session, $allow_partial) + : \wppilot_redo_session($session, $allow_partial); + if ($result instanceof WP_Error) { + redirect_with_notice('error', $result->get_error_message(), ['session' => $session]); + } + redirect_with_notice(session_notice_type($result), session_message($result), ['session' => $session]); +} + +/** + * @param array $result + */ +function session_notice_type(array $result): string +{ + return match ((string) ($result['status'] ?? '')) { + 'completed' => 'success', + 'nothing-to-do' => 'info', + 'stopped' => 'warning', + default => 'error', + }; +} + +/** + * The run's own message, plus the first few conflicts or the failure by name, so the notice says + * what to look at without opening anything. + * + * @param array $result + */ +function session_message(array $result): string +{ + $message = (string) ($result['message'] ?? ''); + $details = []; + // @mago-expect analysis:mixed-assignment -- Conflict rows are checked below. + foreach (array_slice(is_array($result['conflicts'] ?? null) ? $result['conflicts'] : [], 0, 5) as $conflict) { + if (!is_array($conflict)) { + continue; + } + $changed = is_array($conflict['changed'] ?? null) ? implode(', ', array_map('strval', array_slice($conflict['changed'], 0, 6))) : ''; + $details[] = sprintf( + /* translators: 1: ability name, 2: target, 3: fields that differ */ + __('%1$s on %2$s (changed: %3$s)', domain: 'wppilot'), + (string) ($conflict['ability'] ?? ''), + (string) ($conflict['target'] ?? ''), + $changed !== '' ? $changed : __('unknown', domain: 'wppilot'), + ); + } + $failed = is_array($result['failed'] ?? null) ? $result['failed'] : null; + if ($failed !== null) { + $details[] = sprintf( + /* translators: 1: ability name, 2: change id */ + __('Failed: %1$s (%2$s)', domain: 'wppilot'), + (string) ($failed['ability'] ?? ''), + (string) ($failed['change_id'] ?? ''), + ); + } + + return $details === [] ? $message : $message . ' ' . implode('; ', $details) . '.'; +} + +/** + * A session id short enough for a table cell. + */ +function session_label(string $session): string +{ + return strlen($session) > 18 ? substr($session, 0, 18) . '…' : $session; +} + /** * Download what the filters show, as CSV or JSON. */ diff --git a/includes/admin/changes/templates/detail.php b/includes/admin/changes/templates/detail.php index 0763ac5..9e6bbd6 100644 --- a/includes/admin/changes/templates/detail.php +++ b/includes/admin/changes/templates/detail.php @@ -89,6 +89,16 @@ + + + + + + +

+ + + 1) { ?> diff --git a/includes/admin/changes/templates/list.php b/includes/admin/changes/templates/list.php index cd907fa..191dc03 100644 --- a/includes/admin/changes/templates/list.php +++ b/includes/admin/changes/templates/list.php @@ -38,6 +38,7 @@ )))); $datetime_format = \wppilot_get_datetime_format(); $active_group = (string) ($filters['group'] ?? ''); +$active_session = (string) ($filters['session'] ?? ''); ?> @@ -51,6 +52,9 @@ + + +