(internal worker) $root = dirname(__DIR__); function rateAssert(bool $condition, string $message): void { if (!$condition) { fwrite(STDERR, "FAIL: {$message}\n"); exit(1); } } if (($argv[1] ?? '') === 'hits') { define('RAIJU_AGENT_RATE_DIR', $argv[2]); require_once $root . '/includes/ai/agent/rate-limit.php'; $allowed = 0; for ($i = 0; $i < (int)$argv[3]; $i++) { $allowed += raijuAgentRateLimit('shared-user', 'burst', ['limit' => (int)$argv[4], 'window' => (int)$argv[5]], true, 5000)['allowed'] ? 1 : 0; } echo $allowed; exit(0); } $dir = sys_get_temp_dir() . '/raiju-rate-smoke-' . bin2hex(random_bytes(4)); mkdir($dir, 0755, true); register_shutdown_function(static fn() => exec('rm -rf ' . escapeshellarg($dir))); define('RAIJU_AGENT_RATE_DIR', $dir . '/rate'); require_once $root . '/includes/ai/agent/rate-limit.php'; // Defaults from the plan $defaults = raijuAgentRateLimitDefaults(); rateAssert($defaults['runs'] === ['limit' => 10, 'window' => 3600] && $defaults['steps'] === ['limit' => 60, 'window' => 60] && $defaults['uploads'] === ['limit' => 10, 'window' => 600], 'default buckets: 10 runs/h, 60 steps/min, 10 uploads/10 min'); // Sliding window $t = 1_000_000; for ($i = 1; $i <= 10; $i++) { $r = raijuAgentRateLimit('u_alice', 'runs', null, true, $t + $i); rateAssert($r['allowed'] && $r['remaining'] === 10 - $i && $r['limit'] === 10, "run {$i} is allowed"); } $r = raijuAgentRateLimit('u_alice', 'runs', null, true, $t + 11); rateAssert(!$r['allowed'] && $r['remaining'] === 0 && $r['retryAfter'] === 3600 - 10, 'the 11th run is refused with the time until the oldest hit expires: ' . json_encode($r)); rateAssert(!raijuAgentRateLimit('u_alice', 'runs', null, true, $t + 3599)['allowed'], 'still refused just before the first hit expires'); $r = raijuAgentRateLimit('u_alice', 'runs', null, true, $t + 3601); rateAssert($r['allowed'] && $r['remaining'] === 0, 'the window slides: the first hit expired, so one more is allowed (' . json_encode($r) . ')'); $state = json_decode((string)file_get_contents(raijuAgentRateLimitFile('u_alice')), true); rateAssert(count($state['buckets']['runs']) === 10, 'expired hits are pruned from the file'); // Peek does not consume; refused calls do not consume either rateAssert(!raijuAgentRateLimit('u_alice', 'runs', null, false, $t + 3601)['allowed'], 'peek reports a full bucket'); $peek = raijuAgentRateLimit('u_bob', 'steps', null, false, $t); rateAssert($peek['allowed'] && $peek['remaining'] === 60 && !is_file(raijuAgentRateLimitFile('u_bob')), 'peek on a fresh bucket allows and writes nothing'); for ($i = 0; $i < 5; $i++) { rateAssert(!raijuAgentRateLimit('u_alice', 'runs', null, true, $t + 3601)['allowed'], 'full bucket stays full'); } $state = json_decode((string)file_get_contents(raijuAgentRateLimitFile('u_alice')), true); rateAssert(count($state['buckets']['runs']) === 10, 'refused requests are not recorded'); // Users and buckets are independent rateAssert(raijuAgentRateLimit('u_bob', 'runs', null, true, $t)['allowed'], 'another user has their own budget'); for ($i = 0; $i < 60; $i++) { rateAssert(raijuAgentRateLimit('u_carol', 'steps', null, true, $t)['allowed'], 'step ' . $i); } rateAssert(!raijuAgentRateLimit('u_carol', 'steps', null, true, $t)['allowed'], '61st step in a minute is refused'); rateAssert(raijuAgentRateLimit('u_carol', 'steps', null, true, $t + 61)['allowed'], 'steps recover after the minute'); rateAssert(raijuAgentRateLimit('u_carol', 'uploads', null, true, $t)['allowed'], 'buckets do not share budget'); for ($i = 0; $i < 9; $i++) { raijuAgentRateLimit('u_dave', 'uploads', null, true, $t); } rateAssert(raijuAgentRateLimit('u_dave', 'uploads', null, true, $t)['allowed'] && !raijuAgentRateLimit('u_dave', 'uploads', null, true, $t + 599)['allowed'] && raijuAgentRateLimit('u_dave', 'uploads', null, true, $t + 601)['allowed'], '10 uploads per 10 minutes'); // Custom limits $custom = ['limit' => 2, 'window' => 30]; rateAssert(raijuAgentRateLimit('u_erin', 'custom', $custom, true, $t)['allowed'] && raijuAgentRateLimit('u_erin', 'custom', $custom, true, $t)['allowed'], 'custom bucket allows its limit'); rateAssert(raijuAgentRateLimit('u_erin', 'custom', $custom, true, $t + 10)['retryAfter'] === 20, 'custom retryAfter'); raijuAgentAssertRateLimit('u_zoe', 'custom', $custom); raijuAgentAssertRateLimit('u_zoe', 'custom', $custom); try { raijuAgentAssertRateLimit('u_zoe', 'custom', $custom); rateAssert(false, 'assert variant must throw when exhausted'); } catch (RuntimeException $e) { rateAssert($e->getCode() === 429, 'assert variant throws with code 429'); } // Invalid input and file names foreach ([['u_x', 'unknown-bucket'], ['', 'runs'], ['u_x', '../runs'], ['u_x', 'Runs']] as [$user, $bucket]) { try { raijuAgentRateLimit($user, $bucket); rateAssert(false, "bucket '{$bucket}' / user '{$user}' must be rejected"); } catch (InvalidArgumentException $e) { } } $hostile = raijuAgentRateLimitFile('../../../etc/passwd'); rateAssert(dirname($hostile) === $dir . '/rate' && preg_match('#/h_[0-9a-f]{40}\.json$#', $hostile) === 1, 'hostile user keys are hashed into the rate directory'); rateAssert(raijuAgentRateLimitFile('u_alice') === $dir . '/rate/u_alice.json' && raijuAgentRateLimitFile('a@b.c') !== raijuAgentRateLimitFile('a_b_c'), 'safe keys stay readable, others cannot collide'); rateAssert(raijuAgentRateLimit('a@b.c', 'runs', null, true, $t)['allowed'], 'a hashed user key works'); // A damaged state file is set aside instead of locking the user out file_put_contents(raijuAgentRateLimitFile('u_frank'), '{broken'); rateAssert(raijuAgentRateLimit('u_frank', 'runs', null, true, $t)['allowed'], 'damaged state is recovered'); rateAssert(is_file(raijuAgentRateLimitFile('u_frank') . '.corrupt') && is_array(json_decode((string)file_get_contents(raijuAgentRateLimitFile('u_frank')), true)), 'damaged state kept aside, fresh state written'); // Parallel processes share one budget $shared = $dir . '/shared'; $workers = []; for ($i = 0; $i < 10; $i++) { $workers[] = proc_open([PHP_BINARY, __FILE__, 'hits', $shared, '6', '20', '60'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes[$i]); } $allowed = 0; foreach ($workers as $i => $process) { $allowed += (int)stream_get_contents($pipes[$i][1]); proc_close($process); } rateAssert($allowed === 20, "60 parallel hits against a limit of 20 admitted exactly 20 (got {$allowed})"); // Unwritable storage fails closed $blocked = proc_open([PHP_BINARY, '-r', ' define("RAIJU_AGENT_RATE_DIR", ' . var_export($dir . '/not-a-directory/rate', true) . '); file_put_contents(' . var_export($dir . '/not-a-directory', true) . ', "file"); require ' . var_export($root . '/includes/ai/agent/rate-limit.php', true) . '; $r = raijuAgentRateLimit("u_gina", "runs"); echo $r["allowed"] ? "open" : "closed"; '], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $blockedPipes); $answer = stream_get_contents($blockedPipes[1]); proc_close($blocked); rateAssert($answer === 'closed', 'storage failure denies the request instead of running without a limit'); echo "PASS agent rate limit: sliding window, buckets, users, peek, hashed keys, corrupt-state recovery, parallel processes, fail closed\n";