fix(web): pass plugin action params to the wrapper on stdin

POST /api/v3/plugins/action runs a plugin's script through a generated
Python wrapper, and the params went into that wrapper's source as
`params = <json.dumps(params)>`. JSON true, false and null are undefined
names in Python, so any params holding one made the wrapper die with a
NameError before the script ran, and the route answered "Action failed".
The plugin file manager's category toggle sends {"category_name": ...,
"enabled": true}, so of-the-day's category toggle failed every time.

The wrapper now reads the params from its own stdin (json.loads) and the
route passes them there; nothing taken from the request is written into
the generated source any more. The script's side is unchanged: the same
json.dumps(params) on its stdin, LEDMATRIX_ROOT set, stdout parsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 20:50:23 -04:00
co-authored by Claude Opus 5.5
parent ef69201770
commit a025f9897f
3 changed files with 109 additions and 1 deletions
+93
View File
@@ -0,0 +1,93 @@
"""POST /api/v3/plugins/action hands ``params`` to the plugin's script intact.
The route runs the script through a generated wrapper, and the params went
into that wrapper as Python source: ``params = {json.dumps(params)}``. JSON is
not Python. ``true``, ``false`` and ``null`` are undefined names there, so any
params holding a boolean or a null died with a NameError before the script
ran. The plugin file manager's category toggle sends ``{"category_name": ...,
"enabled": true}``, so of-the-day's category toggle failed every time with
"Action failed".
The script's side of the contract is unchanged and pinned here too: the
params arrive on stdin as one JSON document, LEDMATRIX_ROOT is set, and what
the script prints to stdout is what the route parses.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
ACTION_URL = "/api/v3/plugins/action"
# The action script: report what it was handed, as JSON on stdout.
ECHO_SCRIPT = (
"import json, os, sys\n"
"raw = sys.stdin.read()\n"
"print(json.dumps({'status': 'success', 'got': json.loads(raw),\n"
" 'root': os.environ.get('LEDMATRIX_ROOT')}))\n"
)
@pytest.fixture
def echo_plugin(tmp_path, api_v3_module, monkeypatch):
plugin_dir = tmp_path / "demo"
plugin_dir.mkdir()
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": "demo",
"web_ui_actions": [{"id": "toggle", "type": "script", "script": "echo.py"}],
}), encoding="utf-8")
(plugin_dir / "echo.py").write_text(ECHO_SCRIPT, encoding="utf-8")
api_v3_module.api_v3.plugin_catalog.get_plugin_directory.return_value = str(plugin_dir)
# The route runs `python3`; use this interpreter, so the test does not
# depend on what that name resolves to here.
real_run = subprocess.run
def run(cmd, *args, **kwargs):
if isinstance(cmd, list) and cmd and cmd[0] == "python3":
cmd = [sys.executable] + cmd[1:]
return real_run(cmd, *args, **kwargs)
monkeypatch.setattr(subprocess, "run", run)
return plugin_dir
@pytest.mark.parametrize("params", [
{"category_name": "jokes", "enabled": True}, # the file manager's toggle
{"category_name": "jokes", "enabled": False},
{"filename": None},
{"nested": {"list": [1, None, True, 2.5], "empty": {}}},
{"text": "café ✓ \U0001F600"},
{"text": "he said \"hi\" and 'bye' \\ ''' \"\"\" \n\t end"},
], ids=["true", "false", "null", "nested", "unicode", "quotes"])
def test_the_script_receives_the_params_it_was_sent(api_v3_client, echo_plugin, params):
response = api_v3_client.post(ACTION_URL, json={
"plugin_id": "demo", "action_id": "toggle", "params": params})
body = response.get_json()
assert response.status_code == 200, body
assert body["got"] == params
def test_a_param_cannot_run_code_in_the_wrapper(api_v3_client, echo_plugin, tmp_path):
marker = tmp_path / "PWNED"
hostile = "\"}\nopen(%r, 'w').write('ran')\n#" % str(marker)
params = {"name": hostile, "flag": True}
response = api_v3_client.post(ACTION_URL, json={
"plugin_id": "demo", "action_id": "toggle", "params": params})
assert response.status_code == 200, response.get_json()
assert response.get_json()["got"] == params
assert not marker.exists(), "a param value ran as code"
def test_the_script_still_gets_ledmatrix_root(api_v3_client, echo_plugin, api_v3_module):
response = api_v3_client.post(ACTION_URL, json={
"plugin_id": "demo", "action_id": "toggle", "params": {"enabled": True}})
assert response.status_code == 200, response.get_json()
assert response.get_json()["root"] == str(api_v3_module.PROJECT_ROOT)