Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def filter(self, record):

app = FastAPI(
title="Modly API",
version="0.3.5",
version="0.3.6",
lifespan=lifespan,
)

Expand Down
20 changes: 20 additions & 0 deletions api/routers/generation.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import json
import threading
import time
import traceback
import uuid
from typing import Dict
Expand All @@ -16,6 +17,19 @@
_jobs: Dict[str, JobStatus] = {}
_cancelled: set = set()
_cancel_events: Dict[str, threading.Event] = {}
_completed_at: Dict[str, float] = {}

_JOB_TTL = 1800 # purge terminal jobs after 30 minutes


def _purge_old_jobs() -> None:
cutoff = time.monotonic() - _JOB_TTL
stale = [jid for jid, t in _completed_at.items() if t < cutoff]
for jid in stale:
_jobs.pop(jid, None)
_cancelled.discard(jid)
_cancel_events.pop(jid, None)
_completed_at.pop(jid, None)


@router.post("/from-image")
Expand Down Expand Up @@ -63,6 +77,8 @@ async def generate_from_image(
**model_params,
}

_purge_old_jobs()

job = JobStatus(job_id=job_id, status="pending", progress=0)
_jobs[job_id] = job
_cancel_events[job_id] = threading.Event()
Expand Down Expand Up @@ -91,6 +107,7 @@ async def cancel_job(job_id: str):
_cancel_events[job_id].set()
if job.status in ("pending", "running"):
job.status = "cancelled"
_completed_at[job_id] = time.monotonic()
# Kill the active generator subprocess immediately so inference stops now.
# _run_generation will catch the resulting exception, see job_id in _cancelled,
# and return cleanly without setting an error status.
Expand Down Expand Up @@ -162,6 +179,7 @@ def progress_cb(pct: int, step: str = "") -> None:

job.status = "done"
job.progress = 100
_completed_at[job_id] = time.monotonic()
try:
rel = output_path.relative_to(WORKSPACE_DIR)
job.output_url = f"/workspace/{rel.as_posix()}"
Expand All @@ -170,10 +188,12 @@ def progress_cb(pct: int, step: str = "") -> None:

except GenerationCancelled:
job.status = "cancelled"
_completed_at[job_id] = time.monotonic()
except Exception as exc:
if job_id in _cancelled:
return
tb = traceback.format_exc()
print(f"[Generation ERROR] {exc}\n{tb}")
job.status = "error"
job.error = tb.strip()
_completed_at[job_id] = time.monotonic()
52 changes: 52 additions & 0 deletions electron/main/extension-path-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import test from 'node:test'

async function loadGuard() {
return import(new URL('./extension-path-guard.ts', import.meta.url).href)
}

test('assertSafeExtensionId accepts conservative extension ids', async () => {
const { assertSafeExtensionId } = await loadGuard()
assert.equal(assertSafeExtensionId('mesh-process'), 'mesh-process')
assert.equal(assertSafeExtensionId('image_model.v2'), 'image_model.v2')
assert.equal(assertSafeExtensionId('kimodo-soma-rp'), 'kimodo-soma-rp')
})

test('assertSafeExtensionId rejects empty, traversal and absolute ids', async () => {
const { assertSafeExtensionId } = await loadGuard()
assert.throws(() => assertSafeExtensionId(''), /must not be empty/i)
assert.throws(() => assertSafeExtensionId('.'), /is invalid/i)
assert.throws(() => assertSafeExtensionId('..'), /is invalid/i)
assert.throws(() => assertSafeExtensionId('../escape'), /path separators/i)
assert.throws(() => assertSafeExtensionId('..\\escape'), /path separators/i)
assert.throws(() => assertSafeExtensionId('/abs/path'), /absolute path|path separators/i)
})

test('assertSafeExtensionId rejects uppercase and unsupported characters', async () => {
const { assertSafeExtensionId } = await loadGuard()
assert.throws(() => assertSafeExtensionId('Mesh-Process'), /must match/i)
assert.throws(() => assertSafeExtensionId('bad id'), /must match/i)
assert.throws(() => assertSafeExtensionId('bad:id'), /must match/i)
})

test('resolveExtensionPathWithinRoot confines paths to root', async () => {
const { resolveExtensionPathWithinRoot } = await loadGuard()
const root = path.join('/tmp', 'extensions-root')
assert.equal(resolveExtensionPathWithinRoot(root, 'mesh-process'), path.resolve(root, 'mesh-process'))
assert.throws(() => resolveExtensionPathWithinRoot(root, '../escape'), /path separators/i)
})

