Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"php tests/workflow-as-branch-smoke.php",
"php tests/workflow-branch-concurrency-gate-smoke.php",
"php tests/workflow-async-branch-payload-smoke.php",
"php tests/workflow-async-loopback-target-smoke.php",
"php tests/workflow-reconcile-race-smoke.php",
"php tests/workflow-lifecycle-smoke.php",
"php tests/agents-workflow-ability-smoke.php",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -565,8 +565,30 @@ public static function trigger_async_runner( int $workers ): int {
* Rewriting the host to the loopback IP and carrying the original host name in a
* `Host:` header reproduces that pin explicitly: the TCP connection goes straight
* to 127.0.0.1 (no DNS), and the local server still virtual-hosts the request to
* the correct site by the Host header. Only the host is rewritten; scheme, port,
* path, and query (the AS nonce) are preserved unchanged.
* the correct site by the Host header. By default only the host is rewritten;
* scheme, port, path, and query (the AS nonce) are preserved unchanged.
*
* ## Why scheme + port must come from the INTERNAL server, not the public URL
*
* The dispatch URL is derived from `admin_url( 'admin-ajax.php' )`, i.e. the
* site's PUBLIC front door. On a single-server site the loopback and the public
* URL share a scheme/port, so blindly reusing them is correct. But on a SPLIT
* runtime — a public HTTPS front door on `.local:443` fronting an internal
* plain-HTTP worker pool on `localhost:8882` — they diverge. There, reusing the
* public `https://…:443` points the loopback at a TLS listener that does not
* exist on 127.0.0.1, so every concurrent async-runner POST fails the TLS
* handshake and the fan-out silently collapses to the serial WP-Cron drain.
*
* The loopback must therefore target where the local server is ACTUALLY
* reachable on the loop — its real scheme, host, and port — which only the
* runtime knows. The `agents_workflow_async_runner_loopback_base` filter lets a
* runtime (or an mu-plugin) declare that internal base, e.g.
* `http://localhost:8882`. When set, its scheme/host/port replace the public
* ones and the original path + query (the AS nonce) are carried onto it; the
* canonical Host header still names the PUBLIC host:port so the local server
* virtual-hosts the request to the correct site. When the filter is unset the
* behavior is byte-for-byte the pre-filter default (host → 127.0.0.1, scheme +
* port + Host header from the public URL) so no single-server runtime regresses.
*
* When the URL cannot be parsed the original URL is returned with no extra header
* — a safe pass-through that keeps the normal resolution path.
Expand All @@ -585,27 +607,55 @@ private static function loopback_dispatch_target( string $url ): array {
);
}

$host = (string) $parts['host'];
$host = (string) $parts['host'];
$path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
$query = isset( $parts['query'] ) && '' !== (string) $parts['query'] ? '?' . (string) $parts['query'] : '';

// The canonical Host authority is always the PUBLIC host (+ its explicit port,
// if any) — the local server virtual-hosts on it, so a bare host or the
// internal port would not match the site.
$public_port = isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '';
$host_header = '' !== $public_port ? $host . $public_port : $host;

// A runtime whose internal loopback listener differs from its public front
// door (different scheme/host/port) declares that internal base here. Empty
// (the default) keeps the historical behavior below. The base is scheme +
// authority only, e.g. `http://localhost:8882`; the path + query (AS nonce)
// are always taken from the parsed dispatch URL.
$loopback_base = self::string_value( apply_filters( 'agents_workflow_async_runner_loopback_base', '', $url ) );
if ( '' !== $loopback_base ) {
$base_parts = wp_parse_url( $loopback_base );
if ( is_array( $base_parts ) && ! empty( $base_parts['host'] ) ) {
$base_scheme = isset( $base_parts['scheme'] ) && '' !== (string) $base_parts['scheme'] ? (string) $base_parts['scheme'] : 'http';
$base_host = (string) $base_parts['host'];
$base_port = isset( $base_parts['port'] ) ? ':' . (int) $base_parts['port'] : '';

// If the runtime pointed us at a loopback host (localhost / 127.0.0.1),
// no Host header rewrite is needed to dodge mDNS — but we STILL carry the
// canonical public Host header so the local server routes to the right
// site (its listener may serve several by Host).
return array(
'url' => $base_scheme . '://' . $base_host . $base_port . $path . $query,
'headers' => array( 'Host' => $host_header ),
);
}
}

// Already a loopback address — nothing to rewrite, no Host header needed.
// Default (no override): already a loopback address — nothing to rewrite, no
// Host header needed.
if ( '127.0.0.1' === $host || 'localhost' === $host ) {
return array(
'url' => $url,
'headers' => array(),
);
}

// Default (no override): rewrite the host to 127.0.0.1 to dodge mDNS, carrying
// the public scheme + port unchanged (correct for a single-server site).
$scheme = isset( $parts['scheme'] ) && '' !== (string) $parts['scheme'] ? (string) $parts['scheme'] : 'https';
$port = isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '';
$path = isset( $parts['path'] ) ? (string) $parts['path'] : '';
$query = isset( $parts['query'] ) && '' !== (string) $parts['query'] ? '?' . (string) $parts['query'] : '';

