diff --git a/static/js/editor/actions-panel.js b/static/js/editor/actions-panel.js index a41d86817..5cfc61182 100644 --- a/static/js/editor/actions-panel.js +++ b/static/js/editor/actions-panel.js @@ -32,10 +32,10 @@ export const ACTION_COMMANDS = Object.freeze([ const STORAGE_KEY = 'ge-actions'; -function el(tag, className, html) { +function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; - if (html != null) node.innerHTML = html; + if (text != null) node.textContent = text; return node; } @@ -100,7 +100,11 @@ export function createActionsPanel({ showToast = () => {}, getLayers = null, sel } steps.forEach((step, index) => { const command = commandById(step); - const row = el('div', 'ge-actions-step', `${index + 1}${command ? command[1] : step}`); + const row = el('div', 'ge-actions-step'); + row.append( + el('span', 'ge-actions-step-index', String(index + 1)), + el('span', 'ge-actions-step-label', command ? command[1] : step), + ); const remove = el('button', 'ge-action-remove', '✕'); remove.type = 'button'; remove.addEventListener('click', () => { steps.splice(index, 1); renderList(); }); diff --git a/tests/test_editor_actions_panel_security.py b/tests/test_editor_actions_panel_security.py new file mode 100644 index 000000000..269941964 --- /dev/null +++ b/tests/test_editor_actions_panel_security.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_action_names_and_unknown_steps_are_literal_text(): + script = r''' + const fs = require('fs'); + const { chromium } = require('playwright'); + (async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.route('http://actions.test/**', route => route.fulfill({ + status: 200, contentType: 'text/html', body: '', + })); + await page.goto('http://actions.test/'); + const source = fs.readFileSync(process.argv[1], 'utf8'); + const moduleUrl = 'data:text/javascript;base64,' + Buffer.from(source).toString('base64'); + const result = await page.evaluate(async url => { + const attack = ''; + localStorage.setItem('ge-actions', JSON.stringify({ [attack]: ['flip-h'] })); + const { createActionsPanel } = await import(url); + const panel = createActionsPanel(); + document.body.append(panel.element); + panel.setSteps([attack]); + return { + images: panel.element.querySelectorAll('img').length, + saved: panel.element.querySelector('.ge-actions-saved-row button').textContent, + step: panel.element.querySelector('.ge-actions-step-label').textContent, + executed: Boolean(window.__actionsXss), + attack, + }; + }, moduleUrl); + console.log(JSON.stringify(result)); + } finally { await browser.close(); } + })().catch(error => { console.error(error); process.exit(1); }); + ''' + result = subprocess.run( + ['node', '-e', script, str(ROOT / 'static/js/editor/actions-panel.js')], + cwd=ROOT, capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stderr + observed = json.loads(result.stdout) + assert observed['images'] == 0 + assert observed['saved'] == observed['step'] == observed['attack'] + assert not observed['executed'] diff --git a/tests/test_gallery_image_model_picker.py b/tests/test_gallery_image_model_picker.py new file mode 100644 index 000000000..160355210 --- /dev/null +++ b/tests/test_gallery_image_model_picker.py @@ -0,0 +1,39 @@ +"""Source checks for the gallery Settings-tab image-model picker. + +The Quick Edit model picker lives in the editor; HARNESS mirrors it into the +gallery Settings tab, fed by GET /api/editor/image-models and sharing the +same localStorage key (`ge-ai-command-model`) so the two stay in sync. +""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8") + + +def test_settings_tab_has_image_model_picker(): + assert 'id="gallery-image-model"' in GALLERY_JS + assert "/api/editor/image-models" in GALLERY_JS + assert "ge-ai-command-model" in GALLERY_JS + + +def test_image_model_picker_lives_in_the_settings_container(): + settings_idx = GALLERY_JS.index('id="gallery-settings-container"') + picker_idx = GALLERY_JS.index('id="gallery-image-model"') + assert picker_idx > settings_idx + + +def test_picker_persists_with_the_shared_localstorage_key(): + assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS + assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS + + +def test_cloud_image_models_are_listed_alongside_the_local_ones(): + """Gateway endpoints (OpenRouter) register as llm but carry image models. + + The picker must surface those ids too — otherwise the only selectable + edit model is the local Qwen server. + """ + routes = (ROOT / "routes/editor_agent_routes.py").read_text(encoding="utf-8") + assert "from src.image_model_ids import looks_like_image_generation_model" in routes + assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes + assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes