mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
The one ledger-derived D18 split: launcher.py's Windows pythonnet/pywebview preparation (_prepare_windows_webview_runtime, _show_windows_message, _windows_dll_dir_handles) moves whole to ouroboros/launcher_windows_runtime.py (MIGRATION_v7 rows 3998-4000). Drift-probe of the reference leaf against tip monolith bytes was green on every span (ast=tokens=bytes=True, exit 0), so the leaf is tip bytes; the facade re-exports the same objects and differs from the reference facade by exactly upstream dc4c0204's delegated-restart hunk. launcher.py 1582 -> 1484 lines; band re-entry carries an official rationale. Reference pin test_launcher_reexports_the_windows_runtime_leaf appended to tests/test_launcher_sync.py (byte-identical to the reference file afterwards). Everything else the domain owns is classification, recorded in ledger corrections 13-18: cli.py, ouroboros/__init__.py, launcher_server_reaper.py, packaged_cli_install.py byte-identical across tip/reference/base; launcher_bootstrap.py, platform_layer.py pure upstream drift (zero v7 delta); packaged_cli.py::_save_settings HOT-DEFERRED with the half-absorbed settings seam; the utils.py O_BINARY and reaper-test cross-OS deltas superseded by upstream's own class fixes; packaged_runtime/packaging_sync reference deltas deferred to their owning lanes (D33/F2) or F5 (unrowed). (cherry picked from commit 6aad318d5a4100662ed6b31877f94d342c409b6f)
1484 lines
61 KiB
Python
1484 lines
61 KiB
Python
"""Immutable desktop launcher: bootstrap repo, manage server.py, and host UI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
from logging.handlers import RotatingFileHandler
|
|
from typing import Optional
|
|
|
|
# WA6: set sys.dont_write_bytecode BEFORE importing any project module. A signed
|
|
# macOS .app must never write __pycache__/*.pyc into its own bundle at runtime
|
|
# (that breaks the codesign seal and triggers AppTranslocation). os.environ alone
|
|
# is INSUFFICIENT for THIS process: PYTHONDONTWRITEBYTECODE is only read at
|
|
# interpreter startup, so mutating os.environ later does not stop the current
|
|
# process's own subsequent imports — only sys.dont_write_bytecode does. The env
|
|
# vars set further below propagate the same policy to child processes.
|
|
sys.dont_write_bytecode = True
|
|
os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1")
|
|
|
|
from ouroboros.config import (
|
|
AGENT_SERVER_PORT,
|
|
DATA_DIR,
|
|
PANIC_EXIT_CODE,
|
|
PORT_FILE,
|
|
REPO_DIR,
|
|
RESTART_EXIT_CODE,
|
|
SETTINGS_PATH,
|
|
SETTINGS_DEFAULTS,
|
|
acquire_pid_lock,
|
|
apply_settings_to_env as _apply_settings_to_env,
|
|
load_settings,
|
|
get_runtime_mode,
|
|
normalize_runtime_mode,
|
|
read_version,
|
|
release_pid_lock,
|
|
save_settings,
|
|
)
|
|
from ouroboros.launcher_bootstrap import (
|
|
BootstrapContext,
|
|
bootstrap_repo as _bootstrap_repo,
|
|
check_git as _check_git,
|
|
install_deps as _install_deps_impl,
|
|
embedded_python_env,
|
|
sync_existing_repo_from_bundle as _sync_existing_repo_from_bundle_impl,
|
|
)
|
|
from ouroboros.launcher_onboarding import (
|
|
prepare_first_run_settings as _prepare_first_run_settings,
|
|
present_first_run_onboarding as _present_first_run_onboarding,
|
|
)
|
|
from ouroboros.launcher_server_reaper import (
|
|
reap_same_install_strays as _reap_same_install_strays_impl,
|
|
)
|
|
from ouroboros.launcher_windows_runtime import ( # noqa: F401 (re-exported: same objects, prior launcher surface)
|
|
_prepare_windows_webview_runtime,
|
|
_show_windows_message,
|
|
)
|
|
from ouroboros.platform_layer import (
|
|
BUNDLE_DIR_ENV,
|
|
IS_LINUX,
|
|
IS_MACOS,
|
|
IS_WINDOWS,
|
|
assign_pid_to_job,
|
|
close_job,
|
|
create_kill_on_close_job,
|
|
current_process_group_id,
|
|
embedded_python_candidates,
|
|
force_kill_pid,
|
|
git_install_hint,
|
|
install_shutdown_signal_handlers,
|
|
kill_pid_tree,
|
|
kill_process_group_id,
|
|
kill_process_on_port,
|
|
kill_process_tree,
|
|
merge_hidden_kwargs,
|
|
open_path_external,
|
|
pid_is_alive,
|
|
process_command,
|
|
process_group_id,
|
|
resume_process,
|
|
subprocess_new_group_kwargs,
|
|
terminate_job,
|
|
terminate_process_group_id,
|
|
terminate_process_tree,
|
|
)
|
|
from ouroboros.utils import atomic_write_json, utc_now_iso
|
|
|
|
MAX_CRASH_RESTARTS = 5
|
|
CRASH_WINDOW_SEC = 120
|
|
# One bounded, visible retry when a restart's dependency install fails (XG-7B.3):
|
|
# long enough to ride out a transient index/network hiccup, short enough not to
|
|
# stall an offline restart whose requirements are already satisfied.
|
|
_DEPS_RETRY_DELAY_SEC = 5
|
|
_CREATE_SUSPENDED = getattr(subprocess, "CREATE_SUSPENDED", 0x4) if IS_WINDOWS else 0
|
|
_CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if IS_WINDOWS else 0
|
|
|
|
# WA6: globally suppress bytecode writes for the launcher process itself and any
|
|
# naive os.environ.copy() child it spawns. A signed+notarized macOS .app must not
|
|
# write __pycache__/*.pyc into its own bundle at runtime — that breaks the codesign
|
|
# seal and triggers AppTranslocation. Uses the same data_dir/state/pycache
|
|
# convention as launcher_bootstrap.embedded_python_env so caches land outside the
|
|
# bundle. setdefault keeps any explicit caller override.
|
|
os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1")
|
|
_pycache_dir = DATA_DIR / "state" / "pycache"
|
|
try:
|
|
_pycache_dir.mkdir(parents=True, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
os.environ.setdefault("PYTHONPYCACHEPREFIX", str(_pycache_dir))
|
|
|
|
_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
|
_log_dir = DATA_DIR / "logs"
|
|
_log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
_file_handler = RotatingFileHandler(
|
|
_log_dir / "launcher.log",
|
|
maxBytes=2 * 1024 * 1024,
|
|
backupCount=2,
|
|
encoding="utf-8",
|
|
)
|
|
_file_handler.setFormatter(logging.Formatter(_LOG_FORMAT))
|
|
_handlers: list[logging.Handler] = [_file_handler]
|
|
if not getattr(sys, "frozen", False):
|
|
_handlers.append(logging.StreamHandler())
|
|
logging.basicConfig(level=logging.INFO, format=_LOG_FORMAT, handlers=_handlers)
|
|
# Secret-redaction filter (shared SSOT in ouroboros.observability) + quiet
|
|
# third-party HTTP request-URL lines (they can carry URL credentials).
|
|
try:
|
|
from ouroboros.observability import SecretRedactingLogFilter as _RedactFilter
|
|
|
|
for _handler in _handlers:
|
|
_handler.addFilter(_RedactFilter())
|
|
except Exception:
|
|
pass # defensive: a broken observability import must not kill the launcher
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
log = logging.getLogger("launcher")
|
|
|
|
|
|
APP_VERSION = read_version()
|
|
|
|
|
|
def _server_process_record_path() -> pathlib.Path:
|
|
return pathlib.Path(DATA_DIR) / "state" / "server_process.json"
|
|
|
|
|
|
def _hidden_run(command, **kwargs):
|
|
"""subprocess.run() with platform-appropriate hidden-window flags."""
|
|
return subprocess.run(command, **merge_hidden_kwargs(kwargs))
|
|
|
|
|
|
def _hidden_popen(command, **kwargs):
|
|
"""subprocess.Popen() with platform-appropriate hidden-window flags."""
|
|
return subprocess.Popen(command, **merge_hidden_kwargs(kwargs))
|
|
|
|
def _find_embedded_python() -> str:
|
|
"""Locate the embedded python-build-standalone interpreter."""
|
|
if getattr(sys, "frozen", False):
|
|
base = pathlib.Path(sys._MEIPASS)
|
|
else:
|
|
base = pathlib.Path(__file__).parent
|
|
for path in embedded_python_candidates(base):
|
|
if path.exists():
|
|
return str(path)
|
|
return sys.executable
|
|
|
|
|
|
EMBEDDED_PYTHON = _find_embedded_python()
|
|
|
|
|
|
def _bundle_dir() -> pathlib.Path:
|
|
if getattr(sys, "frozen", False):
|
|
return pathlib.Path(sys._MEIPASS)
|
|
return pathlib.Path(__file__).parent
|
|
|
|
|
|
def _bootstrap_context() -> BootstrapContext:
|
|
return BootstrapContext(
|
|
bundle_dir=_bundle_dir(),
|
|
repo_dir=REPO_DIR,
|
|
data_dir=DATA_DIR,
|
|
settings_path=SETTINGS_PATH,
|
|
embedded_python=EMBEDDED_PYTHON,
|
|
app_version=APP_VERSION,
|
|
hidden_run=_hidden_run,
|
|
# Launcher is owner-process boundary; first-launch migration may set runtime mode.
|
|
save_settings=lambda settings: save_settings(settings, allow_elevation=True),
|
|
log=log,
|
|
)
|
|
|
|
|
|
def check_git() -> bool:
|
|
return _check_git(IS_WINDOWS)
|
|
|
|
|
|
def bootstrap_repo() -> bool:
|
|
return _bootstrap_repo(_bootstrap_context())
|
|
|
|
|
|
def _sync_existing_repo_from_bundle() -> None:
|
|
_sync_existing_repo_from_bundle_impl(_bootstrap_context())
|
|
|
|
|
|
def _install_deps() -> bool:
|
|
return _install_deps_impl(_bootstrap_context())
|
|
|
|
_agent_proc: Optional[subprocess.Popen] = None
|
|
_agent_job: Optional[object] = None
|
|
_agent_lock = threading.Lock()
|
|
_shutdown_event = threading.Event()
|
|
# Set by _request_agent_restart(): the next agent exit is a REQUESTED recycle
|
|
# (first-run configuration adopted), not a crash — same shape as the exit-code-42
|
|
# branch, for the case where the launcher, not the agent, decided the restart.
|
|
_agent_restart_requested = threading.Event()
|
|
_webview_window = None
|
|
# Linux-only browser-fallback flag (#56): set by _detect_headless() when no
|
|
# pywebview GUI backend can initialize. Stays False on macOS/Windows — the
|
|
# probe never runs there, so every `if _headless:` branch is dead code on
|
|
# those platforms and their behavior is unchanged.
|
|
_headless = False
|
|
|
|
|
|
def _server_process_identity_matches(record: dict) -> bool:
|
|
try:
|
|
pid = int(record.get("pid") or 0)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
if pid <= 0 or pid == os.getpid() or not pid_is_alive(pid):
|
|
return False
|
|
expected_server = str((REPO_DIR / "server.py").resolve())
|
|
expected_repo = str(REPO_DIR.resolve())
|
|
record_server = str(record.get("server_path") or "")
|
|
record_repo = str(record.get("repo_dir") or "")
|
|
if record_server and record_server != expected_server:
|
|
return False
|
|
if record_repo and record_repo != expected_repo:
|
|
return False
|
|
live_pgid = process_group_id(pid)
|
|
try:
|
|
recorded_pgid = int(record.get("pgid") or 0)
|
|
except (TypeError, ValueError):
|
|
recorded_pgid = 0
|
|
if not IS_WINDOWS and (recorded_pgid <= 0 or live_pgid <= 0 or recorded_pgid != live_pgid):
|
|
return False
|
|
command = process_command(pid)
|
|
if not command:
|
|
return False
|
|
return expected_server in command or ("server.py" in command and expected_repo in command)
|
|
|
|
|
|
def _write_server_process_record(proc: subprocess.Popen, *, port: int, server_py: pathlib.Path) -> None:
|
|
try:
|
|
record = {
|
|
"pid": int(proc.pid),
|
|
"pgid": process_group_id(proc.pid),
|
|
"server_path": str(server_py.resolve()),
|
|
"repo_dir": str(REPO_DIR.resolve()),
|
|
"requested_port": int(port),
|
|
"port": int(port),
|
|
"argv": [str(EMBEDDED_PYTHON), str(server_py.resolve())],
|
|
"created_at": utc_now_iso(),
|
|
}
|
|
atomic_write_json(_server_process_record_path(), record, trailing_newline=True)
|
|
except Exception:
|
|
log.warning("Failed to write server process record", exc_info=True)
|
|
|
|
|
|
def _update_server_process_record_port(pid: int, actual_port: int) -> None:
|
|
try:
|
|
record_path = _server_process_record_path()
|
|
if not record_path.exists():
|
|
return
|
|
record = json.loads(record_path.read_text(encoding="utf-8"))
|
|
if not isinstance(record, dict) or int(record.get("pid") or 0) != int(pid):
|
|
return
|
|
record["port"] = int(actual_port)
|
|
if "requested_port" not in record:
|
|
record["requested_port"] = int(actual_port)
|
|
record["port_updated_at"] = utc_now_iso()
|
|
atomic_write_json(record_path, record, trailing_newline=True)
|
|
except Exception:
|
|
log.debug("Failed to update server process record port", exc_info=True)
|
|
|
|
|
|
def _cleanup_recorded_server_process(reason: str = "preflight") -> None:
|
|
try:
|
|
record_path = _server_process_record_path()
|
|
if not record_path.exists():
|
|
return
|
|
record = json.loads(record_path.read_text(encoding="utf-8"))
|
|
if not isinstance(record, dict):
|
|
record_path.unlink(missing_ok=True)
|
|
return
|
|
if not _server_process_identity_matches(record):
|
|
log.info("Ignoring stale server process record with non-matching identity (%s)", reason)
|
|
record_path.unlink(missing_ok=True)
|
|
return
|
|
pid = int(record.get("pid") or 0)
|
|
pgid = int(record.get("pgid") or 0)
|
|
log.info("Cleaning recorded server process pid=%d pgid=%d (%s)", pid, pgid, reason)
|
|
if not IS_WINDOWS and pgid > 0 and pgid != current_process_group_id():
|
|
terminate_process_group_id(pgid)
|
|
time.sleep(0.5)
|
|
kill_process_group_id(pgid)
|
|
if pid_is_alive(pid):
|
|
kill_pid_tree(pid)
|
|
record_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
log.warning("Failed to clean recorded server process (%s)", reason, exc_info=True)
|
|
|
|
|
|
def _cleanup_recorded_server_group_for_pid(pid: int, reason: str = "agent_exit") -> None:
|
|
try:
|
|
record_path = _server_process_record_path()
|
|
if not record_path.exists():
|
|
return
|
|
record = json.loads(record_path.read_text(encoding="utf-8"))
|
|
if not isinstance(record, dict) or int(record.get("pid") or 0) != int(pid):
|
|
return
|
|
pgid = int(record.get("pgid") or 0)
|
|
live_pgid = process_group_id(int(pid)) if pid_is_alive(int(pid)) else 0
|
|
if not IS_WINDOWS and live_pgid > 0 and pgid > 0 and pgid != live_pgid:
|
|
log.info(
|
|
"Ignoring mismatched recorded server pgid=%d for live pid=%d pgid=%d (%s)",
|
|
pgid,
|
|
pid,
|
|
live_pgid,
|
|
reason,
|
|
)
|
|
pgid = 0
|
|
if not IS_WINDOWS and pgid > 0 and pgid != current_process_group_id():
|
|
log.info("Cleaning server process group pgid=%d after pid=%d exit (%s)", pgid, pid, reason)
|
|
terminate_process_group_id(pgid)
|
|
time.sleep(0.2)
|
|
kill_process_group_id(pgid)
|
|
if pid_is_alive(int(pid)):
|
|
kill_pid_tree(int(pid))
|
|
record_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
log.warning("Failed to clean recorded server process group (%s)", reason, exc_info=True)
|
|
|
|
|
|
def start_agent(port: int = AGENT_SERVER_PORT) -> subprocess.Popen:
|
|
"""Start server.py as the managed agent subprocess."""
|
|
global _agent_proc, _agent_job
|
|
|
|
settings = _load_settings()
|
|
_apply_settings_to_env(settings)
|
|
env = embedded_python_env(DATA_DIR, os.environ.copy())
|
|
env["PYTHONPATH"] = str(REPO_DIR)
|
|
# ENV IS THE AUTHORITY for the bind host (config.SETTINGS_KEYS_NOT_EXPORTED_TO_ENV):
|
|
# `settings` here is the MERGED view, so it usually carries the shipped
|
|
# 127.0.0.1 default that no owner ever authored. Stamping it over an
|
|
# operator-provided environment host reintroduced the exact regression the
|
|
# export exclusion closed — setdefault lets the settings value stand in
|
|
# only when the environment says nothing.
|
|
saved_host = str(settings.get("OUROBOROS_SERVER_HOST") or "").strip()
|
|
if saved_host:
|
|
env.setdefault("OUROBOROS_SERVER_HOST", saved_host)
|
|
env["OUROBOROS_SERVER_PORT"] = str(port)
|
|
env["OUROBOROS_DATA_DIR"] = str(DATA_DIR)
|
|
env["OUROBOROS_REPO_DIR"] = str(REPO_DIR)
|
|
env["OUROBOROS_APP_VERSION"] = str(APP_VERSION)
|
|
env["OUROBOROS_MANAGED_BY_LAUNCHER"] = "1"
|
|
# Owner Surface Fact: the launcher is the only actor that knows HOW this
|
|
# server will be presented. `_headless` is decided in main() before the
|
|
# lifecycle loop ever calls start_agent(), and every managed restart funnels
|
|
# back through here, so the export is re-stamped fresh each time. Absence of
|
|
# the var (source mode, Docker, Colab, CLI server) truthfully means "web".
|
|
# Env-only by design — never a SETTINGS_DEFAULTS key (pop-on-absent would
|
|
# erase an injected value). Known bounded lie: a SIGKILLed launcher can
|
|
# orphan the server with a stale "desktop_window" until the next launcher
|
|
# start reaps it — the same envelope OUROBOROS_MANAGED_BY_LAUNCHER accepts.
|
|
env["OUROBOROS_PRESENTATION"] = "browser_fallback" if _headless else "desktop_window"
|
|
# The server runs out of the managed repo, not the bundle: without this the
|
|
# bundled payloads (node, ripgrep) are invisible to it (platform_layer.
|
|
# bundled_resource_bases).
|
|
env[BUNDLE_DIR_ENV] = str(_bundle_dir())
|
|
|
|
server_py = REPO_DIR / "server.py"
|
|
log.info("Starting agent: %s %s (port=%d)", EMBEDDED_PYTHON, server_py, port)
|
|
|
|
popen_kwargs: dict = {
|
|
"cwd": str(REPO_DIR),
|
|
"env": env,
|
|
"stdout": subprocess.PIPE,
|
|
"stderr": subprocess.STDOUT,
|
|
}
|
|
if IS_WINDOWS:
|
|
popen_kwargs["creationflags"] = (
|
|
popen_kwargs.get("creationflags", 0)
|
|
| _CREATE_NEW_PROCESS_GROUP
|
|
| _CREATE_SUSPENDED
|
|
)
|
|
else:
|
|
popen_kwargs.update(subprocess_new_group_kwargs())
|
|
|
|
proc = _hidden_popen([EMBEDDED_PYTHON, str(server_py)], **popen_kwargs)
|
|
_agent_proc = proc
|
|
|
|
if IS_WINDOWS:
|
|
job = create_kill_on_close_job()
|
|
if job is None:
|
|
log.error(
|
|
"Failed to create Windows Job Object; refusing to run without process-tree ownership."
|
|
)
|
|
proc.kill()
|
|
return proc
|
|
if not assign_pid_to_job(job, proc.pid):
|
|
log.error(
|
|
"Failed to assign agent pid %d to Windows Job Object; refusing to run without process-tree ownership.",
|
|
proc.pid,
|
|
)
|
|
close_job(job)
|
|
proc.kill()
|
|
return proc
|
|
_agent_job = job
|
|
if not resume_process(proc.pid):
|
|
log.error("Failed to resume agent process %d — killing", proc.pid)
|
|
with _agent_lock:
|
|
if _agent_job is job:
|
|
_agent_job = None
|
|
terminate_job(job)
|
|
close_job(job)
|
|
return proc
|
|
log.info("Agent pid %d assigned to Windows Job Object", proc.pid)
|
|
|
|
_write_server_process_record(proc, port=port, server_py=server_py)
|
|
|
|
def _stream_output() -> None:
|
|
log_path = DATA_DIR / "logs" / "agent_stdout.log"
|
|
try:
|
|
with open(log_path, "a", encoding="utf-8") as handle:
|
|
for line in iter(proc.stdout.readline, b""):
|
|
decoded = line.decode("utf-8", errors="replace")
|
|
handle.write(decoded)
|
|
handle.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
threading.Thread(target=_stream_output, daemon=True).start()
|
|
return proc
|
|
|
|
|
|
def stop_agent() -> None:
|
|
"""Gracefully stop the agent process."""
|
|
global _agent_proc, _agent_job
|
|
with _agent_lock:
|
|
if _agent_proc is None:
|
|
return
|
|
proc = _agent_proc
|
|
job = _agent_job
|
|
_agent_proc = None
|
|
_agent_job = None
|
|
|
|
log.info("Stopping agent (pid=%s)...", proc.pid)
|
|
try:
|
|
if IS_WINDOWS:
|
|
proc.terminate()
|
|
else:
|
|
terminate_process_tree(proc)
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
if IS_WINDOWS and job is not None:
|
|
terminate_job(job)
|
|
else:
|
|
kill_process_tree(proc)
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
log.warning("Agent process did not exit after forced stop (pid=%s)", proc.pid)
|
|
except Exception:
|
|
pass
|
|
|
|
if IS_WINDOWS and job is not None:
|
|
close_job(job)
|
|
_cleanup_recorded_server_group_for_pid(proc.pid, "stop_agent")
|
|
|
|
|
|
def _read_port_file() -> int:
|
|
"""Read the active server port from PORT_FILE."""
|
|
try:
|
|
if PORT_FILE.exists():
|
|
return int(PORT_FILE.read_text(encoding="utf-8").strip())
|
|
except (ValueError, OSError):
|
|
pass
|
|
return AGENT_SERVER_PORT
|
|
|
|
|
|
def _kill_stale_on_port(port: int) -> None:
|
|
"""Kill any process listening on a runtime port."""
|
|
if IS_WINDOWS:
|
|
kill_process_on_port(port)
|
|
return
|
|
try:
|
|
# -sTCP:LISTEN keeps this a listener sweep: a bare tcp:PORT selector
|
|
# also matches ESTABLISHED client sockets, and in browser mode the
|
|
# owner's own browser holds one — sweeping it violates the invariant
|
|
# documented on _open_browser_detached (the browser is the owner's
|
|
# application, outside custody). -nP avoids resolver stalls.
|
|
result = subprocess.run(
|
|
["lsof", "-nP", "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
pids = result.stdout.strip().split()
|
|
for pid_str in pids:
|
|
try:
|
|
pid = int(pid_str)
|
|
if pid != os.getpid():
|
|
force_kill_pid(pid)
|
|
except (TypeError, ValueError, ProcessLookupError, PermissionError, OSError):
|
|
pass
|
|
except Exception:
|
|
kill_process_on_port(port)
|
|
|
|
|
|
def _host_service_port() -> int:
|
|
default_port = int(SETTINGS_DEFAULTS.get("OUROBOROS_HOST_SERVICE_PORT", 8767))
|
|
try:
|
|
raw_port = os.environ.get("OUROBOROS_HOST_SERVICE_PORT")
|
|
if not str(raw_port or "").strip():
|
|
raw_port = _load_settings().get("OUROBOROS_HOST_SERVICE_PORT", default_port)
|
|
return int(raw_port)
|
|
except (TypeError, ValueError, OSError):
|
|
return default_port
|
|
|
|
|
|
def _kill_stale_runtime_ports(port: int) -> None:
|
|
"""Clear core runtime listener ports before start/restart."""
|
|
_kill_stale_on_port(port)
|
|
_kill_stale_on_port(_host_service_port())
|
|
|
|
|
|
def _reap_same_install_strays(reason: str) -> list[int]:
|
|
"""Kill leftover generations of THIS install's server; return proven survivors.
|
|
|
|
Only ever called while this process holds the single-instance pid lock, which is what makes the
|
|
identity rule sound: with the lock held, another process running this install's server.py under
|
|
this launcher's stamped environment cannot be a live peer's generation. Never called from a
|
|
panic or window-close path — Emergency Stop tears down what it owns and adds no new killing.
|
|
"""
|
|
try:
|
|
return _reap_same_install_strays_impl(REPO_DIR, DATA_DIR, reason)
|
|
except Exception:
|
|
# A sweep that cannot run must not stop the launcher booting.
|
|
log.warning("Same-install stray sweep failed (%s)", reason, exc_info=True)
|
|
return []
|
|
|
|
|
|
def _pre_generation_cleanup(port: int) -> list[int]:
|
|
"""Clear the previous generation before starting a new one; returns proven stray survivors.
|
|
|
|
Per GENERATION, not once per launcher: exit-42 and crash restarts are where the observed
|
|
double-boot collisions began. Ordered recorded-cleanup -> stray sweep -> port sweep: the
|
|
recorded pid keeps its record-driven path with the record unlinked first, the tree kill runs
|
|
while PPID links are still live, and the port sweep stays the residual net."""
|
|
_cleanup_recorded_server_process("startup")
|
|
survivors = _reap_same_install_strays("startup")
|
|
_kill_stale_runtime_ports(port)
|
|
return survivors
|
|
|
|
|
|
def _wait_for_server(port: int, timeout: float = 30.0, abort_event=None) -> bool:
|
|
"""Wait for the agent HTTP server to respond.
|
|
|
|
``abort_event`` (headless mode) lets a shutdown signal cut the wait short:
|
|
the handlers only set the event, so without this check a SIGTERM during
|
|
startup would still sit out the full readiness timeout."""
|
|
import urllib.request
|
|
|
|
url = f"http://127.0.0.1:{port}/api/health"
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if abort_event is not None and abort_event.is_set():
|
|
return False
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=2) as response:
|
|
if response.status == 200:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.5)
|
|
return False
|
|
|
|
|
|
def _poll_port_file(timeout: float = 30.0) -> int:
|
|
"""Poll until the port file is freshly written."""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
try:
|
|
if PORT_FILE.exists():
|
|
age = time.time() - PORT_FILE.stat().st_mtime
|
|
if age < 10:
|
|
return int(PORT_FILE.read_text(encoding="utf-8").strip())
|
|
except (ValueError, OSError):
|
|
pass
|
|
time.sleep(0.5)
|
|
return _read_port_file()
|
|
|
|
|
|
def _kill_orphaned_children(port: int) -> None:
|
|
"""Final safety net: kill processes still on runtime ports.
|
|
|
|
Module-level so both the window-close handler (_on_closing, main thread) and
|
|
the panic-stop branch (agent_lifecycle_loop, supervisor thread) tear down the
|
|
exact same way.
|
|
"""
|
|
_cleanup_recorded_server_process("window_close")
|
|
_kill_stale_runtime_ports(port)
|
|
_kill_stale_on_port(8766)
|
|
try:
|
|
companions = json.loads((DATA_DIR / "state" / "extension_companions.json").read_text(encoding="utf-8"))
|
|
if isinstance(companions, dict):
|
|
for item in companions.values():
|
|
if not isinstance(item, dict):
|
|
continue
|
|
try:
|
|
force_kill_pid(int(item.get("pid") or 0))
|
|
except (TypeError, ValueError, ProcessLookupError, PermissionError, OSError):
|
|
pass
|
|
for companion_port in item.get("ports") or []:
|
|
try:
|
|
kill_process_on_port(int(companion_port))
|
|
except (TypeError, ValueError, OSError):
|
|
pass
|
|
except Exception:
|
|
pass
|
|
for child in __import__("multiprocessing").active_children():
|
|
try:
|
|
force_kill_pid(child.pid)
|
|
log.info("Killed orphaned child pid=%d", child.pid)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
pass
|
|
|
|
|
|
def agent_lifecycle_loop(port: int = AGENT_SERVER_PORT) -> None:
|
|
"""Start/monitor agent; restart on code 42 or bounded crashes."""
|
|
global _agent_proc, _agent_job
|
|
crash_times: list[float] = []
|
|
|
|
while not _shutdown_event.is_set():
|
|
survivors = _pre_generation_cleanup(port)
|
|
if survivors:
|
|
# Starting now would put a second generation on the same data directory — the exact
|
|
# collision this sweep exists to prevent. The next iteration re-sweeps.
|
|
log.error(
|
|
"Not starting the agent: same-install server process(es) %s are proven "
|
|
"launcher-managed strays but survived every kill pass. Retrying in 3s.",
|
|
survivors,
|
|
)
|
|
time.sleep(3)
|
|
continue
|
|
|
|
try:
|
|
PORT_FILE.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
proc = start_agent(port)
|
|
|
|
actual_port = _poll_port_file(timeout=30)
|
|
_update_server_process_record_port(proc.pid, actual_port)
|
|
if not _wait_for_server(actual_port, timeout=45):
|
|
log.warning("Agent server did not become responsive within 45s (port %d)", actual_port)
|
|
|
|
proc.wait()
|
|
exit_code = proc.returncode
|
|
log.info("Agent exited with code %d", exit_code)
|
|
if exit_code == RESTART_EXIT_CODE:
|
|
try:
|
|
from ouroboros.delegate_recovery import acknowledge_observed_restart_exit
|
|
|
|
if not acknowledge_observed_restart_exit(
|
|
DATA_DIR, supervisor_pid=proc.pid, exit_code=exit_code,
|
|
):
|
|
log.info("No prepared delegated-restart transaction required acknowledgement.")
|
|
except Exception:
|
|
log.warning("Could not acknowledge delegated restart transaction", exc_info=True)
|
|
_cleanup_recorded_server_group_for_pid(proc.pid, "agent_exit")
|
|
|
|
with _agent_lock:
|
|
_agent_proc = None
|
|
if IS_WINDOWS and _agent_job is not None:
|
|
close_job(_agent_job)
|
|
_agent_job = None
|
|
|
|
if _shutdown_event.is_set():
|
|
break
|
|
|
|
if exit_code == PANIC_EXIT_CODE:
|
|
log.info("Panic stop (exit code %d) — shutting down completely.", PANIC_EXIT_CODE)
|
|
_shutdown_event.set()
|
|
# The agent (server child) already exited; tear down any orphans and
|
|
# force-exit the whole process. _webview_window.destroy() from this
|
|
# supervisor thread cannot end the main-thread Cocoa webview loop on
|
|
# macOS (it leaves a black frozen window), so exit with parity to the
|
|
# window-close path: kill orphans, release the pid lock, os._exit(0).
|
|
_kill_orphaned_children(port)
|
|
release_pid_lock()
|
|
os._exit(0)
|
|
|
|
time.sleep(2)
|
|
|
|
if _agent_restart_requested.is_set():
|
|
# Launcher-requested recycle (first-run configuration): deliberate,
|
|
# so it skips crash accounting exactly like the agent's own code-42
|
|
# restart. No dependency sync — nothing about the checkout changed.
|
|
_agent_restart_requested.clear()
|
|
log.info("Restarting the agent to adopt the saved first-run configuration.")
|
|
# No port sweep here: _pre_generation_cleanup at the top of the
|
|
# next iteration runs it as its third phase.
|
|
continue
|
|
|
|
if exit_code == RESTART_EXIT_CODE:
|
|
log.info("Agent requested restart (exit code 42). Restarting...")
|
|
_sync_existing_repo_from_bundle()
|
|
if not _install_deps():
|
|
# An evolved checkout may have added requirements its reviewed
|
|
# commit depends on. Pause visibly and retry once — pip failures
|
|
# are often transient (index/network) — instead of restarting as
|
|
# if nothing happened.
|
|
log.error(
|
|
"Dependency install failed after the restart request; "
|
|
"retrying once in %ds.", _DEPS_RETRY_DELAY_SEC,
|
|
)
|
|
time.sleep(_DEPS_RETRY_DELAY_SEC)
|
|
if not _install_deps():
|
|
log.error(
|
|
"Dependency install failed twice; starting the agent anyway "
|
|
"under the crash fuse (%d crashes in %ds stops the launcher). "
|
|
"If the new commit added packages, the server will fail to "
|
|
"import them — see the pip output above for the cause.",
|
|
MAX_CRASH_RESTARTS, CRASH_WINDOW_SEC,
|
|
)
|
|
# No port sweep here: _pre_generation_cleanup owns it next iteration.
|
|
continue
|
|
|
|
now = time.time()
|
|
crash_times.append(now)
|
|
crash_times[:] = [stamp for stamp in crash_times if (now - stamp) < CRASH_WINDOW_SEC]
|
|
if len(crash_times) >= MAX_CRASH_RESTARTS:
|
|
log.error("Agent crashed %d times in %ds. Stopping.", MAX_CRASH_RESTARTS, CRASH_WINDOW_SEC)
|
|
break
|
|
|
|
log.info("Agent crashed. Restarting in 3s...")
|
|
# No port sweep here: _pre_generation_cleanup owns it next iteration.
|
|
time.sleep(3)
|
|
|
|
def _request_agent_restart() -> None:
|
|
"""Recycle the managed server so it adopts freshly saved settings.
|
|
|
|
The lifecycle loop owns the restart; this flags intent and stops the child.
|
|
"""
|
|
with _agent_lock:
|
|
agent_alive = _agent_proc is not None
|
|
if not agent_alive:
|
|
# Leaving the flag set would make the NEXT ordinary agent exit look like
|
|
# a requested restart and skip the crash fuse.
|
|
log.info("No managed agent to recycle; the next start reads the saved settings.")
|
|
return
|
|
_agent_restart_requested.set()
|
|
stop_agent()
|
|
|
|
|
|
def _await_server_ready(port: int, abort_event=None) -> tuple[bool, int]:
|
|
"""Wait for managed-server health; resolve the AUTHORITATIVE bound port.
|
|
|
|
The server may rebind on conflict and publish the real port in
|
|
``data/state/server_port``; every later consumer (UI URL, onboarding window,
|
|
teardown sweep) must use that value, not the requested one.
|
|
"""
|
|
ready = _wait_for_server(port, timeout=15, abort_event=abort_event)
|
|
actual_port = _read_port_file()
|
|
if actual_port != port:
|
|
ready = _wait_for_server(actual_port, timeout=45, abort_event=abort_event)
|
|
else:
|
|
ready = ready or _wait_for_server(port, timeout=45, abort_event=abort_event)
|
|
return ready, actual_port
|
|
|
|
|
|
def _load_settings() -> dict:
|
|
return load_settings()
|
|
|
|
|
|
def _save_settings(settings: dict) -> None:
|
|
# Owner-process boundary: first-run/env/provider saves may elevate runtime mode.
|
|
save_settings(settings, allow_elevation=True)
|
|
|
|
|
|
def _request_runtime_mode_change(mode: str, confirm_fn) -> dict:
|
|
new_mode = normalize_runtime_mode(mode)
|
|
settings = _load_settings()
|
|
pending_mode = normalize_runtime_mode(settings.get("OUROBOROS_RUNTIME_MODE"))
|
|
active_mode = get_runtime_mode()
|
|
restart_required = new_mode != active_mode
|
|
if new_mode == pending_mode:
|
|
return {"ok": True, "runtime_mode": new_mode, "restart_required": restart_required}
|
|
message = (
|
|
f"Change Ouroboros runtime mode from {pending_mode} to {new_mode}?\n\n"
|
|
f"Current boot is still running in {active_mode} mode. "
|
|
"This is an owner-only operation. The new mode is saved by the "
|
|
"desktop launcher and takes effect after restart."
|
|
)
|
|
if not confirm_fn("Confirm Runtime Mode Change", message):
|
|
return {"ok": False, "error": "Runtime mode change cancelled."}
|
|
settings["OUROBOROS_RUNTIME_MODE"] = new_mode
|
|
_save_settings(settings)
|
|
return {"ok": True, "runtime_mode": new_mode, "restart_required": restart_required}
|
|
|
|
|
|
def _request_auto_grant_reviewed_skills_change(enabled: bool, confirm_fn) -> dict:
|
|
settings = _load_settings()
|
|
old_enabled = str(settings.get("OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS") or "false").strip().lower() in {"1", "true", "yes", "on"}
|
|
new_enabled = bool(enabled)
|
|
if new_enabled == old_enabled:
|
|
return {"ok": True, "enabled": new_enabled}
|
|
if new_enabled:
|
|
message = (
|
|
"Enable auto-grant for reviewed skills?\n\n"
|
|
"After this, any fresh executable skill review will grant the "
|
|
"skill's manifest-declared settings keys and host permissions for "
|
|
"that exact content hash. Only enable this for trusted closed-loop "
|
|
"skill development."
|
|
)
|
|
else:
|
|
message = "Disable auto-grant for reviewed skills?"
|
|
if not confirm_fn("Confirm Reviewed Skill Auto-Grant", message):
|
|
return {"ok": False, "error": "Auto-grant setting change cancelled."}
|
|
settings["OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS"] = "true" if new_enabled else "false"
|
|
_save_settings(settings)
|
|
return {"ok": True, "enabled": new_enabled}
|
|
|
|
|
|
def _request_skill_key_grant(skill: str, keys: list, confirm_fn) -> dict:
|
|
from ouroboros.skill_loader import (
|
|
find_skill,
|
|
requested_core_setting_keys,
|
|
requested_skill_permissions,
|
|
review_status_allows_execution,
|
|
save_skill_grants,
|
|
)
|
|
|
|
skill_name = str(skill or "").strip()
|
|
requested_raw = [str(k or "").strip() for k in (keys or []) if str(k or "").strip()]
|
|
loaded = find_skill(
|
|
DATA_DIR,
|
|
skill_name,
|
|
repo_path=str(_load_settings().get("OUROBOROS_SKILLS_REPO_PATH") or ""),
|
|
)
|
|
if loaded is None:
|
|
return {"ok": False, "error": f"Skill {skill_name!r} not found"}
|
|
if not (loaded.manifest.is_script() or loaded.manifest.is_extension()):
|
|
return {"ok": False, "error": "Key and permission grants are supported for script and extension skills."}
|
|
if not review_status_allows_execution(loaded.review.status) or loaded.review.is_stale_for(loaded.content_hash):
|
|
return {"ok": False, "error": "Key and permission grants require a fresh executable review."}
|
|
allowed = requested_core_setting_keys(list(loaded.manifest.env_from_settings or []))
|
|
allowed_permissions = requested_skill_permissions(
|
|
list(getattr(loaded.manifest, "permissions", []) or []),
|
|
list(getattr(loaded.manifest, "subscribe_events", []) or []),
|
|
)
|
|
allowed_permission_map = {permission.lower(): permission for permission in allowed_permissions}
|
|
requested_keys = [item.upper() for item in requested_raw if item.upper() in allowed]
|
|
requested_permissions = [
|
|
allowed_permission_map[item.lower()]
|
|
for item in requested_raw
|
|
if item.lower() in allowed_permission_map
|
|
]
|
|
if not requested_raw or len(requested_keys) + len(requested_permissions) != len(requested_raw):
|
|
return {
|
|
"ok": False,
|
|
"error": f"Grant items must be requested by the current manifest: keys={allowed}, permissions={allowed_permissions}",
|
|
}
|
|
message = (
|
|
f"Grant skill {loaded.name!r} access to these settings keys / host permissions?\n\n"
|
|
+ "\n".join([*requested_keys, *requested_permissions])
|
|
+ "\n\nOnly grant keys and permissions to reviewed skills you trust."
|
|
)
|
|
if not confirm_fn("Confirm Skill Grant", message):
|
|
return {"ok": False, "error": "Skill grant cancelled."}
|
|
save_skill_grants(
|
|
DATA_DIR,
|
|
loaded.name,
|
|
requested_keys,
|
|
content_hash=loaded.content_hash,
|
|
requested_keys=allowed,
|
|
granted_permissions=requested_permissions,
|
|
requested_permissions=allowed_permissions,
|
|
)
|
|
# Extension grants must reconcile inside server.py, not the immutable launcher process.
|
|
extension_action = None
|
|
extension_reason = None
|
|
extension_load_error = None
|
|
if loaded.manifest.is_extension():
|
|
import json as _json
|
|
import urllib.parse as _urlparse
|
|
import urllib.request as _urlreq
|
|
|
|
try:
|
|
actual_port = _read_port_file() or AGENT_SERVER_PORT
|
|
req = _urlreq.Request(
|
|
f"http://127.0.0.1:{actual_port}/api/skills/"
|
|
f"{_urlparse.quote(loaded.name)}/reconcile",
|
|
method="POST",
|
|
data=b"{}",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with _urlreq.urlopen(req, timeout=10) as resp:
|
|
payload = _json.loads(resp.read().decode("utf-8") or "{}")
|
|
extension_action = payload.get("extension_action")
|
|
extension_reason = payload.get("extension_reason")
|
|
extension_load_error = payload.get("load_error")
|
|
except Exception as exc:
|
|
log.warning(
|
|
"Skill grant saved but server-side reconcile failed for %s: %s",
|
|
loaded.name, exc, exc_info=True,
|
|
)
|
|
extension_reason = "reconcile_call_failed"
|
|
return {
|
|
"ok": True,
|
|
"skill": loaded.name,
|
|
"granted_keys": requested_keys,
|
|
"granted_permissions": requested_permissions,
|
|
"extension_action": extension_action,
|
|
"extension_reason": extension_reason,
|
|
"load_error": extension_load_error,
|
|
}
|
|
|
|
|
|
def _display_ready(backend) -> tuple[bool, str]:
|
|
"""Linux: does a usable DISPLAY actually exist for the chosen backend?
|
|
|
|
`initialize()` only proves the backend MODULE imports (the first real
|
|
display access is `BrowserView.__init__` → `Gdk.Display.get_default()`
|
|
inside webview.start()), so a box WITH gi bindings but WITHOUT a display
|
|
passed the import probe and crashed. The session-env check runs BEFORE
|
|
initialize() in _webview_ready; here the chosen backend is refined: GTK's
|
|
display handle answers None on a dead display, never a raise. DISCLOSED
|
|
RESIDUAL: Qt is judged on the env alone (probing Qt constructs a
|
|
QGuiApplication, which ABORTS). Full rationale: ARCHITECTURE.
|
|
"""
|
|
if str(getattr(backend, "__name__", "")).endswith(".gtk"):
|
|
try:
|
|
from gi.repository import Gdk
|
|
except Exception as exc: # gi vanished between import and probe
|
|
return False, f"GTK backend without gi.repository.Gdk: {type(exc).__name__}: {exc}"
|
|
if Gdk.Display.get_default() is None:
|
|
return False, "GTK backend has no default display (DISPLAY/WAYLAND_DISPLAY is unusable)"
|
|
return True, ""
|
|
|
|
|
|
def _webview_ready() -> tuple[bool, str]:
|
|
"""Probe whether pywebview can actually run a GUI window here.
|
|
|
|
pywebview 5.x picks its backend LAZILY inside webview.start(), so
|
|
`import webview` succeeds without GTK/QT and the process would only die
|
|
later, at the first window site (#56). initialize() reuses pywebview's
|
|
own backend selection as the SSOT (PYWEBVIEW_GUI, session detection);
|
|
its second run inside webview.start() is cheap. On Linux the import
|
|
answer is then refined by `_display_ready`; macOS/Windows keep the
|
|
import-only answer — `_detect_headless` never calls this there.
|
|
"""
|
|
if IS_LINUX and not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
|
# BEFORE initialize(): even SELECTING a Qt backend constructs a
|
|
# QGuiApplication, which can ABORT on a display-less box.
|
|
return False, "no DISPLAY or WAYLAND_DISPLAY in the environment"
|
|
try:
|
|
import webview # noqa: F401 (can itself fail on bare source checkouts)
|
|
from webview.guilib import initialize
|
|
|
|
backend = initialize()
|
|
except Exception as exc:
|
|
return False, f"{type(exc).__name__}: {exc}"
|
|
if not IS_LINUX:
|
|
return True, ""
|
|
return _display_ready(backend)
|
|
|
|
|
|
def _detect_headless() -> None:
|
|
"""Set the module _headless flag on Linux when no GUI backend exists.
|
|
|
|
Linux-only by design (#56): macOS/Windows return before the probe and keep
|
|
their existing behavior untouched. Called once at the top of main().
|
|
"""
|
|
global _headless
|
|
if not IS_LINUX:
|
|
return
|
|
ok, reason = _webview_ready()
|
|
if not ok:
|
|
_headless = True
|
|
log.warning(
|
|
"No usable pywebview GUI backend (%s); continuing in browser mode.",
|
|
reason,
|
|
)
|
|
|
|
|
|
def _headless_signal_handler(signum, frame) -> None:
|
|
"""SIGINT/SIGTERM in headless mode: ONLY set the shutdown event —
|
|
teardown runs on the main thread (signal-frame work risks re-entrancy)."""
|
|
_shutdown_event.set()
|
|
|
|
|
|
def _open_browser_detached(url: str) -> threading.Thread:
|
|
"""Open the default browser without ever blocking the caller.
|
|
|
|
`webbrowser.open` waits for the child on a stdlib-resolved console
|
|
browser (w3m/lynx or an unrecognized $BROWSER), which would stall the
|
|
keep-alive loop for that browser's lifetime; the URL is already printed,
|
|
so the open is best-effort and rides a daemon thread. Returns the thread
|
|
so a short-lived caller (the already-running notice) can bound-join it
|
|
before process exit would kill the daemon thread under the opener.
|
|
|
|
DELIBERATE (owner-approved): the opened browser is the USER'S own
|
|
application, intentionally outside process custody and launcher teardown —
|
|
the Emergency-Stop invariant governs the AGENT'S tree, and killing the
|
|
owner's browser would be hostile. A stdlib-resolved console browser
|
|
(w3m/lynx) may outlive the launcher; the printed URL is primary.
|
|
"""
|
|
def _open() -> None:
|
|
try:
|
|
webbrowser.open(url)
|
|
except Exception:
|
|
log.info("Could not open the default browser for %s", url, exc_info=True)
|
|
|
|
thread = threading.Thread(target=_open, name="ouroboros-open-browser", daemon=True)
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
def _run_headless_main(url: str, port: int, lifecycle_thread: threading.Thread) -> None:
|
|
"""Browser-mode replacement for the main webview window (Linux headless).
|
|
|
|
Prints the URL, best-effort opens the browser, keeps the process alive
|
|
until shutdown. CRITICAL: the keep-alive loop also watches lifecycle-
|
|
thread liveness — the crash fuse exits that thread WITHOUT setting
|
|
_shutdown_event, so waiting on the event alone would leave a zombie
|
|
launcher. Panic needs nothing here (it os._exit()s from the lifecycle
|
|
thread). sys.exit (not os._exit) is correct without a webview.
|
|
Never returns.
|
|
"""
|
|
if not _shutdown_event.is_set():
|
|
# A signal can land between main()'s startup check and this entry:
|
|
# no URL announcement / browser launch mid-shutdown — straight to
|
|
# teardown (the keep-alive loop returns immediately).
|
|
log.info("Headless browser mode: serving the UI at %s", url)
|
|
print(
|
|
f"Ouroboros is running at {url}. No GUI backend (GTK/QT) — "
|
|
"opening in your default browser. Press Ctrl-C to stop.",
|
|
flush=True,
|
|
)
|
|
_open_browser_detached(url)
|
|
# Handlers were installed in main() BEFORE the lifecycle thread started;
|
|
# the browser open above rides a daemon thread because stdlib can resolve
|
|
# a console browser (GenericBrowser) that blocks for its whole lifetime.
|
|
while not _shutdown_event.wait(1.0):
|
|
if not lifecycle_thread.is_alive():
|
|
log.error(
|
|
"Agent lifecycle thread exited without a shutdown request "
|
|
"(crash fuse); shutting down."
|
|
)
|
|
break
|
|
stop_agent()
|
|
_kill_orphaned_children(port)
|
|
# NO explicit release_pid_lock(): sys.exit runs the atexit-registered
|
|
# release, and a second release would unconditionally unlink a lock a
|
|
# NEWER launcher may own (explicit calls stay only on os._exit paths).
|
|
# Event set == requested shutdown; otherwise crash fuse → exit nonzero.
|
|
sys.exit(0 if _shutdown_event.is_set() else 1)
|
|
|
|
|
|
def main():
|
|
if IS_WINDOWS:
|
|
ok, reason = _prepare_windows_webview_runtime()
|
|
if not ok:
|
|
log.error("Windows UI runtime initialization failed: %s", reason)
|
|
_show_windows_message(
|
|
"Ouroboros — Startup Failed",
|
|
"Windows UI runtime initialization failed.\n\n"
|
|
f"{reason}\n\n"
|
|
"Check launcher.log for details.",
|
|
)
|
|
return
|
|
|
|
_detect_headless()
|
|
|
|
if not _headless:
|
|
import webview
|
|
|
|
if not acquire_pid_lock():
|
|
log.error("Another instance already running.")
|
|
if _headless:
|
|
# The lock loss usually races the FIRST launcher's bootstrap
|
|
# (repeated Open clicks): the port file may be absent (unlinked
|
|
# pre-start) or stale from an older run on another port. Poll
|
|
# briefly for a healthy server, re-reading the file between
|
|
# probes, so Open during bootstrap lands on the live UI; on
|
|
# timeout fall back to the last-read port (best-effort notice;
|
|
# the bound is soft — a probe straddling the deadline may run
|
|
# its own urlopen timeout, a couple of seconds of overshoot).
|
|
port = _read_port_file()
|
|
deadline = time.time() + 10.0
|
|
while time.time() < deadline and not _wait_for_server(port, timeout=1.0):
|
|
time.sleep(0.5)
|
|
port = _read_port_file()
|
|
existing_url = f"http://127.0.0.1:{port}"
|
|
print(
|
|
f"Ouroboros is already running at {existing_url}",
|
|
file=sys.stderr,
|
|
)
|
|
# Desktop-icon launches have no visible stderr, so the notice
|
|
# alone reads as "Open does nothing". Surface the running
|
|
# instance the same way a fresh headless boot would — open the
|
|
# default browser at it. Bounded join: the open rides a daemon
|
|
# thread (see _open_browser_detached), and returning immediately
|
|
# would end the process under the opener before it fires.
|
|
_open_browser_detached(existing_url).join(timeout=5.0)
|
|
return
|
|
webview.create_window(
|
|
"Ouroboros",
|
|
html="<html><body style='background:#1a1a2e;color:white;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
|
|
"<div style='text-align:center'><h2>Ouroboros is already running</h2><p>Only one instance can run at a time.</p></div></body></html>",
|
|
width=420,
|
|
height=200,
|
|
)
|
|
webview.start()
|
|
return
|
|
|
|
import atexit
|
|
|
|
atexit.register(release_pid_lock)
|
|
|
|
if not check_git():
|
|
log.warning("Git not found.")
|
|
if _headless:
|
|
# No headless sudo flows: print the platform hint and stop.
|
|
log.error("Git is required; cannot run the GUI install helper in browser mode.")
|
|
print("Git is required to run Ouroboros.", file=sys.stderr)
|
|
print(git_install_hint(), file=sys.stderr)
|
|
sys.exit(1)
|
|
_hint = git_install_hint()
|
|
_install_status = (
|
|
"Installing... A system dialog may appear."
|
|
if IS_MACOS
|
|
else "Installing... Please wait."
|
|
)
|
|
|
|
def _git_page(window):
|
|
window.evaluate_js(
|
|
"""
|
|
document.getElementById('install-btn').onclick = function() {
|
|
document.getElementById('status').textContent = '__INSTALL_STATUS__';
|
|
window.pywebview.api.install_git();
|
|
};
|
|
""".replace("__INSTALL_STATUS__", _install_status)
|
|
)
|
|
|
|
class GitApi:
|
|
def install_git(self):
|
|
if IS_MACOS:
|
|
subprocess.Popen(["xcode-select", "--install"])
|
|
elif IS_WINDOWS:
|
|
_hidden_popen(
|
|
["winget", "install", "Git.Git", "--source", "winget", "--accept-source-agreements"]
|
|
)
|
|
else:
|
|
for cmd in (
|
|
["sudo", "apt", "install", "-y", "git"],
|
|
["sudo", "dnf", "install", "-y", "git"],
|
|
):
|
|
try:
|
|
_hidden_popen(cmd)
|
|
break
|
|
except FileNotFoundError:
|
|
continue
|
|
for _ in range(300):
|
|
time.sleep(3)
|
|
if shutil.which("git"):
|
|
return "installed"
|
|
return "timeout"
|
|
|
|
git_window = webview.create_window(
|
|
"Ouroboros — Setup Required",
|
|
html=(
|
|
"""<html><body style="background:#1a1a2e;color:white;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
|
|
<div style="text-align:center">
|
|
<h2>Git is required</h2>
|
|
<p>Ouroboros needs Git to manage its local repository.</p>
|
|
<button id="install-btn" style="padding:10px 24px;border-radius:8px;border:none;background:#0ea5e9;color:white;cursor:pointer;font-size:14px">
|
|
Install Git (Xcode CLI Tools)
|
|
</button>
|
|
<p id="status" style="color:#fbbf24;margin-top:12px"></p>
|
|
</div></body></html>"""
|
|
if IS_MACOS
|
|
else f"""<html><body style="background:#1a1a2e;color:white;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
|
|
<div style="text-align:center">
|
|
<h2>Git is required</h2>
|
|
<p>Ouroboros needs Git to manage its local repository.</p>
|
|
<p style="color:#94a3b8;font-size:13px;margin-top:8px">{_hint}</p>
|
|
<button id="install-btn" style="padding:10px 24px;border-radius:8px;border:none;background:#0ea5e9;color:white;cursor:pointer;font-size:14px;margin-top:12px">
|
|
Install Git
|
|
</button>
|
|
<p id="status" style="color:#fbbf24;margin-top:12px"></p>
|
|
</div></body></html>"""
|
|
),
|
|
js_api=GitApi(),
|
|
width=520,
|
|
height=300,
|
|
)
|
|
webview.start(func=_git_page, args=[git_window])
|
|
if not check_git():
|
|
sys.exit(1)
|
|
|
|
if not bootstrap_repo():
|
|
# Disclosed, not fatal: an already-provisioned install with satisfied
|
|
# requirements still runs, and a genuinely broken checkout is stopped
|
|
# by the startup health check / crash fuse with this line naming why.
|
|
log.error(
|
|
"Continuing startup after a failed dependency install; the agent may "
|
|
"be missing packages (see the pip output above)."
|
|
)
|
|
|
|
# D-8: decide here, PRESENT after the gateway is healthy. Everything above
|
|
# (single-instance lock, Git, managed-repo bootstrap/seed validation) is a
|
|
# precondition of the server itself and deliberately still precedes it.
|
|
onboarding_settings, onboarding_required = _prepare_first_run_settings()
|
|
|
|
global _webview_window
|
|
port = AGENT_SERVER_PORT
|
|
|
|
# Clear any stale server process or ports before starting the new agent
|
|
_cleanup_recorded_server_process("preflight")
|
|
_reap_same_install_strays("preflight")
|
|
_kill_stale_runtime_ports(port)
|
|
try:
|
|
PORT_FILE.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
# Headless: handlers BEFORE the lifecycle thread spawns server.py — a
|
|
# SIGTERM in the ~60s readiness window must reach our teardown.
|
|
_abort = None
|
|
if _headless:
|
|
install_shutdown_signal_handlers(_headless_signal_handler)
|
|
_abort = _shutdown_event
|
|
|
|
lifecycle_thread = threading.Thread(target=agent_lifecycle_loop, args=(port,), daemon=True)
|
|
lifecycle_thread.start()
|
|
|
|
server_ready, actual_port = _await_server_ready(port, _abort)
|
|
|
|
if server_ready and onboarding_required and not _shutdown_event.is_set():
|
|
# The gateway is live and, with no provider configured, runs WITHOUT a
|
|
# supervisor — the supported state that lets the wizard reach /api/*.
|
|
onboarding = _present_first_run_onboarding(
|
|
onboarding_settings, actual_port, headless=_headless
|
|
)
|
|
if not onboarding["saved"]:
|
|
log.info(
|
|
"Setup was closed without saving. Launching anyway "
|
|
"(the blocking onboarding overlay and Settings remain available)."
|
|
)
|
|
if onboarding["restart_required"] and not _shutdown_event.is_set():
|
|
# The completion changed something this process pinned at boot (its
|
|
# runtime-mode baseline). The launcher owns the process, so it
|
|
# recycles it instead of leaving the owner with a restart nag.
|
|
_request_agent_restart()
|
|
server_ready, actual_port = _await_server_ready(port, _abort)
|
|
|
|
if _headless and _shutdown_event.is_set():
|
|
# Shutdown during startup: same teardown the keep-alive loop performs
|
|
# (an aborted wait is a requested shutdown, not a startup failure).
|
|
log.info("Shutdown requested during headless startup; tearing down.")
|
|
stop_agent()
|
|
_kill_orphaned_children(actual_port)
|
|
# atexit owns release_pid_lock on sys.exit (a second release would
|
|
# unlink a newer launcher's lock).
|
|
sys.exit(0)
|
|
|
|
if not server_ready:
|
|
log.error("Agent failed to become healthy on port %d; aborting UI startup.", actual_port)
|
|
_shutdown_event.set()
|
|
stop_agent()
|
|
lifecycle_thread.join(timeout=5)
|
|
if _headless:
|
|
_kill_orphaned_children(actual_port)
|
|
print(
|
|
"Ouroboros failed to start: the local agent server did not "
|
|
"become ready.\n"
|
|
f"See {_log_dir / 'launcher.log'} and "
|
|
f"{_log_dir / 'agent_stdout.log'} for details.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
webview.create_window(
|
|
"Ouroboros — Startup Failed",
|
|
html=(
|
|
"<html><body style='background:#1a1a2e;color:white;font-family:system-ui;"
|
|
"display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
|
|
"<div style='text-align:center;max-width:460px;padding:24px'>"
|
|
"<h2>Ouroboros failed to start</h2>"
|
|
"<p>The local agent server did not become ready.</p>"
|
|
"<p style='color:#94a3b8;font-size:13px;margin-top:10px'>"
|
|
"Check launcher.log and agent_stdout.log in the Ouroboros data directory "
|
|
"for details.</p>"
|
|
"</div></body></html>"
|
|
),
|
|
width=520,
|
|
height=260,
|
|
)
|
|
webview.start()
|
|
return
|
|
|
|
def _resolve_bridge_file_url(raw_url: str) -> str:
|
|
"""Validate a loopback file-bridge URL, returning the resolved full URL.
|
|
|
|
Shared SSOT for both the download-to-Downloads and open-in-default-app
|
|
bridge methods so the loopback guard cannot drift between them.
|
|
"""
|
|
import urllib.parse
|
|
|
|
full_url = urllib.parse.urljoin(f"http://127.0.0.1:{actual_port}", str(raw_url or ""))
|
|
parsed = urllib.parse.urlparse(full_url)
|
|
if parsed.scheme != "http":
|
|
raise ValueError("file URL must be http://")
|
|
if parsed.hostname not in {"127.0.0.1", "localhost"}:
|
|
raise ValueError("desktop file access is limited to the local Ouroboros server")
|
|
if parsed.port != actual_port:
|
|
raise ValueError("file URL port must match the local Ouroboros server")
|
|
if parsed.path != "/api/files/download" and not parsed.path.startswith("/api/extensions/"):
|
|
raise ValueError("file URL path must be /api/files/download or /api/extensions/<skill>/...")
|
|
return full_url
|
|
|
|
def _unique_bridge_target(directory: pathlib.Path, filename: str) -> pathlib.Path:
|
|
safe_name = pathlib.Path(str(filename or "download")).name or "download"
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
target = directory / safe_name
|
|
stem = target.stem
|
|
suffix = target.suffix
|
|
counter = 1
|
|
while target.exists():
|
|
target = directory / f"{stem}-{counter}{suffix}"
|
|
counter += 1
|
|
return target
|
|
|
|
def _fetch_bridge_url_to(full_url: str, target: pathlib.Path) -> None:
|
|
import urllib.request
|
|
|
|
with urllib.request.urlopen(full_url, timeout=60) as resp: # noqa: S310 - localhost validated above
|
|
with target.open("wb") as fh:
|
|
shutil.copyfileobj(resp, fh)
|
|
|
|
class MainApi:
|
|
def request_runtime_mode_change(self, mode: str) -> dict:
|
|
try:
|
|
return _request_runtime_mode_change(
|
|
mode,
|
|
lambda title, message: bool(
|
|
_webview_window and _webview_window.create_confirmation_dialog(title, message)
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
log.warning("Runtime mode native confirmation failed: %s", exc, exc_info=True)
|
|
return {"ok": False, "error": f"Native confirmation failed: {exc}"}
|
|
|
|
def request_auto_grant_reviewed_skills_change(self, enabled: bool) -> dict:
|
|
try:
|
|
return _request_auto_grant_reviewed_skills_change(
|
|
bool(enabled),
|
|
lambda title, message: bool(
|
|
_webview_window and _webview_window.create_confirmation_dialog(title, message)
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
log.warning("Reviewed-skill auto-grant confirmation failed: %s", exc, exc_info=True)
|
|
return {"ok": False, "error": f"Native confirmation failed: {exc}"}
|
|
|
|
def request_skill_key_grant(self, skill: str, keys: list) -> dict:
|
|
try:
|
|
return _request_skill_key_grant(
|
|
skill,
|
|
keys,
|
|
lambda title, message: bool(
|
|
_webview_window and _webview_window.create_confirmation_dialog(title, message)
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
log.warning("Skill grant native confirmation failed: %s", exc, exc_info=True)
|
|
return {"ok": False, "error": f"Native confirmation failed: {exc}"}
|
|
|
|
def download_file_to_downloads(self, url: str, filename: str, open_external: bool = False) -> dict:
|
|
try:
|
|
full_url = _resolve_bridge_file_url(url)
|
|
target = _unique_bridge_target(pathlib.Path.home() / "Downloads", filename)
|
|
_fetch_bridge_url_to(full_url, target)
|
|
if open_external:
|
|
open_path_external(target)
|
|
return {"ok": True, "path": str(target)}
|
|
except Exception as exc:
|
|
log.warning("Desktop file download failed: %s", exc, exc_info=True)
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
def open_file_with_default_app(self, url: str, filename: str) -> dict:
|
|
"""Open a delivered file in the OS default app (external window).
|
|
|
|
Fetches the loopback file into a private temp dir (NOT ~/Downloads)
|
|
and hands it to the platform default handler. This never navigates
|
|
the in-app WKWebView, which was the original fullscreen-lockup bug.
|
|
"""
|
|
try:
|
|
full_url = _resolve_bridge_file_url(url)
|
|
# Per-open private dir: mkdtemp atomically creates a fresh 0700
|
|
# directory, so a pre-placed symlink/dir at a shared temp path
|
|
# cannot redirect the write (hardens over a fixed shared root).
|
|
open_root = pathlib.Path(tempfile.mkdtemp(prefix="ouroboros-open-"))
|
|
target = _unique_bridge_target(open_root, filename)
|
|
_fetch_bridge_url_to(full_url, target)
|
|
open_path_external(target)
|
|
return {"ok": True, "path": str(target)}
|
|
except Exception as exc:
|
|
log.warning("Desktop open-in-default-app failed: %s", exc, exc_info=True)
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
# Prune stale externally-opened temp copies from previous sessions (privacy + disk).
|
|
for _stale_open in pathlib.Path(tempfile.gettempdir()).glob("ouroboros-open-*"):
|
|
shutil.rmtree(_stale_open, ignore_errors=True)
|
|
|
|
url = f"http://127.0.0.1:{actual_port}"
|
|
|
|
if _headless:
|
|
# Never returns: keep-alive loop + teardown + sys.exit inside.
|
|
_run_headless_main(url, actual_port, lifecycle_thread)
|
|
|
|
window = webview.create_window(
|
|
f"Ouroboros v{APP_VERSION}",
|
|
url=url,
|
|
js_api=MainApi(),
|
|
width=1100,
|
|
height=750,
|
|
min_size=(800, 500),
|
|
background_color="#0d0b0f",
|
|
text_select=True,
|
|
)
|
|
|
|
def _on_closing() -> None:
|
|
log.info("Window closing — graceful shutdown.")
|
|
_shutdown_event.set()
|
|
stop_agent()
|
|
_kill_orphaned_children(port)
|
|
release_pid_lock()
|
|
os._exit(0)
|
|
|
|
window.events.closing += _on_closing
|
|
_webview_window = window
|
|
|
|
webview.start(debug=False)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
from multiprocessing import freeze_support
|
|
|
|
freeze_support()
|
|
|
|
if sys.platform == "darwin":
|
|
try:
|
|
shell_path = subprocess.check_output(
|
|
["/bin/bash", "-l", "-c", "echo $PATH"],
|
|
text=True,
|
|
timeout=5,
|
|
).strip()
|
|
if shell_path:
|
|
os.environ["PATH"] = shell_path
|
|
except Exception:
|
|
pass
|
|
|
|
main()
|