mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 21:37:21 +00:00
openworker-server sandbox-runner serve|attach loads only the runner package. The spec ships the runner and toolkit sources as data, so the zipapp can be packed from the frozen build.
201 lines
7.5 KiB
Python
201 lines
7.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"
|
|
)
|
|
|
|
|
|
_ENGINE_LOCK = None
|
|
|
|
|
|
def _warn_if_state_shared() -> None:
|
|
"""The desktop sidecar runs on a random port precisely so it can coexist with
|
|
a hand-run `openworker-server` on the same state dir, so this entrypoint only
|
|
WARNS about a second engine (statelock.py). `openworker up` refuses outright;
|
|
set COWORKER_STATE_LOCK=strict to make this server refuse too."""
|
|
global _ENGINE_LOCK
|
|
from ..statelock import EngineBusy, acquire
|
|
|
|
strict = os.environ.get("COWORKER_STATE_LOCK") == "strict"
|
|
try:
|
|
_ENGINE_LOCK = acquire(state_dir(), timeout=10.0 if strict else 0.0)
|
|
except EngineBusy as exc:
|
|
if strict:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
raise SystemExit(3)
|
|
print(f"warning: {exc}", file=sys.stderr)
|
|
|
|
|
|
def main(argv=None) -> None:
|
|
from ..sandbox.launch import maybe_run_runner
|
|
|
|
maybe_run_runner(list(sys.argv[1:] if argv is None else argv)) # `sandbox-runner ...`
|
|
_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", "bypass-approvals", "auto-approve"],
|
|
)
|
|
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()
|
|
_warn_if_state_shared()
|
|
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()
|