From a32474c15457b31d0e06399fd3dfcd71d01d4ff3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:55:44 +0000 Subject: [PATCH 1/7] Initial plan From 1c8ffc24f85057ae5c07fe8c4e6427eb3b7e9474 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:01:56 +0000 Subject: [PATCH 2/7] feat: add support for application passwords via env vars and config Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- inc/Runner.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/inc/Runner.php b/inc/Runner.php index 8597156..a3f94d3 100644 --- a/inc/Runner.php +++ b/inc/Runner.php @@ -30,11 +30,33 @@ public static function load_remote_commands() { // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url $bits = parse_url( $http ); $auth = array(); + + // Check wp-cli config for http_user / http_password (lowest priority). + $runner = WP_CLI::get_runner(); + if ( ! empty( $runner->config['http_user'] ) ) { + $auth['type'] = 'basic'; + $auth['username'] = $runner->config['http_user']; + $auth['password'] = ! empty( $runner->config['http_password'] ) ? $runner->config['http_password'] : ''; + } + + // Environment variables override config file values (medium priority). + // An empty username is not valid for authentication, so we skip if it is empty. + // An empty password is allowed (e.g. passwordless setups), consistent with URL embedding. + $env_user = getenv( 'WP_REST_CLI_AUTH_USER' ); + $env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' ); + if ( false !== $env_user && '' !== $env_user ) { + $auth['type'] = 'basic'; + $auth['username'] = $env_user; + $auth['password'] = ( false !== $env_password ) ? $env_password : ''; + } + + // Credentials embedded in the --http URL take highest priority. if ( ! empty( $bits['user'] ) ) { $auth['type'] = 'basic'; $auth['username'] = $bits['user']; $auth['password'] = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; } + foreach ( $api_index['routes'] as $route => $route_data ) { if ( empty( $route_data['schema']['title'] ) ) { WP_CLI::debug( "No schema title found for {$route}, skipping REST command registration.", 'rest' ); From 45233c1154c16ea09b366ad7460cb0ad94db4c90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 07:39:07 +0000 Subject: [PATCH 3/7] refactor: extract resolve_auth helper, fix parse_url scheme, add unit tests Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- inc/Runner.php | 79 ++++++++------ tests/Runner_Resolve_Auth_Test.php | 160 +++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 29 deletions(-) create mode 100644 tests/Runner_Resolve_Auth_Test.php diff --git a/inc/Runner.php b/inc/Runner.php index a3f94d3..41e5d2c 100644 --- a/inc/Runner.php +++ b/inc/Runner.php @@ -27,35 +27,7 @@ public static function load_remote_commands() { if ( ! $api_index ) { WP_CLI::error( "Couldn't find index data from {$api_url}." ); } - // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url - $bits = parse_url( $http ); - $auth = array(); - - // Check wp-cli config for http_user / http_password (lowest priority). - $runner = WP_CLI::get_runner(); - if ( ! empty( $runner->config['http_user'] ) ) { - $auth['type'] = 'basic'; - $auth['username'] = $runner->config['http_user']; - $auth['password'] = ! empty( $runner->config['http_password'] ) ? $runner->config['http_password'] : ''; - } - - // Environment variables override config file values (medium priority). - // An empty username is not valid for authentication, so we skip if it is empty. - // An empty password is allowed (e.g. passwordless setups), consistent with URL embedding. - $env_user = getenv( 'WP_REST_CLI_AUTH_USER' ); - $env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' ); - if ( false !== $env_user && '' !== $env_user ) { - $auth['type'] = 'basic'; - $auth['username'] = $env_user; - $auth['password'] = ( false !== $env_password ) ? $env_password : ''; - } - - // Credentials embedded in the --http URL take highest priority. - if ( ! empty( $bits['user'] ) ) { - $auth['type'] = 'basic'; - $auth['username'] = $bits['user']; - $auth['password'] = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; - } + $auth = self::resolve_auth( $http, WP_CLI::get_runner()->config ); foreach ( $api_index['routes'] as $route => $route_data ) { if ( empty( $route_data['schema']['title'] ) ) { @@ -151,6 +123,55 @@ private static function get_api_index( $api_url ) { return json_decode( $response->body, true ); } + /** + * Resolve HTTP Basic Auth credentials from the available sources. + * + * Priority (highest wins): + * 1. Credentials embedded in the URL (user:pass@host). + * 2. WP_REST_CLI_AUTH_USER / WP_REST_CLI_AUTH_PASSWORD environment variables. + * 3. http_user / http_password keys in the WP-CLI config. + * + * @param string $http The URL passed to --http. + * @param array $config WP-CLI config array (e.g. WP_CLI::get_runner()->config). + * @return array Auth array with 'type', 'username', 'password' keys, or empty array. + */ + public static function resolve_auth( $http, array $config = array() ) { + $auth = array(); + + // Lowest priority: wp-cli config (http_user / http_password). + if ( ! empty( $config['http_user'] ) ) { + $auth['type'] = 'basic'; + $auth['username'] = $config['http_user']; + $auth['password'] = ! empty( $config['http_password'] ) ? $config['http_password'] : ''; + } + + // Medium priority: environment variables. + // An empty username is not valid for authentication, so we skip if it is empty. + // An empty password is allowed (e.g. passwordless setups), consistent with URL embedding. + $env_user = getenv( 'WP_REST_CLI_AUTH_USER' ); + $env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' ); + if ( false !== $env_user && '' !== $env_user ) { + $auth['type'] = 'basic'; + $auth['username'] = $env_user; + $auth['password'] = ( false !== $env_password ) ? $env_password : ''; + } + + // Highest priority: credentials embedded in the URL. + // Ensure the URL has a scheme so parse_url() can extract user:pass correctly. + if ( false === stripos( $http, 'http://' ) && false === stripos( $http, 'https://' ) ) { + $http = 'http://' . $http; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + $bits = parse_url( $http ); + if ( ! empty( $bits['user'] ) ) { + $auth['type'] = 'basic'; + $auth['username'] = $bits['user']; + $auth['password'] = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; + } + + return $auth; + } + /** * Register WP-CLI commands for all endpoints on a route * diff --git a/tests/Runner_Resolve_Auth_Test.php b/tests/Runner_Resolve_Auth_Test.php new file mode 100644 index 0000000..a87afaf --- /dev/null +++ b/tests/Runner_Resolve_Auth_Test.php @@ -0,0 +1,160 @@ +saved_env[ $var ] = false === $val ? false : $val; + putenv( $var ); + } + } + + public function tear_down() { + foreach ( $this->saved_env as $var => $val ) { + if ( false === $val ) { + putenv( $var ); + } else { + putenv( "{$var}={$val}" ); + } + } + } + + public function test_no_auth_when_nothing_set() { + $auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' ); + $this->assertSame( array(), $auth ); + } + + public function test_auth_from_config() { + $auth = \WP_REST_CLI\Runner::resolve_auth( + 'example.com', + array( + 'http_user' => 'admin', + 'http_password' => 'secret', + ) + ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'admin', + 'password' => 'secret', + ), + $auth + ); + } + + public function test_config_allows_empty_password() { + $auth = \WP_REST_CLI\Runner::resolve_auth( + 'example.com', + array( 'http_user' => 'admin' ) + ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'admin', + 'password' => '', + ), + $auth + ); + } + + public function test_env_vars_override_config() { + putenv( 'WP_REST_CLI_AUTH_USER=envuser' ); + putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' ); + $auth = \WP_REST_CLI\Runner::resolve_auth( + 'example.com', + array( + 'http_user' => 'cfguser', + 'http_password' => 'cfgpass', + ) + ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'envuser', + 'password' => 'envpass', + ), + $auth + ); + } + + public function test_env_user_without_env_password_uses_empty_password() { + putenv( 'WP_REST_CLI_AUTH_USER=envuser' ); + $auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'envuser', + 'password' => '', + ), + $auth + ); + } + + public function test_empty_env_user_skips_env_auth() { + putenv( 'WP_REST_CLI_AUTH_USER=' ); + putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' ); + $auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' ); + $this->assertSame( array(), $auth ); + } + + public function test_url_credentials_override_env_vars() { + putenv( 'WP_REST_CLI_AUTH_USER=envuser' ); + putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' ); + $auth = \WP_REST_CLI\Runner::resolve_auth( 'http://urluser:urlpass@example.com' ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'urluser', + 'password' => 'urlpass', + ), + $auth + ); + } + + public function test_url_credentials_without_scheme() { + $auth = \WP_REST_CLI\Runner::resolve_auth( 'urluser:urlpass@example.com' ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'urluser', + 'password' => 'urlpass', + ), + $auth + ); + } + + public function test_url_credentials_with_https_scheme() { + $auth = \WP_REST_CLI\Runner::resolve_auth( 'https://urluser:urlpass@example.com' ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'urluser', + 'password' => 'urlpass', + ), + $auth + ); + } + + public function test_url_credentials_override_config() { + $auth = \WP_REST_CLI\Runner::resolve_auth( + 'http://urluser:urlpass@example.com', + array( + 'http_user' => 'cfguser', + 'http_password' => 'cfgpass', + ) + ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'urluser', + 'password' => 'urlpass', + ), + $auth + ); + } +} From c36eef10c64e876a9807fc68c2c585ec4cdbf079 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 09:03:45 +0100 Subject: [PATCH 4/7] Apply suggestions from code review Co-authored-by: Pascal Birchler --- inc/Runner.php | 74 ++++++++++++++++-------------- tests/Runner_Resolve_Auth_Test.php | 46 +++++++++---------- 2 files changed, 63 insertions(+), 57 deletions(-) diff --git a/inc/Runner.php b/inc/Runner.php index 41e5d2c..8dc0fa7 100644 --- a/inc/Runner.php +++ b/inc/Runner.php @@ -136,40 +136,46 @@ private static function get_api_index( $api_url ) { * @return array Auth array with 'type', 'username', 'password' keys, or empty array. */ public static function resolve_auth( $http, array $config = array() ) { - $auth = array(); - - // Lowest priority: wp-cli config (http_user / http_password). - if ( ! empty( $config['http_user'] ) ) { - $auth['type'] = 'basic'; - $auth['username'] = $config['http_user']; - $auth['password'] = ! empty( $config['http_password'] ) ? $config['http_password'] : ''; - } - - // Medium priority: environment variables. - // An empty username is not valid for authentication, so we skip if it is empty. - // An empty password is allowed (e.g. passwordless setups), consistent with URL embedding. - $env_user = getenv( 'WP_REST_CLI_AUTH_USER' ); - $env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' ); - if ( false !== $env_user && '' !== $env_user ) { - $auth['type'] = 'basic'; - $auth['username'] = $env_user; - $auth['password'] = ( false !== $env_password ) ? $env_password : ''; - } - - // Highest priority: credentials embedded in the URL. - // Ensure the URL has a scheme so parse_url() can extract user:pass correctly. - if ( false === stripos( $http, 'http://' ) && false === stripos( $http, 'https://' ) ) { - $http = 'http://' . $http; - } - // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url - $bits = parse_url( $http ); - if ( ! empty( $bits['user'] ) ) { - $auth['type'] = 'basic'; - $auth['username'] = $bits['user']; - $auth['password'] = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; - } - - return $auth; + $username = null; + $password = ''; + + // Lowest priority: wp-cli config (http_user / http_password). + if ( ! empty( $config['http_user'] ) ) { + $username = $config['http_user']; + $password = ! empty( $config['http_password'] ) ? $config['http_password'] : ''; + } + + // Medium priority: environment variables. + // An empty username is not valid for authentication, so we skip if it is empty. + // An empty password is allowed (e.g. passwordless setups), consistent with URL embedding. + $env_user = getenv( 'WP_REST_CLI_AUTH_USER' ); + if ( false !== $env_user && '' !== $env_user ) { + $username = $env_user; + $env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' ); + $password = ( false !== $env_password ) ? $env_password : ''; + } + + // Highest priority: credentials embedded in the URL. + // Ensure the URL has a scheme so parse_url() can extract user:pass correctly. + if ( false === stripos( $http, 'http://' ) && false === stripos( $http, 'https://' ) ) { + $http = 'http://' . $http; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + $bits = parse_url( $http ); + if ( ! empty( $bits['user'] ) ) { + $username = $bits['user']; + $password = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; + } + + if ( null === $username ) { + return array(); + } + + return array( + 'type' => 'basic', + 'username' => $username, + 'password' => $password, + ); } /** diff --git a/tests/Runner_Resolve_Auth_Test.php b/tests/Runner_Resolve_Auth_Test.php index a87afaf..2a94107 100644 --- a/tests/Runner_Resolve_Auth_Test.php +++ b/tests/Runner_Resolve_Auth_Test.php @@ -116,29 +116,29 @@ public function test_url_credentials_override_env_vars() { ); } - public function test_url_credentials_without_scheme() { - $auth = \WP_REST_CLI\Runner::resolve_auth( 'urluser:urlpass@example.com' ); - $this->assertSame( - array( - 'type' => 'basic', - 'username' => 'urluser', - 'password' => 'urlpass', - ), - $auth - ); - } - - public function test_url_credentials_with_https_scheme() { - $auth = \WP_REST_CLI\Runner::resolve_auth( 'https://urluser:urlpass@example.com' ); - $this->assertSame( - array( - 'type' => 'basic', - 'username' => 'urluser', - 'password' => 'urlpass', - ), - $auth - ); - } + /** + * @dataProvider provide_url_credentials + */ + public function test_url_credentials( $url, $expected_user, $expected_pass ) { + $auth = \WP_REST_CLI\Runner::resolve_auth( $url ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => $expected_user, + 'password' => $expected_pass, + ), + $auth + ); + } + + public function provide_url_credentials() { + return array( + 'no scheme' => array( 'urluser:urlpass@example.com', 'urluser', 'urlpass' ), + 'https scheme' => array( 'https://urluser:urlpass@example.com', 'urluser', 'urlpass' ), + 'user only' => array( 'urluser@example.com', 'urluser', '' ), + 'user only, https' => array( 'https://urluser@example.com', 'urluser', '' ), + ); + } public function test_url_credentials_override_config() { $auth = \WP_REST_CLI\Runner::resolve_auth( From a5c2af7712616b84a4584d712e637814d380e5cc Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 09:06:26 +0100 Subject: [PATCH 5/7] Data provider attribute --- tests/Runner_Resolve_Auth_Test.php | 48 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/tests/Runner_Resolve_Auth_Test.php b/tests/Runner_Resolve_Auth_Test.php index 2a94107..90cc9ee 100644 --- a/tests/Runner_Resolve_Auth_Test.php +++ b/tests/Runner_Resolve_Auth_Test.php @@ -1,6 +1,7 @@ assertSame( - array( - 'type' => 'basic', - 'username' => $expected_user, - 'password' => $expected_pass, - ), - $auth - ); - } - - public function provide_url_credentials() { - return array( - 'no scheme' => array( 'urluser:urlpass@example.com', 'urluser', 'urlpass' ), - 'https scheme' => array( 'https://urluser:urlpass@example.com', 'urluser', 'urlpass' ), - 'user only' => array( 'urluser@example.com', 'urluser', '' ), - 'user only, https' => array( 'https://urluser@example.com', 'urluser', '' ), - ); - } + /** + * @dataProvider provide_url_credentials + */ + #[DataProvider( 'provide_url_credentials' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound + public function test_url_credentials( $url, $expected_user, $expected_pass ) { + $auth = \WP_REST_CLI\Runner::resolve_auth( $url ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => $expected_user, + 'password' => $expected_pass, + ), + $auth + ); + } + + public static function provide_url_credentials() { + return array( + 'no scheme' => array( 'urluser:urlpass@example.com', 'urluser', 'urlpass' ), + 'https scheme' => array( 'https://urluser:urlpass@example.com', 'urluser', 'urlpass' ), + 'user only' => array( 'urluser@example.com', 'urluser', '' ), + 'user only, https' => array( 'https://urluser@example.com', 'urluser', '' ), + ); + } public function test_url_credentials_override_config() { $auth = \WP_REST_CLI\Runner::resolve_auth( From 8cce6fe50358cbc4a9b0bf8f4614e906ce13392f Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 09:06:30 +0100 Subject: [PATCH 6/7] Add .gitattributes --- .gitattributes | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d84f4ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/.actrc export-ignore +/.distignore export-ignore +/.editorconfig export-ignore +/.github export-ignore +/.gitignore export-ignore +/.typos.toml export-ignore +/AGENTS.md export-ignore +/behat.yml export-ignore +/features export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/wp-cli.yml export-ignore From c10237bb1620e0786de05143b35f73a8afeb1bf6 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Fri, 20 Mar 2026 09:08:09 +0100 Subject: [PATCH 7/7] Lint fixes --- inc/Runner.php | 80 +++++++++++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/inc/Runner.php b/inc/Runner.php index 8dc0fa7..3469e94 100644 --- a/inc/Runner.php +++ b/inc/Runner.php @@ -136,46 +136,46 @@ private static function get_api_index( $api_url ) { * @return array Auth array with 'type', 'username', 'password' keys, or empty array. */ public static function resolve_auth( $http, array $config = array() ) { - $username = null; - $password = ''; - - // Lowest priority: wp-cli config (http_user / http_password). - if ( ! empty( $config['http_user'] ) ) { - $username = $config['http_user']; - $password = ! empty( $config['http_password'] ) ? $config['http_password'] : ''; - } - - // Medium priority: environment variables. - // An empty username is not valid for authentication, so we skip if it is empty. - // An empty password is allowed (e.g. passwordless setups), consistent with URL embedding. - $env_user = getenv( 'WP_REST_CLI_AUTH_USER' ); - if ( false !== $env_user && '' !== $env_user ) { - $username = $env_user; - $env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' ); - $password = ( false !== $env_password ) ? $env_password : ''; - } - - // Highest priority: credentials embedded in the URL. - // Ensure the URL has a scheme so parse_url() can extract user:pass correctly. - if ( false === stripos( $http, 'http://' ) && false === stripos( $http, 'https://' ) ) { - $http = 'http://' . $http; - } - // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url - $bits = parse_url( $http ); - if ( ! empty( $bits['user'] ) ) { - $username = $bits['user']; - $password = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; - } - - if ( null === $username ) { - return array(); - } - - return array( - 'type' => 'basic', - 'username' => $username, - 'password' => $password, - ); + $username = null; + $password = ''; + + // Lowest priority: wp-cli config (http_user / http_password). + if ( ! empty( $config['http_user'] ) ) { + $username = $config['http_user']; + $password = ! empty( $config['http_password'] ) ? $config['http_password'] : ''; + } + + // Medium priority: environment variables. + // An empty username is not valid for authentication, so we skip if it is empty. + // An empty password is allowed (e.g. passwordless setups), consistent with URL embedding. + $env_user = getenv( 'WP_REST_CLI_AUTH_USER' ); + if ( false !== $env_user && '' !== $env_user ) { + $username = $env_user; + $env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' ); + $password = ( false !== $env_password ) ? $env_password : ''; + } + + // Highest priority: credentials embedded in the URL. + // Ensure the URL has a scheme so parse_url() can extract user:pass correctly. + if ( false === stripos( $http, 'http://' ) && false === stripos( $http, 'https://' ) ) { + $http = 'http://' . $http; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + $bits = parse_url( $http ); + if ( ! empty( $bits['user'] ) ) { + $username = $bits['user']; + $password = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; + } + + if ( null === $username ) { + return array(); + } + + return array( + 'type' => 'basic', + 'username' => $username, + 'password' => $password, + ); } /**