212 lines
7.1 KiB
Python
212 lines
7.1 KiB
Python
"""OpenClaw integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from omlx.integrations.base import Integration, IntegrationContext
|
|
from omlx.utils.install import get_cli_command_prefix
|
|
|
|
DEFAULT_GATEWAY_PORT = 18789
|
|
|
|
|
|
class OpenClawIntegration(Integration):
|
|
"""OpenClaw integration that writes ~/.openclaw/openclaw.json."""
|
|
|
|
CONFIG_PATH = Path.home() / ".openclaw" / "openclaw.json"
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
name="openclaw",
|
|
display_name="OpenClaw",
|
|
type="config_file",
|
|
install_check="openclaw",
|
|
install_hint="npm install -g openclaw",
|
|
)
|
|
|
|
def get_command(self, ctx: IntegrationContext) -> str:
|
|
return (
|
|
f"{get_cli_command_prefix()} "
|
|
f"launch openclaw --model {ctx.model or 'select-a-model'}"
|
|
)
|
|
|
|
def configure(self, ctx: IntegrationContext) -> None:
|
|
def updater(config: dict) -> None:
|
|
config.setdefault("models", {}).setdefault("providers", {})
|
|
provider_config = {
|
|
"baseUrl": ctx.openai_base_url,
|
|
"apiKey": ctx.auth_token,
|
|
"api": "openai-completions",
|
|
}
|
|
if ctx.model:
|
|
model_entry: dict = {
|
|
"id": ctx.model,
|
|
"name": ctx.model,
|
|
"api": "openai-completions",
|
|
"reasoning": bool(ctx.reasoning)
|
|
if ctx.reasoning is not None
|
|
else False,
|
|
"input": ["text", "image"] if ctx.supports_images else ["text"],
|
|
"cost": {
|
|
"input": 0,
|
|
"output": 0,
|
|
"cacheRead": 0,
|
|
"cacheWrite": 0,
|
|
},
|
|
}
|
|
if ctx.context_window:
|
|
model_entry["contextWindow"] = ctx.context_window
|
|
if ctx.max_tokens:
|
|
model_entry["maxTokens"] = ctx.max_tokens
|
|
provider_config["models"] = [
|
|
model_entry
|
|
]
|
|
config["models"]["providers"]["omlx"] = provider_config
|
|
|
|
# Set as default model
|
|
if ctx.model:
|
|
config.setdefault("agents", {}).setdefault("defaults", {}).setdefault(
|
|
"model", {}
|
|
)
|
|
config["agents"]["defaults"]["model"]["primary"] = f"omlx/{ctx.model}"
|
|
|
|
# Set tools profile
|
|
config.setdefault("tools", {})
|
|
config["tools"]["profile"] = ctx.tools_profile
|
|
|
|
self._write_json_config(self.CONFIG_PATH, updater)
|
|
|
|
def _gateway_info(self) -> tuple[str, int]:
|
|
"""Read gateway token and port from OpenClaw config."""
|
|
token = ""
|
|
port = DEFAULT_GATEWAY_PORT
|
|
try:
|
|
data = json.loads(self.CONFIG_PATH.read_text())
|
|
gw = data.get("gateway", {})
|
|
if p := gw.get("port"):
|
|
port = int(p)
|
|
auth = gw.get("auth", {})
|
|
if t := auth.get("token"):
|
|
token = t
|
|
except Exception:
|
|
pass
|
|
return token, port
|
|
|
|
@staticmethod
|
|
def _port_open(host: str, port: int) -> bool:
|
|
"""Check if a TCP port is accepting connections."""
|
|
try:
|
|
with socket.create_connection((host, port), timeout=0.5):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
@staticmethod
|
|
def _wait_for_port(host: str, port: int, timeout: float = 30.0) -> bool:
|
|
"""Wait until a TCP port is accepting connections."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
with socket.create_connection((host, port), timeout=0.5):
|
|
return True
|
|
except OSError:
|
|
time.sleep(0.25)
|
|
return False
|
|
|
|
def _is_onboarded(self) -> bool:
|
|
"""Check if OpenClaw onboarding was completed."""
|
|
try:
|
|
data = json.loads(self.CONFIG_PATH.read_text())
|
|
return bool(data.get("wizard", {}).get("lastRunAt"))
|
|
except Exception:
|
|
return False
|
|
|
|
EXEC_APPROVALS_PATH = Path.home() / ".openclaw" / "exec-approvals.json"
|
|
|
|
def configure_exec_approvals(self, tools_profile: str = "coding") -> None:
|
|
"""Configure exec-approvals.json based on tools profile.
|
|
|
|
Profiles "coding" and "full" get unrestricted exec.
|
|
Others get allowlist-based restrictions.
|
|
"""
|
|
allow_exec = tools_profile in ("coding", "full")
|
|
|
|
def updater(config: dict) -> None:
|
|
config.setdefault("defaults", {})
|
|
if allow_exec:
|
|
config["defaults"]["security"] = "full"
|
|
config["defaults"]["ask"] = "off"
|
|
else:
|
|
config["defaults"]["security"] = "allowlist"
|
|
config["defaults"]["ask"] = "on-miss"
|
|
|
|
self._write_json_config(self.EXEC_APPROVALS_PATH, updater)
|
|
|
|
def launch(self, ctx: IntegrationContext) -> None:
|
|
self.configure(ctx)
|
|
self.configure_exec_approvals(ctx.tools_profile)
|
|
|
|
env = self._scrubbed_env()
|
|
bin_name = "openclaw"
|
|
|
|
# Run onboarding if not yet completed (like ollama does)
|
|
if not self._is_onboarded():
|
|
print("Setting up OpenClaw with oMLX...")
|
|
print(f" Model: {ctx.model}")
|
|
subprocess.run(
|
|
[
|
|
bin_name,
|
|
"onboard",
|
|
"--non-interactive",
|
|
"--accept-risk",
|
|
"--auth-choice",
|
|
"skip",
|
|
"--gateway-token",
|
|
"omlx",
|
|
"--install-daemon",
|
|
"--skip-channels",
|
|
"--skip-skills",
|
|
],
|
|
env=env,
|
|
)
|
|
# Onboarding overwrites config, re-apply
|
|
self.configure(ctx)
|
|
self.configure_exec_approvals(ctx.tools_profile)
|
|
|
|
_, gw_port = self._gateway_info()
|
|
addr = ("localhost", gw_port)
|
|
|
|
# Restart gateway if already running so it picks up config changes
|
|
if self._port_open(*addr):
|
|
subprocess.run(
|
|
[bin_name, "daemon", "restart"],
|
|
env=env,
|
|
capture_output=True,
|
|
)
|
|
if not self._wait_for_port(*addr, timeout=10.0):
|
|
print("Warning: gateway did not come back after restart")
|
|
|
|
# Start gateway if not running
|
|
if not self._port_open(*addr):
|
|
gw = subprocess.Popen(
|
|
[bin_name, "gateway", "run", "--force"],
|
|
env=env,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
if not self._wait_for_port(*addr, timeout=30.0):
|
|
gw.kill()
|
|
print(f"Gateway did not start on port {gw_port}")
|
|
sys.exit(1)
|
|
|
|
print("OpenClaw is running")
|
|
|
|
# Launch TUI (replaces this process)
|
|
os.execvpe(bin_name, [bin_name, "tui", *ctx.extra_args], env)
|