"""Launch the server with uvicorn. Used by the desktop GUI sidecar and `openworker-server`.""" from __future__ import annotations import argparse import os import secrets import sys from pathlib import Path from ..config import load_config from ..permissions import Mode from ..secrets import state_dir, write_private_text from .app import _WS_MAX_FRAME_BYTES, create_app from .manager import SessionManager def _exit_when_orphaned() -> None: """When launched as a desktop sidecar (`COWORKER_EXIT_WITH_PARENT=1`), exit if the parent process dies — even on an abrupt kill (e.g. the Tauri dev watcher restarting the app, or a crash) that skips the shell's graceful child-kill. Standalone `openworker-server` runs are unaffected. The GUI passes its own PID in `COWORKER_PARENT_PID`. Watching that explicit PID (not getppid) is what makes this work under PyInstaller onefile, where this process is a *grandchild* of the GUI — the bootloader sits in between, so getppid() points at the bootloader and a re-parenting check never fires when the GUI dies (the bug that leaked a server pair on every app quit). POSIX: poll the PID with kill(pid, 0). Windows: no re-parenting semantics at all, so block on a process handle and exit the moment it signals (i.e. the parent exited). """ if os.environ.get("COWORKER_EXIT_WITH_PARENT") != "1": return import threading try: parent = int(os.environ.get("COWORKER_PARENT_PID") or 0) except ValueError: parent = 0 parent = parent or os.getppid() # standalone fallback: our direct spawner if sys.platform == "win32": _watch_parent_windows(parent) return import time original_ppid = os.getppid() def watch() -> None: while True: time.sleep(1.5) try: os.kill(parent, 0) # liveness probe only; signal 0 delivers nothing except ProcessLookupError: os._exit(0) except PermissionError: pass # alive, but owned by someone else (shouldn't happen) — keep waiting # Secondary signal: our direct parent died (covers PID-reuse edge cases). if os.getppid() != original_ppid: os._exit(0) threading.Thread(target=watch, daemon=True).start() def _watch_parent_windows(parent: int) -> None: """Block on a handle to the parent process; exit only when it actually terminates. Best-effort — any failure leaves the parent's RunEvent::ExitRequested kill as the primary cleanup path. Two correctness points that bit us before: - `OpenProcess` returns a 64-bit HANDLE; ctypes defaults the return type to a 32-bit int, which truncates the handle to garbage. Declare restype/argtypes so the handle is valid. - Only `os._exit` on WAIT_OBJECT_0 (the parent genuinely died). A bad handle yields WAIT_FAILED immediately — treating that as "parent died" would kill a perfectly healthy server seconds after startup (exactly the freeze we saw).""" import ctypes import threading from ctypes import wintypes SYNCHRONIZE = 0x0010_0000 INFINITE = 0xFFFF_FFFF WAIT_OBJECT_0 = 0x0000_0000 kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.OpenProcess.restype = wintypes.HANDLE kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] kernel32.WaitForSingleObject.restype = wintypes.DWORD kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] handle = kernel32.OpenProcess(SYNCHRONIZE, False, parent) if not handle: return def watch() -> None: if kernel32.WaitForSingleObject(handle, INFINITE) == WAIT_OBJECT_0: os._exit(0) threading.Thread(target=watch, daemon=True).start() def build_app(workspace: str | None, model: str, mode: str): manager = SessionManager( workspace=Path(workspace).expanduser().resolve() if workspace else None, data_dir=state_dir(), model=model, mode=Mode(mode), ) return create_app(manager) def _ensure_ca_bundle() -> None: """Point SSL at certifi's CA bundle if the interpreter has none configured. macOS framework Python ships without a usable system trust store for `aiohttp` (it builds an `ssl` context with no CAs), so the Slack Socket-Mode client fails with CERTIFICATE_VERIFY_FAILED. `httpx`/`requests` bundle certifi already; aiohttp honours the SSL_CERT_FILE env var, so set it once at startup. """ if os.environ.get("SSL_CERT_FILE"): return try: import certifi os.environ["SSL_CERT_FILE"] = certifi.where() except Exception: pass def _ensure_api_token(port: int) -> Path | None: """Set launch auth; standalone/dev tokens use a user-only, port-specific file.""" if os.environ.get("COWORKER_API_TOKEN"): return None # Tauri supplied an in-memory token; never persist it. token = secrets.token_hex(32) os.environ["COWORKER_API_TOKEN"] = token return write_private_text( state_dir() / f"sidecar-{port}.token", token + "\n" ) def main(argv=None) -> None: _ensure_ca_bundle() cfg = load_config() # global config supplies defaults parser = argparse.ArgumentParser(prog="openworker-server") parser.add_argument("--cwd", default=None, help="optional seed/default workspace") parser.add_argument("--model", default=cfg.model) parser.add_argument( "--mode", default=cfg.mode, choices=["discuss", "plan", "interactive", "auto"], ) parser.add_argument("--host", default=cfg.host) parser.add_argument("--port", type=int, default=cfg.port) args = parser.parse_args(argv) # Publish the ACTUAL bound port so loopback URLs (the managed-OAuth callback) # target this process, not config.port. The desktop shell runs the sidecar on # a random free port (to coexist with a hand-run server on 8765), so the # managed-connect redirect must follow the real port, not the 8765 default. os.environ["COWORKER_PORT"] = str(args.port) generated_token_path = _ensure_api_token(args.port) try: import uvicorn _exit_when_orphaned() app = build_app(args.cwd, args.model, args.mode) uvicorn.run( app, host=args.host, port=args.port, ws_max_size=_WS_MAX_FRAME_BYTES ) finally: if generated_token_path is not None: generated_token_path.unlink(missing_ok=True) os.environ.pop("COWORKER_API_TOKEN", None) if __name__ == "__main__": main()