test('resolvePathWithinRoot rejects canonical escapes', async () => {
const { resolvePathWithinRoot } = await loadGuard()
const root = path.join('/tmp', 'extensions-root')
assert.equal(resolvePathWithinRoot(root, '.modly-backup-safe-123'), path.resolve(root, '.modly-backup-safe-123'))
assert.throws(() => resolvePathWithinRoot(root, '../escape'), /escapes root/i)
})

test('buildExtensionBackupPath stays within root and rejects unsafe ids', async () => {
const { buildExtensionBackupPath } = await loadGuard()
const root = path.join('/tmp', 'extensions-root')
assert.equal(buildExtensionBackupPath(root, 'mesh-process', '123'), path.resolve(root, '.modly-backup-mesh-process-123'))
assert.throws(() => buildExtensionBackupPath(root, '../escape', '123'), /path separators/i)
})
53 changes: 53 additions & 0 deletions electron/main/extension-path-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { isAbsolute, relative, resolve as resolvePath } from 'node:path'

const EXTENSION_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/

export function assertSafeExtensionId(extensionId: unknown): string {
if (typeof extensionId !== 'string') {
throw new Error('Extension id must be a string')
}

const trimmed = extensionId.trim()
if (!trimmed) {
throw new Error('Extension id must not be empty')
}

if (trimmed === '.' || trimmed === '..') {
throw new Error(`Extension id "${extensionId}" is invalid`)
}

if (isAbsolute(trimmed)) {
throw new Error(`Extension id "${extensionId}" must not be an absolute path`)
}

if (trimmed.includes('/') || trimmed.includes('\\')) {
throw new Error(`Extension id "${extensionId}" must not contain path separators`)
}

if (!EXTENSION_ID_PATTERN.test(trimmed)) {
throw new Error(`Extension id "${extensionId}" must match ${EXTENSION_ID_PATTERN}`)
}

return trimmed
}

export function resolvePathWithinRoot(rootDir: string, unsafeLeaf: string): string {
const resolvedRoot = resolvePath(rootDir)
const resolvedCandidate = resolvePath(resolvedRoot, unsafeLeaf)
const normalizedRelative = relative(resolvedRoot, resolvedCandidate).replace(/\\/g, '/')

if (normalizedRelative === '..' || normalizedRelative.startsWith('../') || isAbsolute(normalizedRelative)) {
throw new Error(`Resolved path escapes root: ${unsafeLeaf}`)
}

return resolvedCandidate
}

export function resolveExtensionPathWithinRoot(rootDir: string, extensionId: unknown): string {
return resolvePathWithinRoot(rootDir, assertSafeExtensionId(extensionId))
}

export function buildExtensionBackupPath(rootDir: string, extensionId: unknown, suffix: string): string {
const safeId = assertSafeExtensionId(extensionId)
return resolvePathWithinRoot(rootDir, `.modly-backup-${safeId}-${suffix}`)
}
67 changes: 39 additions & 28 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { logger } from './logger'
import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner'
import { getBuiltinExtensionsDir } from './builtin-sync'
import { spawn } from 'child_process'
import { assertSafeExtensionId, buildExtensionBackupPath, resolveExtensionPathWithinRoot } from './extension-path-guard'

type WindowGetter = () => BrowserWindow | null

Expand Down Expand Up @@ -674,6 +675,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
const manifest = JSON.parse(manifestRaw) as ParsedManifest

if (!manifest.id) throw new Error('manifest.json: required field "id" missing')
const extensionId = assertSafeExtensionId(manifest.id)
manifest.id = extensionId
if (!manifest.nodes?.length) throw new Error('manifest.json: required field "nodes" missing or empty')

const isProcess = manifest.type === 'process'
Expand All @@ -698,10 +701,13 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
// 5. Copy to extensions directory (overwrite if already present)
const extensionsDir = getSettings(app.getPath('userData')).extensionsDir
await mkdir(extensionsDir, { recursive: true })
const destDir = join(extensionsDir, manifest.id)
const destDir = resolveExtensionPathWithinRoot(extensionsDir, extensionId)
const backupDir = existsSync(destDir) ? buildExtensionBackupPath(extensionsDir, extensionId, String(Date.now())) : null

