返回提交历史
Modified
etc/tool/template.html
+2
-2
Modified
g4f/api/__init__.py
+291
-21
Modified
g4f/gui/server/website.py
+28
-4
Modified
g4f/mcp/pa_provider.py
+419
-0
Modified
g4f/mcp/server.py
+227
-4
Modified
g4f/mcp/tools.py
+60
-27
Modified
g4f/requests/cdp.py
+31
-9
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
代码差异
7 个文件
+1058
-67
@@ -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/" />
@@ -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},
@@ -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"""
@@ -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
# ---------------------------------------------------------------------------
@@ -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(