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
2 changes: 2 additions & 0 deletions docs/channels-workflows-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ array(

Cancellation is best-effort. A runtime that can abort provider work immediately may do so; a runtime that cannot should mark the run `cancelling` and let its conversation loop stop at the next interrupt check. `WP_Agent_Chat_Run_Control::cancellation_interrupt_message()` builds the message shape expected by `WP_Agent_Conversation_Loop` `interrupt_source` callbacks.

Read abilities return observer-safe run envelopes for non-operators. Stored run state may contain runtime diagnostics, package/workflow selectors, raw refs, provenance, output, or caller metadata for audit/debugging, but `agents/get-chat-run`, `agents/list-chat-run-events`, `agents/get-task-run`, `agents/get-runtime-package-run`, and `agents/list-runtime-package-run-events` redact high-risk keys unless the caller passes the explicit unredacted read gate. Managers keep full access by default; hosts can extend that path with `agents_chat_run_unredacted_read_permission`, `agents_task_unredacted_read_permission`, or `agents_runtime_package_run_unredacted_read_permission` while leaving broad read access observer-safe.

Queued messages return the same run payload plus `queued_message_id` and `position`. Async runtimes can drain queued messages through their worker, cron, or Action Scheduler integration. Synchronous runtimes can expose queued state and require polling or an explicit continue operation in the consuming product; the substrate does not force a background runner.

## Session, webhook, and idempotency helpers
Expand Down
4 changes: 3 additions & 1 deletion docs/runtime-and-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,9 @@ Canonical keys:
| `capability_scope` | `runtime_local` or `control_plane` | Whether a host may expose the tool to a delegated runtime or should keep it in the parent/control-plane runtime. |
| `environment` | `runtime_local` or `control_plane` | The intended execution environment for a declaration or result. |

The substrate treats `runtime` as a JSON-friendly associative array. It preserves scalar and nested array values with string keys, drops unsupported values, and leaves product-specific interpretation to callers.
The substrate treats `runtime` as a JSON-friendly associative array. It preserves scalar and nested array values with string keys, drops unsupported values, redacts sensitive key names such as tokens, secrets, cookies, nonces, passwords, authorization headers, and API keys, and leaves product-specific interpretation to callers.

`parameter_defaults` is a model/request-facing declaration field, not private secret storage. Defaults for sensitive-looking parameter names are normalized to `[redacted]`; hosts that need credentials should resolve them inside the concrete executor or through an explicit binding/authorization layer that is not serialized into the tool declaration.

Delegated runtime consumers should advertise runtime-local tools with `capability_scope: runtime_local` and `environment: runtime_local`. Control-plane tools such as repository mutation, deployment, approval, or parent orchestration actions should use `capability_scope: control_plane` and stay out of runtime-local declarations. Agents API records and propagates this vocabulary; hosts still own the concrete allow/deny policy and execution adapter.

Expand Down
36 changes: 33 additions & 3 deletions src/Channels/register-agents-chat-run-control-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ static function (): void {
function agents_get_chat_run( array $input ) {
$result = agents_chat_run_control_adapter()->get_run( $input );
if ( null !== $result ) {
return is_wp_error( $result ) ? $result : agents_chat_run_control_normalize_result( $result, 'agents_chat_run_invalid_status' );
if ( is_wp_error( $result ) ) {
return $result;
}

$result = agents_chat_run_control_normalize_result( $result, 'agents_chat_run_invalid_status' );
return is_wp_error( $result ) ? $result : agents_chat_run_observer_payload( $result, $input );
}

$run = WP_Agent_Chat_Run_Control::get_run( agents_chat_run_control_string( $input['run_id'] ?? '' ) );
Expand All @@ -107,7 +112,7 @@ function agents_get_chat_run( array $input ) {
return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested session_id and run_id.' );
}
if ( null !== $run ) {
return $run;
return agents_chat_run_observer_payload( $run, $input );
}

return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested run_id.' );
Expand All @@ -118,7 +123,8 @@ function agents_get_chat_run( array $input ) {
* @return array<string, mixed>|\WP_Error
*/
function agents_list_chat_run_events( array $input ) {
return agents_chat_run_events_normalize_result( agents_chat_run_control_adapter()->list_events( $input ) );
$result = agents_chat_run_events_normalize_result( agents_chat_run_control_adapter()->list_events( $input ) );
return is_wp_error( $result ) ? $result : agents_chat_run_observer_payload( $result, $input );
}

/**
Expand Down Expand Up @@ -242,6 +248,30 @@ function agents_chat_run_write_permission( array $input ): bool {
return $allowed || agents_chat_run_current_user_owns_session( $input );
}

/** @param array<string,mixed> $input Ability input. */
function agents_chat_run_unredacted_read_permission( array $input ): bool {
$allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false;
$agent = sanitize_title( agents_chat_run_control_string( $input['agent'] ?? '' ) );
if ( '' !== $agent && class_exists( '\WP_Agent_Access' ) && class_exists( '\WP_Agent_Access_Grant' ) ) {
$allowed = $allowed || \WP_Agent_Access::can_current_principal_access_agent(
$agent,
\WP_Agent_Access_Grant::ROLE_OPERATOR,
agents_chat_run_control_request_scope( $input )
);
}

return (bool) apply_filters( 'agents_chat_run_unredacted_read_permission', $allowed, $input );
}

/**
* @param array<string,mixed> $payload Run or event-page payload.
* @param array<string,mixed> $input Ability input.
* @return array<string,mixed>
*/
function agents_chat_run_observer_payload( array $payload, array $input ): array {
return agents_chat_run_unredacted_read_permission( $input ) ? $payload : WP_Agent_Run_Control::redacted_observer_payload( $payload );
}

/** @param array<string, mixed> $input Ability input. */
function agents_chat_run_current_user_owns_session( array $input ): bool {
$owner = is_array( $input['session_owner'] ?? null ) ? agents_chat_run_control_string_keyed_array( $input['session_owner'] ) : array();
Expand Down
36 changes: 36 additions & 0 deletions src/Runtime/class-wp-agent-run-control.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,17 @@ public static function normalize_event( array $event ): array {
return $normalized;
}

/**
* Build an observer-safe run/event payload without changing stored state.
*
* @param array<string,mixed> $payload Normalized run or event-page payload.
* @return array<string,mixed> Redacted payload for non-operator readers.
*/
public static function redacted_observer_payload( array $payload ): array {
$redacted = self::redact_payload_value( $payload );
return is_array( $redacted ) ? self::string_keyed_array( $redacted ) : array();
}

/**
* Start or update an addressable run in the selected store.
*
Expand Down Expand Up @@ -413,6 +424,31 @@ private static function int_value( mixed $value ): int {
return is_int( $value ) || is_float( $value ) || is_string( $value ) || is_bool( $value ) ? (int) $value : 0;
}

/**
* @param mixed $value Raw value.
* @return mixed Redacted value.
*/
private static function redact_payload_value( $value, string $key = '' ) {
if ( '' !== $key && self::is_sensitive_payload_key( $key ) ) {
return is_array( $value ) ? array( 'redacted' => true ) : '[redacted]';
}

if ( ! is_array( $value ) ) {
return $value;
}

$redacted = array();
foreach ( $value as $item_key => $item_value ) {
$redacted[ $item_key ] = self::redact_payload_value( $item_value, is_string( $item_key ) ? $item_key : '' );
}

return $redacted;
}

private static function is_sensitive_payload_key( string $key ): bool {
return 1 === preg_match( '/(api[_-]?key|authorization|auth[_-]?token|bearer|cookie|credential|diagnostics|nonce|output|package|password|private[_-]?key|provenance|raw|secret|token|workflow)/i', $key );
}

/**
* @param array{runs:array<string,array<string,mixed>>,queues:array<string,array<int,array<string,mixed>>>,events:array<string,array<int,array<string,mixed>>>} $state
* @param array<string,mixed> $metadata Event metadata.
Expand Down
27 changes: 22 additions & 5 deletions src/Runtime/register-runtime-package-run-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,15 +260,16 @@ function agents_runtime_package_run_dispatch( array $input ) {
function agents_get_runtime_package_run( array $input ) {
$handler = apply_filters( 'wp_agent_runtime_package_run_status_handler', null, $input );
if ( is_callable( $handler ) ) {
return WP_Agent_Run_Control::normalize_run_result( call_user_func( $handler, $input ), 'agents_runtime_package_run_invalid_status' );
$result = WP_Agent_Run_Control::normalize_run_result( call_user_func( $handler, $input ), 'agents_runtime_package_run_invalid_status' );
return is_wp_error( $result ) ? $result : agents_runtime_package_run_observer_payload( $result, $input );
}

$run = WP_Agent_Run_Control::get_run( AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE, agents_runtime_package_run_string( $input['run_id'] ?? '' ) );
if ( null === $run ) {
return new \WP_Error( 'agents_runtime_package_run_not_found', 'No runtime package run was found for the requested run_id.' );
}

return $run;
return agents_runtime_package_run_observer_payload( $run, $input );
}

/**
Expand Down Expand Up @@ -297,7 +298,8 @@ function agents_cancel_runtime_package_run( array $input ) {
function agents_list_runtime_package_run_events( array $input ) {
$handler = apply_filters( 'wp_agent_runtime_package_run_events_handler', null, $input );
if ( is_callable( $handler ) ) {
return WP_Agent_Run_Control::normalize_events_result( call_user_func( $handler, $input ), 'agents_runtime_package_run_invalid_events_result' );
$result = WP_Agent_Run_Control::normalize_events_result( call_user_func( $handler, $input ), 'agents_runtime_package_run_invalid_events_result' );
return is_wp_error( $result ) ? $result : agents_runtime_package_run_observer_payload( $result, $input );
}

$result = WP_Agent_Run_Control::list_events(
Expand All @@ -310,7 +312,7 @@ function agents_list_runtime_package_run_events( array $input ) {
return new \WP_Error( 'agents_runtime_package_run_not_found', 'No runtime package run was found for the requested run_id.' );
}

return $result;
return agents_runtime_package_run_observer_payload( $result, $input );
}

/**
Expand All @@ -332,10 +334,25 @@ function agents_runtime_package_run_permission( array $input ): bool {

/** @param array<string,mixed> $input Ability input. */
function agents_runtime_package_run_read_permission( array $input ): bool {
$allowed = function_exists( 'current_user_can' ) ? current_user_can( 'read' ) : false;
$allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false;
return (bool) apply_filters( 'agents_runtime_package_run_read_permission', $allowed, $input );
}

/** @param array<string,mixed> $input Ability input. */
function agents_runtime_package_run_unredacted_read_permission( array $input ): bool {
$allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false;
return (bool) apply_filters( 'agents_runtime_package_run_unredacted_read_permission', $allowed, $input );
}

/**
* @param array<string,mixed> $payload Run or event-page payload.
* @param array<string,mixed> $input Ability input.
* @return array<string,mixed>
*/
function agents_runtime_package_run_observer_payload( array $payload, array $input ): array {
return agents_runtime_package_run_unredacted_read_permission( $input ) ? $payload : WP_Agent_Run_Control::redacted_observer_payload( $payload );
}

/** @param array<string,mixed> $input Ability input. */
function agents_runtime_package_run_cancel_permission( array $input ): bool {
return agents_runtime_package_run_permission( $input );
Expand Down
20 changes: 18 additions & 2 deletions src/Tasks/register-agents-task-abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ function agents_list_execution_targets( array $input ) {
function agents_get_task_run( array $input ) {
$handler = apply_filters( 'wp_agent_task_run_status_handler', null, $input );
if ( is_callable( $handler ) ) {
return agents_task_normalize_run_control_result( call_user_func( $handler, $input ), 'agents_task_run_invalid_status' );
$result = agents_task_normalize_run_control_result( call_user_func( $handler, $input ), 'agents_task_run_invalid_status' );
return is_wp_error( $result ) ? $result : agents_task_run_observer_payload( $result, $input );
}

$run = WP_Agent_Task_Run_Control::get_run( agents_task_string( $input['run_id'] ?? '' ) );
Expand All @@ -249,7 +250,7 @@ function agents_get_task_run( array $input ) {
return new \WP_Error( 'agents_task_run_not_found', 'No task run was found for the requested session_id and run_id.' );
}
if ( null !== $run ) {
return $run;
return agents_task_run_observer_payload( $run, $input );
}

return new \WP_Error( 'agents_task_run_not_found', 'No task run was found for the requested run_id.' );
Expand Down Expand Up @@ -453,6 +454,21 @@ function agents_task_read_permission( array $input ): bool {
return (bool) apply_filters( 'agents_task_permission', $allowed, $input );
}

/** @param array<string,mixed> $input Ability input. */
function agents_task_unredacted_read_permission( array $input ): bool {
$allowed = function_exists( 'current_user_can' ) ? current_user_can( 'manage_options' ) : false;
return (bool) apply_filters( 'agents_task_unredacted_read_permission', $allowed, $input );
}

/**
* @param array<string,mixed> $payload Run payload.
* @param array<string,mixed> $input Ability input.
* @return array<string,mixed>
*/
function agents_task_run_observer_payload( array $payload, array $input ): array {
return agents_task_unredacted_read_permission( $input ) ? $payload : \AgentsAPI\AI\WP_Agent_Run_Control::redacted_observer_payload( $payload );
}

/** @param array<string,mixed> $input Ability input. */
function agents_run_task_permission( array $input ): bool {
$allowed = agents_task_write_permission( $input );
Expand Down
4 changes: 4 additions & 0 deletions src/Tools/class-wp-agent-tool-declaration.php
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,10 @@ public static function normalizeRuntimeMetadata( $runtime ): array {
if ( ! is_string( $key ) || '' === $key ) {
continue;
}
if ( WP_Agent_Tool_Parameters::sensitiveKey( $key ) ) {
$normalized[ $key ] = WP_Agent_Tool_Parameters::REDACTED_VALUE;
continue;
}

$normalized_value = self::normalizeRuntimeMetadataValue( $value );
if ( null === $normalized_value ) {
Expand Down
5 changes: 3 additions & 2 deletions src/Tools/class-wp-agent-tool-parameters.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ public static function normalizeParameterDefaults( array $tool_definition ): arr
throw new \InvalidArgumentException( 'invalid_parameter_bindings: parameter_defaults' );
}

$normalized[ trim( $parameter_name ) ] = $value;
$parameter_name = trim( $parameter_name );
$normalized[ $parameter_name ] = self::sensitiveKey( $parameter_name ) ? self::REDACTED_VALUE : $value;
}

return $normalized;
Expand Down Expand Up @@ -522,7 +523,7 @@ private static function redactValue( $value, string $path, array $paths ) {
* @param string $key Parameter key.
* @return bool
*/
private static function sensitiveKey( string $key ): bool {
public static function sensitiveKey( string $key ): bool {
return '' !== $key && 1 === preg_match( '/(api[_-]?key|authorization|auth[_-]?token|bearer|cookie|credential|nonce|password|private[_-]?key|secret|session[_-]?id|token)/i', $key );
}

Expand Down
8 changes: 7 additions & 1 deletion tests/chat-run-control-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) {
'status' => 'running',
'started_at' => '2026-01-01T00:00:00Z',
'updated_at' => '2026-01-01T00:00:01Z',
'metadata' => array( 'provider' => 'test' ),
'metadata' => array( 'provider' => 'test', 'token' => 'secret-token' ),
),
10,
2
Expand All @@ -225,6 +225,12 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) {
$status = AgentsAPI\AI\Channels\agents_get_chat_run( array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) );
agents_api_smoke_assert_equals( 'running', $status['status'] ?? null, 'get-run normalizes status payload', $failures, $passes );
agents_api_smoke_assert_equals( 'test', $status['metadata']['provider'] ?? null, 'get-run preserves metadata', $failures, $passes );
agents_api_smoke_assert_equals( 'secret-token', $status['metadata']['token'] ?? null, 'manager get-run preserves operator metadata', $failures, $passes );
$GLOBALS['__agents_api_smoke_caps']['manage_options'] = false;
$observer_status = AgentsAPI\AI\Channels\agents_get_chat_run( array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) );
agents_api_smoke_assert_equals( 'test', $observer_status['metadata']['provider'] ?? null, 'observer get-run preserves safe metadata', $failures, $passes );
agents_api_smoke_assert_equals( '[redacted]', $observer_status['metadata']['token'] ?? null, 'observer get-run redacts metadata secrets', $failures, $passes );
$GLOBALS['__agents_api_smoke_caps']['manage_options'] = true;

add_filter(
'wp_agent_chat_run_cancel_handler',
Expand Down
5 changes: 5 additions & 0 deletions tests/no-product-imports-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
'wp-site generator',
'WPSG',
'wpsg',
'Codebox',
'codebox',
'Studio Native',
'studio-native',
'studio_native',
);

$forbidden_admin_apis = array(
Expand Down
43 changes: 43 additions & 0 deletions tests/runtime-package-run-contract-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ function is_wp_error( $value ): bool {
}
}

if ( ! function_exists( 'current_user_can' ) ) {
function current_user_can( string $capability ): bool {
return ! empty( $GLOBALS['__agents_api_smoke_caps'][ $capability ] );
}
}

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

if ( ! function_exists( 'get_option' ) ) {
function get_option( string $option, $default = false ) {
return $GLOBALS['__agents_api_smoke_options'][ $option ] ?? $default;
}
}

if ( ! function_exists( 'update_option' ) ) {
function update_option( string $option, $value, $autoload = null ): bool {
unset( $autoload );
$GLOBALS['__agents_api_smoke_options'][ $option ] = $value;
return true;
}
}

require_once __DIR__ . '/agents-api-smoke-helpers.php';

if ( ! class_exists( 'WP_Ability' ) ) {
Expand Down Expand Up @@ -331,6 +353,27 @@ static function ( $handler, WP_Agent_Runtime_Package_Run_Request $handler_reques
agents_api_smoke_assert_equals( 'build-site', is_array( $dispatch ) ? $dispatch['result']['workflow_id'] ?? '' : '', 'dispatcher passes workflow to handler', $failures, $passes );
agents_api_smoke_assert_equals( 'runtime log', is_array( $dispatch ) ? $dispatch['evidence_refs'][0]['label'] ?? '' : '', 'dispatcher preserves evidence refs', $failures, $passes );

$observer_run_id = 'observer-runtime-run';
AgentsAPI\AI\WP_Agent_Run_Control::save_run(
AgentsAPI\AI\AGENTS_RUNTIME_PACKAGE_RUN_CONTROL_STORE,
array(
'run_id' => $observer_run_id,
'status' => 'succeeded',
'metadata' => array(
'package' => array( 'slug' => 'site-builder' ),
'workflow' => array( 'id' => 'build-site' ),
),
)
);
$GLOBALS['__agents_api_smoke_caps'] = array( 'read' => true );
agents_api_smoke_assert_equals( false, AgentsAPI\AI\agents_runtime_package_run_read_permission( array( 'run_id' => $observer_run_id ) ), 'runtime package read defaults to operators', $failures, $passes );
$observer_run = AgentsAPI\AI\agents_get_runtime_package_run( array( 'run_id' => $observer_run_id ) );
agents_api_smoke_assert_equals( 'succeeded', $observer_run['status'] ?? '', 'runtime package get-run still returns observer status when called directly', $failures, $passes );
agents_api_smoke_assert_equals( true, $observer_run['metadata']['package']['redacted'] ?? false, 'runtime package observer envelope redacts nested package metadata', $failures, $passes );
$GLOBALS['__agents_api_smoke_caps']['manage_options'] = true;
$operator_run = AgentsAPI\AI\agents_get_runtime_package_run( array( 'run_id' => $observer_run_id ) );
agents_api_smoke_assert_equals( 'site-builder', $operator_run['metadata']['package']['slug'] ?? '', 'runtime package manager get-run preserves operator metadata', $failures, $passes );

echo "\n[4] Public host helper invokes the canonical runtime package boundary:\n";
$helper_dispatch = wp_agent_run_runtime_package(
array(
Expand Down
Loading