Merge branch 'master' into copilot/feature-allow-self-signed-tls

# Conflicts:
#	backend/app/api/gateways.py
#	backend/app/schemas/gateways.py
#	backend/app/services/openclaw/admin_service.py
#	backend/app/services/openclaw/gateway_resolver.py
#	backend/app/services/openclaw/gateway_rpc.py
#	backend/app/services/openclaw/provisioning.py
#	backend/app/services/openclaw/provisioning_db.py
#	frontend/src/api/generated/model/gatewayCreate.ts
#	frontend/src/api/generated/model/gatewayRead.ts
#	frontend/src/api/generated/model/gatewayUpdate.ts
This commit is contained in:
Abhimanyu Saharan
2026-02-22 19:51:27 +05:30
39 changed files with 1357 additions and 196 deletions

View File

@@ -51,6 +51,7 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
disable_device_pairing: bool = False
@pytest.mark.asyncio

View File

@@ -43,6 +43,7 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
disable_device_pairing: bool = False
@pytest.mark.asyncio

View File

@@ -119,6 +119,7 @@ class _GatewayStub:
url: str
token: str | None
workspace_root: str
disable_device_pairing: bool = False
@pytest.mark.asyncio
@@ -229,6 +230,7 @@ async def test_provision_overwrites_user_md_on_first_provision(monkeypatch):
url: str
token: str | None
workspace_root: str
disable_device_pairing: bool = False
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
def _agent_id(self, agent):
@@ -296,6 +298,7 @@ async def test_set_agent_files_update_preserves_user_md_even_when_size_zero():
url: str
token: str | None
workspace_root: str
disable_device_pairing: bool = False
class _Manager(agent_provisioning.BaseAgentLifecycleManager):
def _agent_id(self, agent):

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import base64
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from app.services.openclaw.device_identity import (
build_device_auth_payload,
load_or_create_device_identity,
sign_device_payload,
)
def _base64url_decode(value: str) -> bytes:
padding = "=" * ((4 - len(value) % 4) % 4)
return base64.urlsafe_b64decode(f"{value}{padding}")
def test_load_or_create_device_identity_persists_same_identity(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
identity_path = tmp_path / "identity" / "device.json"
monkeypatch.setenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", str(identity_path))
first = load_or_create_device_identity()
second = load_or_create_device_identity()
assert identity_path.exists()
assert first.device_id == second.device_id
assert first.public_key_pem.strip() == second.public_key_pem.strip()
assert first.private_key_pem.strip() == second.private_key_pem.strip()
def test_build_device_auth_payload_uses_nonce_for_v2() -> None:
payload = build_device_auth_payload(
device_id="dev",
client_id="gateway-client",
client_mode="backend",
role="operator",
scopes=["operator.read", "operator.admin"],
signed_at_ms=123,
token="token",
nonce="nonce-xyz",
)
assert payload == (
"v2|dev|gateway-client|backend|operator|operator.read,operator.admin|123|token|nonce-xyz"
)
def test_sign_device_payload_produces_valid_ed25519_signature(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
) -> None:
identity_path = tmp_path / "identity" / "device.json"
monkeypatch.setenv("OPENCLAW_GATEWAY_DEVICE_IDENTITY_PATH", str(identity_path))
identity = load_or_create_device_identity()
payload = "v1|device|client|backend|operator|operator.read|1|token"
signature = sign_device_payload(identity.private_key_pem, payload)
loaded = serialization.load_pem_public_key(identity.public_key_pem.encode("utf-8"))
assert isinstance(loaded, Ed25519PublicKey)
loaded.verify(_base64url_decode(signature), payload.encode("utf-8"))

View File

@@ -0,0 +1,64 @@
# ruff: noqa: S101
from __future__ import annotations
from uuid import uuid4
from app.models.gateways import Gateway
from app.services.openclaw.gateway_resolver import (
gateway_client_config,
optional_gateway_client_config,
)
from app.services.openclaw.session_service import GatewaySessionService
def _gateway(
*,
disable_device_pairing: bool,
url: str = "ws://gateway.example:18789/ws",
token: str | None = " secret-token ",
) -> Gateway:
return Gateway(
id=uuid4(),
organization_id=uuid4(),
name="Primary gateway",
url=url,
token=token,
workspace_root="~/.openclaw",
disable_device_pairing=disable_device_pairing,
)
def test_gateway_client_config_maps_disable_device_pairing() -> None:
config = gateway_client_config(_gateway(disable_device_pairing=True))
assert config.url == "ws://gateway.example:18789/ws"
assert config.token == "secret-token"
assert config.disable_device_pairing is True
def test_optional_gateway_client_config_maps_disable_device_pairing() -> None:
config = optional_gateway_client_config(_gateway(disable_device_pairing=False))
assert config is not None
assert config.disable_device_pairing is False
def test_optional_gateway_client_config_returns_none_for_missing_or_blank_url() -> None:
assert optional_gateway_client_config(None) is None
assert (
optional_gateway_client_config(
_gateway(disable_device_pairing=False, url=" "),
)
is None
)
def test_to_resolve_query_keeps_gateway_disable_device_pairing_value() -> None:
resolved = GatewaySessionService.to_resolve_query(
board_id=None,
gateway_url="ws://gateway.example:18789/ws",
gateway_token="secret-token",
gateway_disable_device_pairing=True,
)
assert resolved.gateway_disable_device_pairing is True

View File

@@ -1,24 +1,194 @@
from __future__ import annotations
import pytest
import app.services.openclaw.gateway_rpc as gateway_rpc
from app.services.openclaw.gateway_rpc import (
CONTROL_UI_CLIENT_ID,
CONTROL_UI_CLIENT_MODE,
DEFAULT_GATEWAY_CLIENT_ID,
DEFAULT_GATEWAY_CLIENT_MODE,
GATEWAY_OPERATOR_SCOPES,
GatewayConfig,
OpenClawGatewayError,
_build_connect_params,
_build_control_ui_origin,
openclaw_call,
)
def test_build_connect_params_sets_explicit_operator_role_and_scopes() -> None:
def test_build_connect_params_defaults_to_device_pairing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
expected_device_payload = {
"id": "device-id",
"publicKey": "public-key",
"signature": "signature",
"signedAt": 1,
}
def _fake_build_device_connect_payload(
*,
client_id: str,
client_mode: str,
role: str,
scopes: list[str],
auth_token: str | None,
connect_nonce: str | None,
) -> dict[str, object]:
captured["client_id"] = client_id
captured["client_mode"] = client_mode
captured["role"] = role
captured["scopes"] = scopes
captured["auth_token"] = auth_token
captured["connect_nonce"] = connect_nonce
return expected_device_payload
monkeypatch.setattr(
gateway_rpc,
"_build_device_connect_payload",
_fake_build_device_connect_payload,
)
params = _build_connect_params(GatewayConfig(url="ws://gateway.example/ws"))
assert params["role"] == "operator"
assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID
assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE
assert params["device"] == expected_device_payload
assert "auth" not in params
assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID
assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE
assert captured["role"] == "operator"
assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
assert captured["auth_token"] is None
assert captured["connect_nonce"] is None
def test_build_connect_params_includes_auth_token_when_provided() -> None:
def test_build_connect_params_uses_control_ui_when_pairing_disabled() -> None:
params = _build_connect_params(
GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
GatewayConfig(
url="ws://gateway.example/ws",
token="secret-token",
disable_device_pairing=True,
),
)
assert params["auth"] == {"token": "secret-token"}
assert params["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
assert params["client"]["id"] == CONTROL_UI_CLIENT_ID
assert params["client"]["mode"] == CONTROL_UI_CLIENT_MODE
assert "device" not in params
def test_build_connect_params_passes_nonce_to_device_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
def _fake_build_device_connect_payload(
*,
client_id: str,
client_mode: str,
role: str,
scopes: list[str],
auth_token: str | None,
connect_nonce: str | None,
) -> dict[str, object]:
captured["client_id"] = client_id
captured["client_mode"] = client_mode
captured["role"] = role
captured["scopes"] = scopes
captured["auth_token"] = auth_token
captured["connect_nonce"] = connect_nonce
return {"id": "device-id", "nonce": connect_nonce}
monkeypatch.setattr(
gateway_rpc,
"_build_device_connect_payload",
_fake_build_device_connect_payload,
)
params = _build_connect_params(
GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
connect_nonce="nonce-xyz",
)
assert params["auth"] == {"token": "secret-token"}
assert params["client"]["id"] == DEFAULT_GATEWAY_CLIENT_ID
assert params["client"]["mode"] == DEFAULT_GATEWAY_CLIENT_MODE
assert params["device"] == {"id": "device-id", "nonce": "nonce-xyz"}
assert captured["client_id"] == DEFAULT_GATEWAY_CLIENT_ID
assert captured["client_mode"] == DEFAULT_GATEWAY_CLIENT_MODE
assert captured["role"] == "operator"
assert captured["scopes"] == list(GATEWAY_OPERATOR_SCOPES)
assert captured["auth_token"] == "secret-token"
assert captured["connect_nonce"] == "nonce-xyz"
@pytest.mark.parametrize(
("gateway_url", "expected_origin"),
[
("ws://gateway.example/ws", "http://gateway.example"),
("wss://gateway.example/ws", "https://gateway.example"),
("ws://gateway.example:8080/ws", "http://gateway.example:8080"),
("wss://gateway.example:8443/ws", "https://gateway.example:8443"),
("ws://[::1]:8000/ws", "http://[::1]:8000"),
],
)
def test_build_control_ui_origin(gateway_url: str, expected_origin: str) -> None:
assert _build_control_ui_origin(gateway_url) == expected_origin
@pytest.mark.asyncio
async def test_openclaw_call_uses_single_connect_attempt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
call_count = 0
async def _fake_call_once(
method: str,
params: dict[str, object] | None,
*,
config: GatewayConfig,
gateway_url: str,
) -> object:
nonlocal call_count
del method, params, config, gateway_url
call_count += 1
return {"ok": True}
monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once)
payload = await openclaw_call(
"status",
config=GatewayConfig(url="ws://gateway.example/ws"),
)
assert payload == {"ok": True}
assert call_count == 1
@pytest.mark.asyncio
async def test_openclaw_call_surfaces_scope_error_without_device_fallback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_call_once(
method: str,
params: dict[str, object] | None,
*,
config: GatewayConfig,
gateway_url: str,
) -> object:
del method, params, config, gateway_url
raise OpenClawGatewayError("missing scope: operator.read")
monkeypatch.setattr(gateway_rpc, "_openclaw_call_once", _fake_call_once)
with pytest.raises(OpenClawGatewayError, match="missing scope: operator.read"):
await openclaw_call(
"status",
config=GatewayConfig(url="ws://gateway.example/ws", token="secret-token"),
)

View File

@@ -50,6 +50,11 @@ async def test_check_gateway_runtime_compatibility_prefers_schema_version(
return {"version": "2026.2.13"}
raise AssertionError(f"unexpected method: {method}")
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
return None
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
@@ -62,6 +67,34 @@ async def test_check_gateway_runtime_compatibility_prefers_schema_version(
assert result.current_version == "2026.2.13"
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_prefers_connect_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
return {"server": {"version": "2026.2.21-2"}}
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (params, config)
calls.append(method)
raise AssertionError(f"unexpected method: {method}")
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
assert calls == []
assert result.compatible is True
assert result.current_version == "2026.2.21-2"
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_falls_back_to_health(
monkeypatch: pytest.MonkeyPatch,
@@ -77,6 +110,11 @@ async def test_check_gateway_runtime_compatibility_falls_back_to_health(
raise OpenClawGatewayError("unknown method")
return {"version": "2026.2.0"}
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
return None
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
@@ -104,6 +142,11 @@ async def test_check_gateway_runtime_compatibility_uses_health_when_status_has_n
return {"uptime": 1234}
return {"version": "2026.2.0"}
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
_ = config
return None
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
@@ -157,6 +200,24 @@ async def test_admin_service_maps_gateway_transport_errors(
assert "compatibility check failed" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
async def test_admin_service_maps_gateway_scope_errors_with_guidance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
_ = (config, minimum_version)
raise OpenClawGatewayError("missing scope: operator.read")
monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
with pytest.raises(HTTPException) as exc_info:
await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None)
assert exc_info.value.status_code == 502
assert "missing required scope `operator.read`" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_gateway_status_reports_incompatible_version(
monkeypatch: pytest.MonkeyPatch,
@@ -183,6 +244,28 @@ async def test_gateway_status_reports_incompatible_version(
assert response.error == "Gateway version 2026.1.0 is not supported."
@pytest.mark.asyncio
async def test_gateway_status_surfaces_scope_error_guidance(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
_ = (config, minimum_version)
raise OpenClawGatewayError("missing scope: operator.read")
monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
response = await service.get_status(
params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"),
organization_id=uuid4(),
user=None,
)
assert response.connected is False
assert response.error is not None
assert "missing required scope `operator.read`" in response.error
@pytest.mark.asyncio
async def test_gateway_status_returns_sessions_when_version_compatible(
monkeypatch: pytest.MonkeyPatch,