Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,25 @@ setups, local development, and debugging (logs go straight to stderr):

Every tool includes rich descriptions with domain knowledge, Pydantic-validated input, structured output schemas, and proper `ToolAnnotations` — agents understand *how* and *when* to use each one without prompting.

### 🔒 MCP Tool Access Control
### File attachments

The REST endpoint also accepts `multipart/form-data`. Send the `messages` field as
JSON and repeat the `file` field for every attachment:

```bash
curl http://localhost:8080/v1/chat/completions \
-F 'messages=[{"role":"user","content":"Inspect these files"}]' \
-F 'model=auto' \
-F 'file=@server.log' \
-F 'file=@diagnostics.zip' \
-F 'file=@screenshot.png'
```

The MCP `chat_completion` tool accepts `files`, a list of local paths, in addition
to `message`. File extensions and MIME types are not hard-coded: ChatGPT's web UI
makes the final acceptance decision. The bridge validates that every path is a
regular file and enforces `W2A_MAX_UPLOAD_BYTES` (default 50 MiB) per file.


The MCP server has **no authentication of its own** — any client that can reach it can call its tools. Since several tools mutate or delete data on your *real* ChatGPT account, tools are gated by risk tier so the dangerous ones are never exposed by accident:

Expand Down Expand Up @@ -548,11 +566,11 @@ Key docs:
- **Cookie expiry** — auth cookies expire ~2 weeks; re-login needed
- **Serial requests** — one chat at a time through the browser *by default* (concurrent reads are fine). For per-tab parallelism on one shared Chrome, set `parallel_tabs: true` (requires `tab_mode: "owned"`); see [docs/deployment.md](docs/deployment.md) → "Parallel mode (one Chrome, many tabs)".
- **Memory writes** — ChatGPT's `/backend-api/memories` is read-only; creating memories works via chat interface
- **No image input** — text only (CDP file upload not yet implemented)
- **File acceptance** — the web UI decides which file types are accepted; the bridge does not hard-code extensions. Each local file is limited by `W2A_MAX_UPLOAD_BYTES` (default 50 MiB)

## Roadmap

- [ ] Image/file upload to conversations via CDP drag-and-drop
- [x] Image/file upload to conversations via CDP file input
- [ ] Headless mode with anti-detection patches
- [ ] Concurrent chat pooling across multiple Chrome instances (per-tab parallelism on one Chrome landed via `parallel_tabs`; the cross-instance pool/router is still future work)
- [ ] Web search mode (trigger ChatGPT's built-in search)
Expand Down
67 changes: 62 additions & 5 deletions src/chatgpt_web2api/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
import asyncio
import json
import logging
import tempfile
import time
import uuid
from pathlib import Path

from aiohttp import web

Expand All @@ -27,6 +29,7 @@
)
from .config import Config
from .cross_process_lock import LockAcquisitionError
from .file_upload import UploadError, validate_upload_files
from .lock_resolver import MutationLock, OwnedTabRequiredError, resolve_mutation_lock
from .resilience import retry_on_rate_limit

Expand Down Expand Up @@ -230,17 +233,65 @@ async def _handle_projects(self, request: web.Request) -> web.Response:
projects = []
return web.json_response({"object": "list", "data": projects})

async def _parse_chat_request(self, request: web.Request) -> tuple[dict, list[Path]]:
"""Parse JSON or multipart chat input and materialize uploaded files."""
content_type = getattr(request, "content_type", "")
if not isinstance(content_type, str) or not content_type.startswith("multipart/"):
return await request.json(), []

reader = await request.multipart()
fields: dict[str, str] = {}
upload_paths: list[Path] = []
try:
while part := await reader.next():
if part.name in {"file", "files"} and part.filename:
suffix = Path(part.filename).suffix
handle = tempfile.NamedTemporaryFile(
prefix="chatgpt-web2api-upload-", suffix=suffix, delete=False
)
path = Path(handle.name)
try:
while chunk := await part.read_chunk():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Multipart uploads are fully written to disk before the size limit is enforced

_parse_chat_request streams every file part to a NamedTemporaryFile with no bound, and validate_upload_files (which enforces W2A_MAX_UPLOAD_BYTES) runs only after the entire body has been materialized. A single authenticated request with one oversized part (or a very large number of parts, since there is no count cap) fills the temp filesystem before the server returns 400. Fix: enforce the limit inside the read_chunk loop — track bytes written per part (and total), abort and delete the partial file as soon as it exceeds max_upload_bytes(), and cap the number of file parts. Also note handle.write(chunk) is synchronous and blocks the event loop per chunk for large uploads; consider asyncio.to_thread for the write.

