From eb762121f993b88c48abd123865d102bae8a6e6a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 10 Jul 2026 00:30:37 -0500 Subject: [PATCH 01/46] refac --- MANIFESTO.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MANIFESTO.md b/MANIFESTO.md index bbf84c94..54ce201e 100644 --- a/MANIFESTO.md +++ b/MANIFESTO.md @@ -1,6 +1,6 @@ # Manifesto -> **TL;DR** We pushed code and went to dinner. Prod broke. Phone in pocket, laptop at home. The fix was on our computer, not in some fresh cloud box. So we built `cptr`: your actual machine, in any browser. Files, terminal, editor, git, running sessions, all of it. Phone, tablet, laptop, whatever. It literally is your computer. Now go outside. +> **TL;DR** We pushed code and went to dinner. Prod broke. Phone in pocket, laptop at home. The fix was on our computer, not in some fresh cloud box. So we built Open WebUI Computer: your actual machine, in any browser. Files, terminal, editor, git, running sessions, all of it. Phone, tablet, laptop, whatever. It literally is your computer. Now go outside. Your computer is where your work lives. diff --git a/README.md b/README.md index d4ea90a6..16b7db98 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Open WebUI Computer (cptr) +# Open WebUI Computer ![GitHub stars](https://img.shields.io/github/stars/open-webui/computer?style=social) ![GitHub forks](https://img.shields.io/github/forks/open-webui/computer?style=social) From 444b4fab4aac32966f40decf13735ae6c361f1e9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 10 Jul 2026 00:47:13 -0500 Subject: [PATCH 02/46] refac --- README.md | 2 +- cptr/frontend/src/lib/apis/admin.ts | 2 +- .../components/Admin/AgentProfileModal.svelte | 7 +- .../src/lib/components/Admin/Agents.svelte | 6 +- cptr/utils/agents/detection.py | 37 ++ cptr/utils/agents/events.py | 1 + cptr/utils/agents/models.py | 15 +- cptr/utils/agents/pi.py | 322 ++++++++++++++++++ cptr/utils/chat_task.py | 4 +- cptr/utils/model_targets.py | 2 +- 10 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 cptr/utils/agents/pi.py diff --git a/README.md b/README.md index 16b7db98..144634fa 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Bring your own API key (OpenAI, Anthropic, Ollama, or any OpenAI-compatible endp Use the subscriptions you already pay for as native backends on your own machine. No separate API key needed. -**Codex** · **Claude Code** · **Cursor** · **Grok** · **OpenCode** · **Cline** +**Codex** · **Claude Code** · **Cursor** · **Grok** · **OpenCode** · **Cline** · **Pi** Add an agent profile from Settings, pick your models, and it shows up in the model selector like any other provider. Conversations run inside your workspace with full tool access and resume where you left off. diff --git a/cptr/frontend/src/lib/apis/admin.ts b/cptr/frontend/src/lib/apis/admin.ts index e54d32a4..d5a4a18c 100644 --- a/cptr/frontend/src/lib/apis/admin.ts +++ b/cptr/frontend/src/lib/apis/admin.ts @@ -60,7 +60,7 @@ export const updateConfig = (config: Record) => // ── Agents ───────────────────────────────────────────────── -export type AgentType = 'codex' | 'claude_code' | 'cursor' | 'grok' | 'opencode' | 'cline'; +export type AgentType = 'codex' | 'claude_code' | 'cursor' | 'grok' | 'opencode' | 'cline' | 'pi'; export type AgentMode = 'auto' | 'enabled' | 'disabled'; export type AgentStatus = 'ready' | 'not_found' | 'missing_dependency' | 'auth_unknown' | 'error'; diff --git a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte index 93d1986b..a9381e52 100644 --- a/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte +++ b/cptr/frontend/src/lib/components/Admin/AgentProfileModal.svelte @@ -48,7 +48,8 @@ cursor: 'agent', grok: 'grok', opencode: 'opencode', - cline: 'cline' + cline: 'cline', + pi: 'pi' }[agent]; } @@ -59,7 +60,8 @@ cursor: 'Cursor', grok: 'Grok', opencode: 'OpenCode', - cline: 'Cline' + cline: 'Cline', + pi: 'Pi' }[agent]; } @@ -129,6 +131,7 @@ + diff --git a/cptr/frontend/src/lib/components/Admin/Agents.svelte b/cptr/frontend/src/lib/components/Admin/Agents.svelte index a1289929..8731f5a2 100644 --- a/cptr/frontend/src/lib/components/Admin/Agents.svelte +++ b/cptr/frontend/src/lib/components/Admin/Agents.svelte @@ -112,7 +112,8 @@ cursor: 'agent', grok: 'grok', opencode: 'opencode', - cline: 'cline' + cline: 'cline', + pi: 'pi' }[agent]; } @@ -123,7 +124,8 @@ cursor: 'Cursor', grok: 'Grok', opencode: 'OpenCode', - cline: 'Cline' + cline: 'Cline', + pi: 'Pi' }[agent]; } diff --git a/cptr/utils/agents/detection.py b/cptr/utils/agents/detection.py index 672424d1..7ea8cebb 100644 --- a/cptr/utils/agents/detection.py +++ b/cptr/utils/agents/detection.py @@ -170,6 +170,18 @@ async def detect_profile(profile: dict[str, Any]) -> AgentDetection: ) return AgentDetection("ready", command, version, None, models) + if profile.get("agent") == "pi": + models = await _probe_pi_models(command, profile) + if not models: + return AgentDetection( + "auth_unknown", + command, + version, + "Could not discover Pi models. Check Pi provider configuration.", + [], + ) + return AgentDetection("ready", command, version, None, models) + return AgentDetection("error", command, version, "Unknown agent") @@ -235,6 +247,31 @@ async def _probe_codex_models(command: str, profile: dict[str, Any]) -> list[str await client.close() +async def _probe_pi_models(command: str, profile: dict[str, Any]) -> list[str] | None: + from cptr.utils.agents.pi import PiRpcClient + + env = os.environ.copy() + if profile.get("home"): + env["HOME"] = os.path.expanduser(str(profile["home"])) + client = PiRpcClient(command, os.getcwd(), env) + try: + await asyncio.wait_for(client.start(), timeout=5) + response = await asyncio.wait_for(client.request("get_available_models"), timeout=5) + data = response.get("data") if isinstance(response.get("data"), dict) else {} + models = [] + for item in data.get("models", []): + if not isinstance(item, dict): + continue + provider, model = item.get("provider"), item.get("id") + if isinstance(provider, str) and provider and isinstance(model, str) and model: + models.append(f"{provider}/{model}") + return models or None + except Exception: + return None + finally: + await client.close() + + async def _probe_cursor_about(command: str, profile: dict[str, Any]) -> dict[str, Any]: env = os.environ.copy() if profile.get("home"): diff --git a/cptr/utils/agents/events.py b/cptr/utils/agents/events.py index d0dcd6d7..cd42be7f 100644 --- a/cptr/utils/agents/events.py +++ b/cptr/utils/agents/events.py @@ -30,6 +30,7 @@ class AgentToolOutputDelta: call_id: str delta: str stream_kind: str = "tool_output" + replace: bool = False @dataclass diff --git a/cptr/utils/agents/models.py b/cptr/utils/agents/models.py index f2acf667..52cd5429 100644 --- a/cptr/utils/agents/models.py +++ b/cptr/utils/agents/models.py @@ -80,10 +80,20 @@ "models": [], "default_model": "", }, + { + "id": "pi", + "agent": "pi", + "name": "Pi", + "mode": "auto", + "command": "pi", + "home": None, + "models": [], + "default_model": "", + }, ] _PROFILE_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") -_VALID_AGENTS = {"codex", "claude_code", "cursor", "grok", "opencode", "cline"} +_VALID_AGENTS = {"codex", "claude_code", "cursor", "grok", "opencode", "cline", "pi"} _VALID_MODES = {"auto", "enabled", "disabled"} _VALID_CODEX_APPROVAL = {"ask", "auto", "full"} _VALID_CODEX_SANDBOX = {"read-only", "workspace-write", "danger-full-access"} @@ -103,6 +113,7 @@ "grok": {"name": "Grok", "command": "grok", "model": ""}, "opencode": {"name": "OpenCode", "command": "opencode", "model": ""}, "cline": {"name": "Cline", "command": "cline", "model": ""}, + "pi": {"name": "Pi", "command": "pi", "model": ""}, } @@ -119,7 +130,7 @@ def normalize_agent_profile(raw: dict[str, Any]) -> dict[str, Any]: agent = str(profile.get("agent") or "").strip() if agent not in _VALID_AGENTS: raise HTTPException( - 400, "agent must be codex, claude_code, cursor, grok, opencode, or cline" + 400, "agent must be codex, claude_code, cursor, grok, opencode, cline, or pi" ) defaults = _AGENT_DEFAULTS[agent] diff --git a/cptr/utils/agents/pi.py b/cptr/utils/agents/pi.py new file mode 100644 index 00000000..fa1618c8 --- /dev/null +++ b/cptr/utils/agents/pi.py @@ -0,0 +1,322 @@ +"""Pi coding-agent adapter using Pi's public JSONL RPC mode.""" + +from __future__ import annotations + +import asyncio +import codecs +import collections +import json +import os +from contextlib import suppress +from typing import Any, AsyncIterator + +from cptr.utils.agents.attachments import PreparedAgentAttachments +from cptr.utils.agents.events import ( + AgentDone, + AgentError, + AgentEvent, + AgentReasoningDelta, + AgentTextDelta, + AgentToolOutputDelta, + AgentToolUpdate, +) + + +def _content_text(value: Any) -> str: + if not isinstance(value, dict) or not isinstance(value.get("content"), list): + return "" + return "\n".join( + item["text"] + for item in value["content"] + if isinstance(item, dict) + and item.get("type") == "text" + and isinstance(item.get("text"), str) + ) + + +def _tool_name(name: str) -> str: + return "run_command" if name == "bash" else "agent_tool" + + +def _message_text(messages: list[dict[str, Any]], system_prompt: str, resumed: bool) -> str: + source = messages + if resumed: + source = [ + next((message for message in reversed(messages) if message.get("role") == "user"), {}) + ] + + parts = [] + if system_prompt and not resumed: + parts.append(f"[system]\n{system_prompt}") + for message in source: + content = message.get("content", "") + if isinstance(content, list): + content = "\n".join( + str(block.get("text") or "") for block in content if isinstance(block, dict) + ) + if content: + parts.append(f"[{message.get('role', 'user')}]\n{content}") + return "\n\n".join(parts) + + +def translate_event(event: dict[str, Any]) -> AgentEvent | None: + event_type = event.get("type") + if event_type == "message_update": + delta = event.get("assistantMessageEvent") + if not isinstance(delta, dict): + return None + text = delta.get("delta") + if not isinstance(text, str) or not text: + return None + if delta.get("type") == "thinking_delta": + return AgentReasoningDelta(text) + if delta.get("type") == "text_delta": + return AgentTextDelta(text) + elif event_type == "tool_execution_start": + call_id = event.get("toolCallId") + if not isinstance(call_id, str) or not call_id: + return None + raw_name = str(event.get("toolName") or "agent_tool") + args = event.get("args") if isinstance(event.get("args"), dict) else {} + arguments = {"command": args.get("command")} if raw_name == "bash" else {"input": args} + return AgentToolUpdate(call_id, "in_progress", _tool_name(raw_name), arguments) + elif event_type == "tool_execution_end": + call_id = event.get("toolCallId") + if not isinstance(call_id, str) or not call_id: + return None + return AgentToolUpdate( + call_id, + "failed" if event.get("isError") else "completed", + _tool_name(str(event.get("toolName") or "agent_tool")), + output=_content_text(event.get("result")), + ) + return None + + +def tool_output_delta(event: dict[str, Any], sent: dict[str, str]) -> AgentToolOutputDelta | None: + """Convert Pi's accumulated partial result into cptr's append/replace event.""" + call_id = event.get("toolCallId") + if not isinstance(call_id, str) or not call_id: + return None + full = _content_text(event.get("partialResult")) + previous = sent.get(call_id, "") + replace = not full.startswith(previous) + delta = full if replace else full[len(previous) :] + sent[call_id] = full + if not delta and not replace: + return None + return AgentToolOutputDelta( + call_id, + delta, + "command_output" + if _tool_name(str(event.get("toolName") or "")) == "run_command" + else "tool_output", + replace, + ) + + +class PiRpcClient: + """Small stdio client for ``pi --mode rpc``.""" + + def __init__(self, command: str, cwd: str, env: dict[str, str], model: str = "") -> None: + self.command = command + self.cwd = cwd + self.env = env + self.model = model + self.proc: asyncio.subprocess.Process | None = None + self.reader_task: asyncio.Task[None] | None = None + self.stderr_task: asyncio.Task[None] | None = None + self.events: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self.pending: dict[str, asyncio.Future[dict[str, Any]]] = {} + self.stderr_tail: collections.deque[str] = collections.deque(maxlen=20) + self._next_id = 0 + + async def start(self) -> None: + args = ["--mode", "rpc"] + if self.model and self.model != "default": + args.extend(["--model", self.model]) + self.proc = await asyncio.create_subprocess_exec( + self.command, + *args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self.cwd or os.getcwd(), + env=self.env, + ) + self.reader_task = asyncio.create_task(self._read_stdout()) + self.stderr_task = asyncio.create_task(self._read_stderr()) + + async def close(self) -> None: + if self.proc and self.proc.returncode is None: + self.proc.terminate() + with suppress(asyncio.TimeoutError): + await asyncio.wait_for(self.proc.wait(), timeout=3) + if self.proc.returncode is None: + self.proc.kill() + await self.proc.wait() + for task in (self.reader_task, self.stderr_task): + if task and not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + await task + self._fail_pending("Pi RPC client closed") + + async def request(self, command: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + self._next_id += 1 + request_id = str(self._next_id) + future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future() + self.pending[request_id] = future + await self._send({"type": command, "id": request_id, **(params or {})}) + response = await asyncio.wait_for(future, timeout=30) + if not response.get("success"): + raise RuntimeError(str(response.get("error") or f"Pi {command} failed")) + return response + + async def cancel_extension_dialog(self, event: dict[str, Any]) -> bool: + request_id = event.get("id") + if event.get("method") not in {"select", "confirm", "input", "editor"} or not isinstance( + request_id, str + ): + return False + await self._send({"type": "extension_ui_response", "id": request_id, "cancelled": True}) + return True + + async def _send(self, payload: dict[str, Any]) -> None: + if not self.proc or not self.proc.stdin: + raise RuntimeError("Pi RPC process is not running") + self.proc.stdin.write(json.dumps(payload, ensure_ascii=False).encode() + b"\n") + await self.proc.stdin.drain() + + async def _read_stdout(self) -> None: + assert self.proc and self.proc.stdout + decoder = codecs.getincrementaldecoder("utf-8")("replace") + buffer = "" + error = None + try: + while chunk := await self.proc.stdout.read(8192): + buffer += decoder.decode(chunk) + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line.rstrip("\r").strip(): + await self._route(json.loads(line.rstrip("\r"))) + buffer += decoder.decode(b"", final=True) + if buffer.strip(): + raise RuntimeError("Pi RPC ended mid-record") + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 - report malformed RPC output to the chat. + error = str(exc) + finally: + message = f"Pi RPC transport error: {error}" if error else "Pi RPC process exited" + self._fail_pending(message) + await self.events.put({"type": "process_exited", "message": message}) + + async def _route(self, message: dict[str, Any]) -> None: + request_id = message.get("id") + if message.get("type") == "response" and isinstance(request_id, str): + future = self.pending.pop(request_id, None) + if future and not future.done(): + future.set_result(message) + return + await self.events.put(message) + + async def _read_stderr(self) -> None: + assert self.proc and self.proc.stderr + while line := await self.proc.stderr.readline(): + self.stderr_tail.append(line.decode(errors="replace").rstrip()) + + def _fail_pending(self, message: str) -> None: + for future in self.pending.values(): + if not future.done(): + future.set_exception(RuntimeError(message)) + self.pending.clear() + + def error_detail(self, message: str) -> str: + return ( + f"{message}: {' | '.join(list(self.stderr_tail)[-3:])}" if self.stderr_tail else message + ) + + +async def run_pi_agent( + *, + profile: dict[str, Any], + model: str, + workspace: str, + messages: list[dict[str, Any]], + system_prompt: str, + chat_params: dict[str, Any], + resume_state: dict[str, Any] | None, + attachments: PreparedAgentAttachments, +) -> AsyncIterator[AgentEvent]: + env = os.environ.copy() + if profile.get("home"): + env["HOME"] = os.path.expanduser(str(profile["home"])) + client = PiRpcClient(str(profile.get("command") or "pi"), workspace, env, model) + session_file = (resume_state or {}).get("session_file") + resumed = isinstance(session_file, str) and bool(session_file) + + try: + await client.start() + if resumed: + await client.request("switch_session", {"sessionPath": session_file}) + images = [ + {"type": "image", "data": image.base64, "mimeType": image.mime_type} + for image in attachments.images + ] + await client.request( + "prompt", + { + "message": _message_text(messages, system_prompt, resumed), + **({"images": images} if images else {}), + }, + ) + + sent: dict[str, str] = {} + while True: + event = await asyncio.wait_for(client.events.get(), timeout=120) + if event.get("type") == "process_exited": + yield AgentError(client.error_detail(str(event.get("message") or "Pi exited"))) + return + if event.get("type") == "extension_ui_request" and await client.cancel_extension_dialog( + event + ): + yield AgentError( + "Pi extension requested interactive UI, which Computer does not support." + ) + return + if event.get("type") == "tool_execution_update": + output = tool_output_delta(event, sent) + if output: + yield output + continue + translated = translate_event(event) + if translated: + yield translated + if event.get("type") == "agent_settled": + stats = await client.request("get_session_stats") + state = await client.request("get_state") + state_data = state.get("data") if isinstance(state.get("data"), dict) else {} + stat_data = stats.get("data") if isinstance(stats.get("data"), dict) else {} + tokens = ( + stat_data.get("tokens") if isinstance(stat_data.get("tokens"), dict) else None + ) + session = state_data.get("sessionFile") + yield AgentDone( + usage=tokens, + resume_state={"session_file": session} + if isinstance(session, str) and session + else None, + ) + return + if event.get("type") == "extension_error": + yield AgentError(f"Pi extension error: {event.get('error') or 'unknown error'}") + return + except asyncio.CancelledError: + with suppress(Exception): + await asyncio.wait_for(client.request("abort"), timeout=2) + raise + except Exception as exc: # noqa: BLE001 - surfaced in chat. + yield AgentError(client.error_detail(str(exc))) + finally: + await client.close() diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index bba5aa7c..f3e71195 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -1459,6 +1459,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): from cptr.utils.agents.cursor import run_cursor_agent from cptr.utils.agents.grok import run_grok_agent from cptr.utils.agents.opencode import run_opencode_agent + from cptr.utils.agents.pi import run_pi_agent chat_obj = await Chat.get_by_id(chat_id) chat_params = (chat_obj.meta or {}).get("params", {}) if chat_obj else {} @@ -1517,6 +1518,7 @@ async def _run_agent_target(agent_target: AgentModelTarget): "grok": run_grok_agent, "opencode": run_opencode_agent, "cline": run_cline_agent, + "pi": run_pi_agent, } runner = runners.get(agent_target.agent) if runner is None: @@ -1692,7 +1694,7 @@ async def _finish_reasoning_item(): "call_id": event.call_id, "native_agent": True, "output": _append_capped_output( - str(existing_output.get("output") or ""), + "" if event.replace else str(existing_output.get("output") or ""), event.delta, max_chars, ), diff --git a/cptr/utils/model_targets.py b/cptr/utils/model_targets.py index fc01327b..e2bfd6af 100644 --- a/cptr/utils/model_targets.py +++ b/cptr/utils/model_targets.py @@ -24,7 +24,7 @@ class ApiModelTarget: class AgentModelTarget: kind: Literal["agent"] profile_id: str - agent: Literal["codex", "claude_code", "cursor", "grok", "opencode", "cline"] + agent: Literal["codex", "claude_code", "cursor", "grok", "opencode", "cline", "pi"] model: str full_model_id: str config: dict[str, Any] From 0bcf0a3f56b89be3399a1614357df6dd1fcd0ab5 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 10 Jul 2026 11:55:30 -0500 Subject: [PATCH 03/46] refac --- cptr/routers/chat.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cptr/routers/chat.py b/cptr/routers/chat.py index 7e1d7947..755e9d37 100644 --- a/cptr/routers/chat.py +++ b/cptr/routers/chat.py @@ -497,6 +497,29 @@ async def _get_latest_usage_checkpoint( # ── Delete a chat ─────────────────────────────────────────── +class UpdateChatRequest(BaseModel): + title: str + + +@router.patch("/{chat_id}") +async def update_chat(chat_id: str, body: UpdateChatRequest, request: Request): + """Rename a chat.""" + user_id = _get_user(request) + chat = await Chat.get_by_id(chat_id) + if not chat or chat.user_id != user_id: + raise HTTPException(404, "chat not found") + + title = body.title.strip() + if not title: + raise HTTPException(422, "title cannot be empty") + + await Chat.update_title(chat_id, title, now_ms()) + from cptr.socket.main import emit_to_user + + await emit_to_user(user_id, {"chat_id": chat_id, "title": title}) + return {"ok": True, "title": title} + + @router.delete("/{chat_id}") async def delete_chat(chat_id: str, request: Request): """Delete a chat and all its messages.""" @@ -533,7 +556,9 @@ async def fork_chat(chat_id: str, request: Request, body: ForkChatRequest | None if not messages: raise HTTPException(400, "chat has no messages to fork") message_by_id = {message.id: message for message in messages} - source_message_id = (body.message_id if body else None) or chat.current_message_id or messages[-1].id + source_message_id = ( + (body.message_id if body else None) or chat.current_message_id or messages[-1].id + ) source_message = message_by_id.get(source_message_id) if not source_message: raise HTTPException(404, "message not found") From 81bb38010ba72e957fa3dcc3f9dfd07e9334cfab Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 10 Jul 2026 11:55:47 -0500 Subject: [PATCH 04/46] refac --- cptr/utils/chat_task.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index f3e71195..a94d6ad1 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -626,6 +626,12 @@ async def generate_chat_title( if not title: return + # Do not overwrite a title manually set while this request was running. + chat = await Chat.get_by_id(chat_id) + fallback = user_content[:50].strip() or "New Chat" + if not chat or chat.title != fallback: + return + # Persist and notify await Chat.update_title(chat_id, title, now_ms()) await emit_to_user(user_id, {"chat_id": chat_id, "title": title}) From 55b39e39eb640e0e210ab72974e67cc9c8bd0597 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 10 Jul 2026 11:56:05 -0500 Subject: [PATCH 05/46] refac --- cptr/frontend/src/lib/apis/chat.ts | 7 +++++ .../lib/components/chat/ChatHistory.svelte | 18 +++++++++++ .../src/lib/components/chat/ChatPanel.svelte | 30 ++++++++++++++++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/cptr/frontend/src/lib/apis/chat.ts b/cptr/frontend/src/lib/apis/chat.ts index 6c0d6a8e..2e79a8c0 100644 --- a/cptr/frontend/src/lib/apis/chat.ts +++ b/cptr/frontend/src/lib/apis/chat.ts @@ -97,6 +97,13 @@ export const getChat = (chatId: string, modelId?: string) => { export const deleteChat = (chatId: string) => fetchJSON<{ ok: boolean }>(`/api/chats/${chatId}`, { method: 'DELETE' }); +export const updateChatTitle = (chatId: string, title: string) => + fetchJSON<{ ok: boolean; title: string }>(`/api/chats/${chatId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }) + }); + export const forkChat = (chatId: string, messageId?: string | null) => fetchJSON<{ ok: boolean; chat_id: string }>( `/api/chats/${chatId}/fork`, diff --git a/cptr/frontend/src/lib/components/chat/ChatHistory.svelte b/cptr/frontend/src/lib/components/chat/ChatHistory.svelte index 127bd7a0..58cf1c69 100644 --- a/cptr/frontend/src/lib/components/chat/ChatHistory.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatHistory.svelte @@ -9,6 +9,8 @@ chats: ChatInfo[]; onopen: (id: string) => void; ondelete: (id: string) => void; + onrename: (id: string) => void; + oncopy: (id: string) => void; page?: number; totalPages?: number; perPage?: number; @@ -21,6 +23,8 @@ chats, onopen, ondelete, + onrename, + oncopy, page = 1, totalPages = 1, perPage = 10, @@ -144,6 +148,20 @@ anchor={menuAnchor} align="end" items={[ + { + label: $t('files.copyPath'), + icon: 'copy', + onclick: () => { + if (menuChatId) oncopy(menuChatId); + } + }, + { + label: $t('files.rename'), + icon: 'pencil', + onclick: () => { + if (menuChatId) onrename(menuChatId); + } + }, { label: $t('chat.history.delete'), icon: 'trash', diff --git a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte index 1d1d4a08..a8454cf7 100644 --- a/cptr/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/cptr/frontend/src/lib/components/chat/ChatPanel.svelte @@ -3,6 +3,7 @@ getChat, getChats, deleteChat as apiDeleteChat, + updateChatTitle, forkChat as apiForkChat, sendMessage as apiSendMessage, approveToolCall, @@ -445,6 +446,30 @@ previousChats = previousChats.filter((c) => c.id !== id); } + async function renameChat(id: string) { + const chat = previousChats.find((c) => c.id === id); + const title = window.prompt($t('files.rename'), chat?.title)?.trim(); + if (!title || title === chat?.title) return; + try { + await updateChatTitle(id, title); + previousChats = previousChats.map((c) => (c.id === id ? { ...c, title } : c)); + if (chatId === id) { + chatTitle = title; + if (tabId) updateTab(tabId, id, title); + } + } catch (e) { + toast.error(e instanceof Error ? e.message : $t('files.rename')); + } + } + + function copyChatPath(id: string) { + const chat = previousChats.find((c) => c.id === id); + if (!chat) return; + navigator.clipboard.writeText( + `${workspace.replace(/\/$/, '')}/.cptr/chats/${chat.folder ? `${chat.folder}/` : ''}${chat.id}.json` + ); + } + // ── Socket listener ───────────────────────────────────────── let landingRefreshTimer: ReturnType | null = null; @@ -493,8 +518,9 @@ return; } - // Title generated by backend — update tab label + // Title updates also keep open chat tabs in sync. if (data.title && tabId) { + chatTitle = data.title; updateTab(tabId, data.chat_id, data.title); } @@ -1558,6 +1584,8 @@ chats={previousChats} onopen={openChat} ondelete={deleteChat} + onrename={renameChat} + oncopy={copyChatPath} page={chatPage} {totalPages} perPage={CHATS_PAGE_SIZE} From b133e8fbe27db5e69cf8f1e75fe2d5e14381f3d4 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 10 Jul 2026 11:56:22 -0500 Subject: [PATCH 06/46] refac --- .../components/SidebarWorkspaceList.svelte | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte index 98d3a235..6275a49e 100644 --- a/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte +++ b/cptr/frontend/src/lib/components/SidebarWorkspaceList.svelte @@ -4,7 +4,12 @@ import { workspaceList, removeWorkspace, reorderWorkspaces, sidebarOpen } from '$lib/stores'; import { chatEnabled } from '$lib/stores/chat'; import { socketStore } from '$lib/stores/socket.svelte'; - import { deleteChat as apiDeleteChat, getChats, type ChatInfo } from '$lib/apis/chat'; + import { + deleteChat as apiDeleteChat, + getChats, + updateChatTitle, + type ChatInfo + } from '$lib/apis/chat'; import { t } from '$lib/i18n'; import { tooltip } from '$lib/tooltip'; import Sortable from 'sortablejs'; @@ -123,6 +128,30 @@ } } + async function handleRenameChat() { + if (!chatMenu) return; + const { chatId, wsPath } = chatMenu; + const chat = (wsChatsCache.get(wsPath) ?? []).find((item) => item.id === chatId); + const title = window.prompt($t('files.rename'), chat?.title)?.trim(); + if (!title || title === chat?.title) return; + await updateChatTitle(chatId, title); + const chats = wsChatsCache.get(wsPath) ?? []; + wsChatsCache = new Map([ + ...wsChatsCache, + [wsPath, chats.map((item) => (item.id === chatId ? { ...item, title } : item))] + ]); + } + + function copyChatPath() { + if (!chatMenu) return; + const { chatId, wsPath } = chatMenu; + const chat = (wsChatsCache.get(wsPath) ?? []).find((item) => item.id === chatId); + if (!chat) return; + navigator.clipboard.writeText( + `${wsPath.replace(/\/$/, '')}/.cptr/chats/${chat.folder ? `${chat.folder}/` : ''}${chat.id}.json` + ); + } + const seenChatIds = new Set(); function handleChatEvent(data: { @@ -301,6 +330,16 @@ anchor={chatMenu.anchor} align="end" items={[ + { + label: $t('files.copyPath'), + icon: 'copy', + onclick: copyChatPath + }, + { + label: $t('files.rename'), + icon: 'pencil', + onclick: handleRenameChat + }, { label: $t('chat.history.delete'), icon: 'trash', From 5146bbe0704e3d3fbcf510b7b96d775d7f661f32 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 10 Jul 2026 13:37:44 -0500 Subject: [PATCH 07/46] refac --- cptr/app.py | 16 +- cptr/frontend/src/lib/apis/browser.ts | 36 ++ .../src/lib/components/BrowserPreview.svelte | 138 +++++++ .../src/lib/components/GroupTabBar.svelte | 8 + .../src/lib/components/PortPreview.svelte | 383 ------------------ cptr/frontend/src/lib/i18n/locales/de.json | 2 + cptr/frontend/src/lib/i18n/locales/en.json | 2 + cptr/frontend/src/lib/i18n/locales/es.json | 2 + cptr/frontend/src/lib/i18n/locales/fr.json | 2 + cptr/frontend/src/lib/i18n/locales/ja.json | 2 + cptr/frontend/src/lib/i18n/locales/ko.json | 2 + cptr/frontend/src/lib/i18n/locales/pt-BR.json | 2 + cptr/frontend/src/lib/i18n/locales/ru.json | 2 + cptr/frontend/src/lib/i18n/locales/zh-CN.json | 2 + cptr/frontend/src/lib/i18n/locales/zh-TW.json | 2 + cptr/frontend/src/lib/stores.ts | 80 +++- cptr/frontend/src/routes/+page.svelte | 12 +- cptr/frontend/static/browser-runtime.js | 131 ++++++ cptr/routers/__init__.py | 4 +- cptr/routers/browser.py | 291 +++++++++++++ cptr/routers/proxy.py | 286 ------------- cptr/utils/browser/proxy.py | 239 +++++++++++ cptr/utils/proxy_middleware.py | 233 ----------- 23 files changed, 942 insertions(+), 935 deletions(-) create mode 100644 cptr/frontend/src/lib/apis/browser.ts create mode 100644 cptr/frontend/src/lib/components/BrowserPreview.svelte delete mode 100644 cptr/frontend/src/lib/components/PortPreview.svelte create mode 100644 cptr/frontend/static/browser-runtime.js create mode 100644 cptr/routers/browser.py delete mode 100644 cptr/routers/proxy.py create mode 100644 cptr/utils/browser/proxy.py delete mode 100644 cptr/utils/proxy_middleware.py diff --git a/cptr/app.py b/cptr/app.py index e9412dc1..86fbed55 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -11,6 +11,7 @@ auth_router, automations_router, bridge_router, + browser_router, webhook_router, chat_router, events_router, @@ -20,7 +21,6 @@ images_router, memory_router, notifications_router, - proxy_router, search_router, skills_router, state_router, @@ -84,11 +84,13 @@ async def shutdown(): await cancel_all_async_subagents(reason="shutdown") except Exception: pass - # Clean up browser sessions and launched Chrome + # Clean up browser sessions and launched Chrome used by agent tools. try: from cptr.utils.browser.session import session_manager from cptr.utils.browser.launcher import shutdown_browser + from cptr.utils.browser.proxy import manager as browser_proxy_manager + await browser_proxy_manager.close_all() await session_manager.close_all() await shutdown_browser() except Exception: @@ -153,14 +155,6 @@ async def auth_middleware(request: Request, call_next): ) -# Proxy fallback middleware: intercepts sub-resource requests for proxied -# dev servers (JS modules, CSS, etc.) based on the Referer header chain. -# Added after auth middleware so it wraps outermost (runs before auth), -# since proxied sub-resources don't need cptr auth. -from cptr.utils.proxy_middleware import ProxyFallbackMiddleware - -app.add_middleware(ProxyFallbackMiddleware) - # CORS middleware: uses CPTR_CORS_ALLOWED_ORIGINS env var (default "*"). from fastapi.middleware.cors import CORSMiddleware from cptr.env import CORS_ALLOWED_ORIGINS @@ -260,6 +254,7 @@ async def get_config(): app.include_router(auth_router) app.include_router(automations_router) app.include_router(bridge_router) +app.include_router(browser_router) app.include_router(webhook_router) app.include_router(chat_router) app.include_router(events_router) @@ -269,7 +264,6 @@ async def get_config(): app.include_router(images_router) app.include_router(memory_router) app.include_router(notifications_router) -app.include_router(proxy_router) app.include_router(search_router) app.include_router(skills_router) app.include_router(state_router) diff --git a/cptr/frontend/src/lib/apis/browser.ts b/cptr/frontend/src/lib/apis/browser.ts new file mode 100644 index 00000000..7fa6e0fb --- /dev/null +++ b/cptr/frontend/src/lib/apis/browser.ts @@ -0,0 +1,36 @@ +import { fetchHandler, fetchJSON } from '$lib/apis'; + +export interface BrowserSession { + session_id: string; + url: string; + title: string; +} + +export const createBrowserSession = () => + fetchJSON('/api/browser/sessions', { method: 'POST' }); + +export const listBrowserSessions = async () => { + const data = await fetchJSON<{ session_ids: string[] }>('/api/browser/sessions'); + return data.session_ids; +}; + +export const deleteBrowserSession = (sessionId: string) => + fetchHandler(`/api/browser/sessions/${sessionId}`, { method: 'DELETE' }).catch(() => {}); + +export const getBrowserSession = (sessionId: string) => + fetchJSON(`/api/browser/sessions/${sessionId}`); + +export const updateBrowserSession = (sessionId: string, url: string, title: string) => + fetchJSON(`/api/browser/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url, title }) + }); + +export const browserFrameUrl = (sessionId: string, rawUrl: string) => { + const normalized = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`; + const url = new URL(normalized); + return `/api/browser/frame/${sessionId}?url=${encodeURIComponent(url.href)}`; +}; + +export const browserBlankUrl = (sessionId: string) => `/api/browser/sessions/${sessionId}/blank`; diff --git a/cptr/frontend/src/lib/components/BrowserPreview.svelte b/cptr/frontend/src/lib/components/BrowserPreview.svelte new file mode 100644 index 00000000..7058f22c --- /dev/null +++ b/cptr/frontend/src/lib/components/BrowserPreview.svelte @@ -0,0 +1,138 @@ + + +
+
+ + + +
+ event.key === 'Enter' && navigate()} + placeholder="https://example.com" + spellcheck="false" + /> +
+ {#if title}{title}{/if} +
+ {#if loading}
{/if} +
+ {#if error} +

{error}

+ {:else} + + {/if} +
+
+ + diff --git a/cptr/frontend/src/lib/components/GroupTabBar.svelte b/cptr/frontend/src/lib/components/GroupTabBar.svelte index ecbfa9de..ae26ca06 100644 --- a/cptr/frontend/src/lib/components/GroupTabBar.svelte +++ b/cptr/frontend/src/lib/components/GroupTabBar.svelte @@ -9,6 +9,7 @@ reorderTabs, openUntitledFileTab, openTerminalTab, + openBrowserTab, openInSplit, closeGroup, moveTabToGroup, @@ -79,6 +80,8 @@ return 'chat-bubble'; case 'preview': return 'monitor'; + case 'browser': + return 'browser'; default: return 'page'; } @@ -193,6 +196,11 @@ openTerminalTab(group.id); } }, + { + label: $t('bar.newBrowser'), + icon: 'browser', + onclick: () => openBrowserTab(group.id) + }, ...($voiceMemosEnabled ? [ { diff --git a/cptr/frontend/src/lib/components/PortPreview.svelte b/cptr/frontend/src/lib/components/PortPreview.svelte deleted file mode 100644 index c0fcdf64..00000000 --- a/cptr/frontend/src/lib/components/PortPreview.svelte +++ /dev/null @@ -1,383 +0,0 @@ - - -
- -
- - - - - - - - - - -
- -
- - - -
- - - {#if isLoading} -
-
-
- {/if} - - -
- {#if loadError} -
-

{$t('port.cannotConnect')}

-

{$t('port.notResponding', { port })}

- -
- {:else} - {#key iframeKey} - - {/key} - {/if} -
-
- - diff --git a/cptr/frontend/src/lib/i18n/locales/de.json b/cptr/frontend/src/lib/i18n/locales/de.json index 7d5b2b6b..879f7425 100644 --- a/cptr/frontend/src/lib/i18n/locales/de.json +++ b/cptr/frontend/src/lib/i18n/locales/de.json @@ -33,6 +33,8 @@ "sidebar.showMore": "Mehr anzeigen", "bar.newFile": "Neue Datei", "bar.newTerminal": "Neues Terminal", + "bar.newBrowser": "Browser", + "browser.invalidUrl": "Ungültige URL", "bar.closeTab": "Tab schließen", "bar.new": "Neu...", "bar.sidebar": "Seitenleiste", diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 6459607a..1d0efd78 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -33,6 +33,8 @@ "sidebar.showMore": "Show more", "bar.newFile": "New File", "bar.newTerminal": "New Terminal", + "bar.newBrowser": "Browser", + "browser.invalidUrl": "Invalid URL", "bar.closeTab": "Close tab", "bar.new": "New...", "bar.sidebar": "Sidebar", diff --git a/cptr/frontend/src/lib/i18n/locales/es.json b/cptr/frontend/src/lib/i18n/locales/es.json index 4f8089d0..be7d3943 100644 --- a/cptr/frontend/src/lib/i18n/locales/es.json +++ b/cptr/frontend/src/lib/i18n/locales/es.json @@ -33,6 +33,8 @@ "sidebar.showMore": "Mostrar más", "bar.newFile": "Nuevo archivo", "bar.newTerminal": "Nueva terminal", + "bar.newBrowser": "Navegador", + "browser.invalidUrl": "URL no válida", "bar.closeTab": "Cerrar pestaña", "bar.new": "Nuevo...", "bar.sidebar": "Barra lateral", diff --git a/cptr/frontend/src/lib/i18n/locales/fr.json b/cptr/frontend/src/lib/i18n/locales/fr.json index c67836fd..d44932dd 100644 --- a/cptr/frontend/src/lib/i18n/locales/fr.json +++ b/cptr/frontend/src/lib/i18n/locales/fr.json @@ -33,6 +33,8 @@ "sidebar.showMore": "Afficher plus", "bar.newFile": "Nouveau fichier", "bar.newTerminal": "Nouveau terminal", + "bar.newBrowser": "Navigateur", + "browser.invalidUrl": "URL non valide", "bar.closeTab": "Fermer l'onglet", "bar.new": "Nouveau...", "bar.sidebar": "Barre latérale", diff --git a/cptr/frontend/src/lib/i18n/locales/ja.json b/cptr/frontend/src/lib/i18n/locales/ja.json index 9b521729..60722d7b 100644 --- a/cptr/frontend/src/lib/i18n/locales/ja.json +++ b/cptr/frontend/src/lib/i18n/locales/ja.json @@ -33,6 +33,8 @@ "sidebar.showMore": "もっと見る", "bar.newFile": "新規ファイル", "bar.newTerminal": "新規ターミナル", + "bar.newBrowser": "ブラウザ", + "browser.invalidUrl": "無効なURL", "bar.closeTab": "タブを閉じる", "bar.new": "新規...", "bar.sidebar": "サイドバー", diff --git a/cptr/frontend/src/lib/i18n/locales/ko.json b/cptr/frontend/src/lib/i18n/locales/ko.json index 834a4a68..1d954b88 100644 --- a/cptr/frontend/src/lib/i18n/locales/ko.json +++ b/cptr/frontend/src/lib/i18n/locales/ko.json @@ -33,6 +33,8 @@ "sidebar.showMore": "더 보기", "bar.newFile": "새 파일", "bar.newTerminal": "새 터미널", + "bar.newBrowser": "브라우저", + "browser.invalidUrl": "잘못된 URL", "bar.closeTab": "탭 닫기", "bar.new": "새로 만들기...", "bar.sidebar": "사이드바", diff --git a/cptr/frontend/src/lib/i18n/locales/pt-BR.json b/cptr/frontend/src/lib/i18n/locales/pt-BR.json index dac13c71..daaeda5c 100644 --- a/cptr/frontend/src/lib/i18n/locales/pt-BR.json +++ b/cptr/frontend/src/lib/i18n/locales/pt-BR.json @@ -33,6 +33,8 @@ "sidebar.showMore": "Mostrar mais", "bar.newFile": "Novo arquivo", "bar.newTerminal": "Novo terminal", + "bar.newBrowser": "Navegador", + "browser.invalidUrl": "URL inválida", "bar.closeTab": "Fechar aba", "bar.new": "Novo...", "bar.sidebar": "Barra lateral", diff --git a/cptr/frontend/src/lib/i18n/locales/ru.json b/cptr/frontend/src/lib/i18n/locales/ru.json index 4a21cbc5..1356f108 100644 --- a/cptr/frontend/src/lib/i18n/locales/ru.json +++ b/cptr/frontend/src/lib/i18n/locales/ru.json @@ -33,6 +33,8 @@ "sidebar.showMore": "Показать ещё", "bar.newFile": "Новый файл", "bar.newTerminal": "Новый терминал", + "bar.newBrowser": "Браузер", + "browser.invalidUrl": "Недопустимый URL", "bar.closeTab": "Закрыть вкладку", "bar.new": "Создать...", "bar.sidebar": "Боковая панель", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-CN.json b/cptr/frontend/src/lib/i18n/locales/zh-CN.json index 3f24e2ae..69e120a6 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-CN.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-CN.json @@ -33,6 +33,8 @@ "sidebar.showMore": "显示更多", "bar.newFile": "新建文件", "bar.newTerminal": "新建终端", + "bar.newBrowser": "浏览器", + "browser.invalidUrl": "无效网址", "bar.closeTab": "关闭标签", "bar.new": "新建...", "bar.sidebar": "侧边栏", diff --git a/cptr/frontend/src/lib/i18n/locales/zh-TW.json b/cptr/frontend/src/lib/i18n/locales/zh-TW.json index 02716f7f..d45dbfa5 100644 --- a/cptr/frontend/src/lib/i18n/locales/zh-TW.json +++ b/cptr/frontend/src/lib/i18n/locales/zh-TW.json @@ -33,6 +33,8 @@ "sidebar.showMore": "顯示更多", "bar.newFile": "新增檔案", "bar.newTerminal": "新增終端機", + "bar.newBrowser": "瀏覽器", + "browser.invalidUrl": "無效網址", "bar.closeTab": "關閉分頁", "bar.new": "新增...", "bar.sidebar": "側邊欄", diff --git a/cptr/frontend/src/lib/stores.ts b/cptr/frontend/src/lib/stores.ts index 4c2cdf71..2f644a01 100644 --- a/cptr/frontend/src/lib/stores.ts +++ b/cptr/frontend/src/lib/stores.ts @@ -25,6 +25,7 @@ import { saveWorkspaceState } from '$lib/apis/state'; import { listSessions, createSession, deleteSession } from '$lib/apis/terminal'; +import { createBrowserSession, deleteBrowserSession, listBrowserSessions } from '$lib/apis/browser'; import { changeLocale, i18next } from '$lib/i18n'; import { streamingChatTabs } from '$lib/stores/chat'; import { keybindings, loadKeybindings } from '$lib/stores/keybindings'; @@ -44,13 +45,14 @@ export type { AppearancePreferences, Theme, ThemeConfig }; export interface Tab { id: string; - type: 'files' | 'terminal' | 'file' | 'git' | 'chat' | 'preview'; + type: 'files' | 'terminal' | 'file' | 'git' | 'chat' | 'preview' | 'browser'; // preview is migrated on load label: string; filePath?: string; edit?: boolean; path?: string; // generic path (e.g. for chat) sessionId?: string; - port?: number; + port?: number; // legacy preview port, migrated on load + browserSessionId?: string; unsaved?: boolean; permanent?: boolean; badge?: number; @@ -532,12 +534,42 @@ export async function loadWorkspace(path: string): Promise { if (wsData && wsData.groups && (wsData.groups as EditorGroup[]).length > 0) { // Validate terminal sessions are still alive let aliveSessions: Set = new Set(); + let aliveBrowserSessions: Set = new Set(); try { const sessions = await listSessions(); aliveSessions = new Set(sessions.map((s) => s.session_id)); } catch {} + try { + aliveBrowserSessions = new Set(await listBrowserSessions()); + } catch {} const ws = wsData as unknown as WorkspaceState; + ws.groups = await Promise.all( + ws.groups.map(async (group) => ({ + ...group, + tabs: ( + await Promise.all( + group.tabs.map(async (tab) => { + if (tab.type !== 'preview' || !tab.port) return tab; + try { + const session = await createBrowserSession(); + aliveBrowserSessions.add(session.session_id); + const { port, ...browserTab } = tab; + return { + ...browserTab, + type: 'browser' as const, + label: `localhost:${port}`, + browserSessionId: session.session_id, + path: `http://127.0.0.1:${port}/` + }; + } catch { + return null; + } + }) + ) + ).filter((tab): tab is Tab => tab !== null) + })) + ); // Remove dead terminal tabs from all groups const cleanedGroups = ws.groups @@ -546,6 +578,12 @@ export async function loadWorkspace(path: string): Promise { if (t.type === 'terminal' && t.sessionId && !aliveSessions.has(t.sessionId)) { return false; } + if ( + t.type === 'browser' && + (!t.browserSessionId || !aliveBrowserSessions.has(t.browserSessionId)) + ) { + return false; + } return true; }); const activeStillExists = filteredTabs.some((t: Tab) => t.id === g.activeTabId); @@ -861,7 +899,7 @@ export async function openTerminalTab(targetGroupId?: string): Promise { } } -export function openPreviewTab(port: number, targetGroupId?: string): void { +export async function openPreviewTab(port: number, targetGroupId?: string): Promise { const ws = get(currentWorkspace); if (!ws) return; @@ -870,23 +908,34 @@ export function openPreviewTab(port: number, targetGroupId?: string): void { if (!group) return; // Reuse existing tab within this group - const existing = group.tabs.find((t) => t.type === 'preview' && t.port === port); + const url = `http://127.0.0.1:${port}/`; + const existing = group.tabs.find((t) => t.type === 'browser' && t.path === url); if (existing) { setActiveTab(existing.id, gid); return; } - const newTab: Tab = { - id: nextId(), - type: 'preview', - label: `localhost:${port}`, - port - }; + await openBrowserTab(gid, url, `localhost:${port}`); +} - updateGroupTabs(gid, (tabs) => ({ - tabs: [...tabs, newTab], - activeTabId: newTab.id - })); +export async function openBrowserTab(targetGroupId?: string, url?: string, label = 'Browser'): Promise { + const ws = get(currentWorkspace); + if (!ws) return; + const gid = targetGroupId ?? ws.activeGroupId; + if (!ws.groups.some((group) => group.id === gid)) return; + try { + const session = await createBrowserSession(); + const newTab: Tab = { + id: nextId(), + type: 'browser', + label, + browserSessionId: session.session_id, + path: url + }; + updateGroupTabs(gid, (tabs) => ({ tabs: [...tabs, newTab], activeTabId: newTab.id })); + } catch (error) { + console.error('Failed to create browser session:', error); + } } export function openChatTab(chatId?: string, targetGroupId?: string): void { @@ -944,6 +993,9 @@ export function closeTab(tabId: string, groupId?: string): void { if (tab.type === 'terminal' && tab.sessionId) { deleteSession(tab.sessionId); } + if (tab.type === 'browser' && tab.browserSessionId) { + deleteBrowserSession(tab.browserSessionId); + } // Clean up streaming indicator for closed chat tabs if (tab.type === 'chat') { diff --git a/cptr/frontend/src/routes/+page.svelte b/cptr/frontend/src/routes/+page.svelte index 5f6c300f..cf0994a9 100644 --- a/cptr/frontend/src/routes/+page.svelte +++ b/cptr/frontend/src/routes/+page.svelte @@ -32,7 +32,7 @@ import FileEditor from '$lib/components/FileEditor.svelte'; import GitView from '$lib/components/GitView.svelte'; import Terminal from '$lib/components/Terminal.svelte'; - import PortPreview from '$lib/components/PortPreview.svelte'; + import BrowserPreview from '$lib/components/BrowserPreview.svelte'; import ChatPanel from '$lib/components/chat/ChatPanel.svelte'; import DirectoryPicker from '$lib/components/DirectoryPicker.svelte'; import GroupTabBar from '$lib/components/GroupTabBar.svelte'; @@ -465,11 +465,11 @@ ): WorkspaceResume { const tabs = (state.groups ?? []).flatMap((group) => group.tabs ?? []); const previewPorts = tabs - .filter((tab) => tab.type === 'preview' && tab.port) - .map((tab) => tab.port!) + .filter((tab) => tab.type === 'browser' && /^localhost:\d+$/.test(tab.label)) + .map((tab) => Number(tab.label.slice('localhost:'.length))) .filter((port, index, all) => all.indexOf(port) === index); const activeLabels = tabs - .filter((tab) => !tab.permanent && ['terminal', 'chat', 'preview', 'file'].includes(tab.type)) + .filter((tab) => !tab.permanent && ['terminal', 'chat', 'browser', 'file'].includes(tab.type)) .slice(0, 3) .map((tab) => tab.label); @@ -976,9 +976,9 @@ {/each} - {#each group.tabs.filter((tab) => tab.type === 'preview' && tab.port) as tab (tab.id)} + {#each group.tabs.filter((tab) => tab.type === 'browser' && tab.browserSessionId) as tab (tab.id)}
- +
{/each} {#if !groupTab || groupTab.type === 'files'} diff --git a/cptr/frontend/static/browser-runtime.js b/cptr/frontend/static/browser-runtime.js new file mode 100644 index 00000000..c5bc14a6 --- /dev/null +++ b/cptr/frontend/static/browser-runtime.js @@ -0,0 +1,131 @@ +(() => { + const config = window.__cptrBrowser; + if (!config) return; + + const prefix = `/api/browser/frame/${config.session}`; + const api = `/api/browser/sessions/${config.session}`; + const nativeFetch = window.fetch.bind(window); + const nativeWebSocket = window.WebSocket; + + function remoteUrl(value = location.href) { + const url = new URL(value, location.href); + return url.pathname === prefix ? url.searchParams.get('url') || config.url : config.url; + } + + function proxyUrl(value, base = remoteUrl()) { + try { + const current = new URL(value, location.href); + if (current.pathname === prefix && current.searchParams.has('url')) return `${current.pathname}${current.search}`; + const url = new URL(value, base); + if (!/^https?:$/.test(url.protocol)) return value; + return `${prefix}?url=${encodeURIComponent(url.href)}`; + } catch { + return value; + } + } + + function report() { + parent.postMessage({ type: 'cptr-browser-state', url: remoteUrl(), title: document.title }, location.origin); + } + + const originalPushState = history.pushState.bind(history); + const originalReplaceState = history.replaceState.bind(history); + history.pushState = (state, title, url) => { + const result = originalPushState(state, title, url ? proxyUrl(url) : url); + queueMicrotask(report); + return result; + }; + history.replaceState = (state, title, url) => { + const result = originalReplaceState(state, title, url ? proxyUrl(url) : url); + queueMicrotask(report); + return result; + }; + addEventListener('popstate', report); + new MutationObserver(report).observe(document.querySelector('title') || document.head, { + childList: true, + subtree: true, + characterData: true + }); + + window.fetch = (input, init) => { + if (input instanceof Request) return nativeFetch(new Request(proxyUrl(input.url), input), init); + return nativeFetch(proxyUrl(input), init); + }; + + const open = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + return open.call(this, method, proxyUrl(url), ...rest); + }; + + window.WebSocket = function ProxyWebSocket(url, protocols) { + const scheme = location.protocol === 'https:' ? 'wss' : 'ws'; + const native = new nativeWebSocket(`${scheme}://${location.host}${api}/ws`); + const proxy = new EventTarget(); + let state = 0; + let protocol = ''; + Object.defineProperties(proxy, { + url: { value: String(url) }, + readyState: { get: () => state }, + protocol: { get: () => protocol }, + binaryType: { get: () => native.binaryType, set: (value) => { native.binaryType = value; } }, + onopen: { get: () => proxy._onopen, set: (value) => { proxy._onopen = value; } }, + onmessage: { get: () => proxy._onmessage, set: (value) => { proxy._onmessage = value; } }, + onclose: { get: () => proxy._onclose, set: (value) => { proxy._onclose = value; } }, + onerror: { get: () => proxy._onerror, set: (value) => { proxy._onerror = value; } } + }); + const emit = (type, event) => { + proxy.dispatchEvent(event); + proxy[`_on${type}`]?.call(proxy, event); + }; + native.addEventListener('open', () => native.send(JSON.stringify({ url: new URL(url, remoteUrl()).href, protocols }))); + native.addEventListener('message', (event) => { + if (typeof event.data === 'string') { + try { + const control = JSON.parse(event.data); + if (control.type === 'open') { + state = 1; protocol = control.protocol || ''; emit('open', new Event('open')); return; + } + } catch {} + } + emit('message', new MessageEvent('message', { data: event.data })); + }); + native.addEventListener('close', (event) => { state = 3; emit('close', event); }); + native.addEventListener('error', (event) => emit('error', event)); + proxy.send = (data) => { + if (state !== 1) throw new DOMException('WebSocket is not open', 'InvalidStateError'); + native.send(data); + }; + proxy.close = (code, reason) => { state = 2; native.close(code, reason); }; + return proxy; + }; + window.WebSocket.prototype = nativeWebSocket.prototype; + + const NativeEventSource = window.EventSource; + if (NativeEventSource) { + window.EventSource = function ProxyEventSource(url, options) { + return new NativeEventSource(proxyUrl(url), options); + }; + window.EventSource.prototype = NativeEventSource.prototype; + } + + document.addEventListener('click', (event) => { + const link = event.target.closest?.('a[href]'); + if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + const url = new URL(link.href, remoteUrl()); + if (!/^https?:$/.test(url.protocol)) return; + event.preventDefault(); + if (link.target && link.target !== '_self') { + parent.postMessage({ type: 'cptr-browser-popup', url: url.href }, location.origin); + } else { + location.assign(proxyUrl(url.href)); + } + }, true); + + const nativeOpen = window.open.bind(window); + window.open = (url, target, features) => { + if (url) parent.postMessage({ type: 'cptr-browser-popup', url: new URL(url, remoteUrl()).href }, location.origin); + return target === '_blank' ? null : nativeOpen(proxyUrl(url || remoteUrl()), target, features); + }; + + report(); +})(); diff --git a/cptr/routers/__init__.py b/cptr/routers/__init__.py index 07bca2fc..a589fe41 100644 --- a/cptr/routers/__init__.py +++ b/cptr/routers/__init__.py @@ -6,6 +6,7 @@ from cptr.routers.automations import router as automations_router from cptr.routers.bridge import router as bridge_router from cptr.routers.bridge import webhook_router +from cptr.routers.browser import router as browser_router from cptr.routers.chat import router as chat_router from cptr.routers.gateway import router as gateway_router from cptr.routers.events import router as events_router @@ -14,7 +15,6 @@ from cptr.routers.images import router as images_router from cptr.routers.memory import router as memory_router from cptr.routers.notifications import router as notifications_router -from cptr.routers.proxy import router as proxy_router from cptr.routers.search import router as search_router from cptr.routers.skills import router as skills_router from cptr.routers.state import router as state_router @@ -27,6 +27,7 @@ "auth_router", "automations_router", "bridge_router", + "browser_router", "webhook_router", "chat_router", "events_router", @@ -36,7 +37,6 @@ "images_router", "memory_router", "notifications_router", - "proxy_router", "search_router", "skills_router", "state_router", diff --git a/cptr/routers/browser.py b/cptr/routers/browser.py new file mode 100644 index 00000000..971c6c2c --- /dev/null +++ b/cptr/routers/browser.py @@ -0,0 +1,291 @@ +"""Same-origin HTTP/WebSocket proxy backing Browser editor tabs.""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator +from urllib.parse import urljoin, urlsplit, urlunsplit + +import httpx +import websockets +from fastapi import APIRouter, HTTPException, Request, Response, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, StreamingResponse + +from cptr.routers.auth import COOKIE_NAME +from cptr.utils.browser.proxy import manager, rewrite_css, rewrite_html, target_url +from cptr.utils.config import AuthResult, check_access + +router = APIRouter(prefix="/api/browser", tags=["browser"]) + +_HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", +} +_SKIP_REQUEST_HEADERS = _HOP_BY_HOP | { + "accept-encoding", + "host", + "cookie", + "authorization", + "content-length", +} +_SKIP_RESPONSE_HEADERS = _HOP_BY_HOP | { + "content-encoding", + "content-length", + "content-security-policy", + "set-cookie", + "x-frame-options", +} + + +def _owner(auth: AuthResult) -> str: + return auth.user_id or auth.username or "default" + + +def _auth(request: Request) -> AuthResult: + auth = getattr(request.state, "auth", None) + if auth is None: + raise HTTPException(status_code=401, detail="not authenticated") + return auth + + +def _headers(request: Request, upstream_url: str) -> dict[str, str]: + headers = { + key: value + for key, value in request.headers.items() + if key.lower() not in _SKIP_REQUEST_HEADERS and not key.lower().startswith("x-cptr-") + } + parsed = urlsplit(upstream_url) + headers["host"] = parsed.netloc + # The proxy rewrites HTML and CSS after httpx has decoded it. Do not negotiate + # encodings a downstream browser supports but this process may not decode. + headers["accept-encoding"] = "gzip, deflate" + referer = request.headers.get("referer") + if referer and "/api/browser/frame/" in referer: + headers["referer"] = upstream_url + if request.headers.get("origin"): + headers["origin"] = f"{parsed.scheme}://{parsed.netloc}" + return headers + + +def _response_headers(upstream: httpx.Response) -> dict[str, str]: + headers = { + key: value for key, value in upstream.headers.items() if key.lower() not in _SKIP_RESPONSE_HEADERS + } + headers["x-cptr-browser-url"] = str(upstream.url) + return headers + + +def _session_payload(session) -> dict[str, str]: + return {"session_id": session.session_id, "url": session.url, "title": session.title} + + +async def _stream_response(upstream: httpx.Response) -> AsyncIterator[bytes]: + try: + async for chunk in upstream.aiter_bytes(): + yield chunk + finally: + await upstream.aclose() + + +async def proxy_browser_request(request: Request, session_id: str, url: str, owner: str) -> Response: + client = manager.client(session_id, owner) + if client is None: + raise HTTPException(status_code=404, detail="Browser tab expired") + try: + upstream_url = target_url(url) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + body = await request.body() if request.method not in {"GET", "HEAD"} else None + upstream = None + candidates = [upstream_url] + parsed = urlsplit(upstream_url) + if parsed.hostname == "127.0.0.1": + port = f":{parsed.port}" if parsed.port else "" + candidates.append(urlunsplit((parsed.scheme, f"[::1]{port}", parsed.path, parsed.query, ""))) + for candidate in candidates: + try: + upstream = await client.send( + client.build_request(request.method, candidate, headers=_headers(request, candidate), content=body), + stream=True, + ) + break + except httpx.ConnectError: + continue + if upstream is None: + raise HTTPException(status_code=502, detail=f"Cannot connect to {parsed.netloc}") + content_type = upstream.headers.get("content-type", "") + headers = _response_headers(upstream) + + if content_type.startswith("text/event-stream"): + return StreamingResponse( + _stream_response(upstream), + status_code=upstream.status_code, + headers=headers, + media_type=content_type, + ) + + content = await upstream.aread() + await upstream.aclose() + if "text/html" in content_type or "application/xhtml" in content_type: + final_url = upstream_url if parsed.hostname == "127.0.0.1" else str(upstream.url) + if f"/api/browser/frame/{session_id}/" not in urlsplit(final_url).path: + await manager.update(session_id, owner, url=final_url) + text = content.decode(upstream.encoding or "utf-8", errors="replace") + content = rewrite_html(text, upstream_url, session_id, final_url).encode("utf-8") + headers.pop("content-type", None) + content_type = "text/html; charset=utf-8" + elif "text/css" in content_type: + text = content.decode(upstream.encoding or "utf-8", errors="replace") + content = rewrite_css(text, upstream_url, session_id).encode("utf-8") + headers.pop("content-type", None) + content_type = "text/css; charset=utf-8" + return Response(content=content, status_code=upstream.status_code, headers=headers, media_type=content_type) + + +async def _proxy(request: Request, session_id: str, url: str) -> Response: + return await proxy_browser_request(request, session_id, url, _owner(_auth(request))) + + +@router.post("/sessions") +async def create_session(request: Request): + session = await manager.create(_owner(_auth(request))) + return _session_payload(session) + + +@router.get("/sessions") +async def list_sessions(request: Request): + return {"session_ids": manager.ids(_owner(_auth(request)))} + + +@router.delete("/sessions/{session_id}") +async def close_session(session_id: str, request: Request): + if not await manager.close(session_id, _owner(_auth(request))): + raise HTTPException(status_code=404, detail="Browser tab not found") + return {"status": "closed"} + + +@router.get("/sessions/{session_id}") +async def get_session(session_id: str, request: Request): + session = manager.session(session_id, _owner(_auth(request))) + if session is None: + raise HTTPException(status_code=404, detail="Browser tab expired") + return _session_payload(session) + + +@router.patch("/sessions/{session_id}") +async def update_session(session_id: str, request: Request): + payload = await request.json() + url = str(payload.get("url", "")) + title = str(payload.get("title", ""))[:512] + if url: + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise HTTPException(status_code=400, detail="Invalid Browser URL") + session = await manager.update(session_id, _owner(_auth(request)), url=url, title=title) + if session is None: + raise HTTPException(status_code=404, detail="Browser tab expired") + return _session_payload(session) + + +@router.get("/sessions/{session_id}/blank") +async def blank(session_id: str, request: Request): + if manager.session(session_id, _owner(_auth(request))) is None: + raise HTTPException(status_code=404, detail="Browser tab expired") + return HTMLResponse("Browser") + + +@router.api_route( + "/resources/{session_id}/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] +) +async def proxy_resource(session_id: str, path: str, request: Request): + owner = _owner(_auth(request)) + session = manager.session(session_id, owner) + if session is None or not session.origin: + raise HTTPException(status_code=404, detail="Browser tab expired") + url = urljoin(session.origin + "/", path.lstrip("/")) + if request.url.query: + url += f"?{request.url.query}" + return await proxy_browser_request(request, session_id, url, owner) + + +@router.api_route("/frame/{session_id}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) +async def proxy_frame(session_id: str, url: str, request: Request): + return await _proxy(request, session_id, url) + + +@router.api_route( + "/frame/{session_id}/{scheme}/{host}/{path:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"], +) +async def legacy_proxy_frame(session_id: str, scheme: str, host: str, path: str, request: Request): + return await _proxy(request, session_id, urlunsplit((scheme, host, "/" + path.lstrip("/"), request.url.query, ""))) + + +@router.api_route( + "/frame/{session_id}/{scheme}/{host}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] +) +async def legacy_proxy_frame_root(session_id: str, scheme: str, host: str, request: Request): + return await _proxy(request, session_id, urlunsplit((scheme, host, "/", request.url.query, ""))) + + +@router.websocket("/sessions/{session_id}/ws") +async def proxy_websocket(websocket: WebSocket, session_id: str): + client_host = websocket.client.host if websocket.client else "127.0.0.1" + auth = check_access(client_host, websocket.cookies.get(COOKIE_NAME)) + owner = _owner(auth) if auth else "" + if not auth or manager.session(session_id, owner) is None: + await websocket.close(code=4001, reason="unauthorized") + return + await websocket.accept() + try: + open_message = await asyncio.wait_for(websocket.receive_json(), timeout=10) + target = str(open_message.get("url", "")) + parsed = urlsplit(target) + if parsed.scheme not in {"ws", "wss"}: + await websocket.close(code=4000, reason="invalid websocket URL") + return + client = manager.client(session_id, owner) + assert client is not None + prepared = client.build_request("GET", target.replace("ws:", "http:", 1).replace("wss:", "https:", 1)) + headers = {"cookie": prepared.headers["cookie"]} if "cookie" in prepared.headers else None + upstream = await websockets.connect(target, additional_headers=headers) + await websocket.send_json({"type": "open", "protocol": upstream.subprotocol or ""}) + + async def to_upstream() -> None: + while True: + message = await websocket.receive() + if message.get("text") is not None: + await upstream.send(message["text"]) + elif message.get("bytes") is not None: + await upstream.send(message["bytes"]) + else: + break + + async def to_client() -> None: + async for message in upstream: + if isinstance(message, bytes): + await websocket.send_bytes(message) + else: + await websocket.send_text(message) + + done, pending = await asyncio.wait( + (asyncio.create_task(to_upstream()), asyncio.create_task(to_client())), + return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + for task in done: + task.result() + await upstream.close() + except (WebSocketDisconnect, asyncio.TimeoutError, json.JSONDecodeError): + pass + except Exception: + await websocket.close(code=1011, reason="Browser WebSocket failed") diff --git a/cptr/routers/proxy.py b/cptr/routers/proxy.py deleted file mode 100644 index 230da349..00000000 --- a/cptr/routers/proxy.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Reverse proxy to localhost services. - -Proxies HTTP requests and WebSocket connections to 127.0.0.1:{port} -so that dev servers started in terminal sessions are accessible -from any device through the cptr UI. - -The proxy path cache (shared with proxy_middleware) tracks which -request paths belong to which ports, enabling sub-resource loading -without URL rewriting. -""" - -from __future__ import annotations - -import asyncio -import logging -import re -import time - -import httpx -from fastapi import APIRouter, Request, Response, WebSocket, WebSocketDisconnect - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/proxy", tags=["proxy"]) - -# ── Shared async HTTP client ──────────────────────────────────── - -_client: httpx.AsyncClient | None = None - - -def _get_client() -> httpx.AsyncClient: - global _client - if _client is None or _client.is_closed: - _client = httpx.AsyncClient( - timeout=httpx.Timeout(30.0, connect=5.0), - follow_redirects=False, - limits=httpx.Limits(max_connections=50), - ) - return _client - - -# ── Path → port cache (used by both proxy router and middleware) ── - -# Maps request paths to (port, timestamp). Populated when requests -# flow through /api/proxy/{port}/... and also by the middleware when -# it proxies sub-resources based on Referer. -proxy_path_cache: dict[str, tuple[int, float]] = {} -CACHE_TTL = 300 # seconds - - -def cache_path(path: str, port: int) -> None: - """Associate a request path with a proxy port.""" - proxy_path_cache[path] = (port, time.time()) - - # Lazy eviction: prune stale entries when cache gets large - if len(proxy_path_cache) > 2000: - now = time.time() - stale = [k for k, (_, ts) in proxy_path_cache.items() if now - ts > CACHE_TTL] - for k in stale: - proxy_path_cache.pop(k, None) - - -def resolve_cached_port(path: str) -> int | None: - """Look up a port for a previously-proxied path.""" - entry = proxy_path_cache.get(path) - if entry is None: - return None - port, ts = entry - if time.time() - ts > CACHE_TTL: - proxy_path_cache.pop(path, None) - return None - return port - - -# ── Headers ──────────────────────────────────────────────────── - -_HOP_BY_HOP = { - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailers", - "transfer-encoding", - "upgrade", -} - -_SKIP_REQUEST_HEADERS = { - "host", - "connection", - "accept-encoding", -} - - -def _build_request_headers(request: Request, port: int) -> dict[str, str]: - """Build headers to forward to the upstream server.""" - headers = {} - for key, value in request.headers.items(): - if key.lower() not in _SKIP_REQUEST_HEADERS: - headers[key] = value - headers["host"] = f"127.0.0.1:{port}" - return headers - - -def _build_response_headers(upstream_headers: httpx.Headers) -> dict[str, str]: - """Build headers to return to the client, stripping hop-by-hop and frame blockers.""" - headers = {} - for key, value in upstream_headers.items(): - if key.lower() not in _HOP_BY_HOP: - headers[key] = value - - # Remove X-Frame-Options so iframe embedding works - headers.pop("x-frame-options", None) - headers.pop("X-Frame-Options", None) - - # Remove frame-ancestors from CSP - csp = headers.get("content-security-policy", "") - if "frame-ancestors" in csp: - parts = [p.strip() for p in csp.split(";") if "frame-ancestors" not in p] - if parts: - headers["content-security-policy"] = "; ".join(parts) - else: - headers.pop("content-security-policy", None) - - # Drop content-length (we might not change the body, but the framework - # will set it correctly) - headers.pop("content-length", None) - headers.pop("Content-Length", None) - - return headers - - -# ── Core proxy function (reused by middleware) ────────────────── - - -async def proxy_http_request(port: int, path: str, request: Request) -> Response: - """Proxy an HTTP request to localhost:{port}/{path}. - - This is the shared implementation used by both the explicit - /api/proxy/{port}/{path} route and the fallback middleware. - """ - if port < 1 or port > 65535: - return Response(content="Invalid port", status_code=400) - - # Build target URL - target_url = f"http://127.0.0.1:{port}/{path}" - query = str(request.url.query) if request.url.query else "" - if query: - target_url += f"?{query}" - - headers = _build_request_headers(request, port) - body = await request.body() if request.method not in ("GET", "HEAD") else None - client = _get_client() - - # Try IPv4, fall back to IPv6 - upstream: httpx.Response | None = None - for host in ("127.0.0.1", "[::1]"): - url = target_url if host == "127.0.0.1" else target_url.replace("127.0.0.1", "[::1]") - try: - upstream = await client.request( - method=request.method, - url=url, - headers=headers, - content=body, - follow_redirects=False, - ) - break - except httpx.ConnectError: - continue - except httpx.TimeoutException: - return Response( - content=f"Timeout connecting to localhost:{port}", - status_code=504, - media_type="text/plain", - ) - except Exception as e: - logger.error(f"Proxy error for port {port}: {e}") - return Response(content=f"Proxy error: {e}", status_code=502, media_type="text/plain") - - if upstream is None: - return Response( - content=f"Cannot connect to localhost:{port}", status_code=502, media_type="text/plain" - ) - - response_headers = _build_response_headers(upstream.headers) - - return Response( - content=upstream.content, - status_code=upstream.status_code, - headers=response_headers, - media_type=upstream.headers.get("content-type"), - ) - - -# ── HTTP proxy route ──────────────────────────────────────────── - - -@router.api_route( - "/{port}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] -) -async def proxy_route(port: int, path: str, request: Request) -> Response: - """Proxy an HTTP request to localhost:{port}/{path}.""" - # Cache this path so the middleware can resolve sub-resources - cache_path(f"/api/proxy/{port}/{path}", port) - return await proxy_http_request(port, path, request) - - -# ── WebSocket proxy route ────────────────────────────────────── - - -@router.websocket("/{port}/ws-proxy") -@router.websocket("/{port}/ws-proxy/{path:path}") -async def proxy_websocket(websocket: WebSocket, port: int, path: str = ""): - """Proxy a WebSocket connection to localhost:{port}/{path}. - - Used for things like Vite HMR. Relays frames bidirectionally. - """ - if port < 1 or port > 65535: - await websocket.close(code=4000, reason="Invalid port") - return - - await websocket.accept() - - # Build upstream WS URL -- try IPv6 if IPv4 fails - query = str(websocket.url.query) if websocket.url.query else "" - qs = f"?{query}" if query else "" - - upstream_ws = None - for host in ("127.0.0.1", "[::1]"): - ws_url = f"ws://{host}:{port}/{path}{qs}" - try: - import websockets - - upstream_ws = await asyncio.wait_for( - websockets.connect(ws_url, additional_headers={"host": f"127.0.0.1:{port}"}), - timeout=5.0, - ) - break - except Exception: - continue - - if upstream_ws is None: - await websocket.close(code=4002, reason=f"Cannot connect to localhost:{port}") - return - - async def client_to_upstream(): - """Relay messages from client WebSocket to upstream.""" - try: - while True: - data = await websocket.receive() - if "text" in data: - await upstream_ws.send(data["text"]) - elif "bytes" in data: - await upstream_ws.send(data["bytes"]) - else: - break - except (WebSocketDisconnect, Exception): - pass - - async def upstream_to_client(): - """Relay messages from upstream WebSocket to client.""" - try: - async for msg in upstream_ws: - if isinstance(msg, str): - await websocket.send_text(msg) - elif isinstance(msg, bytes): - await websocket.send_bytes(msg) - except Exception: - pass - - try: - done, pending = await asyncio.wait( - [asyncio.create_task(client_to_upstream()), asyncio.create_task(upstream_to_client())], - return_when=asyncio.FIRST_COMPLETED, - ) - for t in pending: - t.cancel() - finally: - try: - await upstream_ws.close() - except Exception: - pass - try: - await websocket.close() - except Exception: - pass diff --git a/cptr/utils/browser/proxy.py b/cptr/utils/browser/proxy.py new file mode 100644 index 00000000..66674117 --- /dev/null +++ b/cptr/utils/browser/proxy.py @@ -0,0 +1,239 @@ +"""In-memory sessions and response rewriting for the Browser proxy tab.""" + +from __future__ import annotations + +import asyncio +import html +import json +import re +import uuid +from dataclasses import dataclass +from html.parser import HTMLParser +from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit + +import httpx + + +_URL_ATTRIBUTES = { + "action", + "background", + "cite", + "data", + "formaction", + "href", + "poster", + "src", +} +_SKIP_SCHEMES = ("data:", "javascript:", "mailto:", "tel:", "about:", "blob:") + + +@dataclass +class BrowserSession: + session_id: str + owner: str + url: str = "" + title: str = "" + origin: str = "" + + +@dataclass +class BrowserProfile: + client: httpx.AsyncClient + + +class BrowserProxyManager: + """Owns ephemeral Browser tab sessions and per-user upstream cookie jars.""" + + def __init__(self) -> None: + self._sessions: dict[str, BrowserSession] = {} + self._profiles: dict[str, BrowserProfile] = {} + self._lock = asyncio.Lock() + + async def create(self, owner: str) -> BrowserSession: + session = BrowserSession(uuid.uuid4().hex, owner) + async with self._lock: + self._sessions[session.session_id] = session + self._profile(owner) + return session + + async def close(self, session_id: str, owner: str) -> bool: + async with self._lock: + session = self._sessions.get(session_id) + if not session or session.owner != owner: + return False + self._sessions.pop(session_id, None) + return True + + def ids(self, owner: str) -> list[str]: + return [session_id for session_id, session in self._sessions.items() if session.owner == owner] + + def session(self, session_id: str, owner: str) -> BrowserSession | None: + session = self._sessions.get(session_id) + return session if session and session.owner == owner else None + + async def update( + self, session_id: str, owner: str, *, url: str | None = None, title: str | None = None + ) -> BrowserSession | None: + async with self._lock: + session = self.session(session_id, owner) + if session is None: + return None + if url is not None: + session.url = url + parsed = urlsplit(url) + session.origin = f"{parsed.scheme}://{parsed.netloc}" if parsed.scheme and parsed.netloc else "" + if title is not None: + session.title = title + return session + + def client(self, session_id: str, owner: str) -> httpx.AsyncClient | None: + session = self.session(session_id, owner) + return self._profile(session.owner).client if session else None + + def _profile(self, owner: str) -> BrowserProfile: + profile = self._profiles.get(owner) + if profile is None: + profile = BrowserProfile( + httpx.AsyncClient( + follow_redirects=True, + timeout=httpx.Timeout(60.0, connect=10.0), + limits=httpx.Limits(max_connections=30), + ) + ) + self._profiles[owner] = profile + return profile + + async def close_all(self) -> None: + clients = [profile.client for profile in self._profiles.values()] + self._sessions.clear() + self._profiles.clear() + await asyncio.gather(*(client.aclose() for client in clients), return_exceptions=True) + + +def proxy_path(session_id: str, url: str) -> str: + """Return the same-origin URL used by the Browser iframe for an upstream URL.""" + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Only HTTP(S) URLs can be opened in Browser") + return f"/api/browser/frame/{session_id}?{urlencode({'url': url})}" + + +def target_url(url: str) -> str: + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Only HTTP(S) URLs can be opened in Browser") + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", parsed.query, "")) + + +def resource_path(session_id: str, url: str) -> str: + parsed = urlsplit(url) + path = parsed.path or "/" + return f"/api/browser/resources/{session_id}{path}" + (f"?{parsed.query}" if parsed.query else "") + + +def rewrite_url(value: str, base_url: str, session_id: str) -> str: + value = value.strip() + if not value or value.startswith("#") or value.lower().startswith(_SKIP_SCHEMES): + return value + try: + url = urljoin(base_url, value) + return proxy_path(session_id, url) + except ValueError: + return value + + +def _rewrite_srcset(value: str, base_url: str, session_id: str) -> str: + parts = [] + for item in value.split(","): + url, *descriptor = item.strip().split(maxsplit=1) + parts.append(" ".join([rewrite_url(url, base_url, session_id), *descriptor])) + return ", ".join(parts) + + +class _HtmlRewriter(HTMLParser): + def __init__(self, base_url: str, session_id: str) -> None: + super().__init__(convert_charrefs=False) + self.base_url = base_url + self.session_id = session_id + self.output: list[str] = [] + + def handle_decl(self, decl: str) -> None: + self.output.append(f"") + + def handle_comment(self, data: str) -> None: + self.output.append(f"") + + def handle_data(self, data: str) -> None: + self.output.append(data) + + def handle_entityref(self, name: str) -> None: + self.output.append(f"&{name};") + + def handle_charref(self, name: str) -> None: + self.output.append(f"&#{name};") + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self._tag(tag, attrs, close=True) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self._tag(tag, attrs, close=False) + + def _tag(self, tag: str, attrs: list[tuple[str, str | None]], close: bool) -> None: + rewritten: list[tuple[str, str | None]] = [] + for key, value in attrs: + lower = key.lower() + if lower == "integrity": + continue + if value and lower in _URL_ATTRIBUTES: + value = rewrite_url(value, self.base_url, self.session_id) + elif value and lower == "srcset": + value = _rewrite_srcset(value, self.base_url, self.session_id) + elif tag.lower() == "meta" and lower == "content": + value = re.sub( + r"(?i)(url\s*=\s*)([^;]+)", + lambda match: match.group(1) + + rewrite_url(match.group(2).strip(), self.base_url, self.session_id), + value, + ) + rewritten.append((key, value)) + attrs_text = "".join( + f" {key}" if value is None else f' {key}="{html.escape(value, quote=True)}"' + for key, value in rewritten + ) + self.output.append(f"<{tag}{attrs_text}{' /' if close else ''}>") + + def handle_endtag(self, tag: str) -> None: + self.output.append(f"") + + +def rewrite_html(content: str, base_url: str, session_id: str, final_url: str) -> str: + parser = _HtmlRewriter(base_url, session_id) + parser.feed(content) + parser.close() + resource_prefix = f"/api/browser/resources/{session_id}/" + runtime = ( + f'' + f'' + '' + ) + rendered = "".join(parser.output) + if re.search(r"]*)?>", rendered, re.I): + return re.sub(r"(]*)?>)", r"\1" + runtime, rendered, count=1, flags=re.I) + return runtime + rendered + + +def rewrite_css(content: str, base_url: str, session_id: str) -> str: + def replace_url(match: re.Match[str]) -> str: + quote, value = match.group(1), match.group(2) + return f"url({quote}{rewrite_url(value, base_url, session_id)}{quote})" + + content = re.sub(r"url\(\s*(['\"]?)([^'\")]+)\1\s*\)", replace_url, content, flags=re.I) + return re.sub( + r"(@import\s+(?:url\()?['\"])([^'\"]+)", + lambda match: match.group(1) + rewrite_url(match.group(2), base_url, session_id), + content, + flags=re.I, + ) + + +manager = BrowserProxyManager() diff --git a/cptr/utils/proxy_middleware.py b/cptr/utils/proxy_middleware.py deleted file mode 100644 index 8cc2a7d8..00000000 --- a/cptr/utils/proxy_middleware.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Proxy fallback middleware. - -Intercepts requests for sub-resources of proxied dev servers. -When a page is loaded through /api/proxy/{port}/, its sub-resources -(JS modules, CSS, images) use absolute paths like /@vite/client or -/node_modules/... which resolve against the cptr origin. This -middleware detects those requests via the Referer header and a -path-to-port cache, then proxies them to the correct local port. - -The cache chain works like this: - 1. /api/proxy/5174/ loads HTML with ' f'' - '' + '' ) rendered = "".join(parser.output) + if urlsplit(final_url).hostname in {"localhost", "127.0.0.1", "::1"}: + rendered = rewrite_javascript(rendered, final_url, session_id) if re.search(r"]*)?>", rendered, re.I): return re.sub(r"(]*)?>)", r"\1" + runtime, rendered, count=1, flags=re.I) return runtime + rendered @@ -236,4 +239,13 @@ def replace_url(match: re.Match[str]) -> str: ) +def rewrite_javascript(content: str, base_url: str, session_id: str) -> str: + """Rewrite root-relative ES module imports used by local dev servers.""" + return re.sub( + r"((?:from\s*|import\s*\(\s*|import\s*)['\"])(/[^'\"]+)", + lambda match: match.group(1) + rewrite_url(match.group(2), base_url, session_id), + content, + ) + + manager = BrowserProxyManager() From c186284484eb7eab88f68e36a7ac6c13b30605e9 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 10 Jul 2026 16:48:43 -0500 Subject: [PATCH 09/46] refac --- cptr/app.py | 2 + cptr/cli.py | 7 +- cptr/frontend/src/lib/apis/browser.ts | 23 + .../src/lib/components/Admin/Web.svelte | 31 + .../src/lib/components/BrowserPreview.svelte | 193 +++-- .../src/lib/components/ChromeBrowser.svelte | 244 ++++++ .../src/lib/components/GroupTabBar.svelte | 16 +- cptr/frontend/src/lib/i18n/locales/de.json | 6 + cptr/frontend/src/lib/i18n/locales/en.json | 6 + cptr/frontend/src/lib/i18n/locales/es.json | 6 + cptr/frontend/src/lib/i18n/locales/fr.json | 6 + cptr/frontend/src/lib/i18n/locales/ja.json | 6 + cptr/frontend/src/lib/i18n/locales/ko.json | 6 + cptr/frontend/src/lib/i18n/locales/pt-BR.json | 6 + cptr/frontend/src/lib/i18n/locales/ru.json | 6 + cptr/frontend/src/lib/i18n/locales/zh-CN.json | 6 + cptr/frontend/src/lib/i18n/locales/zh-TW.json | 6 + cptr/frontend/src/lib/stores.ts | 16 + cptr/frontend/src/routes/+page.svelte | 8 +- cptr/frontend/static/browser-encoder.html | 5 + cptr/frontend/static/browser-encoder.js | 113 +++ cptr/routers/browser.py | 92 ++- cptr/utils/browser/launcher.py | 27 +- cptr/utils/browser/proxy.py | 2 + cptr/utils/browser/viewer.py | 703 ++++++++++++++++++ 25 files changed, 1450 insertions(+), 92 deletions(-) create mode 100644 cptr/frontend/src/lib/components/ChromeBrowser.svelte create mode 100644 cptr/frontend/static/browser-encoder.html create mode 100644 cptr/frontend/static/browser-encoder.js create mode 100644 cptr/utils/browser/viewer.py diff --git a/cptr/app.py b/cptr/app.py index 86fbed55..de66f80e 100644 --- a/cptr/app.py +++ b/cptr/app.py @@ -89,7 +89,9 @@ async def shutdown(): from cptr.utils.browser.session import session_manager from cptr.utils.browser.launcher import shutdown_browser from cptr.utils.browser.proxy import manager as browser_proxy_manager + from cptr.utils.browser.viewer import manager as chrome_viewer_manager + await chrome_viewer_manager.close_all() await browser_proxy_manager.close_all() await session_manager.close_all() await shutdown_browser() diff --git a/cptr/cli.py b/cptr/cli.py index 4820d6d3..df5be2d7 100644 --- a/cptr/cli.py +++ b/cptr/cli.py @@ -20,17 +20,20 @@ def cli(): @click.option("--headless", is_flag=True, default=False, help="Don't open browser.") def run(host: str, port: int, reload: bool, headless: bool): """Start the cptr server.""" - import os, secrets + import os + import secrets display_host = "localhost" if host == "0.0.0.0" else host token = secrets.token_hex(32) os.environ["CPTR_STARTUP_TOKEN"] = token + os.environ["CPTR_PORT"] = str(port) url = f"http://{display_host}:{port}/?token={token}" print(f"\n ➜ {url}\n") if not headless: - import threading, webbrowser + import threading + import webbrowser threading.Timer(1.5, lambda: webbrowser.open(url)).start() uvicorn.run( diff --git a/cptr/frontend/src/lib/apis/browser.ts b/cptr/frontend/src/lib/apis/browser.ts index d9b513f5..2b0b9f78 100644 --- a/cptr/frontend/src/lib/apis/browser.ts +++ b/cptr/frontend/src/lib/apis/browser.ts @@ -4,8 +4,24 @@ export interface BrowserSession { session_id: string; url: string; title: string; + mode: 'proxy' | 'chrome'; + status: string; } +export interface BrowserAvailability { + proxy: { available: true }; + chrome: { + available: boolean; + browser_name: string | null; + experimental: true; + reason: string | null; + }; +} + +let availabilityPromise: Promise | undefined; +export const getBrowserAvailability = () => + (availabilityPromise ??= fetchJSON('/api/browser/availability')); + export const createBrowserSession = () => fetchJSON('/api/browser/sessions', { method: 'POST' }); @@ -27,6 +43,13 @@ export const updateBrowserSession = (sessionId: string, url: string, title: stri body: JSON.stringify({ url, title }) }); +export const setBrowserMode = (sessionId: string, mode: 'proxy' | 'chrome', url: string) => + fetchJSON(`/api/browser/sessions/${sessionId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode, url }) + }); + export const browserFrameUrl = (sessionId: string, rawUrl: string) => { const normalized = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`; const url = new URL(normalized); diff --git a/cptr/frontend/src/lib/components/Admin/Web.svelte b/cptr/frontend/src/lib/components/Admin/Web.svelte index cc84ff91..1bea6a31 100644 --- a/cptr/frontend/src/lib/components/Admin/Web.svelte +++ b/cptr/frontend/src/lib/components/Admin/Web.svelte @@ -2,6 +2,7 @@ import { toast } from 'svelte-sonner'; import { onMount } from 'svelte'; import { getAdminConfig, updateConfig } from '$lib/apis/admin'; + import { getBrowserAvailability } from '$lib/apis/browser'; import { t } from '$lib/i18n'; import ToggleSwitch from '$lib/components/common/ToggleSwitch.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; @@ -28,6 +29,9 @@ // ── Browser ─────────────────────────────────────────── let browserEnabled = $state(false); + let browserTabDefaultMode = $state<'proxy' | 'chrome'>('proxy'); + let chromeTabAvailable = $state(false); + let chromeTabReason = $state(''); let browserProvider = $state<'local' | 'firecrawl' | 'browser_use'>('local'); let cdpUrl = $state('http://localhost:9222'); let autoLaunch = $state(true); @@ -59,6 +63,7 @@ // Browser browserEnabled = config['browser.enabled'] === true || config['browser.enabled'] === 'true'; + browserTabDefaultMode = config['browser.tab_default_mode'] === 'chrome' ? 'chrome' : 'proxy'; browserProvider = (config['browser.provider'] as typeof browserProvider) || 'local'; cdpUrl = (config['browser.cdp_url'] as string) || 'http://localhost:9222'; autoLaunch = @@ -70,6 +75,10 @@ browserUseApiKey = (config['browser.browser_use_api_key'] as string) || ''; browserUseBaseUrl = (config['browser.browser_use_base_url'] as string) || 'https://api.browser-use.com'; + void getBrowserAvailability().then((availability) => { + chromeTabAvailable = availability.chrome.available; + chromeTabReason = availability.chrome.reason || ''; + }); } catch { toast.error($t('admin.failedToLoadConfig')); } @@ -94,6 +103,7 @@ 'web.chat_completions_base_url': ccBaseUrl, 'web.chat_completions_model': ccModel, 'browser.enabled': browserEnabled, + 'browser.tab_default_mode': browserTabDefaultMode, 'browser.provider': browserProvider, 'browser.cdp_url': cdpUrl, 'browser.auto_launch': autoLaunch, @@ -356,6 +366,27 @@

{$t('admin.browser')}

+
+
+ {$t('admin.browserTabDefault')} +

+ {$t('admin.browserTabDefaultHint')} +

+
+ +
+