// Carry the port in the Host header too — the local server virtual-hosts on the
// full host:port authority, so a bare host would not match the site.
$host_header = '' !== $port ? $host . $port : $host;

return array(
'url' => $scheme . '://127.0.0.1' . $port . $path . $query,
'url' => $scheme . '://127.0.0.1' . $public_port . $path . $query,
'headers' => array( 'Host' => $host_header ),
);
}
Expand Down
243 changes: 243 additions & 0 deletions tests/workflow-async-loopback-target-smoke.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
<?php
/**
* Pure-PHP smoke test for the async-runner loopback dispatch TARGET rewrite —
* {@see WP_Agent_Workflow_Action_Scheduler_Branch_Executor::loopback_dispatch_target()}.
*
* Run with: php tests/workflow-async-loopback-target-smoke.php
*
* The loopback burst that warms N async-runner workers must POST to where the
* local server is ACTUALLY reachable on the loop. On a single-server site that is
* the public scheme/port (rewritten to 127.0.0.1 to dodge mDNS). On a SPLIT
* runtime — public HTTPS front door fronting an internal plain-HTTP worker pool
* on a non-443 port — the public `https://…:443` points at a TLS listener that
* does not exist on loopback, so every concurrent POST fails the TLS handshake
* and the fan-out collapses to the serial WP-Cron drain.
*
* Covered:
* - DEFAULT (no filter): a public `https://<.local host>/…` rewrites to
* `https://127.0.0.1/…` with the canonical `Host: <.local host>` header —
* byte-for-byte the historical behavior (no regression for single-server
* sites).
* - OVERRIDE (filter set to `http://localhost:8882`): the target becomes
* `http://localhost:8882/…` carrying the AS nonce, with a canonical
* `Host: <public host>` header — NOT `https://127.0.0.1:443/…`. This is the
* plain-HTTP-non-443 split-runtime case.
* - the AS nonce query survives the rewrite in both cases.
*
* `loopback_dispatch_target()` is private; the test invokes it via a bound
* closure and asserts on the ACTUAL constructed target — not a mock.
*
* No WordPress required.
*
* @package AgentsAPI\Tests
*/

defined( 'ABSPATH' ) || define( 'ABSPATH', __DIR__ . '/' );

$failures = array();
$passes = 0;

echo "workflow-async-loopback-target-smoke\n";

$GLOBALS['__filters'] = array();

if ( ! function_exists( 'add_filter' ) ) {
function add_filter( string $hook, callable $cb, int $priority = 10, int $accepted_args = 1 ): void {
unset( $accepted_args );
$GLOBALS['__filters'][ $hook ][ $priority ][] = $cb;
}
}
if ( ! function_exists( 'remove_all_filters' ) ) {
function remove_all_filters( string $hook ): void {
unset( $GLOBALS['__filters'][ $hook ] );
}
}
if ( ! function_exists( 'apply_filters' ) ) {
function apply_filters( string $hook, $value, ...$args ) {
$cbs = $GLOBALS['__filters'][ $hook ] ?? array();
ksort( $cbs );
foreach ( $cbs as $bucket ) {
foreach ( $bucket as $cb ) {
$value = call_user_func_array( $cb, array_merge( array( $value ), $args ) );
}
}
return $value;
}
}

// Minimal wp_parse_url shim (delegates to PHP's parse_url, WordPress's default).
if ( ! function_exists( 'wp_parse_url' ) ) {
function wp_parse_url( string $url, int $component = -1 ) {
return parse_url( $url, $component );
}
}

function smoke_assert( $expected, $actual, string $name, array &$failures, int &$passes ): void {
if ( $expected === $actual ) {
++$passes;
echo " PASS {$name}\n";
return;
}
$failures[] = $name;
echo " FAIL {$name}\n";
echo ' expected: ' . var_export( $expected, true ) . "\n";
echo ' actual: ' . var_export( $actual, true ) . "\n";
}

function smoke_assert_true( $actual, string $name, array &$failures, int &$passes ): void {
smoke_assert( true, (bool) $actual, $name, $failures, $passes );
}

require_once __DIR__ . '/../src/Workflows/interface-wp-agent-workflow-branch-executor.php';
require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-action-scheduler-branch-executor.php';

use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Action_Scheduler_Branch_Executor;

/**
* Invoke the private static loopback_dispatch_target() via a bound closure.
*
* @param string $url The admin-ajax dispatch URL.
* @return array{url:string,headers:array<string,string>}
*/
function loopback_target( string $url ): array {
$invoke = \Closure::bind(
static function ( string $u ): array {
return WP_Agent_Workflow_Action_Scheduler_Branch_Executor::loopback_dispatch_target( $u );
},
null,
WP_Agent_Workflow_Action_Scheduler_Branch_Executor::class
);
return $invoke( $url );
}

