prefix . 'aicom_base_events'; $snap = [ 'state' => get_option( AICOM_Base_State::OPT, null ), 'identity' => get_option( AICOM_Base_Identity::OPT_IDENTITY, null ), 'policy' => get_option( AICOM_Base_Policy::OPT_ALLOWED, null ), 'url' => get_option( AICOM_Base_State::OPT_URL, null ), 'events' => $wpdb->get_results( "SELECT * FROM $t_events", ARRAY_A ), ]; function restore_state(): void { global $wpdb, $snap, $t_events; foreach ( [ AICOM_Base_State::OPT => 'state', AICOM_Base_Identity::OPT_IDENTITY => 'identity', AICOM_Base_Policy::OPT_ALLOWED => 'policy', AICOM_Base_State::OPT_URL => 'url' ] as $opt => $k ) { if ( $snap[ $k ] === null ) { delete_option( $opt ); } else { update_option( $opt, $snap[ $k ], false ); } } $wpdb->query( "DELETE FROM $t_events" ); foreach ( $snap['events'] as $row ) { $wpdb->insert( $t_events, $row ); } delete_option( AICOM_Base_Heartbeat::LOCK_OPT ); } register_shutdown_function( 'restore_state' ); // ── Test base key + fake connection ─────────────────────────────────────── $base_kp = sodium_crypto_sign_keypair(); $base_sec = sodium_crypto_sign_secretkey( $base_kp ); $base_pub = AICOM_Base_Signer::b64u( sodium_crypto_sign_publickey( $base_kp ) ); $base_kid = AICOM_Base_Signer::base_key_id( $base_pub ); $site_id = wp_generate_uuid4(); function issue_token( array $over = [] ): string { global $base_sec, $base_kid, $site_id; $now = time(); $c = array_merge( [ 'v' => 1, 'iss' => $base_kid, 'aud' => $site_id, 'task_id' => wp_generate_uuid4(), 'task_target_id' => wp_generate_uuid4(), 'session_id' => wp_generate_uuid4(), 'allowed_scopes' => [ 'read.wp' ], 'iat' => $now, 'expires_at' => $now + 600, 'nonce' => bin2hex( random_bytes( 12 ) ), ], $over ); $payload = json_encode( $c ); return AICOM_Base_Signer::b64u( $payload ) . '.' . AICOM_Base_Signer::b64u( sodium_crypto_sign_detached( $payload, $base_sec ) ); } function fake_connect(): void { global $base_pub, $base_kid, $site_id, $wpdb, $t_events; delete_option( AICOM_Base_State::OPT ); AICOM_Base_State::update( [ 'status' => 'connected', 'site_id' => $site_id, 'connection_id' => 'c', 'organization_id' => 'o', 'base_public_key' => $base_pub, 'base_key_id' => $base_kid, 'base_url' => 'http://127.0.0.1:9', 'paused' => false, 'base_lock' => 'none', ] ); $wpdb->query( "DELETE FROM $t_events" ); } function queued(): array { global $wpdb, $t_events; return array_map( static fn( $r ) => json_decode( $r['payload'], true ), $wpdb->get_results( "SELECT payload FROM $t_events ORDER BY id", ARRAY_A ) ); } // ═══ 1. Signing / canonicalisation ═══════════════════════════════════════════ section( 'Canonical string + Ed25519' ); $body = '{"a":1}'; $canon = AICOM_Base_Signer::canonical( 'post', '/api/v1/site/heartbeat', 1790000000, 'nonce_nonce_nonce_1', $body ); ok( 'canonical layout METHOD\\nPATH\\nTS\\nNONCE\\nsha256hex(body), no trailing newline', $canon === "POST\n/api/v1/site/heartbeat\n1790000000\nnonce_nonce_nonce_1\n" . hash( 'sha256', $body ) ); ok( 'empty body hashes the empty string', substr( AICOM_Base_Signer::canonical( 'GET', '/x', 1, 'n', '' ), -64 ) === 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' ); ok( 'query string is part of the canonical path', strpos( AICOM_Base_Signer::canonical( 'GET', '/x?y=1', 1, 'n', '' ), "\n/x?y=1\n" ) !== false ); $kp = sodium_crypto_sign_keypair(); $pub = AICOM_Base_Signer::b64u( sodium_crypto_sign_publickey( $kp ) ); $sig = AICOM_Base_Signer::sign( $canon, sodium_crypto_sign_secretkey( $kp ) ); ok( 'signature verifies', AICOM_Base_Signer::verify( $canon, $sig, $pub ) ); ok( 'tampered message fails', ! AICOM_Base_Signer::verify( $canon . 'x', $sig, $pub ) ); ok( 'public key is raw 32 bytes / 43 chars b64url', strlen( $pub ) === 43 && strlen( AICOM_Base_Signer::b64u_decode( $pub ) ) === 32 ); ok( 'signature is 64 bytes / 86 chars b64url', strlen( $sig ) === 86 ); ok( 'key_id = sk_ + first16hex(sha256(raw pub))', AICOM_Base_Signer::site_key_id( $pub ) === 'sk_' . substr( hash( 'sha256', AICOM_Base_Signer::b64u_decode( $pub ) ), 0, 16 ) ); ok( 'base64url round-trips without padding', AICOM_Base_Signer::b64u_decode( AICOM_Base_Signer::b64u( "\xff\xfe\xfd\x00" ) ) === "\xff\xfe\xfd\x00" && strpos( AICOM_Base_Signer::b64u( "a" ), '=' ) === false ); ok( 'nonce matches [A-Za-z0-9_-]{16,64}', preg_match( '/^[A-Za-z0-9_-]{16,64}$/', AICOM_Base_Signer::nonce() ) === 1 ); ok( 'Identity: private key is not stored in clear', ( function () { $id = AICOM_Base_Identity::ensure(); $raw = wp_json_encode( get_option( AICOM_Base_Identity::OPT_IDENTITY ) ); return $raw !== false && strpos( $raw, 'secret_enc' ) !== false && strlen( (string) $id['public_key'] ) === 43; } )() ); // ═══ 2. Token verification (pure) ════════════════════════════════════════════ section( 'Authorization token (PROTOCOL §9)' ); $v = static fn( string $t, ?int $now = null ) => AICOM_Base_Signer::verify_token( $t, $base_pub, $base_kid, $site_id, $now ); ok( 'valid token accepted', $v( issue_token() )['ok'] === true ); ok( 'expired token refused', ( $v( issue_token( [ 'iat' => time() - 1000, 'expires_at' => time() - 10 ] ) )['error'] ?? '' ) === 'expired' ); ok( 'wrong audience refused', ( $v( issue_token( [ 'aud' => wp_generate_uuid4() ] ) )['error'] ?? '' ) === 'wrong_audience' ); ok( 'wrong issuer refused', ( $v( issue_token( [ 'iss' => 'bk_0000000000000000' ] ) )['error'] ?? '' ) === 'wrong_issuer' ); ok( 'lifetime > 15 min refused', ( $v( issue_token( [ 'expires_at' => time() + 901 ] ) )['error'] ?? '' ) === 'lifetime_too_long' ); ok( 'exactly 15 min accepted', $v( issue_token( [ 'iat' => time(), 'expires_at' => time() + 900 ] ) )['ok'] === true ); $other = sodium_crypto_sign_keypair(); $forged_payload = json_encode( [ 'v' => 1, 'iss' => $base_kid, 'aud' => $site_id, 'task_id' => 't', 'task_target_id' => 'tt', 'session_id' => 's', 'allowed_scopes' => [ 'manage.plugins' ], 'iat' => time(), 'expires_at' => time() + 60, 'nonce' => 'abcdefghijkl' ] ); $forged = AICOM_Base_Signer::b64u( $forged_payload ) . '.' . AICOM_Base_Signer::b64u( sodium_crypto_sign_detached( $forged_payload, sodium_crypto_sign_secretkey( $other ) ) ); ok( 'token signed by a different key refused', ( $v( $forged )['error'] ?? '' ) === 'bad_signature' ); $parts = explode( '.', issue_token() ); $tampered = AICOM_Base_Signer::b64u( str_replace( 'read.wp', 'manage.plugins', (string) AICOM_Base_Signer::b64u_decode( $parts[0] ) ) ) . '.' . $parts[1]; ok( 'tampered payload refused', ( $v( $tampered )['error'] ?? '' ) === 'bad_signature' ); ok( 'garbage refused', ( $v( 'not-a-token' )['error'] ?? '' ) === 'malformed_token' && ( $v( '' )['error'] ?? '' ) === 'malformed_token' ); ok( 'unsupported version refused', ( $v( issue_token( [ 'v' => 2 ] ) )['error'] ?? '' ) === 'unsupported_version' ); section( 'Nonce single-use' ); $n = bin2hex( random_bytes( 12 ) ); ok( 'first use accepted', AICOM_Base_Executor::consume_nonce( $n, time() + 600 ) === true ); ok( 'replay refused', AICOM_Base_Executor::consume_nonce( $n, time() + 600 ) === false ); ok( 'different nonce accepted', AICOM_Base_Executor::consume_nonce( bin2hex( random_bytes( 12 ) ), time() + 600 ) === true ); // ═══ 3. Local policy cap ═════════════════════════════════════════════════════ section( 'Local policy (scope escalation)' ); delete_option( AICOM_Base_Policy::OPT_ALLOWED ); ok( 'default allows non-critical scopes', AICOM_Base_Policy::check_scopes( [ 'read.wp', 'write.wp.posts' ] )['ok'] ); $esc = AICOM_Base_Policy::check_scopes( [ 'read.wp', 'manage.plugins' ] ); ok( 'critical scope (manage.plugins) is refused by default', ! $esc['ok'] && $esc['excess'] === [ 'manage.plugins' ] ); $unk = AICOM_Base_Policy::check_scopes( [ 'read.wp', 'nuke.everything' ] ); ok( 'unknown scope refused outright', ! $unk['ok'] && $unk['unknown'] === [ 'nuke.everything' ] ); AICOM_Base_Policy::set_allowed_scopes( [ 'read.wp' ] ); ok( 'admin-narrowed policy applies', ! AICOM_Base_Policy::check_scopes( [ 'write.wp.posts' ] )['ok'] && AICOM_Base_Policy::check_scopes( [ 'read.wp' ] )['ok'] ); ok( 'heartbeat reports the narrowed local cap', AICOM_Base_Heartbeat::body( [] )['local_allowed_scopes'] === [ 'read.wp' ] ); delete_option( AICOM_Base_Policy::OPT_ALLOWED ); $hb_scopes = AICOM_Base_Heartbeat::body( [] )['local_allowed_scopes']; ok( 'heartbeat reports the default cap (no critical scopes)', $hb_scopes === AICOM_Base_Policy::default_scopes() && ! in_array( 'manage.plugins', $hb_scopes, true ) ); // ═══ 4. Event queue ══════════════════════════════════════════════════════════ section( 'Event queue' ); delete_option( AICOM_Base_State::OPT ); $wpdb->query( "DELETE FROM $t_events" ); AICOM_Base_Events::enqueue( 'lock', [ 'state' => 'soft', 'by' => 'local' ] ); ok( 'nothing is queued while not connected', count( queued() ) === 0 ); fake_connect(); AICOM_Base_Events::enqueue( 'lock', [ 'state' => 'soft', 'by' => 'local' ] ); AICOM_Base_Events::security( 'plugin_theme_change', 'info', [] ); AICOM_Base_Events::enqueue( 'action', [ 'session_id' => 's', 'summary' => 'x', 'duration_ms' => null ] ); $q = queued(); ok( 'events buffered locally (3)', count( $q ) === 3 ); ok( 'each event has type + ISO at', ! array_filter( $q, static fn( $e ) => empty( $e['type'] ) || ! preg_match( '/^\d{4}-\d\d-\d\dT[\d:]+Z$/', $e['at'] ?? '' ) ) ); ok( 'null fields are omitted, empty data is an object', ! array_key_exists( 'duration_ms', $q[2] ) && strpos( (string) $wpdb->get_var( "SELECT payload FROM $t_events WHERE event_type='security'" ), '"data":{}' ) !== false ); $scrubbed = AICOM_Base_Client::scrub( [ 'password' => 'hunter2hunter2', 'note' => 'Bearer abcdefghijklmnopqrstuvwxyz0123', 'key' => 'aicom_deadbeef_' . str_repeat( 'a', 40 ), 'ok' => 'fine', 'nested' => [ 'token' => 'zzzzzzzz' ] ] ); ok( 'scrub redacts passwords, bearer tokens, AICOM keys, nested tokens', $scrubbed['password'] === '[redacted]' && $scrubbed['note'] === '[redacted]' && $scrubbed['key'] === '[redacted]' && $scrubbed['nested']['token'] === '[redacted]' && $scrubbed['ok'] === 'fine' ); $roundtrip = AICOM_Base_Client::scrub( json_decode( (string) $wpdb->get_var( "SELECT payload FROM $t_events WHERE event_type='security'" ) ) ); ok( 'flush path keeps `data:{}` an object after decode + scrub', strpos( (string) wp_json_encode( $roundtrip ), '"data":{}' ) !== false ); $r = AICOM_Base_Events::flush( 1 ); ok( 'failed flush keeps events and counts an attempt', $r['failed'] === true && AICOM_Base_Events::pending_count() === 3 && (int) $wpdb->get_var( "SELECT MIN(attempts) FROM $t_events" ) === 1 ); for ( $i = 0; $i < 205; $i++ ) { AICOM_Base_Events::enqueue( 'lock', [ 'state' => 'none', 'by' => 'local' ] ); } ok( 'queue accepts >200 events (batched on send)', AICOM_Base_Events::pending_count() === 208 ); AICOM_Base_Events::purge(); // ═══ 5. Token-authorized execution, in-process ═══════════════════════════════ section( 'Execute — only inside a session AICOMBase opened' ); fake_connect(); $tt = wp_generate_uuid4(); $sess = wp_generate_uuid4(); $token = issue_token( [ 'task_target_id' => $tt, 'session_id' => $sess, 'allowed_scopes' => [ 'read.wp' ] ] ); $cmd = [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'authorization' => $token, 'task_target_id' => $tt, 'tool_id' => 'wp.site.info', 'params' => [] ]; $keys_before = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}aicom_api_keys WHERE status='active'" ); // No session opened by AICOMBase → nothing runs. $tt0 = wp_generate_uuid4(); AICOM_Base_Executor::execute( [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $tt0, 'tool_id' => 'wp.site.info', 'params' => [], 'authorization' => issue_token( [ 'task_target_id' => $tt0, 'session_id' => wp_generate_uuid4(), 'allowed_scopes' => [ 'read.wp' ] ] ) ] ); ok( 'without an open AICOMBase session the work is refused (NO_ACTIVE_SESSION), nothing ran', strpos( (string) ( AICOM_Base_State::get( 'pending_results', [] )[ $tt0 ]['error'] ?? '' ), 'NO_ACTIVE_SESSION' ) !== false && ! in_array( 'action', array_column( queued(), 'type' ), true ) ); // open_session command → an ordinary AICOM session, tagged as AICOMBase's, listed in WP admin. AICOM_Base_Events::purge(); $local = AICOM_Sessions::open_remote( $sess, 'Bilingual news post', 'Add a bilingual RO/EN article to News.', 'Claude Desktop' ); ok( 'open_session creates an AICOM session tagged aicombase with the agent label', is_array( $local ) && $local['source'] === 'aicombase' && $local['base_session_id'] === $sess && strpos( $local['api_key_label'], 'Claude Desktop' ) !== false ); ok( 'opening it again returns the same session', (int) AICOM_Sessions::open_remote( $sess, 'x', 'y' )['id'] === (int) $local['id'] ); $opened = current( array_filter( queued(), static fn( $e ) => $e['type'] === 'session.started' ) ); ok( 'session.started reported with the AICOMBase session id', ( $opened['session_id'] ?? '' ) === $sess ); AICOM_Base_Events::purge(); ok( 'execute() handles the command', AICOM_Base_Executor::execute( $cmd ) === true ); $ev = queued(); $types = array_column( $ev, 'type' ); $started = current( array_filter( $ev, static fn( $e ) => $e['type'] === 'session.started' ) ); ok( 'task linked to its target inside the session (session.started + task_target_id)', ( $started['session_id'] ?? '' ) === $sess && ( $started['task_target_id'] ?? '' ) === $tt ); $action = current( array_filter( $ev, static fn( $e ) => $e['type'] === 'action' ) ); ok( 'action: tool wp.site.info, success, capability content, in the session', ( $action['tool_id'] ?? '' ) === 'wp.site.info' && ( $action['result'] ?? '' ) === 'success' && ( $action['capability_id'] ?? '' ) === 'content' && ( $action['session_id'] ?? '' ) === $sess ); ok( 'the session stays open after the task (AICOMBase closes it)', ! in_array( 'session.ended', $types, true ) && AICOM_Sessions::find_open_remote( $sess ) !== null ); ok( 'no ephemeral API key left active', (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}aicom_api_keys WHERE status='active'" ) === $keys_before ); ok( 'result kept for retry while AICOMBase is unreachable', isset( AICOM_Base_State::get( 'pending_results', [] )[ $tt ] ) && AICOM_Base_State::get( 'pending_results' )[ $tt ]['status'] === 'completed' ); $blob = (string) wp_json_encode( [ $ev, get_option( AICOM_Base_State::OPT ) ] ); ok( 'no bearer key / secret material in queued events or state', ! preg_match( '/aicom_[A-Za-z0-9]{8}_[a-f0-9]{40}/', $blob ) && strpos( $blob, 'secret_enc' ) === false ); // A second task in the same session runs in the same AICOM session; its change is backed up there and restorable. $post_id = wp_insert_post( [ 'post_title' => 'Original title', 'post_status' => 'draft', 'post_type' => 'post' ] ); $tt8 = wp_generate_uuid4(); AICOM_Base_Executor::execute( [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $tt8, 'tool_id' => 'wp.posts.update', 'params' => [ 'id' => $post_id, 'title' => 'Changed by the agent' ], 'authorization' => issue_token( [ 'task_target_id' => $tt8, 'session_id' => $sess, 'allowed_scopes' => [ 'read.wp', 'write.wp.posts' ] ] ) ] ); ok( 'second task ran in the same session', ( AICOM_Base_State::get( 'pending_results', [] )[ $tt8 ]['status'] ?? '' ) === 'completed' && count( AICOM_Sessions::remote_ids( $sess ) ) === 1 ); ok( 'its change landed', get_the_title( $post_id ) === 'Changed by the agent' ); AICOM_Base_Events::purge(); AICOM_Base_Heartbeat::cmd_restore_session( $sess ); clean_post_cache( $post_id ); ok( 'restore_session puts the backed-up content back', get_the_title( $post_id ) === 'Original title' ); $restored = current( array_filter( queued(), static fn( $e ) => $e['type'] === 'session.restored' ) ); ok( 'session.restored reported to AICOMBase with the count', ( $restored['session_id'] ?? '' ) === $sess && (int) ( $restored['restored'] ?? 0 ) >= 1 ); ok( 'a restored session is closed — nothing more runs in it', AICOM_Sessions::find_open_remote( $sess ) === null ); wp_delete_post( $post_id, true ); // close_session command / admin "Close" → session.ended to AICOMBase. $sess2 = wp_generate_uuid4(); $l2 = AICOM_Sessions::open_remote( $sess2, 'To close', 'closing test' ); AICOM_Base_Events::purge(); AICOM_Sessions::close_remote( $sess2, 'completed' ); $ended = current( array_filter( queued(), static fn( $e ) => $e['type'] === 'session.ended' ) ); ok( 'close_session closes it and reports session.ended', ( $ended['session_id'] ?? '' ) === $sess2 && AICOM_Sessions::find_open_remote( $sess2 ) === null ); $sess3 = wp_generate_uuid4(); $l3 = AICOM_Sessions::open_remote( $sess3, 'Admin closes', 'admin closing test' ); AICOM_Base_Events::purge(); AICOM_Sessions::close_by_id( (int) $l3['id'] ); $ended3 = current( array_filter( queued(), static fn( $e ) => $e['type'] === 'session.ended' ) ); ok( 'closing it in WP admin reports session.ended (cancelled) to AICOMBase', ( $ended3['session_id'] ?? '' ) === $sess3 && ( $ended3['status'] ?? '' ) === 'cancelled' ); // Re-open the first session for the token tests below (replay etc.). AICOM_Sessions::open_remote( $sess, 'Bilingual news post', 'Add a bilingual RO/EN article to News.' ); // Replay of the same authorization AICOM_Base_Events::purge(); AICOM_Base_Executor::execute( $cmd ); $ev = queued(); ok( 'replayed token is refused (credential_anomaly security event, no session)', in_array( 'credential_anomaly', array_column( array_filter( $ev, static fn( $e ) => $e['type'] === 'security' ), 'kind' ), true ) && ! in_array( 'session.started', array_column( $ev, 'type' ), true ) ); // Duplicate DELIVERY of one command (live 2026-10-01: several wake pings → simultaneous check-ins → the same execute // command processed more than once → false credential_anomaly). The second delivery must be skipped quietly. AICOM_Base_Events::purge(); $ttd = wp_generate_uuid4(); $dcmd = [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $ttd, 'tool_id' => 'wp.site.info', 'params' => [], 'expires_at' => time() + 600, 'authorization' => issue_token( [ 'task_target_id' => $ttd, 'session_id' => $sess, 'allowed_scopes' => [ 'read.wp' ] ] ) ]; $pc = new ReflectionMethod( 'AICOM_Base_Heartbeat', 'process_commands' ); $pc->setAccessible( true ); $pc->invoke( null, [ $dcmd ], microtime( true ) + 30 ); $first = queued(); AICOM_Base_State::update( [ 'done_commands' => array_values( array_diff( (array) AICOM_Base_State::get( 'done_commands', [] ), [ $dcmd['id'] ] ) ) ] ); // as if the 2nd check-in raced the 1st AICOM_Base_Events::purge(); $pc->invoke( null, [ $dcmd ], microtime( true ) + 30 ); $second = queued(); ok( 'a command delivered twice runs once', in_array( 'session.started', array_column( $first, 'type' ), true ) && ! in_array( 'session.started', array_column( $second, 'type' ), true ), json_encode( array_column( $second, 'type' ) ) ); ok( 'duplicate delivery raises no credential_anomaly', ! in_array( 'credential_anomaly', array_column( array_filter( $second, static fn( $e ) => $e['type'] === 'security' ), 'kind' ), true ) ); $lk = new ReflectionMethod( 'AICOM_Base_Heartbeat', 'acquire' ); $lk->setAccessible( true ); $rl = new ReflectionMethod( 'AICOM_Base_Heartbeat', 'release' ); $rl->setAccessible( true ); $rl->invoke( null ); ok( 'heartbeat lock: first acquire wins, second loses', $lk->invoke( null ) === true && $lk->invoke( null ) === false ); $rl->invoke( null ); // Scope escalation AICOM_Base_Events::purge(); $tt2 = wp_generate_uuid4(); AICOM_Base_Executor::execute( [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $tt2, 'tool_id' => 'wp.plugins.list', 'params' => [], 'authorization' => issue_token( [ 'task_target_id' => $tt2, 'allowed_scopes' => [ 'manage.plugins' ] ] ) ] ); $sec = array_values( array_filter( queued(), static fn( $e ) => $e['type'] === 'security' && $e['kind'] === 'scope_rejected' ) ); ok( 'scope beyond local policy → refused + security scope_rejected', count( $sec ) === 1 && ( $sec[0]['data']['excess'] ?? [] ) === [ 'manage.plugins' ] ); ok( 'refusal reported with status refused', ( AICOM_Base_State::get( 'pending_results', [] )[ $tt2 ]['status'] ?? '' ) === 'refused' && strpos( AICOM_Base_State::get( 'pending_results' )[ $tt2 ]['error'], 'scope_rejected' ) !== false ); // Tool needing more than the token grants AICOM_Base_Events::purge(); $tt3 = wp_generate_uuid4(); AICOM_Base_Executor::execute( [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $tt3, 'tool_id' => 'wp.posts.create', 'params' => [ 'post_title' => 'x' ], 'authorization' => issue_token( [ 'task_target_id' => $tt3, 'allowed_scopes' => [ 'read.wp' ] ] ) ] ); ok( 'tool scope not covered by token → refused, nothing ran', strpos( (string) ( AICOM_Base_State::get( 'pending_results', [] )[ $tt3 ]['error'] ?? '' ), 'scope_insufficient' ) !== false && ! in_array( 'session.started', array_column( queued(), 'type' ), true ) ); // Wrong task target in token $tt4 = wp_generate_uuid4(); AICOM_Base_Executor::execute( [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $tt4, 'tool_id' => 'wp.site.info', 'params' => [], 'authorization' => issue_token( [ 'allowed_scopes' => [ 'read.wp' ] ] ) ] ); ok( 'token bound to a different task target refused', strpos( (string) ( AICOM_Base_State::get( 'pending_results', [] )[ $tt4 ]['error'] ?? '' ), 'target_mismatch' ) !== false ); // Pause / hard lock / soft lock AICOM_Base_State::update( [ 'paused' => true ] ); $tt5 = wp_generate_uuid4(); AICOM_Base_Executor::execute( [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $tt5, 'tool_id' => 'wp.site.info', 'params' => [], 'authorization' => issue_token( [ 'task_target_id' => $tt5 ] ) ] ); ok( 'paused site refuses remote work', strpos( (string) ( AICOM_Base_State::get( 'pending_results', [] )[ $tt5 ]['error'] ?? '' ), 'paused' ) !== false ); AICOM_Base_State::update( [ 'paused' => false, 'base_lock' => 'hard' ] ); $tt6 = wp_generate_uuid4(); AICOM_Base_Executor::execute( [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $tt6, 'tool_id' => 'wp.site.info', 'params' => [], 'authorization' => issue_token( [ 'task_target_id' => $tt6 ] ) ] ); ok( 'hard lock refuses remote work', strpos( (string) ( AICOM_Base_State::get( 'pending_results', [] )[ $tt6 ]['error'] ?? '' ), 'hard_locked' ) !== false ); AICOM_Base_State::update( [ 'base_lock' => 'soft' ] ); $tt7 = wp_generate_uuid4(); AICOM_Base_Executor::execute( [ 'id' => wp_generate_uuid4(), 'type' => 'execute', 'task_target_id' => $tt7, 'tool_id' => 'wp.posts.create', 'params' => [ 'post_title' => 'x' ], 'authorization' => issue_token( [ 'task_target_id' => $tt7, 'allowed_scopes' => [ 'write.wp.posts' ] ] ) ] ); ok( 'soft lock refuses a write tool', strpos( (string) ( AICOM_Base_State::get( 'pending_results', [] )[ $tt7 ]['error'] ?? '' ), 'soft_locked' ) !== false ); AICOM_Base_State::update( [ 'base_lock' => 'none' ] ); // ═══ 6. Capabilities / inspection ════════════════════════════════════════════ section( 'Capability inventory + inspection' ); $caps = AICOM_Base_Capabilities::build(); $tools = array_merge( ...array_column( $caps, 'tools' ) ); ok( 'inventory has capabilities and tools', count( $caps ) >= 8 && count( $tools ) >= 80 ); ok( 'every tool has id, risk in low|med|high, scopes array, schema object', ! array_filter( $tools, static fn( $t ) => empty( $t['id'] ) || ! in_array( $t['risk'], [ 'low', 'med', 'high' ], true ) || ! is_array( $t['required_scopes'] ) || empty( $t['input_schema'] ) ) ); $posts_create = current( array_filter( $tools, static fn( $t ) => $t['id'] === 'wp.posts.create' ) ); ok( 'wp.posts.create: write.wp.posts scope, med risk', $posts_create && $posts_create['required_scopes'] === [ 'write.wp.posts' ] && $posts_create['risk'] === 'med' ); ok( 'hash is stable across builds', AICOM_Base_Capabilities::hash_of( $caps ) === AICOM_Base_Capabilities::hash_of( AICOM_Base_Capabilities::build() ) ); foreach ( [ 'privacy', 'security', 'technical', 'accessibility' ] as $kind ) { $e = AICOM_Base_Inspector::collect( $kind ); ok( "inspect $kind returns evidence", is_array( $e ) && $e ); } $priv = AICOM_Base_Inspector::collect( 'privacy' ); ok( 'privacy evidence has the PROTOCOL §7 keys', ! array_diff( [ 'active_plugins', 'analytics', 'forms', 'newsletter', 'woocommerce', 'registration_enabled', 'comments_enabled', 'consent_plugin', 'wp' ], array_keys( $priv ) ) ); ok( 'unknown inspection kind → null', AICOM_Base_Inspector::collect( 'nope' ) === null ); $privjson = (string) wp_json_encode( $priv ); ok( 'privacy evidence contains no emails / passwords', ! preg_match( '/[\w.+-]+@[\w-]+\.[\w.]+/', $privjson ) && stripos( $privjson, 'password' ) === false ); // ═══ 7. Wake ping (PROTOCOL §11) ═════════════════════════════════════════════ section( 'Wake ping' ); function issue_wake_token( array $over = [] ): string { global $base_sec, $base_kid, $site_id; $now = time(); $c = array_merge( [ 'v' => 1, 'iss' => $base_kid, 'aud' => $site_id, 'purpose' => 'wake', 'allowed_scopes' => [], 'iat' => $now, 'expires_at' => $now + 60, 'nonce' => bin2hex( random_bytes( 12 ) ), ], $over ); $payload = json_encode( $c ); return AICOM_Base_Signer::b64u( $payload ) . '.' . AICOM_Base_Signer::b64u( sodium_crypto_sign_detached( $payload, $base_sec ) ); } function wake_request( string $token ): array { $req = new WP_REST_Request( 'POST', '/aicom/v1/wake' ); $req->set_body( wp_json_encode( [ 'token' => $token ] ) ); $res = AICOM_Base_Wake::handle( $req ); return [ 'status' => $res->get_status(), 'data' => $res->get_data() ]; } fake_connect(); $r = wake_request( 'not-a-token' ); ok( 'garbage token refused (401)', $r['status'] === 401 && ( $r['data']['error'] ?? '' ) === 'malformed_token' ); $r = wake_request( issue_wake_token( [ 'expires_at' => time() - 10, 'iat' => time() - 100 ] ) ); ok( 'expired wake token refused', $r['status'] === 401 && ( $r['data']['error'] ?? '' ) === 'expired' ); $r = wake_request( issue_wake_token( [ 'aud' => wp_generate_uuid4() ] ) ); ok( 'wrong audience refused', $r['status'] === 401 && ( $r['data']['error'] ?? '' ) === 'wrong_audience' ); // A perfectly valid EXECUTE-style token (no purpose:"wake" claim) must not be usable to wake — the two // token kinds share a verifier but not a use. $r = wake_request( issue_token() ); ok( 'execute-authorization token (no purpose:wake) refused for /wake', $r['status'] === 401 && ( $r['data']['error'] ?? '' ) === 'wrong_purpose' ); $valid = issue_wake_token(); $r1 = wake_request( $valid ); ok( 'valid wake token accepted (200)', $r1['status'] === 200 ); ok( 'response reports how long it lingered', isset( $r1['data']['waited_s'] ) ); $r2 = wake_request( $valid ); ok( 'replayed wake token refused', $r2['status'] === 401 && ( $r2['data']['error'] ?? '' ) === 'replayed' ); AICOM_Base_State::update( [ 'status' => 'pairing' ] ); $r = wake_request( issue_wake_token() ); ok( 'not connected → 409', $r['status'] === 409 && ( $r['data']['error'] ?? '' ) === 'not_connected' ); fake_connect(); // ═══ Summary ═════════════════════════════════════════════════════════════════ restore_state(); echo "\n" . ( $fail ? "FAILED" : "OK" ) . ": $pass passed, $fail failed\n"; exit( $fail ? 1 : 0 );