diff --git a/agent-node/bootstrap_installer.py b/agent-node/bootstrap_installer.py index de27f46..35832df 100644 --- a/agent-node/bootstrap_installer.py +++ b/agent-node/bootstrap_installer.py @@ -132,10 +132,24 @@ _print(f"Config written (raw) to {config_path}") -def _launch(install_dir: str): - """Launches the agent in-place, replacing the bootstrapper process.""" +def _launch(install_dir: str, as_daemon: bool = False): + """Launches the agent in-place, or installs it as a background daemon.""" + if as_daemon: + _print("Installing as a background daemon service...") + daemon_script = os.path.join(install_dir, "install_service.py") + if os.path.exists(daemon_script): + os.chdir(install_dir) + sys.path.insert(0, install_dir) + import subprocess + subprocess.run([sys.executable, daemon_script]) + _print("Bootstrap complete. Agent is running in the background.") + sys.exit(0) + else: + _print(f"ERROR: install_service.py not found at {daemon_script}") + sys.exit(1) + entry = os.path.join(install_dir, "agent_node", "main.py") - _print(f"Launching agent: {sys.executable} {entry}") + _print(f"Launching agent in foreground: {sys.executable} {entry}") sys.stdout.flush() sys.stderr.flush() os.chdir(install_dir) @@ -160,6 +174,7 @@ parser.add_argument("--grpc", default=None, help="gRPC endpoint (default: derived from hub URL)") parser.add_argument("--install-dir", default=INSTALL_DIR, help=f"Install path (default: {INSTALL_DIR})") parser.add_argument("--update-only", action="store_true", help="Only pull latest code, don't re-launch") + parser.add_argument("--daemon", action="store_true", help="Install and run as a persistent background daemon (macOS/Linux)") args = parser.parse_args() # Try loading existing config for defaults @@ -195,8 +210,9 @@ local_version = f.read().strip() if local_version == remote_version and not args.update_only: _print(f"Already at {local_version} — launching existing installation.") - _launch(install_dir) - return # unreachable + _launch(install_dir, as_daemon=args.daemon) + return # unreachable unless daemon + _print(f"Updating {local_version} → {remote_version}") _install(hub_url, token, install_dir) @@ -208,7 +224,7 @@ return _print(f"✅ Agent v{remote_version} installed at {install_dir}") - _launch(install_dir) # replaces this process + _launch(install_dir, as_daemon=args.daemon) # replaces this process or exits if __name__ == "__main__": diff --git a/agent-node/install_service.py b/agent-node/install_service.py new file mode 100755 index 0000000..0c1882e --- /dev/null +++ b/agent-node/install_service.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Cortex Agent Node - Daemon Installer +==================================== +Configures the Cortex Agent to run automatically as a background daemon +on macOS (launchd) or Linux (systemd). + +Usage: + python3 install_service.py +""" + +import os +import sys +import platform +import subprocess + +def get_python_path(): + return sys.executable + +def get_agent_main_path(): + return os.path.abspath(os.path.join(os.path.dirname(__file__), "agent_node", "main.py")) + +def get_working_dir(): + return os.path.abspath(os.path.dirname(__file__)) + +def install_mac_launchd(): + print("Installing macOS launchd service...") + + plist_content = f""" + + + + Label + com.jerxie.cortex.agent + ProgramArguments + + {get_python_path()} + {get_agent_main_path()} + + WorkingDirectory + {get_working_dir()} + KeepAlive + + RunAtLoad + + StandardErrorPath + {os.path.expanduser("~")}/.cortex/agent.err.log + StandardOutPath + {os.path.expanduser("~")}/.cortex/agent.out.log + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + + +""" + agents_dir = os.path.expanduser("~/Library/LaunchAgents") + os.makedirs(agents_dir, exist_ok=True) + os.makedirs(os.path.expanduser("~/.cortex"), exist_ok=True) + + plist_path = os.path.join(agents_dir, "com.jerxie.cortex.agent.plist") + + with open(plist_path, "w") as f: + f.write(plist_content) + + print(f"Created plist at {plist_path}") + + try: + # Unload if exists + subprocess.run(["launchctl", "unload", plist_path], capture_output=True) + # Load new + subprocess.run(["launchctl", "load", plist_path], check=True) + print("✅ macOS Daemon successfully started!") + print(f"Logs: ~/.cortex/agent.out.log and ~/.cortex/agent.err.log") + print("Commands:") + print(f" Stop: launchctl unload {plist_path}") + print(f" Start: launchctl load {plist_path}") + except subprocess.CalledProcessError as e: + print(f"❌ Failed to load launchd service: {e}") + +def install_linux_systemd(): + print("Installing Linux systemd user service...") + + service_content = f"""[Unit] +Description=Cortex Agent Node +After=network.target + +[Service] +Type=simple +ExecStart={get_python_path()} {get_agent_main_path()} +WorkingDirectory={get_working_dir()} +Restart=always +RestartSec=5 +StandardOutput=append:{os.path.expanduser("~")}/.cortex/agent.out.log +StandardError=append:{os.path.expanduser("~")}/.cortex/agent.err.log + +[Install] +WantedBy=default.target +""" + + systemd_dir = os.path.expanduser("~/.config/systemd/user") + os.makedirs(systemd_dir, exist_ok=True) + os.makedirs(os.path.expanduser("~/.cortex"), exist_ok=True) + + service_path = os.path.join(systemd_dir, "cortex-agent.service") + + with open(service_path, "w") as f: + f.write(service_content) + + print(f"Created systemd service at {service_path}") + + try: + subprocess.run(["systemctl", "--user", "daemon-reload"], check=True) + subprocess.run(["systemctl", "--user", "enable", "cortex-agent"], check=True) + subprocess.run(["systemctl", "--user", "restart", "cortex-agent"], check=True) + + # Ensure user services run even when not logged in + subprocess.run(["loginctl", "enable-linger", os.environ.get("USER", "root")], capture_output=True) + + print("✅ Linux Daemon successfully started!") + print(f"Logs: ~/.cortex/agent.out.log and ~/.cortex/agent.err.log") + print("Commands:") + print(" Status: systemctl --user status cortex-agent") + print(" Stop: systemctl --user stop cortex-agent") + print(" Start: systemctl --user start cortex-agent") + print(" Logs: journalctl --user -u cortex-agent -f") + except subprocess.CalledProcessError as e: + print(f"❌ Failed to configure systemd service: {e}") + +def main(): + if not os.path.exists(get_agent_main_path()): + print(f"❌ Error: Could not find main agent script at {get_agent_main_path()}") + sys.exit(1) + + system = platform.system().lower() + + if system == "darwin": + install_mac_launchd() + elif system == "linux": + install_linux_systemd() + else: + print(f"❌ Unsupported OS for automated daemon install: {system}") + print("Please configure your system service manager manually.") + +if __name__ == "__main__": + main()