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