mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 23:03:22 +00:00
176 lines
6.5 KiB
Python
176 lines
6.5 KiB
Python
"""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()
|