Body
', 'hidden' => false])); file_put_contents($site . '/content/events.json', json_encode(['events' => [[ 'id' => '2026-11-05-open-day', 'date' => '2026-11-05', 'time' => '10:00', 'end-date' => '2026-11-05', 'end-time' => '16:00', 'url' => '', 'title' => ['en' => 'Open day', 'de' => 'Tag der offenen Tür'], 'location' => ['en' => 'Hall'], 'description' => ['en' => 'Come
'], 'admission' => ['en' => 'Free'], 'image' => '', ]]])); file_put_contents($site . '/content/pages/footer.json', json_encode(['tagline' => ['en' => 'We build'], 'copyright' => '© Fixture'])); define('SITE_LANG_DEFAULT', 'en'); define('CONTENT_BASE_PATH', $site . '/content/pages/'); require_once $site . '/includes/ai/agent/changeset-apply.php'; $owner = ['userKey' => 'u_editor', 'isAdmin' => false, 'canPublish' => false]; $publisher = ['userKey' => 'u_editor', 'isAdmin' => false, 'canPublish' => true]; $stranger = ['userKey' => 'u_other', 'isAdmin' => false, 'canPublish' => true]; $admin = ['userKey' => 'u_admin', 'isAdmin' => true, 'canPublish' => true]; $allowed = ['https://example.org/source', 'mailto:team@example.org']; function csContent(string $site): array { $files = array_merge(glob($site . '/content/pages/*.json') ?: [], glob($site . '/content/news/*.json') ?: [], [$site . '/content/events.json']); $hashes = []; foreach ($files as $file) $hashes[basename($file)] = is_file($file) ? hash_file('sha256', $file) : null; ksort($hashes); return $hashes; } function csNew(array $meta = []): string { return raijuAgentChangesetCreate($meta + ['owner' => 'u_editor', 'runId' => 'run_1', 'allowedUrls' => ['https://example.org/source', 'mailto:team@example.org']])['id']; } function csStage(string $id, array $request): array { return raijuAgentChangesetStage($id, $request); } function csOk(string $id, array $request, string $what): array { $result = csStage($id, $request); csAssert(!empty($result['ok']), "{$what} must be accepted: " . json_encode($result)); return $result; } function csRejected(string $id, array $request, string $needle, string $what): array { $before = count(raijuAgentChangesetLoad($id)['ops']); $result = csStage($id, $request); csAssert(isset($result['error']) && stripos($result['error'], $needle) !== false, "{$what} must be rejected mentioning '{$needle}': " . json_encode($result)); csAssert(count(raijuAgentChangesetLoad($id)['ops']) === $before, "{$what}: a rejected operation must not be stored"); return $result; } function csDoc(string $site, string $relative): array { return json_decode((string)file_get_contents($site . '/' . $relative), true); } function csWorker(string $site, string $scenario, string ...$args): array { $process = proc_open([PHP_BINARY, '-d', 'display_errors=0', $site . '/tests/agent-changeset-smoke.php', 'worker', $scenario, $site, ...$args], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $site); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); $code = proc_close($process); foreach (explode("\n", $stdout) as $line) { if (str_starts_with($line, 'RESULT:')) return json_decode(substr($line, 7), true, 512, JSON_THROW_ON_ERROR) + ['_code' => $code]; } return ['_stdout' => $stdout, '_stderr' => $stderr, '_code' => $code]; } $pristine = csContent($site); $std = 'page:en_scan-standard'; // ---- Staging: everything the agent has to correct is refused, nothing half-stored ------- $a = csNew(); csRejected($a, ['type' => 'set_field', 'target' => 'page:../x', 'path' => 'title', 'value' => 'x'], 'unknown target', 'path traversal in the target'); csRejected($a, ['type' => 'set_field', 'target' => 'page:en_missing', 'path' => 'title', 'value' => 'x'], 'unknown target', 'missing page'); csRejected($a, ['type' => 'explode', 'target' => $std], 'unknown operation', 'unknown operation'); csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.title', 'value' => 'x', 'file' => '/etc/passwd'], "'file' is not an argument", 'extra argument'); csRejected($a, ['type' => 'set_field', 'target' => $std, 'value' => 'x'], "'path' is required", 'missing path'); $err = csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.0.level', 'value' => 'h4'], 'not an editable field', 'structural field'); csAssert(str_contains($err['hint'], 'text, subtitle'), 'the hint names the editable fields: ' . $err['hint']); csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.9.title', 'value' => 'x'], 'the page has 4 sections', 'section out of range'); csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'title', 'value' => 'x'], 'not an editable field', 'a path that is not rendered'); csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.2.src', 'value' => '/x.png'], 'is an image', 'image path with set_field'); csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.1.title', 'libraryPath' => '/assets/images/second.webp', 'alt' => 'x'], 'is not an image', 'set_image on text'); csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'libraryPath' => '/assets/images/second.webp'], 'needs an alt text', 'image without alt'); csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'libraryPath' => '/assets/images/missing.webp', 'alt' => 'x'], 'not in the media library', 'unknown library image'); csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'libraryPath' => '../../admin/config.php', 'alt' => 'x'], 'not in the media library', 'traversal as image path'); csRejected($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'mediaRef' => 'm_nope1234', 'alt' => 'x'], 'unknown image reference', 'unknown mediaRef'); csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.content', 'value' => ['array']], 'needs an html string', 'wrong value type'); csRejected($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.content', 'value' => str_repeat('word
', 3000)], 'longer than', 'html over the limit'); csRejected($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'url', 'value' => 'https://spam.example/x'], 'external link not allowed', 'external link nobody named'); csRejected($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'url', 'value' => 'javascript:alert(1)'], 'link is not allowed', 'javascript link'); csRejected($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'time', 'value' => '25:99'], 'HH:MM', 'invalid time'); csRejected($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'date', 'value' => '2026-12-01'], 'read-only', 'event date is identity'); csRejected($a, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'slug', 'value' => 'other'], 'read-only', 'news slug is identity'); csRejected($a, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'hidden', 'value' => 'true'], 'read-only', 'news visibility'); csRejected($a, ['type' => 'section_insert', 'target' => $std, 'position' => 0, 'sectionType' => 'nuke', 'fields' => []], "unknown section type 'nuke'", 'unknown section type'); csRejected($a, ['type' => 'section_insert', 'target' => $std, 'position' => 0, 'sectionType' => 'text', 'fields' => ['onclick' => 'x']], "not a field of a text section", 'unknown section field'); csRejected($a, ['type' => 'section_insert', 'target' => $std, 'position' => 0, 'sectionType' => 'image', 'fields' => ['src' => '/assets/images/second.webp']], 'needs an alt text', 'image section without alt'); csRejected($a, ['type' => 'create_news', 'lang' => 'xx', 'title' => 'T', 'date' => '2026-10-10', 'contentHtml' => 'x
'], 'not a language of this site', 'unknown language'); csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'T', 'date' => '2026-13-40', 'contentHtml' => 'x
'], 'YYYY-MM-DD', 'impossible date'); csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Demo post', 'slug' => 'demo-post', 'date' => '2026-10-01', 'contentHtml' => 'x
'], 'already exists', 'news id collision with a stored post'); csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'T', 'date' => '2026-10-10'], "'contentHtml' is required", 'missing news body'); csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'T', 'date' => '2026-10-10', 'contentHtml' => 'x
', 'imagePath' => '/assets/images/nope.webp'], 'not in the media library', 'news image outside the library'); csRejected($a, ['type' => 'create_event', 'lang' => 'en', 'title' => 'T', 'date' => '2026-10-10', 'time' => '10:00'], "'location' is required", 'event without location'); csRejected($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'Bad Slug', 'title' => 'T', 'sections' => [['type' => 'text', 'fields' => []]]], 'not valid', 'invalid page path'); csRejected($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'scan-custom', 'title' => 'T', 'sections' => [['type' => 'text', 'fields' => []]]], 'template already serves', 'page that collides with a template'); csRejected($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'scan-standard', 'title' => 'T', 'sections' => [['type' => 'text', 'fields' => []]]], 'already exists', 'page that already exists'); csRejected($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'empty-page', 'title' => 'T', 'sections' => []], 'at least one section', 'page without sections'); csRejected($a, ['type' => 'revert_op', 'opId' => 'op_99'], 'unknown operation', 'reverting an unknown operation'); csAssert(csContent($site) === $pristine && raijuAgentChangesetLoad($a)['ops'] === [], 'refused requests changed neither content nor the change set'); csAssert(raijuAgentChangesetStage('cs_0000000000000000', ['type' => 'set_field'])['error'] === 'Unknown change set.' && raijuAgentChangesetStage('../../x', ['type' => 'set_field'])['error'] === 'Unknown change set.', 'unknown or malformed change set ids'); // ---- Values are normalised --------------------------------------------------------- $r = csOk($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.title', 'value' => " A bold headline\n with breaks " . str_repeat('x', 400)], 'long text'); $stored = raijuAgentChangesetLoad($a)['ops'][0]['value']; csAssert(!str_contains($stored, '<') && !str_contains($stored, "\n") && mb_strlen($stored) <= 160 && str_starts_with($stored, 'A bold headline with breaks') && $r['warnings'] !== [], 'text: tags stripped, whitespace collapsed, length capped with a warning: ' . $stored); csOk($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.1.content', 'value' => ''], 'html with active content'); $html = raijuAgentChangesetLoad($a)['ops'][1]['value']; csAssert(!preg_match('/onclick|script|iframe|javascript/i', $html) && str_contains($html, 'world') && str_contains($html, 'https://example.org/source') && raijuSanitizeRichHtml($html) === $html, 'html: sanitised and stable under a second pass: ' . $html); csOk($a, ['type' => 'set_image', 'target' => $std, 'path' => 'sections.2.src', 'libraryPath' => '/assets/images/second.webp', 'alt' => ' A photo of things '], 'split image'); $op = raijuAgentChangesetLoad($a)['ops'][2]; csAssert($op['src'] === '/assets/images/second.webp' && $op['alt'] === 'A photo of things' && $op['spec']['fmt'] === 'split' && $op['spec']['altPath'] === 'sections.2.alt', 'section image: library path, plain alt text, split format'); csOk($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'url', 'value' => 'https://example.org/source/'], 'event url named by the user'); csOk($a, ['type' => 'set_field', 'target' => 'news:2026-10-01-demo-post', 'path' => 'excerpt', 'value' => 'Fresh excerpt'], 'news excerpt'); csOk($a, ['type' => 'set_field', 'target' => 'event:2026-11-05-open-day', 'path' => 'title.de', 'value' => 'Neuer Titel'], 'event title in another language'); csOk($a, ['type' => 'set_field', 'target' => 'page:footer', 'path' => 'tagline.en', 'value' => 'We build things'], 'shared footer text'); // Section edits follow the state of the package: insert first, then edit the new section and the shifted one csOk($a, ['type' => 'section_insert', 'target' => $std, 'position' => 0, 'sectionType' => 'text', 'fields' => ['title' => 'Lead-in', 'content' => 'Hello
']], 'section insert'); $r = csOk($a, ['type' => 'set_field', 'target' => $std, 'path' => 'sections.0.title', 'value' => 'Lead-in (edited)'], 'editing the section inserted before'); csAssert($r['preview']['before'] === 'Lead-in', 'the index refers to the package state: ' . json_encode($r['preview'])); csRejected($a, ['type' => 'revert_op', 'opId' => 'op_8'], 'later operations depend', 'reverting an operation that later ones build on'); // Creating new content csOk($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Agent news: a "title"', 'date' => '2026-10-10', 'author' => 'Agent', 'excerpt' => 'Short summary', 'contentHtml' => 'Text source
', 'imagePath' => '/assets/images/library.webp'], 'news'); csOk($a, ['type' => 'create_event', 'lang' => 'en', 'title' => 'Launch party', 'date' => '2026-12-01', 'time' => '18:00', 'location' => 'Berlin', 'description' => 'Join us
', 'url' => 'mailto:team@example.org'], 'event'); csOk($a, ['type' => 'create_page', 'lang' => 'en', 'slug' => 'agent/page', 'title' => 'Agent page', 'description' => 'About the agent', 'sections' => [['type' => 'heading', 'fields' => ['text' => 'Agent page']], ['type' => 'text', 'fields' => ['title' => 'Intro', 'content' => 'Welcome
']]]], 'page'); csRejected($a, ['type' => 'create_news', 'lang' => 'en', 'title' => 'Agent news: a "title"', 'date' => '2026-10-10', 'contentHtml' => 'twice
'], 'already exists', 'creating the same news twice in one package'); $ops = raijuAgentChangesetLoad($a)['ops']; csAssert(count($ops) === 12 && $ops[9]['post']['id'] === '2026-10-10-agent-news-a-title' && $ops[9]['post']['hidden'] === true, 'news id from date and slug, created hidden: ' . ($ops[9]['post']['id'] ?? '')); csAssert($ops[10]['event']['hidden'] === true && $ops[10]['event']['url'] === 'mailto:team@example.org', 'event created hidden'); csAssert($ops[11]['page']['sections'][0]['type'] === 'heading' && $ops[11]['page']['sections'][0]['level'] === 'h1' && !isset($ops[11]['page']['sections'][1]['level']), 'the heading that opens a new page is its title (h1): ' . json_encode($ops[11]['page']['sections'][0])); $page = $ops[11]['page']; csAssert($page['visibility']['status'] === 'private' && strlen($page['visibility']['passwordHash']) > 40 && $page['nav'] === [] && count($page['sections']) === 2, 'page created private, random password, out of the navigation'); csAssert($ops[11]['target'] === 'page:en_agent__page', 'nested path becomes a flat content key'); csAssert(csContent($site) === $pristine, 'staging never touches stored content'); csAssert(is_dir($site . '/content/ai-agent/changesets') && !glob($site . '/content/ai-agent/changesets/*.tmp'), 'change sets live under content/ai-agent'); // ---- Review: validation, render check, fingerprint ------------------------------------ $report = raijuAgentChangesetFinalize($a); csAssert($report['status'] === 'ready' && $report['validation']['ok'] && $report['renderCheck']['status'] === 'passed', 'the package is ready: ' . json_encode($report)); $targets = $report['renderCheck']['targets']; foreach ([$std, 'news:2026-10-01-demo-post', 'event:2026-11-05-open-day', 'news:2026-10-10-agent-news-a-title', 'event:2026-12-01-launch-party', 'page:en_agent__page'] as $target) { csAssert(($targets[$target]['status'] ?? '') === 'passed', "{$target} passes the render check: " . json_encode($targets[$target] ?? null)); } csAssert($targets['page:footer']['status'] === 'skipped' && str_contains($targets['page:footer']['messages'][0], 'not checked by rendering'), 'shared content is skipped with a visible note'); csAssert(preg_match('/^[a-f0-9]{64}$/', $report['previewHash']) === 1 && raijuAgentChangesetFinalize($a)['previewHash'] === $report['previewHash'], 'the preview hash is stable'); csAssert(csContent($site) === $pristine, 'validating and rendering write nothing to content'); csAssert(!is_dir($site . '/content/analytics'), 'the render check is not counted as a visit'); $review = raijuAgentChangesetPreview(raijuAgentChangesetLoad($a)); csAssert($review['status'] === 'ready' && $review['conflicts'] === [] && $review['previewHash'] === $report['previewHash'], 'review data identity'); $first = $review['targets'][$std][0]; csAssert($first['opId'] === 'op_1' && $first['before'] === 'A text' && str_starts_with($first['after'], 'A bold headline') && $first['html'] === false, 'before/after of a text change'); csAssert(count($review['targets']) === 7 && $review['targets']['page:en_agent__page'][0]['type'] === 'create_page', 'operations are grouped by target'); $labels = array_map(static fn($ops) => array_column($ops, 'label'), $review['targets']); csAssert($labels[$std][0] === 'Change “Title” in section 2 (text): “A text”', 'a review label says what changes and where: ' . $labels[$std][0]); csAssert($labels[$std][2] === 'Set image “Image” in section 3 (image)' && $labels[$std][3] === 'Add text section', 'image and section labels: ' . json_encode($labels[$std])); csAssert($labels['page:en_agent__page'][0] === 'Create page' && !str_contains(implode('|', $labels[$std]), 'sections.'), 'no raw paths in the labels'); $encoded = json_encode($review, JSON_UNESCAPED_SLASHES); csAssert(str_contains($encoded, 'Hello world') && !preg_match('/onclick|