Add PA file handling and secure serving in MCPServer; update FileWriteTool to include file URL

This commit is contained in:
hlohaus
2026-04-05 23:36:52 +02:00
parent 758df77264
commit b09317fe47
3 changed files with 88 additions and 8 deletions
+9 -6
View File
@@ -188,7 +188,12 @@ def _exec_in_thread(
prev = sys.getrecursionlimit()
sys.setrecursionlimit(max_depth)
try:
exec(compiled, safe_globals, local_vars) # noqa: S102
# Use safe_globals as both globals and locals so that top-level
# imports (e.g. ``from aiohttp import ClientSession``) are stored in
# the same namespace that class method ``__globals__`` points to.
# Otherwise imports land only in the locals dict and are invisible
# to class methods (NameError at call time).
exec(compiled, safe_globals, safe_globals) # noqa: S102
except Exception: # noqa: BLE001
exc_box.append(traceback.format_exc())
finally:
@@ -349,8 +354,6 @@ def execute_safe_code(
if extra_globals:
safe_globals.update(extra_globals)
local_vars: Dict[str, Any] = {}
# Compile outside the thread so SyntaxErrors surface immediately.
try:
compiled = compile(code, "<pa_provider>", "exec")
@@ -369,7 +372,7 @@ def execute_safe_code(
exc_box: List = []
thread = threading.Thread(
target=_exec_in_thread,
args=(compiled, safe_globals, local_vars, max_depth, exc_box),
args=(compiled, safe_globals, safe_globals, max_depth, exc_box),
daemon=True,
name="g4f-sandbox",
)
@@ -410,8 +413,8 @@ def execute_safe_code(
success=True,
stdout=stdout,
stderr=stderr,
result=local_vars.get("result"),
locals=local_vars,
result=safe_globals.get("result"),
locals=safe_globals,
)
+74 -1
View File
@@ -392,6 +392,75 @@ class MCPServer:
sys.stderr.write(f"Synthesize error: {e}\n")
return web.Response(status=500, text=f"Synthesize error: {str(e)}")
_WORKSPACE_SAFE_TYPES: Dict[str, str] = {
"html": "text/html; charset=utf-8",
"htm": "text/html; charset=utf-8",
"css": "text/css; charset=utf-8",
"js": "application/javascript; charset=utf-8",
"mjs": "application/javascript; charset=utf-8",
"json": "application/json; charset=utf-8",
"txt": "text/plain; charset=utf-8",
"md": "text/markdown; charset=utf-8",
"svg": "image/svg+xml",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
"webp": "image/webp",
"ico": "image/x-icon",
"woff": "font/woff",
"woff2": "font/woff2",
"ttf": "font/ttf",
"otf": "font/otf",
}
async def handle_pa_providers(request: web.Request) -> web.Response:
"""List all PA providers from workspace."""
from .pa_provider import get_pa_registry
providers = get_pa_registry().list_providers()
return web.json_response(providers, headers={"access-control-allow-origin": "*"})
async def handle_pa_file(request: web.Request) -> web.Response:
"""Securely serve a workspace file for browser rendering.
Only files within ``~/.g4f/workspace`` are served. Path traversal
is blocked. Only the MIME types in ``_WORKSPACE_SAFE_TYPES`` are
allowed — ``.py``, ``.env``, and other sensitive types return 403.
HTML files are served with a ``Content-Security-Policy: sandbox``
header so they run in an isolated null origin.
"""
from .pa_provider import get_workspace_dir
workspace = get_workspace_dir()
file_path = request.match_info.get("file_path", "")
try:
resolved = (workspace / file_path).resolve()
resolved.relative_to(workspace.resolve())
except (ValueError, Exception):
return web.Response(status=403, text="Path traversal is not allowed")
if not resolved.exists() or not resolved.is_file():
return web.Response(status=404, text=f"File not found: {file_path}")
ext = resolved.suffix.lstrip(".").lower()
mime = _WORKSPACE_SAFE_TYPES.get(ext)
if mime is None:
return web.Response(status=403, text=f"File type not allowed: .{ext}")
content = resolved.read_bytes()
headers: Dict[str, str] = {"access-control-allow-origin": "*"}
if ext in ("html", "htm"):
req_origin = f"{request.scheme}://{request.host}"
headers["content-security-policy"] = (
f"sandbox allow-scripts allow-forms allow-popups; "
f"default-src {req_origin}; "
f"img-src {req_origin} data: blob:; "
f"font-src {req_origin}; "
f"style-src {req_origin} 'unsafe-inline'; "
f"script-src {req_origin} 'unsafe-inline'"
)
return web.Response(body=content, content_type=mime.split(";")[0].strip(), headers=headers)
# Create aiohttp application
app = web.Application()
app.router.add_options('/mcp', lambda request: web.Response(headers={"access-control-allow-origin": "*", "access-control-allow-methods": "POST, OPTIONS", "access-control-allow-headers": "Content-Type"}))
@@ -399,13 +468,17 @@ class MCPServer:
app.router.add_get('/health', handle_health)
app.router.add_get('/media/{filename:.*}', handle_media)
app.router.add_get('/backend-api/v2/synthesize/{provider}', handle_synthesize)
app.router.add_get('/pa/providers', handle_pa_providers)
app.router.add_get('/pa/files/{file_path:.*}', handle_pa_file)
# Start server
sys.stderr.write(f"Starting {self.server_info['name']} v{self.server_info['version']} (HTTP mode)\n")
sys.stderr.write(f"Listening on http://{host}:{port}\n")
sys.stderr.write(f"MCP endpoint: http://{host}:{port}/mcp\n")
sys.stderr.write(f"Health check: http://{host}:{port}/health\n")
sys.stderr.write(f"Media files: http://{host}:{port}/media/{{filename}}\n")
sys.stderr.write(f"PA providers: http://{host}:{port}/pa/providers\n")
sys.stderr.write(f"PA files: http://{host}:{port}/pa/files/{{path}}\n")
sys.stderr.flush()
runner = web.AppRunner(app)
+5 -1
View File
@@ -682,11 +682,15 @@ class FileWriteTool(MCPTool):
f.write(content)
else:
target.write_text(content, encoding="utf-8")
return {
result: Dict[str, Any] = {
"path": rel_path,
"size": len(content),
"appended": append,
}
origin = arguments.get("origin")
if origin:
result["url"] = f"{origin}/pa/files/{rel_path}"
return result
except Exception as exc:
return {"error": f"Write failed: {exc}"}