if (existsSync(destDir)) {
await rmAsync(destDir, { recursive: true, force: true })
try {
if (backupDir) {
terminateProcessRunner(extensionId)
await rename(destDir, backupDir)
}
await cp(extractDir, destDir, { recursive: true })

Expand All @@ -726,15 +732,10 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
if (existsSync(join(destDir, 'setup.py'))) {
emit({ step: 'setting_up', message: 'Setting up Python environment…' })
const { sm: gpuSm, cudaVersion } = await detectGpuInfo()
try {
await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => {
logger.info(`[ext-setup] ${line}`)
emit({ step: 'setting_up', message: line })
})
} catch (err) {
logger.warn(`[ext-setup] setup.py failed: ${err}`)
emit({ step: 'setting_up', message: `Warning: setup failed — ${err}` })
}
await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => {
logger.info(`[ext-setup] ${line}`)
emit({ step: 'setting_up', message: line })
})
}
} else if (isProcess) {
// 6b. JS process extension: npm install if package.json present
Expand Down Expand Up @@ -767,26 +768,33 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
if (existsSync(join(destDir, 'setup.py'))) {
emit({ step: 'setting_up', message: 'Setting up Python environment…' })
const { sm: gpuSm, cudaVersion } = await detectGpuInfo()
try {
await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => {
logger.info(`[ext-setup] ${line}`)
emit({ step: 'setting_up', message: line })
})
} catch (setupErr: any) {
throw new Error(`Extension setup failed: ${setupErr?.message ?? setupErr}`)
}
await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => {
logger.info(`[ext-setup] ${line}`)
emit({ step: 'setting_up', message: line })
})
}

try {
await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 })
} catch { /* Python might not be running yet */ }
}

emit({ step: 'done', extensionId: manifest.id })
if (backupDir) {
await rmAsync(backupDir, { recursive: true, force: true })
}
} catch (installErr) {
await rmAsync(destDir, { recursive: true, force: true }).catch(() => {})
if (backupDir && existsSync(backupDir)) {
await rename(backupDir, destDir)
}
throw installErr
}

emit({ step: 'done', extensionId })

const trustedRepos = await fetchTrustedRepos()
const ext = parseExtensionManifest(manifest, manifest.id, trustedRepos)
return { success: true, extensionId: manifest.id, extension: ext }
const ext = parseExtensionManifest(manifest, extensionId, trustedRepos)
return { success: true, extensionId, extension: ext }

} catch (err) {
emit({ step: 'error', message: String(err) })
Expand All @@ -801,16 +809,17 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
// Uninstall an extension — built-ins cannot be uninstalled
ipcMain.handle('extensions:uninstall', async (_, extensionId: string) => {
const userData = app.getPath('userData')
const builtinPath = join(getBuiltinExtensionsDir(), extensionId)
const safeExtensionId = assertSafeExtensionId(extensionId)
const builtinPath = resolveExtensionPathWithinRoot(getBuiltinExtensionsDir(), safeExtensionId)
if (existsSync(builtinPath)) {
return { success: false, error: `"${extensionId}" is a built-in extension and cannot be uninstalled.` }
return { success: false, error: `"${safeExtensionId}" is a built-in extension and cannot be uninstalled.` }
}

const extensionsDir = getSettings(userData).extensionsDir
const extPath = join(extensionsDir, extensionId)
const extPath = resolveExtensionPathWithinRoot(extensionsDir, safeExtensionId)
try {
// Terminate process runner if it's a process extension
terminateProcessRunner(extensionId)
terminateProcessRunner(safeExtensionId)

await rmAsync(extPath, { recursive: true, force: true })
// Hot-reload Python so it stops using the deleted model extension
Expand All @@ -826,7 +835,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
// Re-run setup.py for a model extension (creates the venv if missing)
ipcMain.handle('extensions:repair', async (_, extensionId: string) => {
try {
const extDir = join(getSettings(app.getPath('userData')).extensionsDir, extensionId)
const safeExtensionId = assertSafeExtensionId(extensionId)
const extDir = resolveExtensionPathWithinRoot(getSettings(app.getPath('userData')).extensionsDir, safeExtensionId)
if (!existsSync(join(extDir, 'setup.py'))) {
return { success: false, error: 'No setup.py found for this extension' }
}
Expand All @@ -843,6 +853,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe

// Trigger Python extension reload (without touching the filesystem)
ipcMain.handle('extensions:reload', async () => {
terminateAllProcessRunners()
try {
const res = await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 })
return { success: true, errors: (res.data as { errors?: Record<string, string> }).errors ?? {} }
Expand Down
2 changes: 1 addition & 1 deletion electron/main/python-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export class PythonBridge {
MODELS_DIR: this.resolveModelsDir(),
WORKSPACE_DIR: this.resolveWorkspaceDir(),
EXTENSIONS_DIR: this.resolveExtensionsDir(),
SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '',
...(process.env['SELECTED_MODEL_ID'] ? { SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] } : {}),
HUGGING_FACE_HUB_TOKEN: this.resolveHfToken(),
HF_TOKEN: this.resolveHfToken(),
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "modly",
"version": "0.3.5",
"version": "0.3.6",
"description": "Local AI-powered 3D mesh generation from images",
"main": "./out/main/index.js",
"author": "Modly",
Expand Down
Loading
Loading