jmri_server.services.scripts
ScriptsService — JSON wire interface for the scripting host.
JMRI's stock JSON API doesn't expose script management; this is a rewrite-specific extension. Wire shapes:
method=list→[{name, started_at, finished, error, stdout, stderr, running}].method=get,data={"name": ...}→ one status entry.method=put,data={"name": ..., "source": "...", "save": bool}→ start a script from inline source; whensaveis true, also write it to<profile>/python/<name>.pyso it autoloads next boot.method=post,data={"name": ..., "action": ...}— verbs:"stop"(default) — signal the automaton to stop"delete"— stop AND remove the on-disk file. Refuses when the file lives under the shared<settings-root>/python/dir because those are operator-managed globals; the UI only manages per-profile scripts.
method=list,data={"library": true}→ list scripts from the shared and per-profile python dirs so the UI can render the library table. Each entry carries asourcetag ("shared"or"profile") so the UI can lock down the actions for shared scripts.
Autoload / library scan sequence — same for both:
<settings-root>/python/— cross-profile shared helpers.<profile>/python/— per-profile scripts.
The service uses one ScriptRunner per server context, lazily
constructed on first use. The runner's event loop is the FastAPI app's
event loop, so the sync shim wrappers correctly bridge.
1"""``ScriptsService`` — JSON wire interface for the scripting host. 2 3JMRI's stock JSON API doesn't expose script management; this is a 4rewrite-specific extension. Wire shapes: 5 6* ``method=list`` → ``[{name, started_at, finished, error, stdout, 7 stderr, running}]``. 8* ``method=get``, ``data={"name": ...}`` → one status entry. 9* ``method=put``, ``data={"name": ..., "source": "...", "save": bool}`` 10 → start a script from inline source; when ``save`` is true, also 11 write it to ``<profile>/python/<name>.py`` so it autoloads next boot. 12* ``method=post``, ``data={"name": ..., "action": ...}`` — verbs: 13 - ``"stop"`` (default) — signal the automaton to stop 14 - ``"delete"`` — stop AND remove the on-disk file. Refuses when 15 the file lives under the shared ``<settings-root>/python/`` dir 16 because those are operator-managed globals; the UI only 17 manages per-profile scripts. 18* ``method=list``, ``data={"library": true}`` → list scripts from 19 the shared and per-profile python dirs so the UI can render the 20 library table. Each entry carries a ``source`` tag (``"shared"`` 21 or ``"profile"``) so the UI can lock down the actions for shared 22 scripts. 23 24Autoload / library scan sequence — same for both: 25 261. ``<settings-root>/python/`` — cross-profile shared helpers. 272. ``<profile>/python/`` — per-profile scripts. 28 29The service uses one :class:`ScriptRunner` per server context, lazily 30constructed on first use. The runner's event loop is the FastAPI app's 31event loop, so the sync shim wrappers correctly bridge. 32""" 33from __future__ import annotations 34 35import asyncio 36import pathlib 37from typing import Any 38 39from jmri_core.persistence.profile import Profile 40from jmri_scripting import ScriptRunner 41 42from jmri_server.auth.models import Role 43from jmri_server.service import JsonService, JsonServiceError 44 45 46def _status_to_dict(s: Any) -> dict[str, Any]: 47 return { 48 "name": s.name, 49 "started_at": s.started_at, 50 "finished": s.finished, 51 "error": s.error, 52 "running": s.thread is not None and s.thread.is_alive(), 53 # Captured per-script stdout/stderr. Bounded (~64 KB with FIFO 54 # truncation and a leading marker) so a runaway loop doesn't 55 # OOM the response. Empty string if the script produced none. 56 "stdout": s.stdout, 57 "stderr": s.stderr, 58 } 59 60 61_LIBRARY_NAME_RE = __import__("re").compile(r"^[A-Za-z0-9_.-]+$") 62 63 64def _sanitize_library_name(name: str) -> str: 65 """Reject anything that isn't a plain filename component. 66 67 Prevents path traversal (``../../etc/passwd``), absolute paths, and 68 other tricks against the profile scripts directory. The rule is 69 intentionally strict — script names live in the URL space and get 70 stored as filenames, so restricting to ``[A-Za-z0-9_.-]`` costs 71 nothing and avoids edge cases. 72 """ 73 if not name or not _LIBRARY_NAME_RE.match(name) or ".." in name: 74 raise JsonServiceError(400, f"invalid script name: {name!r}") 75 return name 76 77 78class ScriptsService(JsonService): 79 topic = "scripts" 80 # Scripts execute arbitrary Python on the server — that's 81 # remote-code-execution surface. Restrict mutations to admin 82 # so a compromised operator token can't drop a script that 83 # steals the settings dir or reads users.yaml. Reads (list / 84 # get) are fine for operators — they may want to inspect 85 # what's running. 86 REQUIRED_ROLE = { 87 "list": Role.OPERATOR, 88 "get": Role.OPERATOR, 89 "put": Role.ADMIN, 90 "post": Role.ADMIN, 91 } 92 93 @property 94 def _runner(self) -> ScriptRunner: 95 # Cache the runner on the context. Lazy because the event loop 96 # isn't running at app-construction time. 97 cached = getattr(self.ctx, "_script_runner", None) 98 if cached is None: 99 loop = asyncio.get_event_loop() 100 cached = ScriptRunner.for_connection(loop, self.ctx.connection) 101 self.ctx._script_runner = cached # type: ignore[attr-defined] 102 return cached 103 104 def _python_dirs(self) -> list[tuple[pathlib.Path, str]]: 105 """The two autoload/library dirs, in scan order, tagged by 106 source (``"shared"`` for the settings-root dir, ``"profile"`` 107 for the active profile's dir). Missing profile → empty list.""" 108 profile = getattr(self.ctx, "_roster_profile", None) 109 if not isinstance(profile, Profile): 110 return [] 111 return [ 112 (profile.shared_python_dir, "shared"), 113 (profile.python_dir, "profile"), 114 ] 115 116 def _profile_python_dir(self) -> pathlib.Path | None: 117 """Save target for user-submitted scripts. Shared scripts are 118 operator-managed; the UI writes to the per-profile dir only.""" 119 profile = getattr(self.ctx, "_roster_profile", None) 120 return profile.python_dir if isinstance(profile, Profile) else None 121 122 def _find_script_on_disk(self, name: str) -> pathlib.Path | None: 123 """Locate ``<name>.py`` under the per-profile or shared dir. 124 125 Per-profile wins when both exist — matches the autoload 126 order that shipped in the python-dir migration. Returns 127 None when neither dir has the file; the caller surfaces 128 that as 404 so the UI can distinguish "script not on 129 disk" from "run failed".""" 130 for dir_path, _tag in self._python_dirs(): 131 candidate = dir_path / f"{name}.py" 132 if candidate.is_file(): 133 return candidate 134 return None 135 136 async def on_list(self, data: dict[str, Any] | None) -> Any: 137 data = data or {} 138 if data.get("library"): 139 # Library shape: everything the runner *knows about* (either 140 # running or persisted on disk) so the UI can render a 141 # single table. Scripts are keyed by name — a per-profile 142 # file with the same name as a shared one wins (last write 143 # in scan order), matching the runner's autoload behaviour. 144 statuses = {s.name: _status_to_dict(s) for s in self._runner.list()} 145 for dir_path, source_tag in self._python_dirs(): 146 if not dir_path.is_dir(): 147 continue 148 for p in sorted(dir_path.glob("*.py")): 149 if p.name.startswith("_"): 150 continue 151 entry = statuses.setdefault(p.stem, { 152 "name": p.stem, "started_at": None, 153 "finished": False, "error": None, "running": False, 154 "stdout": "", "stderr": "", 155 }) 156 entry["path"] = str(p) 157 entry["source"] = source_tag 158 return list(statuses.values()) 159 return [_status_to_dict(s) for s in self._runner.list()] 160 161 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 162 data = data or {} 163 name = data.get("name") 164 if not isinstance(name, str): 165 raise JsonServiceError(400, "scripts.get requires 'name'") 166 status = self._runner.get(name) 167 if status is None: 168 raise JsonServiceError(404, f"no script named {name!r}") 169 return _status_to_dict(status) 170 171 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 172 data = data or {} 173 name = data.get("name") 174 source = data.get("source") 175 save = bool(data.get("save", False)) 176 if not isinstance(name, str) or not isinstance(source, str): 177 raise JsonServiceError(400, "scripts.put requires 'name' and 'source'") 178 if save: 179 # Persist to <profile>/python/<name>.py so the script 180 # autoloads on next boot. Requires an active profile. 181 # Shared scripts under <settings>/python/ are not writable 182 # via the API — operator drops them by hand. 183 _sanitize_library_name(name) 184 pdir = self._profile_python_dir() 185 if pdir is None: 186 raise JsonServiceError( 187 409, 188 "cannot save: no active profile has a python dir", 189 ) 190 pdir.mkdir(parents=True, exist_ok=True) 191 path = pdir / f"{name}.py" 192 path.write_text(source, encoding="utf-8") 193 status = self._runner.run_file(path, name=name) 194 else: 195 status = self._runner.run_source(source, name=name) 196 return _status_to_dict(status) 197 198 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 199 data = data or {} 200 name = data.get("name") 201 action = data.get("action", "stop") 202 if not isinstance(name, str): 203 raise JsonServiceError(400, "scripts.post requires 'name'") 204 if action == "delete": 205 _sanitize_library_name(name) 206 # Stop any running instance, then remove the on-disk file. 207 # Only per-profile scripts are removable via the API — 208 # shared scripts under <settings>/python/ are operator 209 # artefacts. A shared file with the same name blocks the 210 # delete with 409 so the caller sees why. 211 pdir = self._profile_python_dir() 212 profile_path = (pdir / f"{name}.py") if pdir else None 213 shared_dir = None 214 profile = getattr(self.ctx, "_roster_profile", None) 215 if isinstance(profile, Profile): 216 shared_dir = profile.shared_python_dir 217 shared_path = ( 218 shared_dir / f"{name}.py" if shared_dir else None 219 ) 220 if ( 221 (profile_path is None or not profile_path.is_file()) 222 and shared_path is not None 223 and shared_path.is_file() 224 ): 225 raise JsonServiceError( 226 409, 227 f"script {name!r} lives under the shared python dir; " 228 f"remove it from disk manually", 229 ) 230 self._runner.stop(name) 231 if profile_path is not None and profile_path.is_file(): 232 profile_path.unlink() 233 status = self._runner.get(name) 234 return _status_to_dict(status) if status else {"name": name, "deleted": True} 235 if action == "run_file": 236 # Re-read the on-disk file (per-profile first, then 237 # shared) and hand it to the runner. This is the "debug 238 # loop" primitive — edit the file externally, click 239 # Run in the UI, see fresh output. The runner replaces 240 # any existing status entry with the same name so 241 # re-runs stack cleanly (previous automatons get 242 # stopped first). 243 _sanitize_library_name(name) 244 path = self._find_script_on_disk(name) 245 if path is None: 246 raise JsonServiceError( 247 404, 248 f"no script named {name!r} on disk under the " 249 "profile or shared python dirs", 250 ) 251 status = self._runner.run_file(path, name=name) 252 return _status_to_dict(status) 253 if action != "stop": 254 raise JsonServiceError(400, f"unknown scripts action: {action!r}") 255 if not self._runner.stop(name): 256 raise JsonServiceError(404, f"no script named {name!r}") 257 status = self._runner.get(name) 258 return _status_to_dict(status) if status else {"name": name}
79class ScriptsService(JsonService): 80 topic = "scripts" 81 # Scripts execute arbitrary Python on the server — that's 82 # remote-code-execution surface. Restrict mutations to admin 83 # so a compromised operator token can't drop a script that 84 # steals the settings dir or reads users.yaml. Reads (list / 85 # get) are fine for operators — they may want to inspect 86 # what's running. 87 REQUIRED_ROLE = { 88 "list": Role.OPERATOR, 89 "get": Role.OPERATOR, 90 "put": Role.ADMIN, 91 "post": Role.ADMIN, 92 } 93 94 @property 95 def _runner(self) -> ScriptRunner: 96 # Cache the runner on the context. Lazy because the event loop 97 # isn't running at app-construction time. 98 cached = getattr(self.ctx, "_script_runner", None) 99 if cached is None: 100 loop = asyncio.get_event_loop() 101 cached = ScriptRunner.for_connection(loop, self.ctx.connection) 102 self.ctx._script_runner = cached # type: ignore[attr-defined] 103 return cached 104 105 def _python_dirs(self) -> list[tuple[pathlib.Path, str]]: 106 """The two autoload/library dirs, in scan order, tagged by 107 source (``"shared"`` for the settings-root dir, ``"profile"`` 108 for the active profile's dir). Missing profile → empty list.""" 109 profile = getattr(self.ctx, "_roster_profile", None) 110 if not isinstance(profile, Profile): 111 return [] 112 return [ 113 (profile.shared_python_dir, "shared"), 114 (profile.python_dir, "profile"), 115 ] 116 117 def _profile_python_dir(self) -> pathlib.Path | None: 118 """Save target for user-submitted scripts. Shared scripts are 119 operator-managed; the UI writes to the per-profile dir only.""" 120 profile = getattr(self.ctx, "_roster_profile", None) 121 return profile.python_dir if isinstance(profile, Profile) else None 122 123 def _find_script_on_disk(self, name: str) -> pathlib.Path | None: 124 """Locate ``<name>.py`` under the per-profile or shared dir. 125 126 Per-profile wins when both exist — matches the autoload 127 order that shipped in the python-dir migration. Returns 128 None when neither dir has the file; the caller surfaces 129 that as 404 so the UI can distinguish "script not on 130 disk" from "run failed".""" 131 for dir_path, _tag in self._python_dirs(): 132 candidate = dir_path / f"{name}.py" 133 if candidate.is_file(): 134 return candidate 135 return None 136 137 async def on_list(self, data: dict[str, Any] | None) -> Any: 138 data = data or {} 139 if data.get("library"): 140 # Library shape: everything the runner *knows about* (either 141 # running or persisted on disk) so the UI can render a 142 # single table. Scripts are keyed by name — a per-profile 143 # file with the same name as a shared one wins (last write 144 # in scan order), matching the runner's autoload behaviour. 145 statuses = {s.name: _status_to_dict(s) for s in self._runner.list()} 146 for dir_path, source_tag in self._python_dirs(): 147 if not dir_path.is_dir(): 148 continue 149 for p in sorted(dir_path.glob("*.py")): 150 if p.name.startswith("_"): 151 continue 152 entry = statuses.setdefault(p.stem, { 153 "name": p.stem, "started_at": None, 154 "finished": False, "error": None, "running": False, 155 "stdout": "", "stderr": "", 156 }) 157 entry["path"] = str(p) 158 entry["source"] = source_tag 159 return list(statuses.values()) 160 return [_status_to_dict(s) for s in self._runner.list()] 161 162 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 163 data = data or {} 164 name = data.get("name") 165 if not isinstance(name, str): 166 raise JsonServiceError(400, "scripts.get requires 'name'") 167 status = self._runner.get(name) 168 if status is None: 169 raise JsonServiceError(404, f"no script named {name!r}") 170 return _status_to_dict(status) 171 172 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 173 data = data or {} 174 name = data.get("name") 175 source = data.get("source") 176 save = bool(data.get("save", False)) 177 if not isinstance(name, str) or not isinstance(source, str): 178 raise JsonServiceError(400, "scripts.put requires 'name' and 'source'") 179 if save: 180 # Persist to <profile>/python/<name>.py so the script 181 # autoloads on next boot. Requires an active profile. 182 # Shared scripts under <settings>/python/ are not writable 183 # via the API — operator drops them by hand. 184 _sanitize_library_name(name) 185 pdir = self._profile_python_dir() 186 if pdir is None: 187 raise JsonServiceError( 188 409, 189 "cannot save: no active profile has a python dir", 190 ) 191 pdir.mkdir(parents=True, exist_ok=True) 192 path = pdir / f"{name}.py" 193 path.write_text(source, encoding="utf-8") 194 status = self._runner.run_file(path, name=name) 195 else: 196 status = self._runner.run_source(source, name=name) 197 return _status_to_dict(status) 198 199 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 200 data = data or {} 201 name = data.get("name") 202 action = data.get("action", "stop") 203 if not isinstance(name, str): 204 raise JsonServiceError(400, "scripts.post requires 'name'") 205 if action == "delete": 206 _sanitize_library_name(name) 207 # Stop any running instance, then remove the on-disk file. 208 # Only per-profile scripts are removable via the API — 209 # shared scripts under <settings>/python/ are operator 210 # artefacts. A shared file with the same name blocks the 211 # delete with 409 so the caller sees why. 212 pdir = self._profile_python_dir() 213 profile_path = (pdir / f"{name}.py") if pdir else None 214 shared_dir = None 215 profile = getattr(self.ctx, "_roster_profile", None) 216 if isinstance(profile, Profile): 217 shared_dir = profile.shared_python_dir 218 shared_path = ( 219 shared_dir / f"{name}.py" if shared_dir else None 220 ) 221 if ( 222 (profile_path is None or not profile_path.is_file()) 223 and shared_path is not None 224 and shared_path.is_file() 225 ): 226 raise JsonServiceError( 227 409, 228 f"script {name!r} lives under the shared python dir; " 229 f"remove it from disk manually", 230 ) 231 self._runner.stop(name) 232 if profile_path is not None and profile_path.is_file(): 233 profile_path.unlink() 234 status = self._runner.get(name) 235 return _status_to_dict(status) if status else {"name": name, "deleted": True} 236 if action == "run_file": 237 # Re-read the on-disk file (per-profile first, then 238 # shared) and hand it to the runner. This is the "debug 239 # loop" primitive — edit the file externally, click 240 # Run in the UI, see fresh output. The runner replaces 241 # any existing status entry with the same name so 242 # re-runs stack cleanly (previous automatons get 243 # stopped first). 244 _sanitize_library_name(name) 245 path = self._find_script_on_disk(name) 246 if path is None: 247 raise JsonServiceError( 248 404, 249 f"no script named {name!r} on disk under the " 250 "profile or shared python dirs", 251 ) 252 status = self._runner.run_file(path, name=name) 253 return _status_to_dict(status) 254 if action != "stop": 255 raise JsonServiceError(400, f"unknown scripts action: {action!r}") 256 if not self._runner.stop(name): 257 raise JsonServiceError(404, f"no script named {name!r}") 258 status = self._runner.get(name) 259 return _status_to_dict(status) if status else {"name": name}
Base class for a topic's service handlers.
Subclasses set topic and override the methods they support;
unsupported methods raise 405 by default. Subclasses can also
override REQUIRED_ROLE to change per-method role gates.
REQUIRED_ROLE =
{'list': <Role.OPERATOR: 'operator'>, 'get': <Role.OPERATOR: 'operator'>, 'put': <Role.ADMIN: 'admin'>, 'post': <Role.ADMIN: 'admin'>}
async def
on_list(self, data: dict[str, typing.Any] | None) -> Any:
137 async def on_list(self, data: dict[str, Any] | None) -> Any: 138 data = data or {} 139 if data.get("library"): 140 # Library shape: everything the runner *knows about* (either 141 # running or persisted on disk) so the UI can render a 142 # single table. Scripts are keyed by name — a per-profile 143 # file with the same name as a shared one wins (last write 144 # in scan order), matching the runner's autoload behaviour. 145 statuses = {s.name: _status_to_dict(s) for s in self._runner.list()} 146 for dir_path, source_tag in self._python_dirs(): 147 if not dir_path.is_dir(): 148 continue 149 for p in sorted(dir_path.glob("*.py")): 150 if p.name.startswith("_"): 151 continue 152 entry = statuses.setdefault(p.stem, { 153 "name": p.stem, "started_at": None, 154 "finished": False, "error": None, "running": False, 155 "stdout": "", "stderr": "", 156 }) 157 entry["path"] = str(p) 158 entry["source"] = source_tag 159 return list(statuses.values()) 160 return [_status_to_dict(s) for s in self._runner.list()]
async def
on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
162 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 163 data = data or {} 164 name = data.get("name") 165 if not isinstance(name, str): 166 raise JsonServiceError(400, "scripts.get requires 'name'") 167 status = self._runner.get(name) 168 if status is None: 169 raise JsonServiceError(404, f"no script named {name!r}") 170 return _status_to_dict(status)
async def
on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
172 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 173 data = data or {} 174 name = data.get("name") 175 source = data.get("source") 176 save = bool(data.get("save", False)) 177 if not isinstance(name, str) or not isinstance(source, str): 178 raise JsonServiceError(400, "scripts.put requires 'name' and 'source'") 179 if save: 180 # Persist to <profile>/python/<name>.py so the script 181 # autoloads on next boot. Requires an active profile. 182 # Shared scripts under <settings>/python/ are not writable 183 # via the API — operator drops them by hand. 184 _sanitize_library_name(name) 185 pdir = self._profile_python_dir() 186 if pdir is None: 187 raise JsonServiceError( 188 409, 189 "cannot save: no active profile has a python dir", 190 ) 191 pdir.mkdir(parents=True, exist_ok=True) 192 path = pdir / f"{name}.py" 193 path.write_text(source, encoding="utf-8") 194 status = self._runner.run_file(path, name=name) 195 else: 196 status = self._runner.run_source(source, name=name) 197 return _status_to_dict(status)
async def
on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
199 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 200 data = data or {} 201 name = data.get("name") 202 action = data.get("action", "stop") 203 if not isinstance(name, str): 204 raise JsonServiceError(400, "scripts.post requires 'name'") 205 if action == "delete": 206 _sanitize_library_name(name) 207 # Stop any running instance, then remove the on-disk file. 208 # Only per-profile scripts are removable via the API — 209 # shared scripts under <settings>/python/ are operator 210 # artefacts. A shared file with the same name blocks the 211 # delete with 409 so the caller sees why. 212 pdir = self._profile_python_dir() 213 profile_path = (pdir / f"{name}.py") if pdir else None 214 shared_dir = None 215 profile = getattr(self.ctx, "_roster_profile", None) 216 if isinstance(profile, Profile): 217 shared_dir = profile.shared_python_dir 218 shared_path = ( 219 shared_dir / f"{name}.py" if shared_dir else None 220 ) 221 if ( 222 (profile_path is None or not profile_path.is_file()) 223 and shared_path is not None 224 and shared_path.is_file() 225 ): 226 raise JsonServiceError( 227 409, 228 f"script {name!r} lives under the shared python dir; " 229 f"remove it from disk manually", 230 ) 231 self._runner.stop(name) 232 if profile_path is not None and profile_path.is_file(): 233 profile_path.unlink() 234 status = self._runner.get(name) 235 return _status_to_dict(status) if status else {"name": name, "deleted": True} 236 if action == "run_file": 237 # Re-read the on-disk file (per-profile first, then 238 # shared) and hand it to the runner. This is the "debug 239 # loop" primitive — edit the file externally, click 240 # Run in the UI, see fresh output. The runner replaces 241 # any existing status entry with the same name so 242 # re-runs stack cleanly (previous automatons get 243 # stopped first). 244 _sanitize_library_name(name) 245 path = self._find_script_on_disk(name) 246 if path is None: 247 raise JsonServiceError( 248 404, 249 f"no script named {name!r} on disk under the " 250 "profile or shared python dirs", 251 ) 252 status = self._runner.run_file(path, name=name) 253 return _status_to_dict(status) 254 if action != "stop": 255 raise JsonServiceError(400, f"unknown scripts action: {action!r}") 256 if not self._runner.stop(name): 257 raise JsonServiceError(404, f"no script named {name!r}") 258 status = self._runner.get(name) 259 return _status_to_dict(status) if status else {"name": name}