Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
8b7e838
fix(deps): bump electron stack and resolve all 22 npm audit findings
kairoxxai May 13, 2026
aa2f91c
pin Vite 7, bump electron patches, fix esbuild vuln
Lorchie Jun 20, 2026
0f2b7ff
feat: dynamic N-handle multi-image input for extensions
iammojogo-sudo Jun 22, 2026
1358ca7
fix: two-way tab sync between Generate and Workflows (params persist …
iammojogo-sudo Jun 23, 2026
f5b7e79
play a short ding sound when generation or workflow run completes
iammojogo-sudo Jun 27, 2026
2abbb76
fix(extensions): allow npm lifecycle scripts on install
TupiC Jul 24, 2026
b9db826
fix(extensions): validate model registration
DrHepa Jul 25, 2026
f316702
Merge pull request #234 from lightningpixel/main
lightningpixel Jul 25, 2026
3fce213
Merge pull request #231 from TupiC/dev
lightningpixel Jul 25, 2026
9d7c3dc
Add shared cuda->mps->cpu device-selection helper for generators
MarcBarnaba Jul 30, 2026
ba1d694
Enable PyTorch's MPS CPU-fallback for extension subprocesses on macOS
MarcBarnaba Jul 30, 2026
ae98558
Merge branch 'dev' into ui-communication-fixes
iammojogo-sudo Aug 1, 2026
e5eac44
Merge branch 'dev' into extension-multi-image-fix
iammojogo-sudo Aug 1, 2026
148c6a7
Merge pull request #233 from DrHepa/fix/extension-registration-valida…
lightningpixel Aug 9, 2026
993e1bb
Fix JSON CLI argument errors
Aug 13, 2026
5aed279
Merge pull request #257 from TheGreeter/thegreeter-friendly-broccoli
lightningpixel Aug 13, 2026
af01a4d
fix(extensions): honor pickerIntent on string params
Souptik96 Aug 14, 2026
d76d0cf
Merge pull request #258 from Souptik96/fix/extension-node-picker-intent
lightningpixel Aug 17, 2026
f0b8554
fix(workflows): repair the two-way tab sync introduced in #180
lightningpixel Aug 18, 2026
897c595
fix(extensions): drop the "bad JSON:" prefix from the subprocess log
lightningpixel Aug 18, 2026
2d117ed
Merge pull request #180 from iammojogo-sudo/ui-communication-fixes
lightningpixel Aug 18, 2026
a607358
fix(extensions): force UTF-8 stdio on Windows to stop UnicodeEncodeEr…
rekcilyssup Aug 19, 2026
6742a42
Merge pull request #254 from MarcBarnaba/macos-mps-support
lightningpixel Aug 20, 2026
d016716
Merge pull request #272 from rekcilyssup/fix/utf8-stdio-windows
lightningpixel Aug 20, 2026
87112c3
fix: resync package-lock.json with package.json
lightningpixel Aug 21, 2026
bc6eff6
fix(tests): skip the symlink test where the OS forbids symlinks
lightningpixel Aug 21, 2026
4ffa4ea
docs: add a security policy with a private reporting route
lightningpixel Aug 21, 2026
7e70c5a
Merge pull request #276 from lightningpixel/fix/test-suite-on-windows
lightningpixel Aug 21, 2026
fc2fb2b
Merge pull request #277 from lightningpixel/docs/security-policy
lightningpixel Aug 21, 2026
cfb3ae9
fix(api): file /workflow-runs/from-image into the caller's collection…
kevin9327 Aug 21, 2026
b1d8913
Merge branch 'pr-145' into pr-145-merge
lightningpixel Aug 21, 2026
717abc6
chore(deps): refresh lockfile to clear 15 npm audit findings
lightningpixel Aug 21, 2026
f49f1de
ci: run on Node 22, as electron 42 requires
lightningpixel Aug 21, 2026
aded4c6
Merge pull request #145 from kairoxxai/laptop/electron-stack-vuln-fix
lightningpixel Aug 21, 2026
be2eacf
fix: contain the collection name to the workspace root
lightningpixel Aug 26, 2026
924a5a6
fix: close the follow-up gaps the second review pass found
lightningpixel Aug 26, 2026
ed81278
Merge pull request #279 from kevin9327/fix/workflow-runs-collection
lightningpixel Aug 26, 2026
496ad0f
fix: restore the multi-image routing e5eac44 silently dropped
lightningpixel Aug 26, 2026
ea3f91a
Merge pull request #193 from iammojogo-sudo/extension-multi-image-fix
lightningpixel Aug 26, 2026
05f0309
feat: native OS notification alongside the completion sound
lightningpixel Aug 26, 2026
98f6314
Merge pull request #209 from iammojogo-sudo/notification_bell_addition
lightningpixel Aug 28, 2026
2e80608
fix: rely on native OS notification sound instead of a synthesized ding
lightningpixel Aug 28, 2026
3c88d71
dump version 0.4.2
lightningpixel Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Security Policy

## Scope

Modly is designed to run locally. It is an Electron desktop application that
spawns a Python backend bound to `127.0.0.1`, and it runs AI models on the
user's own machine. Our threat model assumes:

- The user installed Modly through a supported channel: the installer published
on the project's GitHub releases page, or a manual install following the
README.
- The user has not installed untrusted extensions. Extensions are arbitrary
Python code and are trusted as much as any other software the user chooses to
install.
- The user may open and run workflow files authored by someone else. Sharing
workflows is a normal thing to do, so a workflow is untrusted input.
- **Any web page the user has open in a browser is an untrusted caller of the
local API.** The backend listens on loopback, but a page in the user's browser
is on the same machine — it must not be able to read, write, or trigger
anything through Modly.
- Model weights are downloaded from the repositories Modly ships or from
repositories the user explicitly chooses.
- Python dependencies are at the versions Modly installs during first-run setup.

A report is in scope only if it affects a user operating within this threat
model.

## What We Consider a Vulnerability

We want to hear about issues where a reasonable user — someone who does not
install untrusted extensions — can be harmed by Modly itself.

The clearest examples:

- A **workflow file** that such a user might plausibly open and run, using only
built-in nodes and installed extensions, that leads to code execution,
file access outside the expected directories, or data exfiltration.
- A **web page** that, simply by being open while Modly is running, can reach
the local API to read files, write files, or start work on the user's machine.
- An **extension manifest** that escapes its own directory, or that causes code
outside the extension to be loaded into a privileged context.
- A flaw in the **auto-update** mechanism on the platforms where it is enabled
(Windows and Linux; macOS updates manually): unverified or improperly verified
update payloads, or signature checks that fail open.
- Reaching **Node or main-process privileges** from renderer content, or
otherwise defeating the `contextIsolation` boundary between the renderer and
the preload bridge.

When submitting a report, please include a clear description of why this is a
problem for a typical local Modly user. Reports without this context are
difficult to act on.

## What We Do Not Consider a Security Vulnerability

Please report the following through regular GitHub issues instead. Filing them
as security reports will likely cause them to be deprioritized or closed.

- **Issues that require the user to deliberately expose the backend to the
network.** Modly binds to `127.0.0.1` and offers no option to do otherwise. If
you put a reverse proxy or a port forward in front of it, you have chosen to
expose it and are responsible for securing that deployment. Note that this
exclusion does *not* cover attacks from a web page on the user's own machine —
those need no exposure and are in scope, as described above.
- **Issues that require a specific third-party extension to be installed.**
Extensions are third-party code. Report those to the maintainer of the
extension.
- **Malicious content inside model weights the user chooses to download.**
Modly fetches weights from the repository named by an extension or by the
user. Report those to the repository host; if an extension points at a
malicious repository, report it to that extension's maintainer.
- **Vulnerabilities that depend on dependency versions we neither ship nor
recommend.**
- **Crashes, hangs, or memory exhaustion** from a heavy mesh, a large image, or
a runaway workflow. Annoying, but not a security issue in our model. File a
regular bug.
- Automated scanner output submitted without a working reproduction.

## Supported Versions

Modly is pre-1.0 (currently 0.x). Security fixes ship in the most recent
release only. Please confirm the issue on the latest version before reporting.

## Reporting

If you believe you have found an issue that falls within the scope above, please
report it privately via GitHub's
[Report a vulnerability](https://github.com/lightningpixel/modly/security/advisories/new)
feature rather than opening a public issue, discussion, or Discord message.

Please include:

- A description of the vulnerability and the affected component.
- Reproduction steps, ideally with a minimal workflow file or proof of concept.
- The Modly version, install method, and operating system.
- An explanation of how this affects a typical local user as described in the
threat model.

We aim to acknowledge valid reports within 3 business days, and we will
coordinate a fix and a disclosure timeline with you. Reporters are credited in
the resulting advisory and in the release notes unless they prefer to remain
anonymous.
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm install
Expand All @@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm install
Expand All @@ -46,7 +46,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm install
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Install dependencies
Expand Down Expand Up @@ -83,7 +83,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Install dependencies
Expand Down Expand Up @@ -117,7 +117,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Install dependencies
Expand Down
5 changes: 4 additions & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
from fastapi.responses import FileResponse
from fastapi import HTTPException

from services.stdio_utf8 import ensure_utf8_stdio
ensure_utf8_stdio() # must run before any print/logging hits the pipe

from routers import generation, model, optimize, status, settings, extensions, export, workflow_runs, agent


Expand All @@ -31,7 +34,7 @@ def filter(self, record):

app = FastAPI(
title="Modly API",
version="0.4.1",
version="0.4.2",
lifespan=lifespan,
)

Expand Down
11 changes: 8 additions & 3 deletions api/routers/extensions.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import asyncio
import subprocess
import sys
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Body, HTTPException

router = APIRouter(tags=["extensions"])


@router.post("/reload")
async def reload_extensions():
async def reload_extensions(payload: dict | None = Body(default=None)):
"""
Re-scans the extensions/ folder and reloads the registry without restarting FastAPI.
Unloads all currently loaded generators before reloading.
"""
from services.generator_registry import generator_registry
generator_registry.reload()
validation_capability = None
if isinstance(payload, dict):
candidate = payload.get("validationCapability")
if isinstance(candidate, dict):
validation_capability = candidate
generator_registry.reload(validation_capability)
return {
"reloaded": True,
"models": list(generator_registry._generators.keys()),
Expand Down
55 changes: 50 additions & 5 deletions api/routers/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

router = APIRouter(tags=["generation"])

# Shared with workflow_runs.create_run_from_image so the two endpoints can't drift apart on
# what counts as a valid remesh mode the way they had drifted on `collection` before #238.
VALID_REMESH_MODES = ("quad", "triangle", "none")

_jobs: Dict[str, JobStatus] = {}
_cancelled: set = set()
_cancel_events: Dict[str, threading.Event] = {}
Expand All @@ -32,6 +36,47 @@ def _purge_old_jobs() -> None:
_completed_at.pop(jid, None)


def sanitize_collection(collection: str) -> str:
"""Normalize a caller-supplied collection name into a safe workspace subfolder.

The value becomes a directory under the workspace (``WORKSPACE_DIR / collection``), so a
name carrying a path separator or a drive/wildcard character could escape that root or fail
to create on Windows. Such a name, or an empty one, falls back to ``"Default"`` rather than
raising, because a generation the caller already paid for should still land somewhere
sensible. Shared so every entry point that routes output into a collection sanitizes it the
same way; a second copy of this rule is a second chance to forget a character.

Legality and containment are different questions, so they are asked separately: the
reserved characters above are refused outright, and containment is put to the path
library rather than to the spelling -- the same ``relative_to`` check
``generator_registry._path_belongs_to`` uses, so the two containment checks in this
backend agree rather than drifting on their own semantics. A character blocklist alone
lets ``".."`` through -- it contains none of the listed characters -- and
``WORKSPACE_DIR / ".."`` resolves to the workspace's *parent*, so the generated mesh
would land outside the root.

A name ending in a dot or space is refused too, even once it clears both checks above:
Windows silently drops trailing dots/spaces from the final path component it actually
creates, so ``mkdir()`` on ``"Exports..."`` lands in the very same folder as
``"Exports"`` -- two collections that look distinct to this function would otherwise
merge their output on disk without either caller being told.
"""
collection = (collection or "").strip()
if (
not collection
or _re.search(r'[/:*?"<>|\\]', collection)
or collection != collection.rstrip(". ")
):
return "Default"

try:
(WORKSPACE_DIR / collection).resolve().relative_to(WORKSPACE_DIR.resolve())
except (OSError, ValueError):
return "Default"

return collection


@router.post("/from-image")
async def generate_from_image(
background_tasks: BackgroundTasks,
Expand All @@ -46,13 +91,10 @@ async def generate_from_image(
if not image.content_type or not image.content_type.startswith("image/"):
raise HTTPException(400, "File must be an image")

if remesh not in ("quad", "triangle", "none"):
if remesh not in VALID_REMESH_MODES:
raise HTTPException(400, "remesh must be 'quad', 'triangle', or 'none'")

# Sanitize collection name: strip, forbid path separators and special chars
collection = collection.strip()
if not collection or _re.search(r'[/:*?"<>|\\]', collection):
collection = "Default"
collection = sanitize_collection(collection)

# Verify the requested model exists in the registry
try:
Expand Down Expand Up @@ -127,6 +169,9 @@ async def _run_generation(job_id: str, image_bytes: bytes, params: dict, collect
job.status = "running"

def progress_cb(pct: int, step: str = "") -> None:
# Monotonic: the loading phase walks the bar up on a background thread and
# extensions then report their own 0->100 scale, so an unguarded assignment
# yanks the bar backwards on the first generation progress message.
if pct > job.progress:
job.progress = pct
if step:
Expand Down
39 changes: 30 additions & 9 deletions api/routers/workflow_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, UploadFile
from pydantic import BaseModel

from routers.generation import _cancel_events, _cancelled, _jobs, _run_generation
from routers.generation import (
VALID_REMESH_MODES,
_cancel_events,
_cancelled,
_jobs,
_run_generation,
sanitize_collection,
)
from schemas.generation import JobStatus
from services.generator_registry import generator_registry

Expand All @@ -27,18 +34,16 @@ async def create_run_from_image(
background_tasks: BackgroundTasks,
image: UploadFile = File(...),
model_id: str = Form("sf3d"),
# Where the result is filed. The legacy /generate/from-image already accepts this; the
# canonical endpoint hardcoded "Default", so a run driven over REST/MCP landed in a folder
# the Library does not index and stayed invisible in the app (#238). Same field, same
# sanitizer, so both surfaces route output the same way.
collection: str = Form("Default"),
params: str = Form("{}"),
):
if not image.content_type or not image.content_type.startswith("image/"):
raise HTTPException(400, "File must be an image")

try:
generator_registry.get_generator(model_id)
except ValueError as e:
raise HTTPException(400, str(e))

generator_registry.switch_model(model_id)

try:
model_params = json.loads(params)
except (json.JSONDecodeError, TypeError):
Expand All @@ -51,13 +56,29 @@ async def create_run_from_image(
**model_params,
}

# Same constraint /generate/from-image enforces on this field, checked before touching
# the registry below for the same reason that endpoint checks it first: switch_model()
# unloads whatever generator is currently active, and a request rejected for a bad
# remesh value should not pay for -- or force a reload after -- evicting it.
if full_params["remesh"] not in VALID_REMESH_MODES:
raise HTTPException(400, "remesh must be 'quad', 'triangle', or 'none'")

collection = sanitize_collection(collection)

try:
generator_registry.get_generator(model_id)
except ValueError as e:
raise HTTPException(400, str(e))

generator_registry.switch_model(model_id)

job_id = str(uuid.uuid4())
image_bytes = await image.read()

_jobs[job_id] = JobStatus(job_id=job_id, status="pending", progress=0)
_cancel_events[job_id] = threading.Event()

background_tasks.add_task(_run_generation, job_id, image_bytes, full_params, "Default")
background_tasks.add_task(_run_generation, job_id, image_bytes, full_params, collection)

return {"run_id": job_id, "status": "pending"}

Expand Down
6 changes: 6 additions & 0 deletions api/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
if str(EXT_DIR) not in sys.path:
sys.path.insert(0, str(EXT_DIR))

# Match the UTF-8 pipe readers on the parent side even when the OS locale
# is a legacy codepage (cp1252/cp932 on Windows). Without this, any Unicode
# print() from a generator (e.g. tqdm output) crashes the worker.
from services.stdio_utf8 import ensure_utf8_stdio
ensure_utf8_stdio()


# ------------------------------------------------------------------ #
# Protocol helpers
Expand Down
Loading
Loading