getMessage(), $contains), $message . ' (got: ' . $e->getMessage() . ')'); return; } settingsAssert(false, $message . ' (no error)'); } $root = dirname(__DIR__); $site = sys_get_temp_dir() . '/raiju-agent-settings-' . bin2hex(random_bytes(4)); mkdir($site . '/content', 0755, true); register_shutdown_function(static fn() => exec('rm -rf ' . escapeshellarg($site))); foreach (['includes', 'admin', 'VERSION'] as $item) exec('cp -R ' . escapeshellarg($root . '/' . $item) . ' ' . escapeshellarg($site . '/')); foreach (['admin/config.php', 'admin/smtp-config.php', 'includes/nav-config.php'] as $local) @unlink($site . '/' . $local); define('SITE_LANG_DEFAULT', 'en'); $_SESSION = ['admin_role' => 'admin', 'admin_user_id' => 'u_admin', 'admin_username' => 'admin']; require_once $site . '/includes/ai/agent/research.php'; require_once $site . '/includes/ai/agent/media.php'; const BRAVE = 'BSA-brave-secret-key-1234567890'; const PEXELS = 'pexels-secret-key-ABCDEFGH123456'; $base = ['enabled' => true, 'provider' => 'openai-compatible', 'baseUrl' => 'http://127.0.0.1:9', 'allowLocalProvider' => true, 'apiKey' => 'provider-secret-key-0000', 'chatModel' => 'gpt-4.1-mini', 'features' => ['backendAssistant' => true, 'seoTextGeneration' => true, 'imageGeneration' => true]]; $file = RAIJU_AI_SETTINGS_PATH; $stored = static fn(): array => raijuAiReadStoredSettings(); // keys merged in, as the code reads them // ---- Defaults ------------------------------------------------------------------------------------------ $public = raijuAiLoadSettings(true); settingsAssert($public['agent']['enabled'] === false && $public['agent']['roles']['editor']['publish'] === false && $public['agent']['roles']['editor']['createNews'] === true, 'defaults: off, editors may not publish'); settingsAssert($public['research']['hasBraveApiKey'] === false && $public['stock']['hasPexelsApiKey'] === false && $public['research']['respectRobots'] === true, 'defaults: no keys, robots.txt respected'); // ---- Save and load ---------------------------------------------------------------------------------------- $input = $base + [ 'agent' => ['enabled' => true, 'limits' => ['runsPerHour' => 0, 'maxTurns' => 999, 'maxToolCalls' => 25, 'maxCostCentsPerRun' => 50000, 'maxRuntimeSeconds' => 10, 'uploadsPer10Minutes' => 5, 'evil' => 1], 'roles' => ['editor' => ['publish' => true, 'research' => false, 'superuser' => true]], 'mediaFolder' => 'team-photos', 'creditInNews' => false, 'sourcesInNews' => true, 'extra' => 'x'], 'research' => ['braveApiKey' => BRAVE, 'allowDomains' => "News.Example.org\nhttps://www.good.test/path\nnot a domain", 'denyDomains' => ['blocked.test'], 'respectRobots' => false], 'stock' => ['pexelsApiKey' => PEXELS], ]; $saved = raijuAiSaveSettings($input); $agent = $saved['agent']; settingsAssert($agent['enabled'] === true && $agent['limits']['runsPerHour'] === 1 && $agent['limits']['maxTurns'] === 40 && $agent['limits']['maxToolCalls'] === 25 && $agent['limits']['maxCostCentsPerRun'] === 10000 && $agent['limits']['maxRuntimeSeconds'] === 60 && $agent['limits']['uploadsPer10Minutes'] === 5, 'limits are clamped: ' . json_encode($agent['limits'])); settingsAssert($agent['roles']['editor']['publish'] === true && $agent['roles']['editor']['research'] === false && $agent['roles']['editor']['createPages'] === true && !isset($agent['roles']['editor']['superuser']), 'rights: only known ones, others keep their defaults'); settingsAssert($agent['mediaFolder'] === 'team-photos' && $agent['creditInNews'] === false && $agent['sourcesInNews'] === true && !isset($agent['extra']) && !isset($agent['limits']['evil']), 'only known settings are stored'); settingsAssert($saved['research']['allowDomains'] === ['news.example.org', 'good.test'] && $saved['research']['denyDomains'] === ['blocked.test'] && $saved['research']['respectRobots'] === false, 'domains are normalised: ' . json_encode($saved['research'])); settingsAssert($saved['research']['hasBraveApiKey'] === true && $saved['stock']['hasPexelsApiKey'] === true, 'the keys are reported as set'); $encoded = json_encode($saved); settingsAssert(!str_contains($encoded, BRAVE) && !str_contains($encoded, PEXELS) && !str_contains($encoded, 'provider-secret'), 'the browser never gets a key'); $loaded = json_encode(raijuAiLoadSettings(true)); settingsAssert(!str_contains($loaded, BRAVE) && !str_contains($loaded, PEXELS) && !str_contains($loaded, 'provider-secret') && !str_contains($loaded, 'apiKey'), 'neither does a later load'); settingsAssert($stored()['research']['brave']['apiKey'] === BRAVE && $stored()['stock']['pexels']['apiKey'] === PEXELS, 'the keys are stored for the server'); // The keys live in content/ai-secrets.json; the settings file holds none and can be committed. $secretsFile = dirname($file) . '/ai-secrets.json'; $settingsText = (string)file_get_contents($file); settingsAssert(!str_contains($settingsText, BRAVE) && !str_contains($settingsText, PEXELS) && !str_contains($settingsText, 'provider-secret') && !str_contains($settingsText, 'apiKey'), 'the settings file holds no key'); $secretsData = json_decode((string)file_get_contents($secretsFile), true); settingsAssert($secretsData['research']['brave']['apiKey'] === BRAVE && $secretsData['stock']['pexels']['apiKey'] === PEXELS && str_contains((string)json_encode($secretsData['providerCredentials']), 'provider-secret'), 'the secrets file holds the provider, Brave and Pexels keys'); settingsAssert(DIRECTORY_SEPARATOR === '\\' || (fileperms($secretsFile) & 0777) === 0600, 'the secrets file is for the owner only'); settingsAssert(!isset($secretsData['research']['allowDomains']) && !isset($secretsData['agent']), 'and holds nothing but keys'); // A settings file of an older version (keys inside) still works and is moved on request, once. $legacy = json_decode($settingsText, true); $legacy['apiKey'] = 'legacy-provider-key'; $legacy['providerCredentials']['openai-compatible']['apiKey'] = 'legacy-provider-key'; $legacy['research']['brave']['apiKey'] = 'BSA-legacy-brave-key-123'; $legacy['stock']['pexels']['apiKey'] = 'legacy-pexels-key-12345'; unlink($secretsFile); file_put_contents($file, json_encode($legacy)); settingsAssert(raijuAiReadStoredSettings()['research']['brave']['apiKey'] === 'BSA-legacy-brave-key-123' && raijuAiLoadSettings(false)['apiKey'] === 'legacy-provider-key', 'an older file is read as before'); settingsAssert(raijuAiMigrateSecrets() === true && raijuAiMigrateSecrets() === false, 'the keys move once'); $movedText = (string)file_get_contents($file); settingsAssert(!str_contains($movedText, 'legacy-') && !str_contains($movedText, 'BSA-legacy') && !str_contains($movedText, 'apiKey') && is_file($secretsFile), 'the settings file is clean afterwards'); settingsAssert(raijuAiLoadSettings(false)['apiKey'] === 'legacy-provider-key' && raijuAiReadStoredSettings()['stock']['pexels']['apiKey'] === 'legacy-pexels-key-12345' && raijuAiReadStoredSettings()['agent'] === $legacy['agent'], 'nothing is lost by the move'); // Restore the state the following checks expect $restored = json_decode($movedText, true); $restored['research']['brave']['apiKey'] = BRAVE; $restored['stock']['pexels']['apiKey'] = PEXELS; raijuAiWriteStoredSettings(array_replace_recursive(raijuAiReadStoredSettings(), ['research' => ['brave' => ['apiKey' => BRAVE]], 'stock' => ['pexels' => ['apiKey' => PEXELS]]])); // The agent code reads what was saved settingsAssert(raijuAgentSettings()['limits']['maxToolCalls'] === 25 && raijuAgentSettings()['mediaFolder'] === 'team-photos', 'raijuAgentSettings() sees the saved values'); settingsAssert(raijuAgentResearchSettings()['available'] === true && raijuAgentResearchSettings()['apiKey'] === BRAVE && raijuAgentResearchSettings()['respectRobots'] === false, 'research settings see the key'); settingsAssert(raijuAgentStockSettings()['available'] === true && raijuAgentStockSettings()['apiKey'] === PEXELS, 'stock settings see the key'); $_SESSION['admin_role'] = 'editor'; settingsAssert(raijuAgentCan('publish') === true && raijuAgentCan('research') === false && raijuAgentCan('createNews') === true, 'rights follow the saved settings'); $_SESSION['admin_role'] = 'admin'; // ---- Keys stay when the fields are empty, go when cleared ---------------------------------------------- $again = raijuAiSaveSettings($base + ['apiKey' => '', 'agent' => ['enabled' => true], 'research' => ['braveApiKey' => '', 'allowDomains' => 'news.example.org'], 'stock' => ['pexelsApiKey' => '']]); settingsAssert($stored()['research']['brave']['apiKey'] === BRAVE && $stored()['stock']['pexels']['apiKey'] === PEXELS && $again['research']['hasBraveApiKey'] && $again['stock']['hasPexelsApiKey'], 'empty key fields keep the saved keys'); settingsAssert($again['research']['allowDomains'] === ['news.example.org'] && $again['research']['denyDomains'] === ['blocked.test'] && $again['research']['respectRobots'] === false, 'fields that were not sent keep their values'); settingsAssert($again['agent']['limits']['maxToolCalls'] === 25 && $again['agent']['roles']['editor']['publish'] === true && $again['agent']['mediaFolder'] === 'team-photos', 'agent values that were not sent stay as they were'); $replaced = raijuAiSaveSettings($base + ['apiKey' => '', 'research' => ['braveApiKey' => 'BSA-another-brave-key-999'], 'stock' => []]); settingsAssert($stored()['research']['brave']['apiKey'] === 'BSA-another-brave-key-999' && $stored()['stock']['pexels']['apiKey'] === PEXELS, 'a new key replaces the old one, the other stays'); $cleared = raijuAiSaveSettings($base + ['apiKey' => '', 'research' => ['clearBraveApiKey' => true], 'stock' => ['clearPexelsApiKey' => true]]); settingsAssert(($stored()['research']['brave']['apiKey'] ?? '') === '' && ($stored()['stock']['pexels']['apiKey'] ?? '') === '' && !str_contains((string)file_get_contents($secretsFile), 'BSA-') && !$cleared['research']['hasBraveApiKey'] && !$cleared['stock']['hasPexelsApiKey'], 'keys can be removed'); settingsAssert(!raijuAgentResearchSettings()['available'] && !raijuAgentStockSettings()['available'], 'without keys there is no research and no stock photos'); // ---- Sections survive callers that know nothing about them ----------------------------------------------------- raijuAiSaveSettings($base + ['apiKey' => '', 'agent' => ['enabled' => true, 'mediaFolder' => 'kept'], 'research' => ['braveApiKey' => BRAVE], 'stock' => ['pexelsApiKey' => PEXELS]]); $before = $stored(); raijuAiSaveSettings(array_replace($base, ['apiKey' => '', 'chatModel' => 'gpt-4.1'])); $after = $stored(); settingsAssert($after['agent'] === $before['agent'] && $after['research'] === $before['research'] && $after['stock'] === $before['stock'] && $after['chatModel'] === 'gpt-4.1', 'a save without the agent sections keeps them (the classic settings form of older versions)'); // ---- Refusals ------------------------------------------------------------------------------------------------------------------ foreach ([['research' => ['braveApiKey' => 'short']], ['research' => ['braveApiKey' => 'has spaces in it 12345']], ['stock' => ['pexelsApiKey' => "line\nbreak-key-12345678"]], ['stock' => ['pexelsApiKey' => str_repeat('k', 201)]]] as $bad) { settingsThrows(static fn() => raijuAiSaveSettings($base + ['apiKey' => ''] + $bad), 'API key', 'an invalid key is refused: ' . json_encode($bad)); } foreach (['Bad Folder', '../up', 'UPPER', '', str_repeat('a', 41), 5] as $folder) { settingsThrows(static fn() => raijuAiSaveSettings($base + ['apiKey' => '', 'agent' => ['mediaFolder' => $folder]]), 'media folder', 'an invalid media folder is refused: ' . json_encode($folder)); } settingsAssert($stored()['agent']['mediaFolder'] === 'kept', 'a refused save changes nothing'); // ---- Admin status --------------------------------------------------------------------------------------------------------------- $status = raijuAgentStatusForAdmin(); settingsAssert(is_bool($status['available']) && is_bool($status['gd']) && is_bool($status['webp']) && isset($status['limits']['maxInputTokens']) && !str_contains(json_encode($status), BRAVE), 'the status tells what the agent needs, without keys'); echo "PASS agent settings: save and load, keys masked and kept, clamping, domains, rights, sections kept for older callers, refusals\n";