// A realistic public admin-ajax dispatch URL: HTTPS front door on a Studio
// `.local` host, carrying the AS async-runner action + nonce.
$public_url = 'https://fisiostetic.local/wp-admin/admin-ajax.php?action=as_async_request_queue_runner&nonce=abc123';

// ═════════════════════════════════════════════════════════════════════════════
// 1. DEFAULT (no filter) — historical behavior preserved byte-for-byte.
// ═════════════════════════════════════════════════════════════════════════════

remove_all_filters( 'agents_workflow_async_runner_loopback_base' );

$default = loopback_target( $public_url );
smoke_assert(
'https://127.0.0.1/wp-admin/admin-ajax.php?action=as_async_request_queue_runner&nonce=abc123',
$default['url'],
'default: public https/.local rewrites to https://127.0.0.1 (host dodge, scheme/port preserved)',
$failures,
$passes
);
smoke_assert(
'fisiostetic.local',
$default['headers']['Host'] ?? '',
'default: canonical Host header carries the public host',
$failures,
$passes
);

// A loopback host in the public URL is passed through untouched (no override).
$default_loopback = loopback_target( 'http://localhost:8888/wp-admin/admin-ajax.php?action=as_async_request_queue_runner&nonce=z' );
smoke_assert(
'http://localhost:8888/wp-admin/admin-ajax.php?action=as_async_request_queue_runner&nonce=z',
$default_loopback['url'],
'default: already-loopback URL passes through unchanged',
$failures,
$passes
);
smoke_assert_true(
empty( $default_loopback['headers'] ),
'default: already-loopback URL needs no Host header',
$failures,
$passes
);

// A public URL with an explicit non-default port keeps that port in URL + Host.
$default_ported = loopback_target( 'https://front.local:8443/wp-admin/admin-ajax.php?action=as_async_request_queue_runner&nonce=p' );
smoke_assert(
'https://127.0.0.1:8443/wp-admin/admin-ajax.php?action=as_async_request_queue_runner&nonce=p',
$default_ported['url'],
'default: explicit public port is preserved in the 127.0.0.1 target',
$failures,
$passes
);
smoke_assert(
'front.local:8443',
$default_ported['headers']['Host'] ?? '',
'default: explicit public port is carried in the Host authority',
$failures,
$passes
);

// ═════════════════════════════════════════════════════════════════════════════
// 2. OVERRIDE — the split-runtime plain-HTTP-non-443 case (the fix).
// ═════════════════════════════════════════════════════════════════════════════
// A runtime whose internal loopback is plain HTTP on localhost:8882 declares that
// base. The target must point there (correct scheme + host + port), NOT at the
// nonexistent https://127.0.0.1:443 TLS listener — while still carrying the
// canonical public Host header so the local server routes to the right site.

remove_all_filters( 'agents_workflow_async_runner_loopback_base' );
add_filter(
'agents_workflow_async_runner_loopback_base',
static function (): string {
return 'http://localhost:8882';
}
);

$override = loopback_target( $public_url );
smoke_assert(
'http://localhost:8882/wp-admin/admin-ajax.php?action=as_async_request_queue_runner&nonce=abc123',
$override['url'],
'override: split runtime targets its internal http://localhost:8882 (correct scheme + port)',
$failures,
$passes
);
smoke_assert(
'fisiostetic.local',
$override['headers']['Host'] ?? '',
'override: canonical public Host header preserved so the local server routes to the right site',
$failures,
$passes
);

// The regression this fixes: the target must NOT be the dead https/:443 form.
smoke_assert_true(
'https://127.0.0.1/wp-admin/admin-ajax.php?action=as_async_request_queue_runner&nonce=abc123' !== $override['url']
&& false === strpos( $override['url'], 'https://127.0.0.1' ),
'override: target is NOT the dead https://127.0.0.1(:443) TLS form',
$failures,
$passes
);

// The AS nonce query survives the override rewrite.
smoke_assert_true(
false !== strpos( $override['url'], 'action=as_async_request_queue_runner&nonce=abc123' ),
'override: the AS async-runner action + nonce survive the rewrite',
$failures,
$passes
);

// Filter receives the source URL as its second arg (so a runtime can branch on it).
remove_all_filters( 'agents_workflow_async_runner_loopback_base' );
$seen_url = '';
add_filter(
'agents_workflow_async_runner_loopback_base',
static function ( string $base, string $url ) use ( &$seen_url ): string {
$seen_url = $url;
return 'http://localhost:8882';
},
10,
2
);
loopback_target( $public_url );
smoke_assert(
$public_url,
$seen_url,
'override: filter receives the source dispatch URL as its second argument',
$failures,
$passes
);

echo "Passed: {$passes}, Failed: " . count( $failures ) . "\n";
exit( count( $failures ) > 0 ? 1 : 0 );