handle.write(chunk)
finally:
handle.close()
upload_paths.append(path)
else:
fields[part.name] = await part.text()
if "messages" in fields:
body = {"messages": json.loads(fields["messages"])}
for name in ("model", "stream", "project_id", "gizmo_id", "conversation_id"):
if name in fields:
value = fields[name]
body[name] = json.loads(value) if name == "stream" else value
elif "body" in fields:
body = json.loads(fields["body"])
else:
raise UploadError("Multipart requests must include a JSON 'messages' field")
if upload_paths:
validate_upload_files(upload_paths)
return body, upload_paths

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Temp-file cleanup misses CancelledError (BaseException) paths

Both cleanup sites use except Exception, which does not catch asyncio.CancelledError (a BaseException since Python 3.8). If the client disconnects or the handler task is cancelled while multipart parts are being streamed, the except Exception: block in _parse_chat_request is skipped and already-written temp files leak in the temp directory; the outer except Exception as e: in _handle_chat has the same gap. Fix: perform the unlink loops in a try/finally (or catch BaseException, clean up, and re-raise) so temp files are removed on cancellation.

except Exception:
for path in upload_paths:
path.unlink(missing_ok=True)
raise

@staticmethod
def _cleanup_uploads(paths: list[Path]) -> None:
for path in paths:
path.unlink(missing_ok=True)

async def _handle_chat(self, request: web.Request) -> web.Response:
if err := self._check_auth(request):
return err

self._request_count += 1

try:
body = await request.json()
except json.JSONDecodeError:
body, upload_paths = await self._parse_chat_request(request)
except (json.JSONDecodeError, UploadError, ValueError) as exc:
return web.json_response(
{"error": {"message": "Invalid JSON", "type": "invalid_request_error"}},
{"error": {"message": str(exc), "type": "invalid_request_error"}},
status=400,
)

Expand Down Expand Up @@ -387,12 +438,18 @@ async def _handle_chat(self, request: web.Request) -> web.Response:
await self._driver.navigate_new_chat(gizmo_id=project_id)
self._last_project_id = project_id

if upload_paths:
await self._driver.upload_files([str(path) for path in upload_paths])
if stream:
return await self._stream_response(request, model_slug, full_text, timeout)
response = await self._stream_response(request, model_slug, full_text, timeout)
else:
return await self._full_response(request, model_slug, full_text, timeout)
response = await self._full_response(request, model_slug, full_text, timeout)
self._cleanup_uploads(upload_paths)
upload_paths = []
return response

except Exception as e:
self._cleanup_uploads(upload_paths)
logger.error("Chat error: %s", e, exc_info=True)
self._last_error = f"{type(e).__name__}: {e}"
return self._error_response(e)
Expand Down
17 changes: 17 additions & 0 deletions src/chatgpt_web2api/backend_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,28 @@
import json
import logging
import time
import uuid

from .breakers import BreakerKind

logger = logging.getLogger(__name__)


def normalize_conversation_id(conversation_id: str) -> str:
"""Return the native UUID from a UI/backend conversation identifier.

Some ChatGPT Web surfaces expose IDs with a transport namespace such as
``WEB:<uuid>``. The backend endpoint accepts only the UUID path segment.
"""
value = str(conversation_id or "").strip()
if value.startswith("WEB:"):
value = value[4:]
try:
return str(uuid.UUID(value))
except (ValueError, AttributeError, TypeError) as exc:
raise ValueError(f"invalid conversation id: {conversation_id!r}") from exc


class _Transient404(Exception):
"""Sentinel raised by ``_fetch_recent_conversation_projection`` on a backend 404.

Expand Down Expand Up @@ -280,6 +296,7 @@ async def _fetch_recent_conversation_projection(
from .cdp_driver import AuthExpiredError, CDPJSError

d = self._driver
conversation_id = normalize_conversation_id(conversation_id)
await self._driver.ensure_token()
raw = await d._js_with_data_strict(
CONVERSATION_PROJECTION_JS,
Expand Down
10 changes: 9 additions & 1 deletion src/chatgpt_web2api/cdp_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import asyncio
import json
import logging
import os
import re
import time
import urllib.parse
Expand Down Expand Up @@ -1448,6 +1449,12 @@ async def ensure_current_conversation(self, conversation_id: str) -> None:

# ── Message Input ─────────────────────────────────────────

async def upload_files(self, paths: list[str | os.PathLike[str]]) -> list:
"""Attach local files to the current ChatGPT composer."""
from .file_upload import upload_files_to_composer

return await upload_files_to_composer(self, paths)

async def type_message(self, text: str) -> None:
"""Type text into the ChatGPT composer.

