XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
返回提交历史

XFEstudio/gpt4free

Enhance secret workspace management and screenshot handling

- Implement user-specific workspace directories for secret conversations and files, ensuring secure storage and retrieval. - Add functionality to save, list, retrieve, and delete secret conversations, with support for encryption using a workspace secret. - Introduce cross-device secret-sharing requests, allowing users to confirm access from new devices. - Improve screenshot capturing logic to handle retries and manage file naming for cached screenshots. - Update tools to resolve workspace paths based on user identity and workspace secret, enhancing file access control. - Refactor existing code to streamline workspace management and improve error handling.

42bdd1a2
hlohaus <983577+hlohaus@users.noreply.github.com>
提交于

代码差异

7 个文件 +1058 -67
Modified etc/tool/template.html +2 -2
@@ -15,13 +15,13 @@
15 15 <meta property="og:description" content="GPT4Free documentation for GPT4Free Documentation Hub. Free AI endpoints, examples, and comprehensive guides." />
16 16 <meta property="og:url" content="https://g4f.dev/docs/" />
17 17 <meta property="og:site_name" content="GPT4Free Documentation" />
18 <meta property="og:image" content="https://g4f.dev/dist/img/g4f-512x512.png" />
18 <meta property="og:image" content="https://g4f.space/screenshot/docs" />
19 19
20 20 <!-- Twitter Card -->
21 21 <meta name="twitter:card" content="summary_large_image" />
22 22 <meta name="twitter:title" content="GPT4Free Documentation Hub | GPT4Free Documentation" />
23 23 <meta name="twitter:description" content="Free AI endpoints and comprehensive documentation for GPT4Free Documentation Hub." />
24 <meta name="twitter:image" content="https://g4f.dev/dist/img/g4f-512x512.png" />
24 <meta name="twitter:image" content="https://g4f.space/screenshot/docs" />
25 25
26 26 <!-- Canonical -->
27 27 <link rel="canonical" href="https://g4f.dev/docs/" />
Modified g4f/api/__init__.py +291 -21
@@ -30,6 +30,7 @@ from fastapi.security import APIKeyHeader
30 30 from starlette.exceptions import HTTPException
31 31 from starlette.status import (
32 32 HTTP_200_OK,
33 HTTP_400_BAD_REQUEST,
33 34 HTTP_404_NOT_FOUND,
34 35 HTTP_401_UNAUTHORIZED,
35 36 HTTP_403_FORBIDDEN,
@@ -1310,29 +1311,28 @@ class Api:
1310 1311 return ErrorResponse.from_exception(
1311 1312 e, config, HTTP_500_INTERNAL_SERVER_ERROR
1312 1313 )
1314
1315 lock = asyncio.Lock()
1313 1316
1314 1317 @self.app.get("/screenshot", responses=responses)
1315 1318 async def image_from_url(
1316 1319 url: str,
1317 1320 ):
1318 1321 try:
1319 from g4f.requests.cdp import CDPSession
1320 session = CDPSession(headless=True)
1321 await session.start()
1322 try:
1323 image_bytes = await session.capture_screenshot(f"{url}&noads={int(time.time())}" if "?" in url else f"{url}?noads={int(time.time())}")
1324 # You might want to save this image or return it directly
1325 # For now, let's return it as a FileResponse
1326 # Create a temporary file to store the image
1327 import tempfile
1328 import os
1329 with tempfile.NamedTemporaryFile(delete=False, suffix=".png") as tmp:
1330 tmp.write(image_bytes)
1331 tmp_path = tmp.name
1332
1333 return FileResponse(tmp_path, media_type="image/png", background=BackgroundTask(lambda: os.remove(tmp_path)))
1334 finally:
1335 await session.close()
1322 async with lock:
1323 from g4f.requests.cdp import CDPSession
1324 session = CDPSession(headless=True)
1325 await session.start()
1326 try:
1327 screenshot_path = await session.capture_screenshot(url)
1328 print(f"Screenshot saved to: {screenshot_path}")
1329 return FileResponse(
1330 screenshot_path,
1331 media_type="image/jpeg",
1332 #headers={"Cache-Control": "max-age=8600"},
1333 )
1334 finally:
1335 await session.close()
1336 1336 except Exception as e:
1337 1337 logger.exception(e)
1338 1338 return ErrorResponse.from_exception(
@@ -1487,14 +1487,18 @@ class Api:
1487 1487 the ``sandbox`` directive; they are leaf resources and do not run
1488 1488 in their own browsing context.
1489 1489 """
1490 from g4f.mcp.pa_provider import get_workspace_dir
1490 from g4f.mcp.pa_provider import resolve_workspace_path
1491 1491
1492 workspace = get_workspace_dir()
1492 # Extract user_id and workspace_secret from headers if available
1493 user_id = request.headers.get("x-user-id", "")
1494 workspace_secret = request.headers.get("x-workspace-secret", "")
1493 1495
1494 1496 # Normalise and check for traversal
1495 1497 try:
1496 resolved = (workspace / file_path).resolve()
1497 resolved.relative_to(workspace.resolve())
1498 resolved, workspace = resolve_workspace_path(
1499 file_path, user_id=user_id, workspace_secret=workspace_secret, for_write=False
1500 )
1501 resolved.relative_to(workspace)
1498 1502 except (ValueError, Exception):
1499 1503 return ErrorResponse.from_message(
1500 1504 "Path traversal is not allowed", HTTP_403_FORBIDDEN
@@ -1577,6 +1581,272 @@ class Api:
1577 1581 headers=headers,
1578 1582 )
1579 1583
1584 # ------------------------------------------------------------------ #
1585 # Secret conversation endpoints (per-user, stored in secret workspace) #
1586 # ------------------------------------------------------------------ #
1587
1588 @self.app.get(
1589 "/v1/secret/conversations",
1590 responses={
1591 HTTP_200_OK: {},
1592 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1593 },
1594 )
1595 async def list_secret_conversations_endpoint(request: Request):
1596 """List all secret conversations for the authenticated user."""
1597 from g4f.mcp.pa_provider import list_secret_conversations
1598
1599 user_id = request.headers.get("x-user-id", "")
1600 if not user_id:
1601 return ErrorResponse.from_message(
1602 "User ID is required (provide x-user-id header)",
1603 HTTP_401_UNAUTHORIZED,
1604 )
1605 return {"conversations": list_secret_conversations(user_id)}
1606
1607 @self.app.post(
1608 "/v1/secret/conversations",
1609 responses={
1610 HTTP_200_OK: {},
1611 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1612 },
1613 )
1614 async def save_secret_conversation_endpoint(request: Request):
1615 """Save a conversation to the user's secret workspace."""
1616 from g4f.mcp.pa_provider import save_secret_conversation
1617
1618 user_id = request.headers.get("x-user-id", "")
1619 if not user_id:
1620 return ErrorResponse.from_message(
1621 "User ID is required (provide x-user-id header)",
1622 HTTP_401_UNAUTHORIZED,
1623 )
1624 workspace_secret = request.headers.get("x-workspace-secret", "")
1625 try:
1626 body = await request.json()
1627 except Exception:
1628 return ErrorResponse.from_message("Invalid JSON body")
1629 result = save_secret_conversation(user_id, body, workspace_secret or None)
1630 return result
1631
1632 @self.app.post(
1633 "/v1/secret/conversations/sync",
1634 responses={
1635 HTTP_200_OK: {},
1636 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1637 },
1638 )
1639 async def sync_secret_conversations_endpoint(request: Request):
1640 """Sync (upload) multiple conversations to the user's secret workspace."""
1641 from g4f.mcp.pa_provider import save_secret_conversation
1642
1643 user_id = request.headers.get("x-user-id", "")
1644 if not user_id:
1645 return ErrorResponse.from_message(
1646 "User ID is required (provide x-user-id header)",
1647 HTTP_401_UNAUTHORIZED,
1648 )
1649 workspace_secret = request.headers.get("x-workspace-secret", "")
1650 try:
1651 body = await request.json()
1652 except Exception:
1653 return ErrorResponse.from_message("Invalid JSON body")
1654 conversations = body.get("conversations", []) if isinstance(body, dict) else body
1655 saved = 0
1656 errors = []
1657 for conv in conversations:
1658 res = save_secret_conversation(user_id, conv, workspace_secret or None)
1659 if res.get("saved"):
1660 saved += 1
1661 else:
1662 errors.append(res.get("error", "Unknown error"))
1663 return {"saved": saved, "errors": errors}
1664
1665 @self.app.get(
1666 "/v1/secret/conversations/{conversation_id}",
1667 responses={
1668 HTTP_200_OK: {},
1669 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1670 HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
1671 },
1672 )
1673 async def get_secret_conversation_endpoint(
1674 conversation_id: str, request: Request
1675 ):
1676 """Retrieve a single secret conversation by ID."""
1677 from g4f.mcp.pa_provider import get_secret_conversation
1678
1679 user_id = request.headers.get("x-user-id", "")
1680 if not user_id:
1681 return ErrorResponse.from_message(
1682 "User ID is required (provide x-user-id header)",
1683 HTTP_401_UNAUTHORIZED,
1684 )
1685 workspace_secret = request.headers.get("x-workspace-secret", "")
1686 conv = get_secret_conversation(user_id, conversation_id, workspace_secret or None)
1687 if conv is None:
1688 return ErrorResponse.from_message(
1689 f"Conversation '{conversation_id}' not found",
1690 HTTP_404_NOT_FOUND,
1691 )
1692 return conv
1693
1694 @self.app.delete(
1695 "/v1/secret/conversations/{conversation_id}",
1696 responses={
1697 HTTP_200_OK: {},
1698 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1699 HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
1700 },
1701 )
1702 async def delete_secret_conversation_endpoint(
1703 conversation_id: str, request: Request
1704 ):
1705 """Delete a secret conversation by ID."""
1706 from g4f.mcp.pa_provider import delete_secret_conversation
1707
1708 user_id = request.headers.get("x-user-id", "")
1709 if not user_id:
1710 return ErrorResponse.from_message(
1711 "User ID is required (provide x-user-id header)",
1712 HTTP_401_UNAUTHORIZED,
1713 )
1714 deleted = delete_secret_conversation(user_id, conversation_id)
1715 if not deleted:
1716 return ErrorResponse.from_message(
1717 f"Conversation '{conversation_id}' not found",
1718 HTTP_404_NOT_FOUND,
1719 )
1720 return {"deleted": True, "id": conversation_id}
1721
1722 # ── Cross-device workspace secret sharing ───────────────────────────
1723
1724 @self.app.post(
1725 "/v1/secret/request",
1726 responses={
1727 HTTP_200_OK: {},
1728 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1729 },
1730 )
1731 async def create_secret_request_endpoint(request: Request):
1732 """Create a pending secret-sharing request from a new device."""
1733 from g4f.mcp.pa_provider import create_secret_request
1734
1735 user_id = request.headers.get("x-user-id", "")
1736 if not user_id:
1737 return ErrorResponse.from_message(
1738 "User ID is required (provide x-user-id header)",
1739 HTTP_401_UNAUTHORIZED,
1740 )
1741 body = {}
1742 try:
1743 body = await request.json()
1744 except Exception:
1745 pass
1746 device_name = body.get("device_name", "") if isinstance(body, dict) else ""
1747 return create_secret_request(user_id, device_name)
1748
1749 @self.app.get(
1750 "/v1/secret/requests",
1751 responses={
1752 HTTP_200_OK: {},
1753 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1754 },
1755 )
1756 async def list_secret_requests_endpoint(request: Request):
1757 """List pending secret-sharing requests for the online device."""
1758 from g4f.mcp.pa_provider import list_secret_requests
1759
1760 user_id = request.headers.get("x-user-id", "")
1761 if not user_id:
1762 return ErrorResponse.from_message(
1763 "User ID is required (provide x-user-id header)",
1764 HTTP_401_UNAUTHORIZED,
1765 )
1766 return {"requests": list_secret_requests(user_id)}
1767
1768 @self.app.post(
1769 "/v1/secret/request/confirm",
1770 responses={
1771 HTTP_200_OK: {},
1772 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1773 HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
1774 },
1775 )
1776 async def confirm_secret_request_endpoint(request: Request):
1777 """Confirm a secret request by sending the workspace secret."""
1778 from g4f.mcp.pa_provider import confirm_secret_request
1779
1780 user_id = request.headers.get("x-user-id", "")
1781 if not user_id:
1782 return ErrorResponse.from_message(
1783 "User ID is required (provide x-user-id header)",
1784 HTTP_401_UNAUTHORIZED,
1785 )
1786 try:
1787 body = await request.json()
1788 except Exception:
1789 return ErrorResponse.from_message("Invalid JSON body", HTTP_400_BAD_REQUEST)
1790 request_id = body.get("request_id", "")
1791 workspace_secret = body.get("workspace_secret", "")
1792 if not request_id or not workspace_secret:
1793 return ErrorResponse.from_message(
1794 "request_id and workspace_secret are required",
1795 HTTP_400_BAD_REQUEST,
1796 )
1797 result = confirm_secret_request(user_id, request_id, workspace_secret)
1798 if "error" in result:
1799 return ErrorResponse.from_message(result["error"], HTTP_404_NOT_FOUND)
1800 return result
1801
1802 @self.app.get(
1803 "/v1/secret/request/{request_id}",
1804 responses={
1805 HTTP_200_OK: {},
1806 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1807 },
1808 )
1809 async def poll_secret_request_endpoint(
1810 request_id: str, request: Request
1811 ):
1812 """Poll a secret request to check if it has been confirmed."""
1813 from g4f.mcp.pa_provider import poll_secret_request
1814
1815 user_id = request.headers.get("x-user-id", "")
1816 if not user_id:
1817 return ErrorResponse.from_message(
1818 "User ID is required (provide x-user-id header)",
1819 HTTP_401_UNAUTHORIZED,
1820 )
1821 return poll_secret_request(user_id, request_id)
1822
1823 @self.app.delete(
1824 "/v1/secret/request/{request_id}",
1825 responses={
1826 HTTP_200_OK: {},
1827 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
1828 HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
1829 },
1830 )
1831 async def delete_secret_request_endpoint(
1832 request_id: str, request: Request
1833 ):
1834 """Cancel / delete a secret-sharing request."""
1835 from g4f.mcp.pa_provider import delete_secret_request
1836
1837 user_id = request.headers.get("x-user-id", "")
1838 if not user_id:
1839 return ErrorResponse.from_message(
1840 "User ID is required (provide x-user-id header)",
1841 HTTP_401_UNAUTHORIZED,
1842 )
1843 deleted = delete_secret_request(user_id, request_id)
1844 if not deleted:
1845 return ErrorResponse.from_message(
1846 "Request not found", HTTP_404_NOT_FOUND
1847 )
1848 return {"deleted": True, "request_id": request_id}
1849
1580 1850 responses = {
1581 1851 HTTP_200_OK: {"model": TranscriptionResponseModel},
1582 1852 HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
Modified g4f/gui/server/website.py +28 -4
@@ -320,14 +320,38 @@ class Website:
320 320 """
321 321
322 322 # Screenshot / logo section
323 logo_url = f"/screenshot?url={p.get('url', (p.get('base_url', p.get('baseUrl', ''))).replace('https://', '').replace('http://', '').replace('playground.ai.', '').replace('console.', '').replace('api.', '').replace('router.', '').split('/')[0])}"
323 logo_url = f"{p.get('url', (p.get('base_url', p.get('baseUrl', '')))).replace('playground.ai.', '').replace('https://', '').replace('http://', '').replace('api.', '').replace('console.', '').replace('api.', '').replace('router.', '').split('/')[0]}"
324 logo_url = f"api.airforce" if logo_url == "airforce" else logo_url
325 logo_url = f"/screenshot?url=https://{logo_url}"
324 326 screenshot_html = f"""
325 327 <div class="screenshot-section">
326 <img src="{logo_url}" alt="{escape(p['name'])} logo" class="provider-logo"
327 onerror="this.onerror=null;this.src='https://image.thum.io/get/width/600/{escape(p['url'] or '')}'"
328 <img data-src="{logo_url}" alt="{escape(p['name'])} logo" class="provider-logo"
328 329 style="max-width:100%;border-radius:8px;border:1px solid var(--card-border)" />
329 <p class="screenshot-caption">Logo from g4f.space / screenshot from {escape(p['url'] or 'N/A')}</p>
330 <p class="screenshot-caption">Load screenshot from {escape(p['url'] or 'N/A')}</p>
330 331 </div>
332 <script>
333 const img = document.querySelector('img[data-src="{logo_url}"]');
334 let n = 1;
335 const orgSrc = img.dataset.src;
336 img.onload = () => {{
337 n = n + 1;
338 if (n <= 3) {{
339 setTimeout(() => {{
340 img.src = orgSrc + `_${{n}}.jpg`;
341 }}, 1000);
342 }}
343 }};
344 img.onerror = () => {{
345 if (n === 1) {{
346 img.src = 'https://image.thum.io/get/width/600/{logo_url}';
347 return;
348 }}
349 if (imgs.src == orgSrc) return;
350 n = 3; // Stop carousel on error
351 img.src = orgSrc;
352 }};
353 img.src = img.dataset.src;
354 </script>
331 355 """
332 356
333 357 detail_html = f"""
Modified g4f/mcp/pa_provider.py +419 -0
@@ -60,6 +60,7 @@ import io
60 60 import os as _os
61 61 import sys
62 62 import json
63 import re
63 64 import hashlib
64 65 import threading
65 66 import time as _time_module
@@ -69,6 +70,7 @@ import builtins as _builtins
69 70 from pathlib import Path
70 71 from typing import Any, Dict, FrozenSet, List, Optional, Tuple, Type
71 72 from .. import debug
73 from ..files import secure_filename
72 74
73 75 # ---------------------------------------------------------------------------
74 76 # Workspace directory
@@ -82,11 +84,428 @@ def get_workspace_dir() -> Path:
82 84 return workspace
83 85
84 86
87 def get_user_workspace_dir(user_id: str) -> Path:
88 """Return a per-user workspace subdirectory ``~/.g4f/workspace/users/<user_id>``.
89
90 The directory is created on demand. ``user_id`` is sanitised so that
91 only alphanumeric characters, ``-`` and ``_`` are kept, preventing path
92 traversal.
93 """
94 if not user_id:
95 return get_workspace_dir()
96 safe_id = re.sub(r"[^a-zA-Z0-9_\-]+", "_", user_id).strip("_") or "anonymous"
97 user_workspace = get_workspace_dir() / "users" / safe_id
98 user_workspace.mkdir(parents=True, exist_ok=True)
99 return user_workspace
100
101
102 def get_secret_workspace_dir(user_id: str) -> Path:
103 """Return a per-user *secret* workspace ``~/.g4f/workspace/secret/<user_id>``.
104
105 This is used when a workspace secret is provided. Files are saved here
106 when the user is logged in; reads fall back to the root workspace when
107 the file does not exist in the secret workspace.
108 """
109 if not user_id:
110 return get_workspace_dir()
111 safe_id = re.sub(r"[^a-zA-Z0-9_\-]+", "_", user_id).strip("_") or "anonymous"
112 secret_workspace = get_workspace_dir() / "secret" / safe_id
113 secret_workspace.mkdir(parents=True, exist_ok=True)
114 return secret_workspace
115
116
117 def resolve_workspace_path(
118 rel_path: str,
119 user_id: str = None,
120 workspace_secret: str = None,
121 for_write: bool = False,
122 ) -> Tuple[Path, Path]:
123 """Resolve a relative path to an actual filesystem path with fallback.
124
125 When *workspace_secret* and *user_id* are provided, writes go to the
126 user's secret workspace and reads first check the secret workspace, then
127 fall back to the root workspace.
128
129 Returns a tuple ``(target, workspace_root)`` where *target* is the
130 resolved path to use and *workspace_root* is the workspace root that
131 contains it (used for containment checks).
132 """
133 root = get_workspace_dir().resolve()
134 if workspace_secret and user_id:
135 user_ws = get_secret_workspace_dir(user_id).resolve()
136 target = (user_ws / rel_path).resolve()
137 if for_write:
138 return target, user_ws
139 # Read: check secret workspace first, fall back to root
140 if target.exists():
141 return target, user_ws
142 # Fall back to root workspace
143 target = (root / rel_path).resolve()
144 return target, root
145 target = (root / rel_path).resolve()
146 return target, root
147
148
85 149 def is_hidden_file(path: str) -> bool:
86 150 """Return True if *path* is a hidden file (starts with a dot)."""
87 151 return any(part.startswith(".") or part.startswith("__") for part in str(path).replace("\\", "/").split("/"))
88 152
89 153
154 # ---------------------------------------------------------------------------
155 # Secret conversation storage
156 # ---------------------------------------------------------------------------
157
158 def _derive_key(workspace_secret: str) -> bytes:
159 """Derive a 32-byte AES key from the workspace secret via SHA-256."""
160 return hashlib.sha256(workspace_secret.encode("utf-8")).digest()
161
162
163 def _encrypt_data(data: bytes, workspace_secret: str) -> bytes:
164 """Encrypt *data* with AES-256-GCM using *workspace_secret*.
165
166 Returns a binary blob: ``nonce (12) || ciphertext || tag (16)``.
167 The nonce is randomly generated for each encryption.
168 """
169 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
170 import os
171
172 key = _derive_key(workspace_secret)
173 nonce = os.urandom(12)
174 aesgcm = AESGCM(key)
175 ciphertext = aesgcm.encrypt(nonce, data, None)
176 return nonce + ciphertext
177
178
179 def _decrypt_data(blob: bytes, workspace_secret: str) -> Optional[bytes]:
180 """Decrypt a blob produced by :func:`_encrypt_data`.
181
182 Returns ``None`` if decryption fails (wrong key / corrupted data).
183 """
184 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
185
186 if len(blob) < 28: # 12-byte nonce + 16-byte GCM tag minimum
187 return None
188 key = _derive_key(workspace_secret)
189 nonce = blob[:12]
190 ciphertext = blob[12:]
191 aesgcm = AESGCM(key)
192 try:
193 return aesgcm.decrypt(nonce, ciphertext, None)
194 except Exception:
195 return None
196
197
198 def _encrypt_json(obj: dict, workspace_secret: str) -> bytes:
199 """Serialise *obj* to JSON and encrypt with AES-256-GCM."""
200 raw = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
201 return _encrypt_data(raw, workspace_secret)
202
203
204 def _decrypt_json(blob: bytes, workspace_secret: str) -> Optional[dict]:
205 """Decrypt a blob and parse the plaintext as JSON."""
206 raw = _decrypt_data(blob, workspace_secret)
207 if raw is None:
208 return None
209 try:
210 return json.loads(raw.decode("utf-8"))
211 except (json.JSONDecodeError, UnicodeDecodeError):
212 return None
213
214
215 def _is_encrypted_blob(data: bytes) -> bool:
216 """Heuristic: check if *data* looks like an encrypted binary blob.
217
218 Encrypted files start with the magic prefix ``G4FENC`` followed by
219 a version byte. Plaintext JSON files start with ``{``.
220 """
221 return data[:6] == b"G4FENC"
222
223
224 def _encrypt_json_file(obj: dict, workspace_secret: str) -> bytes:
225 """Encrypt *obj* as JSON with a magic header for easy identification.
226
227 Format: ``G4FENC (6) || version (1) || nonce (12) || ciphertext || tag``
228 """
229 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
230 import os
231
232 raw = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
233 key = _derive_key(workspace_secret)
234 nonce = os.urandom(12)
235 aesgcm = AESGCM(key)
236 ciphertext = aesgcm.encrypt(nonce, raw, None)
237 return b"G4FENC" + b"\x01" + nonce + ciphertext
238
239
240 def _decrypt_json_file(data: bytes, workspace_secret: str) -> Optional[dict]:
241 """Decrypt and parse a file produced by :func:`_encrypt_json_file`.
242
243 Falls back to plaintext JSON parsing if the data is not encrypted
244 (for backward compatibility with previously stored plaintext files).
245 """
246 if not _is_encrypted_blob(data):
247 # Plaintext fallback — old files stored before encryption
248 try:
249 return json.loads(data.decode("utf-8"))
250 except (json.JSONDecodeError, UnicodeDecodeError):
251 return None
252 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
253
254 version = data[6]
255 if version != 1:
256 return None
257 nonce = data[7:19]
258 ciphertext = data[19:]
259 key = _derive_key(workspace_secret)
260 aesgcm = AESGCM(key)
261 try:
262 raw = aesgcm.decrypt(nonce, ciphertext, None)
263 return json.loads(raw.decode("utf-8"))
264 except Exception:
265 return None
266
267
268 def get_secret_conversation_dir(user_id: str) -> Path:
269 """Return the directory used to store secret conversations for *user_id*.
270
271 Conversations are saved as individual JSON files under
272 ``~/.g4f/workspace/secret/<user_id>/conversations/``. An index file
273 ``index.json`` lists all stored conversation IDs.
274 """
275 secret_ws = get_secret_workspace_dir(user_id)
276 conv_dir = secret_ws / "conversations"
277 conv_dir.mkdir(parents=True, exist_ok=True)
278 return conv_dir
279
280
281 def save_secret_conversation(user_id: str, conversation: dict, workspace_secret: str = None) -> dict:
282 """Save a single conversation to the user's secret workspace.
283
284 *conversation* must contain an ``id`` field. The conversation is
285 written as ``<id>.json`` (encrypted with AES-256-GCM when
286 *workspace_secret* is provided) and the index file is updated.
287 """
288 conv_id = conversation.get("id")
289 if not conv_id:
290 return {"error": "Conversation must have an 'id' field"}
291 conv_dir = get_secret_conversation_dir(user_id)
292 safe_id = secure_filename(str(conv_id))
293 conv_file = conv_dir / f"{safe_id}.json"
294 if workspace_secret:
295 blob = _encrypt_json_file(conversation, workspace_secret)
296 conv_file.write_bytes(blob)
297 else:
298 conv_file.write_text(json.dumps(conversation, ensure_ascii=False, indent=2), encoding="utf-8")
299 _update_secret_conversation_index(user_id, conversation)
300 return {"saved": True, "id": conv_id, "path": str(conv_file.name), "encrypted": bool(workspace_secret)}
301
302
303 def _update_secret_conversation_index(user_id: str, conversation: dict) -> None:
304 """Update the index file with a summary of *conversation*."""
305 conv_dir = get_secret_conversation_dir(user_id)
306 index_file = conv_dir / "index.json"
307 index: list = []
308 if index_file.exists():
309 try:
310 index = json.loads(index_file.read_text(encoding="utf-8"))
311 except (json.JSONDecodeError, OSError):
312 index = []
313 conv_id = conversation.get("id")
314 # Remove existing entry for this conversation
315 index = [e for e in index if e.get("id") != conv_id]
316 # Add fresh entry
317 entry = {
318 "id": conv_id,
319 "title": conversation.get("title") or conversation.get("new_title") or "",
320 "updated": conversation.get("updated"),
321 "added": conversation.get("added"),
322 "items_count": len(conversation.get("items", [])),
323 }
324 index.append(entry)
325 # Sort by updated descending
326 index.sort(key=lambda e: e.get("updated") or e.get("added") or 0, reverse=True)
327 index_file.write_text(json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
328
329
330 def list_secret_conversations(user_id: str) -> list:
331 """Return the index of all secret conversations for *user_id*."""
332 conv_dir = get_secret_conversation_dir(user_id)
333 index_file = conv_dir / "index.json"
334 if index_file.exists():
335 try:
336 return json.loads(index_file.read_text(encoding="utf-8"))
337 except (json.JSONDecodeError, OSError):
338 return []
339 return []
340
341
342 def get_secret_conversation(user_id: str, conv_id: str, workspace_secret: str = None) -> Optional[dict]:
343 """Retrieve a single secret conversation by ID.
344
345 If *workspace_secret* is provided, encrypted files are decrypted.
346 Plaintext files (stored before encryption was enabled) are read as-is.
347 """
348 conv_dir = get_secret_conversation_dir(user_id)
349 safe_id = secure_filename(str(conv_id))
350 conv_file = conv_dir / f"{safe_id}.json"
351 if not conv_file.exists():
352 return None
353 try:
354 raw = conv_file.read_bytes()
355 except OSError:
356 return None
357 return _decrypt_json_file(raw, workspace_secret or "")
358
359
360 def delete_secret_conversation(user_id: str, conv_id: str) -> bool:
361 """Delete a secret conversation and update the index."""
362 conv_dir = get_secret_conversation_dir(user_id)
363 safe_id = secure_filename(str(conv_id))
364 conv_file = conv_dir / f"{safe_id}.json"
365 deleted = False
366 if conv_file.exists():
367 conv_file.unlink()
368 deleted = True
369 # Update index
370 index_file = conv_dir / "index.json"
371 if index_file.exists():
372 try:
373 index = json.loads(index_file.read_text(encoding="utf-8"))
374 index = [e for e in index if e.get("id") != conv_id]
375 index_file.write_text(json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
376 except (json.JSONDecodeError, OSError):
377 pass
378 return deleted
379
380 # ---------------------------------------------------------------------------
381 # Cross-device workspace secret sharing
382 # ---------------------------------------------------------------------------
383
384 def _get_secret_requests_dir(user_id: str) -> Path:
385 """Return the directory for pending secret-sharing requests.
386
387 Stored under ``~/.g4f/workspace/secret/<user_id>/secret_requests/``.
388 """
389 secret_ws = get_secret_workspace_dir(user_id)
390 req_dir = secret_ws / "secret_requests"
391 req_dir.mkdir(parents=True, exist_ok=True)
392 return req_dir
393
394
395 def create_secret_request(user_id: str, device_name: str = "") -> dict:
396 """Create a pending secret-sharing request from a new device.
397
398 Returns a dict with ``request_id`` and ``status``. The online device
399 polls ``list_secret_requests`` to discover it.
400 """
401 import uuid
402
403 request_id = uuid.uuid4().hex[:12]
404 req_dir = _get_secret_requests_dir(user_id)
405 req_file = req_dir / f"{request_id}.json"
406 now = _time_module.time()
407 request_data = {
408 "id": request_id,
409 "user_id": user_id,
410 "device_name": device_name or "unknown",
411 "status": "pending", # pending -> confirmed -> completed
412 "created": now,
413 "expires": now + 300, # 5-minute expiry
414 "secret": None, # filled in by the confirming device
415 }
416 req_file.write_text(json.dumps(request_data, ensure_ascii=False, indent=2), encoding="utf-8")
417 return {"request_id": request_id, "status": "pending"}
418
419
420 def list_secret_requests(user_id: str) -> list:
421 """List all pending secret-sharing requests for *user_id*.
422
423 Expired requests (older than 5 minutes) are automatically removed.
424 """
425 req_dir = _get_secret_requests_dir(user_id)
426 now = _time_module.time()
427 requests = []
428 for req_file in req_dir.glob("*.json"):
429 try:
430 data = json.loads(req_file.read_text(encoding="utf-8"))
431 if data.get("expires", 0) < now and data.get("status") != "completed":
432 req_file.unlink(missing_ok=True)
433 continue
434 # Don't expose the secret in the list
435 safe = {k: v for k, v in data.items() if k != "secret"}
436 requests.append(safe)
437 except (json.JSONDecodeError, OSError):
438 continue
439 requests.sort(key=lambda r: r.get("created", 0), reverse=True)
440 return requests
441
442
443 def confirm_secret_request(user_id: str, request_id: str, workspace_secret: str) -> dict:
444 """Confirm a pending secret request by providing the workspace secret.
445
446 Called by the online device that already has the secret.
447 """
448 req_dir = _get_secret_requests_dir(user_id)
449 safe_id = secure_filename(str(request_id))
450 req_file = req_dir / f"{safe_id}.json"
451 if not req_file.exists():
452 return {"error": "Request not found"}
453 try:
454 data = json.loads(req_file.read_text(encoding="utf-8"))
455 except (json.JSONDecodeError, OSError):
456 return {"error": "Invalid request file"}
457 if data.get("status") != "pending":
458 return {"error": f"Request is not pending (status={data.get('status')})"}
459 data["status"] = "confirmed"
460 data["secret"] = workspace_secret
461 data["confirmed_at"] = _time_module.time()
462 req_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
463 return {"confirmed": True, "request_id": request_id}
464
465
466 def poll_secret_request(user_id: str, request_id: str) -> dict:
467 """Poll a secret request to check if it has been confirmed.
468
469 Returns the request data including the secret if confirmed.
470 If the secret has been retrieved, the request is marked as completed
471 and cleaned up.
472 """
473 req_dir = _get_secret_requests_dir(user_id)
474 safe_id = secure_filename(str(request_id))
475 req_file = req_dir / f"{safe_id}.json"
476 if not req_file.exists():
477 return {"status": "not_found"}
478 try:
479 data = json.loads(req_file.read_text(encoding="utf-8"))
480 except (json.JSONDecodeError, OSError):
481 return {"status": "error", "error": "Invalid request file"}
482 now = _time_module.time()
483 if data.get("expires", 0) < now and data.get("status") != "completed":
484 req_file.unlink(missing_ok=True)
485 return {"status": "expired"}
486 if data.get("status") == "confirmed":
487 # Mark as completed and clean up
488 data["status"] = "completed"
489 req_file.unlink(missing_ok=True)
490 return {
491 "status": "confirmed",
492 "secret": data.get("secret"),
493 "request_id": request_id,
494 }
495 return {"status": data.get("status", "pending"), "request_id": request_id}
496
497
498 def delete_secret_request(user_id: str, request_id: str) -> bool:
499 """Delete (cancel) a secret-sharing request."""
500 req_dir = _get_secret_requests_dir(user_id)
501 safe_id = secure_filename(str(request_id))
502 req_file = req_dir / f"{safe_id}.json"
503 if req_file.exists():
504 req_file.unlink()
505 return True
506 return False
507
508
90 509 # ---------------------------------------------------------------------------
91 510 # Whitelisted modules
92 511 # ---------------------------------------------------------------------------
Modified g4f/mcp/server.py +227 -4
@@ -56,6 +56,8 @@ class MCPRequest:
56 56 method: Optional[str] = None
57 57 params: Optional[Dict[str, Any]] = None
58 58 origin: Optional[str] = None
59 user_id: Optional[str] = None
60 workspace_secret: Optional[str] = None
59 61
60 62
61 63 @dataclass
@@ -152,6 +154,12 @@ class MCPServer:
152 154 tool_name = params.get("name")
153 155 tool_arguments = params.get("arguments", {})
154 156 tool_arguments.setdefault("origin", request.origin)
157 # Pass through user identity and workspace secret so file tools
158 # can resolve per-user workspace paths.
159 if request.user_id:
160 tool_arguments.setdefault("user_id", request.user_id)
161 if request.workspace_secret:
162 tool_arguments.setdefault("workspace_secret", request.workspace_secret)
155 163
156 164 if tool_name not in self.tools:
157 165 return MCPResponse(
@@ -266,6 +274,8 @@ class MCPServer:
266 274 method=request_data.get("method"),
267 275 params=request_data.get("params"),
268 276 origin=origin,
277 user_id=request.headers.get("x-user-id"),
278 workspace_secret=request.headers.get("x-workspace-secret"),
269 279 )
270 280
271 281 # Handle request
@@ -461,14 +471,20 @@ class MCPServer:
461 471 allowed — ``.py``, ``.env``, and other sensitive types return 403.
462 472 HTML files are served with a ``Content-Security-Policy: sandbox``
463 473 header so they run in an isolated null origin.
464 """
465 from .pa_provider import get_workspace_dir
466 474
467 workspace = get_workspace_dir().resolve()
475 When ``x-user-id`` and ``x-workspace-secret`` headers are present,
476 files are resolved from the user's secret workspace first, falling
477 back to the root workspace.
478 """
479 from .pa_provider import resolve_workspace_path
468 480
469 481 file_path = request.match_info.get("file_path", "")
482 user_id = request.headers.get("x-user-id", "")
483 workspace_secret = request.headers.get("x-workspace-secret", "")
470 484 try:
471 resolved = (workspace / file_path).resolve()
485 resolved, workspace = resolve_workspace_path(
486 file_path, user_id=user_id, workspace_secret=workspace_secret, for_write=False
487 )
472 488 # Security: ensure the resolved path is still inside the workspace directory
473 489 resolved.relative_to(workspace)
474 490 except (ValueError, Exception):
@@ -501,6 +517,203 @@ class MCPServer:
501 517 body=content, content_type=mime.split(";")[0].strip(), headers=headers
502 518 )
503 519
520 async def handle_secret_conversations_list(request: web.Request) -> web.Response:
521 """List all secret conversations for the authenticated user."""
522 from .pa_provider import list_secret_conversations
523
524 user_id = request.headers.get("x-user-id", "")
525 if not user_id:
526 return web.json_response(
527 {"error": "User ID is required"}, status=401
528 )
529 return web.json_response(
530 {"conversations": list_secret_conversations(user_id)},
531 headers={"access-control-allow-origin": "*"},
532 )
533
534 async def handle_secret_conversations_save(request: web.Request) -> web.Response:
535 """Save a conversation to the user's secret workspace."""
536 from .pa_provider import save_secret_conversation
537
538 user_id = request.headers.get("x-user-id", "")
539 if not user_id:
540 return web.json_response(
541 {"error": "User ID is required"}, status=401
542 )
543 workspace_secret = request.headers.get("x-workspace-secret", "")
544 try:
545 body = await request.json()
546 except Exception:
547 return web.json_response({"error": "Invalid JSON body"}, status=400)
548 result = save_secret_conversation(user_id, body, workspace_secret or None)
549 return web.json_response(
550 result, headers={"access-control-allow-origin": "*"}
551 )
552
553 async def handle_secret_conversations_sync(request: web.Request) -> web.Response:
554 """Sync (upload) multiple conversations to the user's secret workspace."""
555 from .pa_provider import save_secret_conversation
556
557 user_id = request.headers.get("x-user-id", "")
558 if not user_id:
559 return web.json_response(
560 {"error": "User ID is required"}, status=401
561 )
562 workspace_secret = request.headers.get("x-workspace-secret", "")
563 try:
564 body = await request.json()
565 except Exception:
566 return web.json_response({"error": "Invalid JSON body"}, status=400)
567 conversations = body.get("conversations", []) if isinstance(body, dict) else body
568 saved = 0
569 errors = []
570 for conv in conversations:
571 res = save_secret_conversation(user_id, conv, workspace_secret or None)
572 if res.get("saved"):
573 saved += 1
574 else:
575 errors.append(res.get("error", "Unknown error"))
576 return web.json_response(
577 {"saved": saved, "errors": errors},
578 headers={"access-control-allow-origin": "*"},
579 )
580
581 async def handle_secret_conversation_get(request: web.Request) -> web.Response:
582 """Retrieve a single secret conversation by ID."""
583 from .pa_provider import get_secret_conversation
584
585 user_id = request.headers.get("x-user-id", "")
586 if not user_id:
587 return web.json_response(
588 {"error": "User ID is required"}, status=401
589 )
590 workspace_secret = request.headers.get("x-workspace-secret", "")
591 conv_id = request.match_info.get("conversation_id", "")
592 conv = get_secret_conversation(user_id, conv_id, workspace_secret or None)
593 if conv is None:
594 return web.json_response(
595 {"error": f"Conversation '{conv_id}' not found"}, status=404
596 )
597 return web.json_response(conv, headers={"access-control-allow-origin": "*"})
598
599 async def handle_secret_conversation_delete(request: web.Request) -> web.Response:
600 """Delete a secret conversation by ID."""
601 from .pa_provider import delete_secret_conversation
602
603 user_id = request.headers.get("x-user-id", "")
604 if not user_id:
605 return web.json_response(
606 {"error": "User ID is required"}, status=401
607 )
608 conv_id = request.match_info.get("conversation_id", "")
609 deleted = delete_secret_conversation(user_id, conv_id)
610 if not deleted:
611 return web.json_response(
612 {"error": f"Conversation '{conv_id}' not found"}, status=404
613 )
614 return web.json_response(
615 {"deleted": True, "id": conv_id},
616 headers={"access-control-allow-origin": "*"},
617 )
618
619 # ── Cross-device workspace secret sharing ───────────────────────────
620
621 async def handle_secret_request_create(request: web.Request) -> web.Response:
622 """Create a pending secret-sharing request from a new device."""
623 from .pa_provider import create_secret_request
624
625 user_id = request.headers.get("x-user-id", "")
626 if not user_id:
627 return web.json_response(
628 {"error": "User ID is required"}, status=401
629 )
630 device_name = ""
631 try:
632 body = await request.json()
633 device_name = body.get("device_name", "") if isinstance(body, dict) else ""
634 except Exception:
635 pass
636 result = create_secret_request(user_id, device_name)
637 return web.json_response(
638 result, headers={"access-control-allow-origin": "*"}
639 )
640
641 async def handle_secret_request_list(request: web.Request) -> web.Response:
642 """List pending secret-sharing requests (for the online device to confirm)."""
643 from .pa_provider import list_secret_requests
644
645 user_id = request.headers.get("x-user-id", "")
646 if not user_id:
647 return web.json_response(
648 {"error": "User ID is required"}, status=401
649 )
650 requests_list = list_secret_requests(user_id)
651 return web.json_response(
652 {"requests": requests_list},
653 headers={"access-control-allow-origin": "*"},
654 )
655
656 async def handle_secret_request_confirm(request: web.Request) -> web.Response:
657 """Confirm a secret request by sending the workspace secret to the new device."""
658 from .pa_provider import confirm_secret_request
659
660 user_id = request.headers.get("x-user-id", "")
661 if not user_id:
662 return web.json_response(
663 {"error": "User ID is required"}, status=401
664 )
665 try:
666 body = await request.json()
667 except Exception:
668 return web.json_response({"error": "Invalid JSON body"}, status=400)
669 request_id = body.get("request_id", "")
670 workspace_secret = body.get("workspace_secret", "")
671 if not request_id or not workspace_secret:
672 return web.json_response(
673 {"error": "request_id and workspace_secret are required"}, status=400
674 )
675 result = confirm_secret_request(user_id, request_id, workspace_secret)
676 if "error" in result:
677 return web.json_response(result, status=404)
678 return web.json_response(
679 result, headers={"access-control-allow-origin": "*"}
680 )
681
682 async def handle_secret_request_poll(request: web.Request) -> web.Response:
683 """Poll a secret request to check if it has been confirmed."""
684 from .pa_provider import poll_secret_request
685
686 user_id = request.headers.get("x-user-id", "")
687 if not user_id:
688 return web.json_response(
689 {"error": "User ID is required"}, status=401
690 )
691 request_id = request.match_info.get("request_id", "")
692 result = poll_secret_request(user_id, request_id)
693 return web.json_response(
694 result, headers={"access-control-allow-origin": "*"}
695 )
696
697 async def handle_secret_request_delete(request: web.Request) -> web.Response:
698 """Cancel / delete a secret-sharing request."""
699 from .pa_provider import delete_secret_request
700
701 user_id = request.headers.get("x-user-id", "")
702 if not user_id:
703 return web.json_response(
704 {"error": "User ID is required"}, status=401
705 )
706 request_id = request.match_info.get("request_id", "")
707 deleted = delete_secret_request(user_id, request_id)
708 if not deleted:
709 return web.json_response(
710 {"error": "Request not found"}, status=404
711 )
712 return web.json_response(
713 {"deleted": True, "request_id": request_id},
714 headers={"access-control-allow-origin": "*"},
715 )
716
504 717 # Create aiohttp application
505 718 app = web.Application()
506 719 app.router.add_options(
@@ -519,6 +732,16 @@ class MCPServer:
519 732 app.router.add_get("/backend-api/v2/synthesize/{provider}", handle_synthesize)
520 733 app.router.add_get("/pa/providers", handle_pa_providers)
521 734 app.router.add_get("/pa/files/{file_path:.*}", handle_pa_file)
735 app.router.add_get("/v1/secret/conversations", handle_secret_conversations_list)
736 app.router.add_post("/v1/secret/conversations", handle_secret_conversations_save)
737 app.router.add_post("/v1/secret/conversations/sync", handle_secret_conversations_sync)
738 app.router.add_get("/v1/secret/conversations/{conversation_id}", handle_secret_conversation_get)
739 app.router.add_delete("/v1/secret/conversations/{conversation_id}", handle_secret_conversation_delete)
740 app.router.add_post("/v1/secret/request", handle_secret_request_create)
741 app.router.add_get("/v1/secret/requests", handle_secret_request_list)
742 app.router.add_post("/v1/secret/request/confirm", handle_secret_request_confirm)
743 app.router.add_get("/v1/secret/request/{request_id}", handle_secret_request_poll)
744 app.router.add_delete("/v1/secret/request/{request_id}", handle_secret_request_delete)
522 745
523 746 # Start server
524 747 sys.stderr.write(
Modified g4f/mcp/tools.py +60 -27
Modified g4f/requests/cdp.py +31 -9