From 4e508eb7758ceb78182df9a9cb3325fbff102c3f Mon Sep 17 00:00:00 2001 From: agentforce314 Date: Fri, 7 Aug 2026 23:31:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20clawcodex=20serve=20=E2=80=94?= =?UTF-8?q?=20the=20desktop=20gateway=20backend=20+=20boot=20rewire=20(sta?= =?UTF-8?q?ge=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python gains the serve surface the desktop shell expects, and the shell's boot ladder now resolves real clawcodex runtimes end-to-end. Server (new): - src/entrypoints/serve_cli.py: `clawcodex serve` — loopback HTTP+WS on one OS-assigned port, CLAWCODEX_BACKEND_READY stdout announce + ready-file, token via flag/env/generated, agent-server-grade permission plumbing (bypass sanitization + safety, flags-only availability on this multi-session transport). - src/server/desktop_serve.py: Starlette app — /api/health (open), /api/status + /api/config (token; config served through redact_secrets so the env block and key-bearing fields never cross REST), token-carrying /, /api/ws. - src/server/desktop_gateway*.py: JSON-RPC gateway — number/string ids echoed, {method:'event'} pushes; sessions run the in-process agent core (make_spawn_agent) with a pump translating frames to renderer events (message.delta/interim/complete with usage mapping, reasoning.delta, tool.start/complete from SDK envelopes, session.info from init); approval round-trip parks can_use_tool asks and maps approval.respond{once|session|always|deny} onto permission replies with suggestion-scoped grants; resume = fresh spawn + resume control (spawn's third arg is a permission mode, not a resume id); interrupt control; model.options/get_settings bridging; unknown methods error without dropping the socket; unsupported ask subtypes deny instead of hanging. - serve added to the CLI sieve; starlette/uvicorn promoted from transitive (via mcp) to declared deps. Desktop shell: - backend-command: no more legacy dashboard fallback (never existed here); serve support sniffed from src/cli.py's sieve. - main: probe reads src/cli.py; a runtime without serve is an actionable update-required boot error; python -m src.cli everywhere; source-root check = src/cli.py; managed venv is .venv (~/.clawcodex/clawcodex/.venv, install.sh layout); About version from pyproject.toml. Verification: 18 new pytest tests (fake-agent tier + real-spawn tier incl. a real permission round-trip where the tool executes), full suite 9,830 passed; live smoke against a real serve process (uvicorn WS handshake, session create/close, auth matrix, wrong-token rejection); ui-desktop typecheck green, lint 0 errors/88 warnings (= reference), electron vitest 78 files green. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 4 + requirements.txt | 6 + src/cli.py | 3 + src/entrypoints/serve_cli.py | 298 +++++++++++++ src/server/desktop_gateway.py | 108 +++++ src/server/desktop_gateway_methods.py | 448 ++++++++++++++++++++ src/server/desktop_gateway_translate.py | 197 +++++++++ src/server/desktop_serve.py | 174 ++++++++ tests/server/test_desktop_gateway.py | 425 +++++++++++++++++++ tests/server/test_desktop_serve.py | 139 ++++++ ui-desktop/electron/backend-command.test.ts | 52 +-- ui-desktop/electron/backend-command.ts | 43 +- ui-desktop/electron/main.ts | 78 ++-- 13 files changed, 1866 insertions(+), 109 deletions(-) create mode 100644 src/entrypoints/serve_cli.py create mode 100644 src/server/desktop_gateway.py create mode 100644 src/server/desktop_gateway_methods.py create mode 100644 src/server/desktop_gateway_translate.py create mode 100644 src/server/desktop_serve.py create mode 100644 tests/server/test_desktop_gateway.py create mode 100644 tests/server/test_desktop_serve.py diff --git a/pyproject.toml b/pyproject.toml index 4aea36efe..a18b1c5bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,10 @@ dependencies = [ # new; ``experimental`` is gone). Do that on its own branch, with the # websocket transport either ported or explicitly dropped. "mcp>=1.27.0,<2", + # Desktop gateway server (`clawcodex serve`): direct imports in + # src/server/desktop_serve.py; already transitive via mcp. + "starlette>=0.27", + "uvicorn>=0.31.1", # Image decode/resize/encode for the FileReadTool image pipeline (port # of TS imageResizer.ts which uses sharp). Pillow has pure-Python wheels # for all platforms and covers PNG/JPEG/GIF/WebP and palette quantization. diff --git a/requirements.txt b/requirements.txt index 3e043f09e..25be4ac57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,12 @@ httpx-sse>=0.4 # THIS is the file CI installs from (ci.yml -> requirements.dev.txt -> here); # pyproject.toml is packaging metadata only, so both need the cap. mcp>=1.27.0,<2 + +# Desktop gateway server (`clawcodex serve`, ui-desktop backend). Both are +# already transitive deps of `mcp`; declared directly because +# src/server/desktop_serve.py imports them directly. +starlette>=0.27 +uvicorn>=0.31.1 Pillow>=10.0 markdownify>=0.11 pydantic>=2.0 diff --git a/src/cli.py b/src/cli.py index 958d0da6b..175dfdd22 100644 --- a/src/cli.py +++ b/src/cli.py @@ -118,6 +118,9 @@ def main(): if token == 'agent-server': from src.entrypoints.agent_server_cli import run_agent_server_subcommand return run_agent_server_subcommand(rest) + if token == 'serve': + from src.entrypoints.serve_cli import run_serve_subcommand + return run_serve_subcommand(rest) if token == 'tui': return _run_tui_subcommand(rest) if token == 'migrate': diff --git a/src/entrypoints/serve_cli.py b/src/entrypoints/serve_cli.py new file mode 100644 index 000000000..8b18f7b9a --- /dev/null +++ b/src/entrypoints/serve_cli.py @@ -0,0 +1,298 @@ +"""``clawcodex serve`` — run the desktop gateway server. + +Backend for the ClawCodex Desktop app (``ui-desktop/``): one loopback HTTP +server exposing ``/api/health``, ``/api/status``, a token-bearing ``/`` page, +and the JSON-RPC WebSocket at ``/api/ws`` that carries the entire chat +surface. Sessions run in-process on the same agent core the TUI uses +(``src.server.agent_server``); this transport only adapts wire shapes. + +The desktop boot contract (``ui-desktop/electron/backend-ready.ts``): + +- spawned as ``clawcodex serve --host 127.0.0.1 --port 0`` (port 0 = OS pick), +- announces readiness by printing ``CLAWCODEX_BACKEND_READY port=`` on + stdout (and writing ``{"port": N}`` to ``$CLAWCODEX_DESKTOP_READY_FILE`` + when set), +- authenticates REST via the ``X-ClawCodex-Session-Token`` header and the + WebSocket via ``?token=``; the spawn token arrives in + ``$CLAWCODEX_DASHBOARD_SESSION_TOKEN``, +- serves ``window.__CLAWCODEX_SESSION_TOKEN__`` on ``GET /`` so the shell can + adopt the token of an already-running backend it recognizes. + +Usage:: + + clawcodex serve [--host H] [--port P] [--token T] [--workspace DIR] + [--provider NAME] [--model M] [--effort E] + [--permission-mode MODE] + [--dangerously-skip-permissions] + [--allow-dangerously-skip-permissions] +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import secrets +import sys + +# Same per-process default as the agent-server entry: experimental API betas +# off unless the user opts in. This process makes the API calls. +os.environ.setdefault("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", "true") +from pathlib import Path + +from src.utils.startup_profiler import profile_checkpoint + +profile_checkpoint("serve_import_start") + +from src.server.agent_server import ( + DEFAULT_MAX_TURNS, + AgentServerConfig, + PROTOCOL_VERSION, + make_spawn_agent, +) +from src.server.session_manager import SessionManager + +profile_checkpoint("serve_import_end") + +logger = logging.getLogger(__name__) + +READY_MARKER = "CLAWCODEX_BACKEND_READY" +TOKEN_ENV = "CLAWCODEX_DASHBOARD_SESSION_TOKEN" +READY_FILE_ENV = "CLAWCODEX_DESKTOP_READY_FILE" + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="clawcodex serve", + description="Run the desktop gateway server (ClawCodex Desktop backend).", + ) + parser.add_argument("--host", default="127.0.0.1", + help="Bind address (default: 127.0.0.1 — loopback only).") + parser.add_argument("--port", type=int, default=0, + help="Port (default: 0 — OS-assigned, announced on stdout).") + parser.add_argument("--token", default=None, + help=f"Session token; default ${TOKEN_ENV} or generated.") + parser.add_argument("--profile", default=None, + help="Profile name (accepted for desktop compatibility; " + "single-profile for now).") + parser.add_argument("--workspace", default=None, + help="Default workspace for new sessions (default: cwd).") + parser.add_argument("--provider", default=None, help="Provider name override.") + parser.add_argument("--model", default=None, help="Model override.") + parser.add_argument( + "--effort", default=None, + choices=("low", "medium", "high", "xhigh", "max"), + help="Reasoning effort seed for sessions.", + ) + parser.add_argument( + "--fallback-model", default=None, dest="fallback_model", + help="Model to switch to after repeated overloaded errors.", + ) + parser.add_argument("--permission-mode", default="default", dest="permission_mode", + help="default | acceptEdits | bypassPermissions | plan | auto") + parser.add_argument("--dangerously-skip-permissions", action="store_true", + dest="dangerously_skip_permissions", + help="Bypass all permission checks (start in bypassPermissions).") + parser.add_argument("--allow-dangerously-skip-permissions", action="store_true", + dest="allow_dangerously_skip_permissions", + help="Make bypassPermissions available without starting in it.") + parser.add_argument("--max-turns", type=int, default=DEFAULT_MAX_TURNS, + dest="max_turns") + parser.add_argument( + "--exit-on-parent", action="store_true", dest="exit_on_parent", + help="Exit when stdin reaches EOF (the desktop shell owns this child).", + ) + return parser + + +def _exit_when_stdin_closes() -> None: + """Exit when stdin EOFs — the Electron shell holds the pipe open. + + Mirrors the agent-server's parent watch: if the desktop app dies without + cleanup, the OS closes the pipe and this backend exits instead of leaking. + """ + import threading + + def _watch() -> None: + try: + sys.stdin.buffer.read() + except Exception: # noqa: BLE001 + pass + os._exit(0) + + threading.Thread(target=_watch, name="serve-parent-watch", daemon=True).start() + + +def run_serve_subcommand(argv: list[str]) -> int: + """Entry point for ``clawcodex serve`` (fast-path subcommand).""" + try: + from src.utils.legacy_migration import migrate_user_dir_once + migrate_user_dir_once() + except Exception: # noqa: BLE001 — migration is best-effort by contract + pass + + args = _build_parser().parse_args(argv) + + # Interactive task surface, same classification as the agent-server: this + # process backs an interactive client even though its stdio are pipes. + from src.bootstrap.state import set_is_interactive + + set_is_interactive(True) + + if args.exit_on_parent: + _exit_when_stdin_closes() + + if args.permission_mode == "bubble": + print("serve: --permission-mode 'bubble' is a runtime-only sub-agent " + "mode; use default | plan | acceptEdits | bypassPermissions | auto", + file=sys.stderr) + return 2 + + dangerously = bool(args.dangerously_skip_permissions) + allow_dangerously = bool(args.allow_dangerously_skip_permissions) + from src.permissions.dangerous_safety import ( + enforce_dangerous_skip_permissions_safety, + ) + + enforce_dangerous_skip_permissions_safety( + bypass_requested=dangerously or allow_dangerously, + ) + + from src.permissions.modes import is_bypass_permissions_mode_disabled + + disabled = is_bypass_permissions_mode_disabled() + if dangerously and not disabled: + args.permission_mode = "bypassPermissions" + elif dangerously and disabled: + logger.warning("Bypass permissions mode disabled by settings/policy; " + "ignoring --dangerously-skip-permissions") + if args.permission_mode == "bypassPermissions" and disabled: + logger.warning("Bypass permissions mode disabled by settings/policy; " + "ignoring --permission-mode bypassPermissions") + args.permission_mode = "default" + + # Multi-session transport: bypass availability comes from FLAGS only, + # exactly like the agent-server's --http path. The desktop launcher (the + # single local operator) resolves settings at ITS boundary and forwards + # flags; folding host settings in here would unlock bypass for every + # client of this port. Lockdown still revokes an explicit request. + is_bypass_available = (dangerously or allow_dangerously) and not disabled + + workspace = str(Path(args.workspace).resolve()) if args.workspace else str(Path.cwd()) + + if args.fallback_model and args.fallback_model == args.model: + print("serve: --fallback-model must differ from --model", file=sys.stderr) + return 2 + + token = args.token if args.token is not None else os.environ.get(TOKEN_ENV) or "" + if not token: + token = secrets.token_urlsafe(32) + + agent_config = AgentServerConfig( + provider_name=args.provider, + model=args.model, + effort=args.effort, + fallback_model=args.fallback_model, + permission_mode=args.permission_mode, + is_bypass_available=is_bypass_available, + bypass_selectable=is_bypass_available, + max_turns=args.max_turns, + ) + + try: + return asyncio.run(_serve(args, workspace, token, agent_config)) + except KeyboardInterrupt: + print("\nserve: shutting down", file=sys.stderr) + return 0 + + +def _announce_ready(port: int) -> None: + """Print the desktop's readiness marker and honor the ready-file contract. + + stdout is the desktop's primary channel (``backend-ready.ts`` parses + ``CLAWCODEX_BACKEND_READY port=``); the ready file is the fallback for + hosts where child stdout is unreliable. + """ + print(f"{READY_MARKER} port={port}", flush=True) + ready_file = os.environ.get(READY_FILE_ENV) + if not ready_file: + return + try: + path = Path(ready_file) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(json.dumps({"port": port}), encoding="utf-8") + tmp.replace(path) + except OSError: + logger.warning("serve: could not write ready file %s", ready_file, + exc_info=True) + + +async def _serve(args, workspace: str, token: str, + agent_config: AgentServerConfig) -> int: + import uvicorn + + from src.server.desktop_serve import DesktopServeState, build_app + from src.utils.clawcodex_dirs import get_user_config_dir + + index_path = Path(get_user_config_dir()) / "server-sessions.json" + index_path.parent.mkdir(parents=True, exist_ok=True) + manager = SessionManager(workspace=workspace, index_path=index_path) + spawn = make_spawn_agent(agent_config) + + state = DesktopServeState( + token=token, + workspace=workspace, + manager=manager, + spawn_agent=spawn, + protocol_version=PROTOCOL_VERSION, + ) + app = build_app(state) + + config = uvicorn.Config( + app, + host=args.host, + port=args.port, + log_level="warning", + access_log=False, + lifespan="on", + ) + server = uvicorn.Server(config) + serve_task = asyncio.create_task(server.serve()) + + # uvicorn flips ``started`` after binding; port 0 resolves to the real + # port only then. A failed bind ends serve_task instead — surface that + # rather than spinning forever. + while not server.started: + if serve_task.done(): + exc = serve_task.exception() + if exc: + print(f"serve: failed to start: {exc}", file=sys.stderr) + return 1 + await asyncio.sleep(0.01) + + bound_port = 0 + for srv in server.servers or []: + for sock in srv.sockets or []: + bound_port = sock.getsockname()[1] + break + if bound_port: + break + _announce_ready(bound_port) + logger.info("serve: listening on http://%s:%s (workspace %s)", + args.host, bound_port, workspace) + + try: + await serve_task + finally: + await state.shutdown() + return 0 + + +__all__ = ["run_serve_subcommand"] + + +if __name__ == "__main__": + raise SystemExit(run_serve_subcommand(sys.argv[1:])) diff --git a/src/server/desktop_gateway.py b/src/server/desktop_gateway.py new file mode 100644 index 000000000..91fe0c430 --- /dev/null +++ b/src/server/desktop_gateway.py @@ -0,0 +1,108 @@ +"""JSON-RPC gateway socket for the ClawCodex Desktop app. + +One WebSocket (``/api/ws``) carries the whole chat surface. Wire contract +(client: ``ui-desktop/packages/shared/src/json-rpc-gateway.ts``): + +- client → server: ``{"jsonrpc":"2.0","id":"r","method":M,"params":P}`` + (ids are opaque strings/numbers; echo them back untouched), +- server → client (reply): ``{"id":,"result":R}`` or + ``{"id":,"error":{"message":str}}``, +- server → client (push): ``{"method":"event","params":{"type":T, + "session_id":S?,"payload":P?}}`` — no ``id`` on pushes, ever (a pushed + ``id`` would be swallowed by the client's pending-call map). + +Sessions are the same in-process agents the TUI backend runs +(``make_spawn_agent``); this module only adapts wire shapes: JSON-RPC methods +→ agent inbound frames, agent outbound frames → gateway events. The +translation tables live in :mod:`src.server.desktop_gateway_translate`. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from starlette.websockets import WebSocket, WebSocketDisconnect + +from src.server.desktop_serve import DesktopServeState + +logger = logging.getLogger(__name__) + + +async def _send_json(websocket: WebSocket, obj: dict[str, Any]) -> None: + try: + await websocket.send_json(obj) + except Exception: # noqa: BLE001 — a dying socket must not kill the pump + pass + + +async def send_event( + websocket: WebSocket, + type_: str, + payload: Any = None, + session_id: str | None = None, +) -> None: + params: dict[str, Any] = {"type": type_} + if session_id is not None: + params["session_id"] = session_id + if payload is not None: + params["payload"] = payload + await _send_json(websocket, {"method": "event", "params": params}) + + +async def handle_gateway_socket(websocket: WebSocket, state: DesktopServeState) -> None: + """Accept one gateway socket and pump it until disconnect.""" + await websocket.accept() + + from src.server.desktop_gateway_methods import GatewayConnection + + conn = GatewayConnection(websocket=websocket, state=state) + await conn.on_open() + try: + while True: + try: + frame = await websocket.receive_json() + except WebSocketDisconnect: + break + except Exception: # noqa: BLE001 — non-JSON frames are ignored + continue + if not isinstance(frame, dict): + continue + await _dispatch(conn, frame) + finally: + await conn.on_close() + + +async def _dispatch(conn: Any, frame: dict[str, Any]) -> None: + method = frame.get("method") + request_id = frame.get("id") + params = frame.get("params") or {} + if not isinstance(method, str): + return + + handler = conn.method_handlers.get(method) + if handler is None: + if request_id is not None: + await _send_json( + conn.websocket, + {"id": request_id, "error": {"message": f"method not found: {method}"}}, + ) + return + + try: + result = await handler(params) + except Exception as exc: # noqa: BLE001 — one bad call must not drop the socket + logger.warning("gateway: %s failed", method, exc_info=True) + if request_id is not None: + await _send_json( + conn.websocket, + {"id": request_id, "error": {"message": str(exc) or method}}, + ) + return + + if request_id is not None: + await _send_json(conn.websocket, {"id": request_id, "result": result}) + + +__all__ = ["handle_gateway_socket", "send_event"] diff --git a/src/server/desktop_gateway_methods.py b/src/server/desktop_gateway_methods.py new file mode 100644 index 000000000..e48d24b4d --- /dev/null +++ b/src/server/desktop_gateway_methods.py @@ -0,0 +1,448 @@ +"""Gateway sessions + JSON-RPC method handlers for ``clawcodex serve``. + +The desktop renderer speaks the same RPC vocabulary the TUI app does; the +TUI's local adapter (``ui-tui/src/gatewayClient.ts``) is the reference for +how each method maps onto the agent protocol — this module is its +server-side, multi-session counterpart: + +- ``prompt.submit`` → inbound ``{"type":"user"}`` frame, acked immediately + (turn progress is event-driven), +- ``session.interrupt`` → fire-and-forget ``interrupt`` control, +- settings-ish methods → ``control_request``/``control_response`` round-trips + into the session's agent (``_control_query``), +- permission asks ← server-initiated ``can_use_tool`` control_requests, parked + per session and resolved by ``approval.respond {choice, session_id}``. + +Every live session is one in-process agent (``make_spawn_agent``) plus a pump +task that translates its outbound frames into gateway events and broadcasts +them to every connected socket. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from typing import Any + +from starlette.websockets import WebSocket + +from src.server.desktop_gateway import send_event +from src.server.desktop_gateway_translate import ( + approval_request_payload, + translate_frame, + usage_payload, +) +from src.server.desktop_serve import DesktopServeState + +logger = logging.getLogger(__name__) + +CONTROL_TIMEOUT_S = 30.0 + + +def _init_session_info(init: dict[str, Any]) -> dict[str, Any]: + """system/init frame → the ``session.info`` payload the renderer reads.""" + payload: dict[str, Any] = {"running": False} + cwd = init.get("cwd") + if cwd: + payload["cwd"] = cwd + mode = init.get("permissionMode") or init.get("permission_mode") + if mode: + payload["approval_mode"] = mode + model = init.get("model") + if model: + payload["model"] = model + session_id = init.get("session_id") + if session_id: + payload["stored_session_id"] = session_id + return payload + + +class DesktopSession: + """One live agent session, pumped to the gateway sockets.""" + + def __init__(self, session_id: str, state: DesktopServeState) -> None: + self.session_id = session_id + self.state = state + self.agent: Any = None + self.pump_task: asyncio.Task | None = None + self.init_info: dict[str, Any] = {} + self.init_seen = asyncio.Event() + # My queries INTO the agent (control_request → control_response). + self._pending_control: dict[str, asyncio.Future] = {} + # The agent's asks OF the user (can_use_tool …), keyed by request_id; + # the newest is what approval.respond resolves (the renderer parks one + # approval per session). + self._pending_asks: dict[str, dict[str, Any]] = {} + self._last_ask_id: str | None = None + self.sockets: set[WebSocket] = set() + + # ── lifecycle ──────────────────────────────────────────────────────────── + + async def start(self, cwd: str) -> None: + # spawn's third arg is a permission-mode override, NOT a resume id; + # resuming a stored session is a post-init `resume` control request. + self.agent = await self.state.spawn_agent(self.session_id, cwd, None) + self.pump_task = asyncio.create_task( + self._pump(), name=f"desktop-session-{self.session_id}" + ) + + async def shutdown(self) -> None: + if self.pump_task is not None: + self.pump_task.cancel() + if self.agent is not None: + try: + await self.agent.shutdown() + except Exception: # noqa: BLE001 + pass + for fut in self._pending_control.values(): + if not fut.done(): + fut.cancel() + + # ── broadcast ──────────────────────────────────────────────────────────── + + async def _broadcast(self, type_: str, payload: Any = None) -> None: + for ws in list(self.sockets): + await send_event(ws, type_, payload, session_id=self.session_id) + + # ── the pump: agent frames → gateway events ───────────────────────────── + + async def _pump(self) -> None: + try: + async for frame in self.agent.messages_from_agent(): + if not isinstance(frame, dict): + continue + await self._route(frame) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + logger.exception("desktop session %s pump died", self.session_id) + await self._broadcast( + "message.complete", + {"text": "", "status": "error", "error": "backend session ended unexpectedly"}, + ) + + async def _route(self, frame: dict[str, Any]) -> None: + kind = frame.get("type") + + if kind == "control_response": + body = frame.get("response") or {} + rid = body.get("request_id") + fut = self._pending_control.pop(str(rid), None) + if fut and not fut.done(): + fut.set_result(body.get("response")) + return + + if kind == "control_request": + await self._route_ask(frame) + return + + if kind == "system": + subtype = frame.get("subtype") + if subtype == "init": + self.init_info = dict(frame) + self.init_seen.set() + await self._broadcast("session.info", _init_session_info(frame)) + return + + for type_, payload in translate_frame(frame): + await self._broadcast(type_, payload) + if kind == "result": + # The renderer clears busy from message.complete; refresh the info + # line too (permission mode may have flipped server-side). + mode = frame.get("permission_mode") + if mode: + await self._broadcast("session.info", {"approval_mode": mode, "running": False}) + + async def _route_ask(self, frame: dict[str, Any]) -> None: + rid = str(frame.get("request_id") or "") + request = frame.get("request") or {} + subtype = request.get("subtype") + if not rid: + return + self._pending_asks[rid] = request + self._last_ask_id = rid + + if subtype == "can_use_tool": + await self._broadcast("approval.request", approval_request_payload(request)) + return + # Unknown ask: deny rather than hang the agent behind an invisible + # prompt (the desktop has no surface for it yet). + logger.warning("desktop session %s: unsupported ask %s — denying", self.session_id, subtype) + self._pending_asks.pop(rid, None) + self._last_ask_id = None + await self._reply_ask(rid, {"behavior": "deny", "message": f"unsupported prompt {subtype}"}) + + async def _reply_ask(self, rid: str, response: Any) -> None: + await self.agent.send_to_agent( + {"type": "control_response", "response": {"request_id": rid, "response": response}} + ) + + # ── client-facing operations ──────────────────────────────────────────── + + async def submit_prompt(self, text: str) -> None: + await self._broadcast("message.start", {}) + await self.agent.send_to_agent( + {"type": "user", "message": {"role": "user", "content": text}} + ) + + async def interrupt(self) -> None: + await self.agent.send_to_agent( + { + "type": "control_request", + "request_id": f"srv-{uuid.uuid4().hex[:12]}", + "request": {"subtype": "interrupt"}, + } + ) + + async def control_query(self, subtype: str, params: dict[str, Any], + timeout: float = CONTROL_TIMEOUT_S) -> Any: + rid = f"srv-{uuid.uuid4().hex[:12]}" + fut: asyncio.Future = asyncio.get_running_loop().create_future() + self._pending_control[rid] = fut + await self.agent.send_to_agent( + {"type": "control_request", "request_id": rid, + "request": {"subtype": subtype, **params}} + ) + try: + return await asyncio.wait_for(fut, timeout) + except asyncio.TimeoutError: + self._pending_control.pop(rid, None) + return None + + async def respond_approval(self, choice: str) -> dict[str, Any]: + rid = self._last_ask_id + request = self._pending_asks.pop(rid, None) if rid else None + self._last_ask_id = None + if not rid or request is None: + return {"resolved": False} + + if choice == "deny": + await self._reply_ask(rid, {"behavior": "deny", "message": "Denied by user"}) + return {"resolved": True} + + reply: dict[str, Any] = { + "behavior": "allow", + "updatedInput": request.get("input") or {}, + } + if choice in ("session", "always"): + wanted = "session" if choice == "session" else "always" + chosen = [ + s for s in (request.get("suggestions") or []) + if isinstance(s, dict) and _suggestion_scope(s) == wanted + ] + if chosen: + reply["chosen_updates"] = chosen + await self._reply_ask(rid, reply) + return {"resolved": True} + + +def _suggestion_scope(suggestion: dict[str, Any]) -> str: + """Bucket a permission suggestion as a session or always/persistent grant.""" + destination = str(suggestion.get("destination") or "").lower() + if destination == "session": + return "session" + return "always" + + +class GatewayConnection: + """One accepted gateway socket: method table + session subscription.""" + + def __init__(self, websocket: WebSocket, state: DesktopServeState) -> None: + self.websocket = websocket + self.state = state + self.method_handlers = { + "session.create": self.session_create, + "session.resume": self.session_resume, + "session.activate": self.session_activate, + "session.close": self.session_close, + "session.active_list": self.session_active_list, + "session.list": self.session_active_list, + "session.interrupt": self.session_interrupt, + "session.clear": self.session_clear, + "prompt.submit": self.prompt_submit, + "approval.respond": self.approval_respond, + "permission.cycle": self.permission_cycle, + "model.options": self.model_options, + "config.get": self.config_get, + "commands.catalog": self.commands_catalog, + "complete.slash": self.complete_empty, + "complete.path": self.complete_empty, + "setup.status": self.setup_status, + } + + async def on_open(self) -> None: + for session in self.state.sessions.values(): + session.sockets.add(self.websocket) + await send_event(self.websocket, "gateway.ready", {"app": "clawcodex"}) + + async def on_close(self) -> None: + for session in self.state.sessions.values(): + session.sockets.discard(self.websocket) + + # ── helpers ────────────────────────────────────────────────────────────── + + def _session(self, params: dict[str, Any]) -> DesktopSession: + session_id = str(params.get("session_id") or "") + session = self.state.sessions.get(session_id) + if session is None: + raise ValueError(f"unknown session: {session_id or ''}") + return session + + async def _create(self, cwd: str | None, resume: str | None) -> DesktopSession: + manager = self.state.manager + workspace = cwd or self.state.workspace + if resume and resume in self.state.sessions: + return self.state.sessions[resume] + # A resumed stored session still gets a fresh runtime session: spawn, + # then load the stored conversation via the `resume` control below. + info = manager.create_session(cwd=workspace) + session_id = info.id + session = DesktopSession(session_id, self.state) + session.sockets.add(self.websocket) + self.state.sessions[session_id] = session + try: + await session.start(workspace) + except Exception: + self.state.sessions.pop(session_id, None) + raise + try: + manager.mark_running(session_id) + except Exception: # noqa: BLE001 — index upkeep is best-effort + pass + # The composer unlocks once the agent announced itself. + try: + await asyncio.wait_for(session.init_seen.wait(), CONTROL_TIMEOUT_S) + except asyncio.TimeoutError: + logger.warning("session %s: no system/init within %ss", session_id, CONTROL_TIMEOUT_S) + if resume: + reply = await session.control_query("resume", {"session_id": resume}) + if not isinstance(reply, dict) or reply.get("ok") is False: + logger.warning("session %s: resume of %s refused: %r", + session_id, resume, reply) + return session + + # ── methods ────────────────────────────────────────────────────────────── + + async def session_create(self, params: dict[str, Any]) -> dict[str, Any]: + session = await self._create(params.get("cwd"), None) + return { + "session_id": session.session_id, + "stored_session_id": session.session_id, + "info": _init_session_info(session.init_info), + } + + async def session_resume(self, params: dict[str, Any]) -> dict[str, Any]: + wanted = str(params.get("session_id") or "") or None + session = await self._create(params.get("cwd"), wanted) + return { + "session_id": session.session_id, + "stored_session_id": wanted or session.session_id, + "resumed": wanted or session.session_id, + # Transcript hydration ships with the sessions REST stage; the + # agent's context IS restored (resume control), history paints + # lazily once /api/sessions lands. + "message_count": 0, + "messages": [], + "messages_omitted": True, + "info": _init_session_info(session.init_info), + } + + async def session_activate(self, params: dict[str, Any]) -> dict[str, Any]: + if params.get("session_id"): + return await self.session_resume(params) + return await self.session_create(params) + + async def session_close(self, params: dict[str, Any]) -> dict[str, Any]: + session_id = str(params.get("session_id") or "") + session = self.state.sessions.pop(session_id, None) + if session is not None: + await session.shutdown() + try: + await self.state.manager.stop_session(session_id) + except Exception: # noqa: BLE001 — index upkeep is best-effort + pass + return {"ok": True} + + async def session_active_list(self, _: dict[str, Any]) -> dict[str, Any]: + sessions = [] + for session in self.state.sessions.values(): + info = _init_session_info(session.init_info) + sessions.append({"session_id": session.session_id, **info}) + return {"sessions": sessions} + + async def session_interrupt(self, params: dict[str, Any]) -> dict[str, Any]: + await self._session(params).interrupt() + return {"ok": True} + + async def session_clear(self, params: dict[str, Any]) -> dict[str, Any]: + result = await self._session(params).control_query("clear", {}) + return {"ok": (result or {}).get("ok", True) is not False} + + async def prompt_submit(self, params: dict[str, Any]) -> dict[str, Any]: + session = self._session(params) + text = str(params.get("text") or "") + await session.submit_prompt(text) + return {"ok": True} + + async def approval_respond(self, params: dict[str, Any]) -> dict[str, Any]: + return await self._session(params).respond_approval(str(params.get("choice") or "deny")) + + async def permission_cycle(self, params: dict[str, Any]) -> dict[str, Any]: + result = await self._session(params).control_query("cycle_permission_mode", {}) + return result or {} + + async def model_options(self, params: dict[str, Any]) -> dict[str, Any]: + session = self._first_session(params) + if session is None: + return {"providers": []} + result = await session.control_query("list_model_providers", {}) + if isinstance(result, dict) and result.get("providers"): + return { + "model": result.get("fusion") or result.get("model"), + "provider": result.get("provider"), + "providers": result.get("providers"), + } + settings = await session.control_query("get_settings", {}) or {} + models = settings.get("available_models") or [] + provider = str(settings.get("provider") or "clawcodex") + return { + "model": settings.get("fusion") or settings.get("model"), + "provider": provider, + "providers": [ + { + "authenticated": True, + "is_current": True, + "models": models, + "name": provider, + "slug": provider, + "total_models": len(models), + } + ], + } + + def _first_session(self, params: dict[str, Any]) -> DesktopSession | None: + session_id = str(params.get("session_id") or "") + if session_id and session_id in self.state.sessions: + return self.state.sessions[session_id] + for session in self.state.sessions.values(): + return session + return None + + async def config_get(self, params: dict[str, Any]) -> dict[str, Any]: + session = self._first_session(params) + if session is None: + return {} + return await session.control_query("get_settings", {}) or {} + + async def commands_catalog(self, _: dict[str, Any]) -> dict[str, Any]: + return {"commands": []} + + async def complete_empty(self, _: dict[str, Any]) -> dict[str, Any]: + return {"items": []} + + async def setup_status(self, _: dict[str, Any]) -> dict[str, Any]: + return {"provider_configured": True} + + +__all__ = ["DesktopSession", "GatewayConnection"] diff --git a/src/server/desktop_gateway_translate.py b/src/server/desktop_gateway_translate.py new file mode 100644 index 000000000..d1d5b51ef --- /dev/null +++ b/src/server/desktop_gateway_translate.py @@ -0,0 +1,197 @@ +"""Frame translation: agent-server protocol → desktop gateway events. + +Left side: the NDJSON frames the in-process agent emits +(``src/server/agent_server.py`` — ``stream_event``/SDK envelopes/``result``/ +``system``/server-initiated ``control_request``). +Right side: the event vocabulary the desktop renderer consumes +(``ui-desktop/src/app/session/hooks/use-message-stream/gateway-event.ts``). + +Each translator returns a list of ``(event_type, payload)`` tuples to push +(session_id is added by the caller). Kept pure for unit testing. +""" + +from __future__ import annotations + +from typing import Any + +# ── usage mapping ──────────────────────────────────────────────────────────── +# Backend usage counters are token-based ({input_tokens, output_tokens, …}); +# the renderer merges {calls, input, output, total} into its session stats. + + +def usage_payload(usage: dict[str, Any] | None) -> dict[str, int] | None: + if not isinstance(usage, dict): + return None + input_tokens = int(usage.get("input_tokens") or 0) + output_tokens = int(usage.get("output_tokens") or 0) + return { + "calls": 1, + "input": input_tokens, + "output": output_tokens, + "total": input_tokens + output_tokens, + } + + +# ── content-block helpers ──────────────────────────────────────────────────── + + +def _iter_blocks(content: Any): + if isinstance(content, list): + for block in content: + if isinstance(block, dict): + yield block + + +def _text_of(content: Any) -> str: + if isinstance(content, str): + return content + parts = [b.get("text", "") for b in _iter_blocks(content) if b.get("type") == "text"] + return "".join(p for p in parts if isinstance(p, str)) + + +def _tool_output_text(content: Any) -> str: + """Best-effort plain text of a tool_result block's content.""" + if isinstance(content, str): + return content + return _text_of(content) + + +# ── frame translators ──────────────────────────────────────────────────────── + + +def translate_stream_event(frame: dict[str, Any]) -> list[tuple[str, Any]]: + event = frame.get("event") or {} + if event.get("type") != "content_block_delta": + return [] + delta = event.get("delta") or {} + kind = delta.get("type") + if kind == "text_delta": + text = delta.get("text") + return [("message.delta", {"text": text})] if text else [] + if kind == "thinking_delta": + thinking = delta.get("thinking") + return [("reasoning.delta", {"text": thinking})] if thinking else [] + return [] + + +def translate_sdk_envelope(frame: dict[str, Any]) -> list[tuple[str, Any]]: + """Tool lifecycle out of SDK message envelopes. + + An assistant envelope's ``tool_use`` blocks start tools; a user envelope's + ``tool_result`` blocks complete them. Streaming text itself already arrived + via ``stream_event`` deltas, so text blocks are NOT re-emitted here (the + renderer would double-print). + """ + message = frame.get("message") or {} + content = message.get("content") + events: list[tuple[str, Any]] = [] + if frame.get("type") == "assistant": + for block in _iter_blocks(content): + if block.get("type") == "tool_use": + events.append( + ( + "tool.start", + { + "tool_id": block.get("id") or "", + "name": block.get("name") or "", + "args": block.get("input") or {}, + }, + ) + ) + elif frame.get("type") == "user": + for block in _iter_blocks(content): + if block.get("type") == "tool_result": + events.append( + ( + "tool.complete", + { + "tool_id": block.get("tool_use_id") or "", + # The renderer keys rows by tool_id and falls back + # to name; the id is authoritative here. + "name": "", + "output": _tool_output_text(block.get("content")), + "is_error": bool(block.get("is_error")), + }, + ) + ) + return events + + +def translate_result(frame: dict[str, Any]) -> list[tuple[str, Any]]: + is_error = bool(frame.get("is_error")) + payload: dict[str, Any] = { + "text": frame.get("result") or "", + "status": "error" if is_error else "ok", + } + if is_error: + error = frame.get("error") or frame.get("result") or "agent error" + payload["error"] = error + # The streamed text (if any) is real output worth keeping. + payload["partial"] = bool(frame.get("result")) + usage = usage_payload(frame.get("usage")) + if usage: + payload["usage"] = usage + return [("message.complete", payload)] + + +def approval_request_payload(request: dict[str, Any]) -> dict[str, Any]: + """``can_use_tool`` control_request → ``approval.request`` payload. + + The renderer shows ``command`` (monospace) when present and + ``description`` otherwise; a Bash-like tool's command string is the most + useful thing to surface. + """ + tool_name = request.get("tool_name") or "" + tool_input = request.get("input") or {} + command = "" + if isinstance(tool_input, dict): + raw = tool_input.get("command") + if isinstance(raw, str): + command = raw + description = request.get("session_label") or f"Use {tool_name}" if tool_name else "Tool approval" + payload: dict[str, Any] = { + "command": command, + "description": description, + "tool_name": tool_name, + "input": tool_input, + } + warning = request.get("warning") + if warning: + payload["warning"] = warning + suggestions = request.get("suggestions") + if suggestions: + payload["suggestions"] = suggestions + return payload + + +def translate_frame(frame: dict[str, Any]) -> list[tuple[str, Any]]: + """Translate one agent frame into desktop gateway events. + + Server-initiated ``control_request`` frames (permissions) are NOT handled + here — they need connection state (pending map) and are handled by the + session pump directly. + """ + kind = frame.get("type") + if kind == "stream_event": + return translate_stream_event(frame) + if kind in ("assistant", "user"): + return translate_sdk_envelope(frame) + if kind == "result": + return translate_result(frame) + if kind == "text": + # Final rendered text also arrives via `result`; interim standalone + # text frames seal an interim bubble so the final complete doesn't + # wipe streamed commentary. + text = frame.get("text") or "" + return [("message.interim", {"text": text})] if text else [] + return [] + + +__all__ = [ + "approval_request_payload", + "translate_frame", + "translate_result", + "translate_sdk_envelope", + "translate_stream_event", + "usage_payload", +] diff --git a/src/server/desktop_serve.py b/src/server/desktop_serve.py new file mode 100644 index 000000000..6c109b690 --- /dev/null +++ b/src/server/desktop_serve.py @@ -0,0 +1,174 @@ +"""Starlette app for ``clawcodex serve`` — the ClawCodex Desktop backend. + +Route surface (all consumed by ``ui-desktop``): + +- ``GET /api/health`` — unauthenticated liveness probe + (``electron/backend-health.ts`` polls it before showing the shell). +- ``GET /api/status`` — token-gated backend facts + (``electron/connection-config.ts`` reads ``auth_required`` to pick the + auth mode; local mode is token auth, never OAuth). +- ``GET /`` — one inline page carrying ``window.__CLAWCODEX_SESSION_TOKEN__`` + so ``electron/dashboard-token.ts`` can adopt an already-running backend's + token when it recognizes the process as ours. +- ``WS /api/ws`` — the JSON-RPC gateway socket (chat surface); handled by + :mod:`src.server.desktop_gateway`. + +Auth: REST accepts the ``X-ClawCodex-Session-Token`` header or a Bearer +token; the WebSocket accepts ``?token=``. One constant-time comparison, +loopback binding, no cookies — this is the local token mode of the desktop's +connection config. +""" + +from __future__ import annotations + +import hmac +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, Response +from starlette.routing import Route, WebSocketRoute +from starlette.websockets import WebSocket + + +@dataclass +class DesktopServeState: + """Process-wide state shared by the routes and the gateway sockets.""" + + token: str + workspace: str + manager: Any + spawn_agent: Callable[..., Awaitable[Any]] + protocol_version: str + # session_id -> live DesktopSession (created lazily by the gateway). + sessions: dict[str, Any] = field(default_factory=dict) + + async def shutdown(self) -> None: + """Best-effort shutdown of every live agent session.""" + for session in list(self.sessions.values()): + try: + await session.shutdown() + except Exception: # noqa: BLE001 — teardown must not raise + pass + self.sessions.clear() + + +def _token_ok(state: DesktopServeState, presented: str | None) -> bool: + if not presented: + return False + return hmac.compare_digest(state.token, presented) + + +def _rest_token(request: Request) -> str | None: + header = request.headers.get("x-clawcodex-session-token") + if header: + return header + auth = request.headers.get("authorization") or "" + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return request.query_params.get("token") + + +def ws_token(websocket: WebSocket) -> str | None: + """Token presented on a gateway socket (query param, header fallback).""" + return ( + websocket.query_params.get("token") + or websocket.headers.get("x-clawcodex-session-token") + ) + + +def build_app(state: DesktopServeState) -> Starlette: + async def health(_: Request) -> Response: + return JSONResponse({"status": "ok"}) + + async def status(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return JSONResponse( + { + "status": "ok", + "auth_required": False, + "protocol_version": state.protocol_version, + "workspace": state.workspace, + "app": "clawcodex", + } + ) + + async def config(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + from src.config import load_config + + return JSONResponse(redact_secrets(load_config())) + + async def index(_: Request) -> Response: + # dashboard-token.ts scrapes this global from the served page to adopt + # a running backend's token. Serve nothing else here — the desktop + # renderer ships in the app, not from this server. + html = ( + "" + "ClawCodex" + "" + "ClawCodex backend" + ) + return HTMLResponse(html) + + async def gateway_ws(websocket: WebSocket) -> None: + if not _token_ok(state, ws_token(websocket)): + # Starlette closes with 403 by default on close before accept; + # accept-then-close(4401) would leak a frame — just reject. + await websocket.close(code=4401) + return + # Lazy: the gateway pulls in the agent stack; unauthorized probes + # (and the REST-only tests) never pay for it. + from src.server.desktop_gateway import handle_gateway_socket + + await handle_gateway_socket(websocket, state) + + routes = [ + Route("/api/health", health), + Route("/api/status", status), + Route("/api/config", config), + Route("/", index), + WebSocketRoute("/api/ws", gateway_ws), + ] + return Starlette(routes=routes) + + +_SECRET_KEY_MARKERS = ("api_key", "apikey", "token", "secret", "password") + + +def redact_secrets(value: Any) -> Any: + """Deep-copy ``value`` with secret-bearing entries removed. + + The merged config is the single config file — it carries the ``env`` + block (user API keys) and provider ``api_key`` fields. The desktop only + needs the behavioral sections (display/agent/terminal/…); secrets never + cross this REST surface (env management gets its own guarded routes + later, mirroring the reference's reveal flow). + """ + if isinstance(value, dict): + clean: dict[str, Any] = {} + for key, item in value.items(): + lowered = str(key).lower() + if lowered == "env": + continue + if any(marker in lowered for marker in _SECRET_KEY_MARKERS): + continue + clean[key] = redact_secrets(item) + return clean + if isinstance(value, list): + return [redact_secrets(item) for item in value] + return value + + +def _js_string(value: str) -> str: + """Serialize ``value`` as a safe JS string literal for the inline page.""" + import json + + return json.dumps(value) + + +__all__ = ["DesktopServeState", "build_app", "ws_token"] diff --git a/tests/server/test_desktop_gateway.py b/tests/server/test_desktop_gateway.py new file mode 100644 index 000000000..24103da61 --- /dev/null +++ b/tests/server/test_desktop_gateway.py @@ -0,0 +1,425 @@ +"""Tests for the desktop gateway (``/api/ws`` JSON-RPC surface of serve). + +Two tiers: + +- **Fake-agent tier** — a scripted agent handle exercises the JSON-RPC pump, + frame translation, and the approval round-trip deterministically. +- **Real-spawn tier** — ``make_spawn_agent`` with the provider/tool stack + stubbed (same patch set as ``test_agent_server_e2e``) drives a real turn + end-to-end through the Starlette app: create → submit → streamed events → + ``message.complete``; and the permission control-plane: ``can_use_tool`` → + ``approval.request`` event → ``approval.respond`` → tool runs. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from starlette.testclient import TestClient + +from src.providers.base import ChatResponse +from src.server.agent_server import AgentServerConfig, make_spawn_agent +from src.server.desktop_serve import DesktopServeState, build_app +from src.server.session_manager import SessionManager + +pytestmark = pytest.mark.integration + +TOKEN = "gw-test-token" + + +# ─── helpers ───────────────────────────────────────────────────────────────── + + +def _connect(client: TestClient): + return client.websocket_connect(f"/api/ws?token={TOKEN}") + + +def _drain_for_response(ws, request_id, collected_events, limit=200): + """Read frames until the reply for ``request_id`` arrives.""" + for _ in range(limit): + frame = ws.receive_json() + if frame.get("id") == request_id: + return frame + if frame.get("method") == "event": + collected_events.append(frame["params"]) + raise AssertionError(f"no response for {request_id} within {limit} frames") + + +def _drain_for_event(ws, type_, collected_events, limit=200): + for _ in range(limit): + frame = ws.receive_json() + if frame.get("method") == "event": + collected_events.append(frame["params"]) + if frame["params"].get("type") == type_: + return frame["params"] + raise AssertionError(f"no {type_} event within {limit} frames") + + +def _rpc(ws, rid, method, params): + ws.send_text(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})) + + +# ─── fake-agent tier ───────────────────────────────────────────────────────── + + +class FakeAgent: + """Scripted agent handle: records inbound frames, emits queued outbound.""" + + def __init__(self) -> None: + self.inbound: list[dict] = [] + self.queue: asyncio.Queue = asyncio.Queue() + self.shutdown_called = False + + async def send_to_agent(self, frame: dict) -> None: + self.inbound.append(frame) + if frame.get("type") == "user": + # One scripted streamed turn per user message. + await self.queue.put( + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "hel"}, + }, + } + ) + await self.queue.put( + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "lo"}, + }, + } + ) + await self.queue.put( + { + "type": "result", + "subtype": "success", + "num_turns": 1, + "result": "hello", + "is_error": False, + "usage": {"input_tokens": 3, "output_tokens": 2}, + } + ) + + async def messages_from_agent(self): + yield { + "type": "system", + "subtype": "init", + "cwd": "/tmp/w", + "permissionMode": "default", + "model": "fake", + } + while True: + yield await self.queue.get() + + async def shutdown(self) -> None: + self.shutdown_called = True + + +class FakeManager: + def __init__(self) -> None: + self.created: list[str] = [] + self._n = 0 + + def create_session(self, cwd: str): + self._n += 1 + session_id = f"fake-{self._n}" + self.created.append(session_id) + return SimpleNamespace(id=session_id, cwd=cwd) + + def mark_running(self, session_id: str) -> None: + pass + + +def _fake_state(tmp_path: Path) -> tuple[DesktopServeState, list[FakeAgent]]: + agents: list[FakeAgent] = [] + + async def spawn(session_id, cwd, resume): + agent = FakeAgent() + agents.append(agent) + return agent + + state = DesktopServeState( + token=TOKEN, + workspace=str(tmp_path), + manager=FakeManager(), + spawn_agent=spawn, + protocol_version="0.1.0", + ) + return state, agents + + +def test_gateway_ready_is_first_event(tmp_path: Path) -> None: + state, _ = _fake_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + frame = ws.receive_json() + assert frame["method"] == "event" + assert frame["params"]["type"] == "gateway.ready" + + +def test_create_submit_stream_complete(tmp_path: Path) -> None: + state, agents = _fake_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() # gateway.ready + + events: list[dict] = [] + _rpc(ws, 1, "session.create", {}) + created = _drain_for_response(ws, 1, events) + session_id = created["result"]["session_id"] + assert session_id == "fake-1" + assert created["result"]["info"]["approval_mode"] == "default" + + _rpc(ws, 2, "prompt.submit", {"session_id": session_id, "text": "hi"}) + _drain_for_response(ws, 2, events) + complete = _drain_for_event(ws, "message.complete", events) + + assert agents[0].inbound[-1] == { + "type": "user", + "message": {"role": "user", "content": "hi"}, + } + types = [e["type"] for e in events] + ["message.complete"] + assert "message.start" in types + deltas = [e for e in events if e["type"] == "message.delta"] + assert "".join(d["payload"]["text"] for d in deltas) == "hello" + assert complete["payload"]["text"] == "hello" + assert complete["payload"]["status"] == "ok" + assert complete["payload"]["usage"] == { + "calls": 1, "input": 3, "output": 2, "total": 5, + } + assert complete["session_id"] == session_id + + +def test_interrupt_sends_control(tmp_path: Path) -> None: + state, agents = _fake_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() + events: list[dict] = [] + _rpc(ws, 1, "session.create", {}) + session_id = _drain_for_response(ws, 1, events)["result"]["session_id"] + _rpc(ws, 2, "session.interrupt", {"session_id": session_id}) + _drain_for_response(ws, 2, events) + + control = [f for f in agents[0].inbound if f.get("type") == "control_request"] + assert control and control[-1]["request"]["subtype"] == "interrupt" + + +def test_unknown_method_errors_without_dropping_socket(tmp_path: Path) -> None: + state, _ = _fake_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() + _rpc(ws, 7, "voice.start", {}) + frame = ws.receive_json() + assert frame["id"] == 7 + assert "method not found" in frame["error"]["message"] + # Socket still serves after the error. + _rpc(ws, 8, "setup.status", {}) + assert _drain_for_response(ws, 8, [])["result"] == {"provider_configured": True} + + +def test_approval_roundtrip_fake(tmp_path: Path) -> None: + state, agents = _fake_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() + events: list[dict] = [] + _rpc(ws, 1, "session.create", {}) + session_id = _drain_for_response(ws, 1, events)["result"]["session_id"] + agent = agents[0] + + # Agent asks for permission. + agent.queue.put_nowait( + { + "type": "control_request", + "request_id": "ask-1", + "request": { + "subtype": "can_use_tool", + "tool_name": "Bash", + "input": {"command": "rm -rf /tmp/x"}, + "suggestions": [{"destination": "session", "rule": "Bash(rm:*)"}], + "session_label": "allow rm during this session", + }, + } + ) + ask = _drain_for_event(ws, "approval.request", events) + assert ask["payload"]["command"] == "rm -rf /tmp/x" + assert ask["session_id"] == session_id + + _rpc(ws, 2, "approval.respond", {"session_id": session_id, "choice": "once"}) + assert _drain_for_response(ws, 2, events)["result"] == {"resolved": True} + + def reply_frames(): + return [f for f in agent.inbound if f.get("type") == "control_response"] + + for _ in range(100): + if reply_frames(): + break + reply = reply_frames()[-1]["response"] + assert reply["request_id"] == "ask-1" + assert reply["response"]["behavior"] == "allow" + assert reply["response"]["updatedInput"] == {"command": "rm -rf /tmp/x"} + + +# ─── real-spawn tier (provider/tool stack stubbed, agent real) ─────────────── + + +class _TextProvider: + def __init__(self, api_key=None, base_url=None, model=None): + self.model = model or "fake" + + def chat(self, messages, tools=None, **kw): + return ChatResponse( + content="hi back", + model=self.model, + usage={"input_tokens": 3, "output_tokens": 2}, + finish_reason="stop", + tool_uses=None, + ) + + def chat_stream_response(self, *a, **kw): + raise NotImplementedError + + +def _patches(provider_cls, registry): + return [ + patch("src.config.get_default_provider", lambda: "anthropic"), + patch( + "src.config.get_provider_config", + lambda n: {"api_key": "x", "default_model": "fake", "base_url": None}, + ), + patch("src.providers.get_provider_class", lambda n: provider_cls), + patch("src.providers.provider_requires_api_key", lambda n: False), + patch("src.providers.resolve_api_key", lambda n, c: "x"), + patch( + "src.tool_system.defaults.build_default_registry", + lambda provider=None: registry, + ), + patch( + "src.query.agent_loop_compat.build_effective_system_prompt", + lambda *a, **k: "You are a test assistant.", + ), + patch( + "src.outputStyles.resolve_output_style", + lambda *a, **k: SimpleNamespace(prompt=""), + ), + ] + + +def _real_state(tmp_path: Path) -> DesktopServeState: + manager = SessionManager(workspace=str(tmp_path), index_path=tmp_path / "idx.json") + spawn = make_spawn_agent(AgentServerConfig(single_session=False)) + return DesktopServeState( + token=TOKEN, + workspace=str(tmp_path), + manager=manager, + spawn_agent=spawn, + protocol_version="0.1.0", + ) + + +def test_real_agent_turn_streams_to_gateway(tmp_path: Path) -> None: + from src.tool_system.registry import ToolRegistry + + with contextlib.ExitStack() as stack: + for p in _patches(_TextProvider, ToolRegistry([])): + stack.enter_context(p) + + state = _real_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() # gateway.ready + events: list[dict] = [] + _rpc(ws, 1, "session.create", {"cwd": str(tmp_path)}) + created = _drain_for_response(ws, 1, events) + session_id = created["result"]["session_id"] + assert session_id + + _rpc(ws, 2, "prompt.submit", {"session_id": session_id, "text": "hello?"}) + _drain_for_response(ws, 2, events) + complete = _drain_for_event(ws, "message.complete", events) + + assert complete["payload"]["status"] == "ok" + assert "hi back" in complete["payload"]["text"] + types = [e["type"] for e in events] + assert "message.start" in types + + +class _ToolThenTextProvider: + """Turn 1: call the ask-tool. Turn 2: final text.""" + + def __init__(self, api_key=None, base_url=None, model=None): + self.model = model or "fake" + self._turn = 0 + + def chat(self, messages, tools=None, **kw): + self._turn += 1 + if self._turn == 1: + return ChatResponse( + content="running the tool", + model=self.model, + usage={"input_tokens": 4, "output_tokens": 3}, + finish_reason="tool_use", + tool_uses=[{"id": "t1", "name": "DoThing", "input": {"x": "1"}}], + ) + return ChatResponse( + content="all done", + model=self.model, + usage={"input_tokens": 6, "output_tokens": 4}, + finish_reason="stop", + tool_uses=None, + ) + + def chat_stream_response(self, *a, **kw): + raise NotImplementedError + + +def test_real_agent_permission_roundtrip_runs_tool(tmp_path: Path) -> None: + """can_use_tool → approval.request event → approval.respond 'once' → the + tool actually runs and the turn finishes with tool.start/complete events.""" + from src.permissions.types import PermissionPassthroughResult + from src.tool_system.build_tool import build_tool + from src.tool_system.protocol import ToolResult + from src.tool_system.registry import ToolRegistry + + ran: list = [] + ask_tool = build_tool( + name="DoThing", + description="does a thing (asks first)", + input_schema={"type": "object", "properties": {"x": {"type": "string"}}}, + call=lambda ti, c: ran.append(dict(ti)) or ToolResult(name="DoThing", output={"ok": True}), + check_permissions=lambda ti, c: PermissionPassthroughResult(), + ) + + with contextlib.ExitStack() as stack: + for p in _patches(_ToolThenTextProvider, ToolRegistry([ask_tool])): + stack.enter_context(p) + + state = _real_state(tmp_path) + with TestClient(build_app(state)) as client, _connect(client) as ws: + ws.receive_json() # gateway.ready + events: list[dict] = [] + _rpc(ws, 1, "session.create", {"cwd": str(tmp_path)}) + session_id = _drain_for_response(ws, 1, events)["result"]["session_id"] + + _rpc(ws, 2, "prompt.submit", {"session_id": session_id, "text": "go"}) + _drain_for_response(ws, 2, events) + + ask = _drain_for_event(ws, "approval.request", events) + assert ask["payload"]["tool_name"] == "DoThing" + + _rpc(ws, 3, "approval.respond", {"session_id": session_id, "choice": "once"}) + assert _drain_for_response(ws, 3, events)["result"] == {"resolved": True} + + complete = _drain_for_event(ws, "message.complete", events) + assert complete["payload"]["status"] == "ok" + assert "all done" in complete["payload"]["text"] + assert ran == [{"x": "1"}], "tool must run exactly once after approval" + + types = [e["type"] for e in events] + assert "tool.start" in types + assert "tool.complete" in types diff --git a/tests/server/test_desktop_serve.py b/tests/server/test_desktop_serve.py new file mode 100644 index 000000000..8a76df264 --- /dev/null +++ b/tests/server/test_desktop_serve.py @@ -0,0 +1,139 @@ +"""Tests for the desktop gateway server surface (``clawcodex serve``). + +Covers the boot contract the Electron shell (``ui-desktop``) depends on: +ready-marker announce + ready file, REST auth (header/bearer), the +token-carrying index page, and WebSocket token rejection. The JSON-RPC +gateway protocol itself is covered in ``test_desktop_gateway.py``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +from src.server.desktop_serve import DesktopServeState, build_app + + +TOKEN = "test-token-123" + + +def _state(tmp_path: Path) -> DesktopServeState: + async def _spawn(*a, **kw): # pragma: no cover - not reached in these tests + raise AssertionError("spawn_agent must not run for REST surface tests") + + return DesktopServeState( + token=TOKEN, + workspace=str(tmp_path), + manager=None, + spawn_agent=_spawn, + protocol_version="0.1.0", + ) + + +@pytest.fixture() +def client(tmp_path: Path) -> TestClient: + return TestClient(build_app(_state(tmp_path))) + + +def test_health_is_unauthenticated(client: TestClient) -> None: + res = client.get("/api/health") + assert res.status_code == 200 + assert res.json() == {"status": "ok"} + + +def test_status_requires_token(client: TestClient) -> None: + assert client.get("/api/status").status_code == 401 + + +def test_status_accepts_session_token_header(client: TestClient) -> None: + res = client.get("/api/status", headers={"X-ClawCodex-Session-Token": TOKEN}) + assert res.status_code == 200 + body = res.json() + assert body["status"] == "ok" + assert body["auth_required"] is False + assert body["protocol_version"] == "0.1.0" + + +def test_status_accepts_bearer(client: TestClient) -> None: + res = client.get("/api/status", headers={"Authorization": f"Bearer {TOKEN}"}) + assert res.status_code == 200 + + +def test_status_rejects_wrong_token(client: TestClient) -> None: + res = client.get("/api/status", headers={"X-ClawCodex-Session-Token": "nope"}) + assert res.status_code == 401 + + +def test_index_serves_adoptable_token(client: TestClient) -> None: + res = client.get("/") + assert res.status_code == 200 + assert "window.__CLAWCODEX_SESSION_TOKEN__" in res.text + assert json.dumps(TOKEN) in res.text + + +def test_config_requires_token_and_redacts_secrets( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + assert client.get("/api/config").status_code == 401 + + fake = { + "display": {"skin": "dark"}, + "env": {"TAVILY_API_KEY": "sk-hidden"}, + "providers": {"anthropic": {"api_key": "sk-secret", "default_model": "m"}}, + "mcp": [{"name": "s", "auth_token": "t-secret", "url": "http://x"}], + } + monkeypatch.setattr("src.config.load_config", lambda: fake) + res = client.get("/api/config", headers={"X-ClawCodex-Session-Token": TOKEN}) + assert res.status_code == 200 + body = res.json() + assert body["display"] == {"skin": "dark"} + assert "env" not in body + assert body["providers"]["anthropic"] == {"default_model": "m"} + assert body["mcp"] == [{"name": "s", "url": "http://x"}] + assert "secret" not in res.text and "sk-hidden" not in res.text + + +def test_ws_rejects_missing_or_wrong_token(client: TestClient) -> None: + from starlette.websockets import WebSocketDisconnect + + for query in ("", "?token=wrong"): + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect(f"/api/ws{query}") as ws: + ws.receive_json() + + +def test_announce_ready_prints_marker_and_writes_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture +) -> None: + from src.entrypoints.serve_cli import READY_FILE_ENV, _announce_ready + + ready = tmp_path / "nested" / "ready.json" + monkeypatch.setenv(READY_FILE_ENV, str(ready)) + _announce_ready(43210) + + out = capsys.readouterr().out + assert "CLAWCODEX_BACKEND_READY port=43210" in out + assert json.loads(ready.read_text()) == {"port": 43210} + + +def test_serve_cli_parser_defaults() -> None: + from src.entrypoints.serve_cli import _build_parser + + args = _build_parser().parse_args([]) + assert args.host == "127.0.0.1" + assert args.port == 0 + assert args.permission_mode == "default" + + +def test_serve_cli_accepts_desktop_spawn_shape() -> None: + # The Electron shell spawns exactly this arg shape (backend-command.ts). + from src.entrypoints.serve_cli import _build_parser + + args = _build_parser().parse_args( + ["--profile", "default", "--host", "127.0.0.1", "--port", "0"] + ) + assert args.profile == "default" + assert args.port == 0 diff --git a/ui-desktop/electron/backend-command.test.ts b/ui-desktop/electron/backend-command.test.ts index 6d4052cbf..737896f4a 100644 --- a/ui-desktop/electron/backend-command.test.ts +++ b/ui-desktop/electron/backend-command.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command' +import { serveBackendArgs, sourceDeclaresServe } from './backend-command' test('serveBackendArgs builds a headless serve invocation', () => { assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0']) @@ -12,53 +12,17 @@ test('serveBackendArgs pins a profile when provided', () => { assert.deepEqual(serveBackendArgs('worker'), ['--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0']) }) -test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => { - const serve = ['-m', 'clawcodex_cli.main', 'serve', '--host', '127.0.0.1', '--port', '0'] - assert.deepEqual(dashboardFallbackArgs(serve), [ - '-m', - 'clawcodex_cli.main', - 'dashboard', - '--no-open', - '--host', - '127.0.0.1', - '--port', - '0' - ]) -}) - -test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => { - const serve = ['-m', 'clawcodex_cli.main', '--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0'] - assert.deepEqual(dashboardFallbackArgs(serve), [ - '-m', - 'clawcodex_cli.main', - '--profile', - 'worker', - 'dashboard', - '--no-open', - '--host', - '127.0.0.1', - '--port', - '0' - ]) -}) - -test('dashboardFallbackArgs is a no-op (copy) when there is no serve token', () => { - const args = ['-m', 'clawcodex_cli.main', 'dashboard', '--no-open'] - const out = dashboardFallbackArgs(args) - assert.deepEqual(out, args) - assert.notEqual(out, args, 'should return a copy, not the same reference') -}) - -test('sourceDeclaresServe detects the serve subparser registration', () => { - assert.equal(sourceDeclaresServe('subparsers.add_parser("serve", help="...")'), true) - assert.equal(sourceDeclaresServe("subparsers.add_parser('serve')"), true) - assert.equal(sourceDeclaresServe('subparsers.add_parser(\n "serve",\n)'), true) +test('sourceDeclaresServe detects the serve route in the CLI sieve', () => { + assert.equal(sourceDeclaresServe("if token == 'serve':\n return run_serve_subcommand(rest)"), true) + assert.equal(sourceDeclaresServe('if token == "serve":'), true) + assert.equal(sourceDeclaresServe('if token == "serve" :'), true) }) test('sourceDeclaresServe does not false-positive on the substring "server"', () => { const oldSource = ` - dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard") - from clawcodex_cli.web_server import start_server # web server + if token == 'agent-server': + from src.entrypoints.agent_server_cli import run_agent_server_subcommand + return run_agent_server_subcommand(rest) # web server ` assert.equal(sourceDeclaresServe(oldSource), false) diff --git a/ui-desktop/electron/backend-command.ts b/ui-desktop/electron/backend-command.ts index f4f7fe729..50917b0c4 100644 --- a/ui-desktop/electron/backend-command.ts +++ b/ui-desktop/electron/backend-command.ts @@ -1,13 +1,12 @@ // Backend subcommand routing for the desktop-managed ClawCodex process. // -// The desktop app launches its own headless backend via `clawcodex serve` — it -// must NEVER depend on or launch the browser `dashboard`. But `serve` is a -// newer subcommand: a runtime that predates it (an older managed install the -// app hasn't updated yet, or an older `clawcodex` resolved from PATH) only knows -// `dashboard --no-open`. To avoid bricking those users mid-upgrade we detect -// whether the resolved runtime understands `serve` and, only when it does not, -// fall back to the legacy `dashboard --no-open` invocation. Both produce the -// exact same headless gateway; `serve` is just the decoupled name. +// The desktop app launches its own headless backend via `clawcodex serve` +// (src/entrypoints/serve_cli.py in the backend repo). `serve` shipped after +// some managed installs were cloned, so a stale runtime may not know the +// subcommand. There is no legacy fallback form — a runtime without `serve` +// cannot back the desktop at all — so detection exists to fail the launch +// with an actionable "update the runtime" error instead of letting the CLI +// misparse `serve` as a free-form prompt. // // These helpers are pure so they can be unit-tested without Electron. @@ -22,27 +21,11 @@ export function serveBackendArgs(profile?: string) { } /** - * Rewrite a resolved backend argv from `serve` to the legacy - * `dashboard --no-open` form, preserving every other argument (incl. a leading - * `-m clawcodex_cli.main` and any `--profile `). Returns a copy; if there is - * no `serve` token the argv is returned unchanged. + * True when a runtime's `src/cli.py` source routes the `serve` subcommand. + * Matches the subcommand sieve (`token == 'serve'` / `token == "serve"`) + * specifically so substrings like "server" (e.g. `agent-server`, + * "web server") never produce a false positive. */ -export function dashboardFallbackArgs(args) { - const i = args.indexOf('serve') - - if (i === -1) { - return args.slice() - } - - return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)] -} - -/** - * True when a runtime's `clawcodex_cli/subcommands/dashboard.py` source registers - * the `serve` subcommand. Matches `add_parser("serve"` / `add_parser('serve'` - * specifically so the substring "server" (e.g. "start_server", "web server") - * never produces a false positive. - */ -export function sourceDeclaresServe(dashboardPySource) { - return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || '')) +export function sourceDeclaresServe(cliPySource) { + return /token\s*==\s*["']serve["']/.test(String(cliPySource || '')) } diff --git a/ui-desktop/electron/main.ts b/ui-desktop/electron/main.ts index 9a42c1756..34d6a7e69 100644 --- a/ui-desktop/electron/main.ts +++ b/ui-desktop/electron/main.ts @@ -33,7 +33,7 @@ import nodePty from 'node-pty' import { classifyActiveRuntime } from './active-runtime-state' import { stopBackendChild as stopBackendChildImpl } from './backend-child' -import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' +import { sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { buildDesktopBackendEnv, clawcodexManagedNodePathEntries, normalizeClawCodexHomeRoot } from './backend-env' import { isReauthRequiredError, waitForClawCodexReady } from './backend-health' @@ -589,7 +589,9 @@ function pathWithClawCodexManagedNode(...entries) { // up with identical layouts and can share one install. const ACTIVE_CLAWCODEX_ROOT = path.join(CLAWCODEX_CONFIG_DIR, 'clawcodex') // VENV_ROOT — venv lives inside the repo, exactly like install.ps1 does it. -const VENV_ROOT = path.join(ACTIVE_CLAWCODEX_ROOT, 'venv') +// install.sh creates the uv venv INSIDE the clone, named `.venv` +// (~/.clawcodex/clawcodex/.venv) — same layout a dev checkout uses. +const VENV_ROOT = path.join(ACTIVE_CLAWCODEX_ROOT, '.venv') // BOOTSTRAP_COMPLETE_MARKER — written by the first-launch bootstrap runner // (Phase 1D) after install.ps1 has completed all stages and the user has // finished initial configuration. Presence of this marker means the install @@ -1879,15 +1881,15 @@ function unwrapWindowsVenvClawCodexCommand(command, backendArgs) { } // Does the resolved runtime understand the `serve` subcommand? The desktop -// spawns `clawcodex serve`; runtimes older than serve only have `dashboard`. We -// detect support so getBackendArgsForRuntime() can route old runtimes through -// the legacy `dashboard --no-open` form instead of crashing on an unknown -// subcommand (would brick every user mid-upgrade — #54568 follow-up). +// spawns `clawcodex serve`; a runtime cloned before serve shipped doesn't know +// it, and the CLI would misparse `serve` as a free-form prompt — so detection +// gates the launch and assertBackendServes() turns "too old" into an +// actionable update error instead of a hung/garbled spawn. // -// Fast path: read the runtime's own dashboard.py (instant, covers managed -// installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once -// (covers a bare `clawcodex` resolved from PATH with no known source root). Result -// is cached per resolved runtime so we probe at most once per backend. +// Fast path: read the runtime's own src/cli.py (instant, covers managed +// installs and dev checkouts). Fallback: probe the CLI once (covers a bare +// `clawcodex` resolved from PATH with no known source root). Result is cached +// per resolved runtime so we probe at most once per backend. const _serveSupportCache = new Map() function backendSupportsServe(backend) { @@ -1905,7 +1907,7 @@ function backendSupportsServe(backend) { if (backend.root) { try { - const src = fs.readFileSync(path.join(backend.root, 'clawcodex_cli', 'subcommands', 'dashboard.py'), 'utf8') + const src = fs.readFileSync(path.join(backend.root, 'src', 'cli.py'), 'utf8') supported = sourceDeclaresServe(src) } catch { supported = null // source unreadable — fall through to the probe @@ -1941,17 +1943,23 @@ function backendSupportsServe(backend) { _serveSupportCache.set(key, supported) rememberLog( - `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}` + `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → runtime update required'} for ${backend.label || key}` ) return supported } -// Given a resolved backend whose args target `serve`, return the args the -// runtime actually understands: unchanged when `serve` is supported, or -// rewritten to `dashboard --no-open` for older runtimes. -function getBackendArgsForRuntime(backend) { - return backendSupportsServe(backend) ? backend.args : dashboardFallbackArgs(backend.args) +// Refuse to spawn a runtime that predates `clawcodex serve`. There is no +// legacy invocation that can back the desktop, so the only correct outcome is +// a boot failure whose message tells the user to update the runtime (the +// standard backend-start-failure surface renders it with the log tail). +function assertBackendServes(backend) { + if (!backendSupportsServe(backend)) { + throw new Error( + `The ClawCodex runtime at ${backend.root || backend.command} does not support \`clawcodex serve\`. ` + + 'Update it (re-run install.sh, or `git pull` in the runtime checkout) and relaunch the app.' + ) + } } function normalizeExecutablePathForCompare(commandPath) { @@ -1998,7 +2006,7 @@ function looksLikeDesktopAppBinary(commandPath) { } function isClawCodexSourceRoot(root) { - return directoryExists(root) && fileExists(path.join(root, 'clawcodex_cli', 'main.py')) + return directoryExists(root) && fileExists(path.join(root, 'src', 'cli.py')) } function findPythonForRoot(root) { @@ -3837,7 +3845,7 @@ function createPythonBackend(root, label, backendArgs, options: any = {}) { kind: 'python', label, command, - args: ['-m', 'clawcodex_cli.main', ...backendArgs], + args: ['-m', 'src.cli', ...backendArgs], env: buildDesktopBackendEnv({ clawcodexHome: CLAWCODEX_CONFIG_DIR, pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)], @@ -3861,7 +3869,7 @@ function createActiveBackend(backendArgs) { kind: 'python', label: `ClawCodex at ${ACTIVE_CLAWCODEX_ROOT}`, command, - args: ['-m', 'clawcodex_cli.main', ...backendArgs], + args: ['-m', 'src.cli', ...backendArgs], env: buildDesktopBackendEnv({ clawcodexHome: CLAWCODEX_CONFIG_DIR, pythonPathEntries: [ACTIVE_CLAWCODEX_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)], @@ -4014,7 +4022,7 @@ function resolveClawCodexBackend(backendArgs) { kind: 'python', label: `installed clawcodex_cli module via ${python}`, command: python, - args: ['-m', 'clawcodex_cli.main', ...backendArgs], + args: ['-m', 'src.cli', ...backendArgs], bootstrap: false, env: {}, shell: false @@ -8184,8 +8192,8 @@ async function spawnPoolBackend(profile, entry) { // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. const backendArgs = ['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0'] const backend = await ensureRuntime(resolveClawCodexBackend(backendArgs)) - // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. - backend.args = getBackendArgsForRuntime(backend) + // A runtime without `serve` cannot back the desktop — fail with guidance. + assertBackendServes(backend) const clawcodexCwd = resolveClawCodexCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null @@ -8471,8 +8479,8 @@ async function startClawCodex() { } const backend = setup.backend - // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. - backend.args = getBackendArgsForRuntime(backend) + // A runtime without `serve` cannot back the desktop — fail with guidance. + assertBackendServes(backend) const clawcodexCwd = resolveClawCodexCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null @@ -11432,19 +11440,19 @@ ipcMain.handle('clawcodex:updates:branch:set', async (_event, name) => { return { branch } }) -// Resolve the canonical ClawCodex version (the one `release.py` bumps in -// clawcodex_cli/__init__.py + pyproject.toml) so the desktop About panel shows the -// real ClawCodex version instead of the Electron app's own package.json version, -// which historically drifted (stuck at 0.0.2). Falls back to app.getVersion() -// when the source tree can't be read (e.g. a packaged build without the repo). +// Resolve the canonical ClawCodex version (the `version` field in the +// runtime's pyproject.toml) so the desktop About panel shows the real +// ClawCodex version instead of the Electron app's own package.json version. +// Falls back to app.getVersion() when the source tree can't be read (e.g. a +// packaged build without the repo). function resolveClawCodexVersion() { try { const root = resolveUpdateRoot() - const initPath = path.join(root, 'clawcodex_cli', '__init__.py') + const pyprojectPath = path.join(root, 'pyproject.toml') - if (fileExists(initPath)) { - const raw = fs.readFileSync(initPath, 'utf8') - const match = raw.match(/__version__\s*=\s*["']([^"']+)["']/) + if (fileExists(pyprojectPath)) { + const raw = fs.readFileSync(pyprojectPath, 'utf8') + const match = raw.match(/^version\s*=\s*["']([^"']+)["']/m) if (match) { return match[1] @@ -11535,7 +11543,7 @@ async function getUninstallSummary() { try { const child = spawn( py, - ['-m', 'clawcodex_cli.main', 'uninstall', '--gui-summary'], + ['-m', 'src.cli', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({ cwd: agentRoot, env: { ...process.env, CLAWCODEX_CONFIG_DIR, NO_COLOR: '1' },