Expand Down Expand Up @@ -1596,7 +1603,8 @@ async def _verify_send_acknowledged(self) -> bool | None:
Polls briefly (3s at 0.5s intervals). Never raises.
"""
import time as _time
from .chatgpt_dom import COMPOSER_SELECTOR, COMPOSER_FALLBACK_SELECTOR

from .chatgpt_dom import COMPOSER_FALLBACK_SELECTOR, COMPOSER_SELECTOR

pre_send_count = getattr(self, "_pre_send_user_count", None)
if pre_send_count is None:
Expand Down
91 changes: 77 additions & 14 deletions src/chatgpt_web2api/chatgpt_dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,20 +450,83 @@ async def click_send(self) -> None:
break
await asyncio.sleep(SEND_BUTTON_POLL_INTERVAL_S)

result = await d._js(
"(function() {"
f" var btn = document.querySelector('{SEND_BUTTON_SELECTOR}')"
f" || document.querySelector('{SEND_BUTTON_FALLBACK_SELECTOR}')"
f" || document.querySelector('{SEND_BUTTON_BROAD_SELECTOR}');"
" if (!btn) return 'no send button';"
" if (btn.disabled) return 'button disabled';"
" var evts = ['pointerdown','mousedown','pointerup','mouseup','click'];"
" for (var i = 0; i < evts.length; i++) {"
" btn.dispatchEvent(new MouseEvent(evts[i], {bubbles:true, cancelable:true, view:window}));"
" }"
" return 'sent';"
"})()"
)
if getattr(d, "_file_upload_active", False) is True:
rect_raw = await d._js(
"""(function() {
var btn = document.querySelector('button[aria-label*="Send" i]:not([data-testid="stop-button"])')
|| document.querySelector('button[data-testid="send-button"]');
if (!btn) return '';
var r = btn.getBoundingClientRect();
return JSON.stringify({x:r.left + r.width / 2, y:r.top + r.height / 2});
})()"""
)
try:
rect = json.loads(rect_raw)
mouse_params = {
"x": rect["x"],
"y": rect["y"],
"button": "left",
"clickCount": 1,
}
for event_type in ("mousePressed", "mouseReleased"):
await d._cdp(
"Input.dispatchMouseEvent",
{"type": event_type, "buttons": 1 if event_type == "mousePressed" else 0, **mouse_params},
)

# With attachments, ChatGPT starts the upload on the first send
# click. The button remains enabled, but the UI briefly exposes
# "File upload pending" and ignores that click as a submission.
# Wait for that transition and click again only after it clears.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] click_send upload path stalls ~30s when 'File upload pending' never appears, and flag reset is skipped on error

In the _file_upload_active branch of click_send, the wait loop only breaks after a pending→ready transition (elif saw_pending: break). If the pending state never appears (upload already finished at attach time, or UI wording differs), all 60 iterations sleep 0.5s, adding a fixed ~30s delay to every file-upload send before it (correctly) skips the second click. Additionally, d._file_upload_active = False is only reached on the success path; on the (json.JSONDecodeError, KeyError, TypeError) path the flag stays True, so the next non-upload message is sent via the CDP coordinate-click path instead of the normal JS dispatch. Fix: break the wait on an observed user message/new conversation (or a shorter deadline), and reset _file_upload_active in a finally block.

saw_pending = False
for _ in range(60):
state = await d._js(
"""(function() {
return /file upload pending/i.test((document.body && document.body.innerText) || '')
? 'pending' : 'ready';
})()"""
)
if state == "pending":
saw_pending = True
elif saw_pending:
break
await asyncio.sleep(0.5)
if saw_pending:
rect_raw = await d._js(
"""(function() {
var btn = document.querySelector('button[aria-label*="Send" i]:not([data-testid="stop-button"])')
|| document.querySelector('button[data-testid="send-button"]');
if (!btn) return '';
var r = btn.getBoundingClientRect();
return JSON.stringify({x:r.left + r.width / 2, y:r.top + r.height / 2});
})()"""
)
rect = json.loads(rect_raw)
mouse_params.update({"x": rect["x"], "y": rect["y"]})
for event_type in ("mousePressed", "mouseReleased"):
await d._cdp(
"Input.dispatchMouseEvent",
{"type": event_type, "buttons": 1 if event_type == "mousePressed" else 0, **mouse_params},
)
d._file_upload_active = False
result = "sent"
except (json.JSONDecodeError, KeyError, TypeError):
result = "send button coordinates unavailable"
else:
result = await d._js(
"(function() {"
f" var btn = document.querySelector('{SEND_BUTTON_SELECTOR}')"
f" || document.querySelector('{SEND_BUTTON_FALLBACK_SELECTOR}')"
f" || document.querySelector('{SEND_BUTTON_BROAD_SELECTOR}');"
" if (!btn) return 'no send button';"
" if (btn.disabled) return 'button disabled';"
" var evts = ['pointerdown','mousedown','pointerup','mouseup','click'];"
" for (var i = 0; i < evts.length; i++) {"
" btn.dispatchEvent(new MouseEvent(evts[i], {bubbles:true, cancelable:true, view:window}));"
" }"
" return 'sent';"
"})()"
)
if result != "sent":
await d._capture_selector_diagnostic("send-button (click_send)")
if d._breakers:
Expand Down
Loading