mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
feat(computer-use): provision cua-driver at install time and on toolset enable
Choosing Computer Use should be a config flip, not a hunt for
'hermes computer-use install'. Three provisioning rungs:
- install.sh / install.ps1 pre-install cua-driver (best-effort,
non-fatal, time-boxed at 660s above the upstream installer's 600s
lock window; --skip-computer-use / -SkipComputerUse to opt out;
Termux and unwritable-/Applications skipped cleanly)
- PUT /api/tools/toolsets/{name} (dashboard + desktop toggle) spawns
the background 'hermes tools post-setup cua_driver' action when the
toolset is enabled while the binary is missing — previously the
toggle 'saved' but the tool never appeared in the schema because
check_computer_use_requirements() couldn't find the binary
- hermes tools interactive flow already installed via
_toolset_needs_configuration_prompt/_POST_SETUP_INSTALLED (unchanged)
Docs: computer-use.md enabling section rewritten around the new flow;
installation.md documents --skip-computer-use.
This commit is contained in:
1 parent
ae56c97c60
commit
7060ac7bed
8 files changed
+367
-8
No files matched your search
@@ -175,11 +175,53 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str]
|
||||
_save_platform_tools(config, target_platform, enabled)
|
||||
|
||||
await asyncio.to_thread(_run)
|
||||
|
||||
# Install-on-enable: when the newly enabled toolset's provider carries a
|
||||
# post_setup hook with a registered, UNSATISFIED install-state predicate
|
||||
# (cua-driver binary missing, etc. — see _POST_SETUP_INSTALLED), spawn the
|
||||
# same background install `hermes tools` runs interactively. Without this,
|
||||
# a dashboard/desktop toggle "saves" but the tool silently never appears
|
||||
# in the schema because its check_fn can't find the binary — the exact
|
||||
# dead-end that forced users to discover `hermes computer-use install`
|
||||
# by hand. Best-effort: a spawn failure never fails the toggle.
|
||||
post_setup_started: Optional[str] = None
|
||||
if body.enabled and name not in _CONFIG_ONLY_TOOLSETS:
|
||||
def _pending_install_key() -> Optional[str]:
|
||||
from hermes_cli.tools_config import (
|
||||
TOOL_CATEGORIES,
|
||||
_post_setup_already_installed,
|
||||
_visible_providers,
|
||||
)
|
||||
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
if not cat:
|
||||
return None
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
for prov in _visible_providers(cat, config):
|
||||
key = prov.get("post_setup")
|
||||
if key and not _post_setup_already_installed(key):
|
||||
return key
|
||||
return None
|
||||
|
||||
try:
|
||||
pending_key = await asyncio.to_thread(_pending_install_key)
|
||||
if pending_key:
|
||||
_spawn_hermes_action(
|
||||
_profile_cli_args(body.profile or profile)
|
||||
+ ["tools", "post-setup", pending_key],
|
||||
"tools-post-setup",
|
||||
)
|
||||
post_setup_started = pending_key
|
||||
except Exception:
|
||||
_log.exception("install-on-enable post-setup spawn failed for %s", name)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"platform": target_platform,
|
||||
"enabled": body.enabled,
|
||||
"post_setup_started": post_setup_started,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
param(
|
||||
[switch]$NoVenv,
|
||||
[switch]$SkipSetup,
|
||||
[switch]$SkipComputerUse,
|
||||
[string]$Branch = "main",
|
||||
# -Commit and -Tag are higher-precedence variants of -Branch for users
|
||||
# who need reproducible installs (desktop installer pinning, CI, release
|
||||
@@ -3169,6 +3170,7 @@ function Install-NodeDeps {
|
||||
}
|
||||
|
||||
Install-BrowserUseCli
|
||||
Install-CuaDriver
|
||||
}
|
||||
|
||||
# The Browser Use CLI is the default browser backend when it is runnable
|
||||
@@ -3213,6 +3215,56 @@ function Install-BrowserUseCli {
|
||||
}
|
||||
}
|
||||
|
||||
# cua-driver powers the computer_use toolset (background desktop control).
|
||||
# Provision it at install time so enabling the tool later -- via `hermes
|
||||
# tools`, the dashboard, or the desktop app -- is a config flip, not a
|
||||
# surprise multi-minute binary fetch. Best-effort and non-fatal: the enable
|
||||
# paths still lazy-install via install_cua_driver() (hermes_cli/tools_config)
|
||||
# when this step was skipped or failed.
|
||||
function Install-CuaDriver {
|
||||
if ($SkipComputerUse) {
|
||||
Write-Info "Skipping Computer Use (cua-driver) install (-SkipComputerUse)"
|
||||
return
|
||||
}
|
||||
if (Get-Command cua-driver -ErrorAction SilentlyContinue) {
|
||||
Write-Success "Computer Use driver (cua-driver) already installed"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "Installing Computer Use driver (cua-driver)..."
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
# Same upstream installer `hermes computer-use install` runs. Bounded
|
||||
# via a background job: the upstream installer serializes with its own
|
||||
# lock (600s stale window), so the ceiling sits above that -- matching
|
||||
# Hermes' _CUA_INSTALLER_TIMEOUT (660s).
|
||||
$job = Start-Job -ScriptBlock {
|
||||
Invoke-RestMethod -UseBasicParsing "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1" | Invoke-Expression
|
||||
}
|
||||
if (Wait-Job $job -Timeout 660) {
|
||||
Receive-Job $job -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Job $job -Force -ErrorAction SilentlyContinue
|
||||
if (Get-Command cua-driver -ErrorAction SilentlyContinue) {
|
||||
Write-Success "Computer Use driver installed (enable via 'hermes tools' -> Computer Use)"
|
||||
} else {
|
||||
Write-Warn "Computer Use driver install did not complete -- it will install on demand when you enable the tool."
|
||||
Write-Info "Install later with: hermes computer-use install"
|
||||
}
|
||||
} else {
|
||||
Stop-Job $job -ErrorAction SilentlyContinue
|
||||
Remove-Job $job -Force -ErrorAction SilentlyContinue
|
||||
Write-Warn "Computer Use driver install timed out -- it will install on demand when you enable the tool."
|
||||
Write-Info "Install later with: hermes computer-use install"
|
||||
}
|
||||
} catch {
|
||||
Write-Warn "Computer Use driver install failed: $_"
|
||||
Write-Info "Install later with: hermes computer-use install"
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEAP
|
||||
}
|
||||
}
|
||||
|
||||
# Clear the cached Electron download + any half-written unpacked output so the
|
||||
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
|
||||
# the per-user Electron download cache - most often a partial download resumed
|
||||
|
||||
@@ -70,6 +70,7 @@ DETECTED_BROWSER_EXECUTABLE=""
|
||||
USE_VENV=true
|
||||
RUN_SETUP=true
|
||||
SKIP_BROWSER=false
|
||||
SKIP_COMPUTER_USE=false
|
||||
NO_SKILLS=false
|
||||
BRANCH="main"
|
||||
INSTALL_COMMIT=""
|
||||
@@ -106,6 +107,10 @@ while [[ $# -gt 0 ]]; do
|
||||
SKIP_BROWSER=true
|
||||
shift
|
||||
;;
|
||||
--skip-computer-use)
|
||||
SKIP_COMPUTER_USE=true
|
||||
shift
|
||||
;;
|
||||
--no-skills)
|
||||
NO_SKILLS=true
|
||||
shift
|
||||
@@ -165,6 +170,7 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " --no-venv Don't create virtual environment"
|
||||
echo " --skip-setup Skip interactive setup wizard"
|
||||
echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)"
|
||||
echo " --skip-computer-use Skip the cua-driver (Computer Use) install"
|
||||
echo " --no-skills Start with a blank slate — seed no bundled skills, and"
|
||||
echo " write \$HERMES_HOME/.no-bundled-skills so future"
|
||||
echo " 'hermes update' runs never inject bundled skills either"
|
||||
@@ -2434,6 +2440,54 @@ install_browser_use_cli() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_computer_use_driver() {
|
||||
# cua-driver powers the computer_use toolset (background desktop control).
|
||||
# Provision it at install time so enabling the tool later — via
|
||||
# `hermes tools`, the dashboard, or the desktop app — is a config flip,
|
||||
# not a surprise multi-minute binary fetch (the confusion this fixes:
|
||||
# users had to discover `hermes computer-use install` on their own).
|
||||
# Best-effort and non-fatal: the enable paths still lazy-install via
|
||||
# install_cua_driver() when this step was skipped or failed.
|
||||
if [ "$SKIP_COMPUTER_USE" = true ]; then
|
||||
log_info "Skipping Computer Use (cua-driver) install (--skip-computer-use)"
|
||||
return 0
|
||||
fi
|
||||
case "$DISTRO" in
|
||||
termux)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
if command -v cua-driver >/dev/null 2>&1; then
|
||||
log_success "Computer Use driver (cua-driver) already installed"
|
||||
return 0
|
||||
fi
|
||||
# Non-admin macOS accounts can't receive the CuaDriver.app bundle in
|
||||
# /Applications; skip cleanly instead of failing loudly (#47865 class).
|
||||
if [ "$(uname -s)" = "Darwin" ] && [ -d /Applications ] && [ ! -w /Applications ]; then
|
||||
log_info "Skipping Computer Use driver (cua-driver): /Applications is not writable"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Installing Computer Use driver (cua-driver)..."
|
||||
# Same upstream installer `hermes computer-use install` runs; time-boxed
|
||||
# so a stalled GitHub download can't hang the Hermes install. The
|
||||
# upstream installer serializes with its own lock (600s stale window),
|
||||
# so give it a ceiling above that — matching Hermes'
|
||||
# _CUA_INSTALLER_TIMEOUT (660s).
|
||||
local cua_log
|
||||
cua_log="$(mktemp)"
|
||||
if run_with_timeout 660 /bin/bash -c \
|
||||
'curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh | /bin/bash' \
|
||||
>"$cua_log" 2>&1; then
|
||||
log_success "Computer Use driver installed (enable via 'hermes tools' → Computer Use)"
|
||||
else
|
||||
log_warn "Computer Use driver install failed — it will install on demand when you enable the tool."
|
||||
log_info "Install later with: hermes computer-use install"
|
||||
tail -n 5 "$cua_log" >&2 || true
|
||||
fi
|
||||
rm -f "$cua_log"
|
||||
}
|
||||
|
||||
run_setup_wizard() {
|
||||
if [ "$RUN_SETUP" = false ]; then
|
||||
log_info "Skipping setup wizard (--skip-setup)"
|
||||
@@ -3241,6 +3295,7 @@ run_stage_body() {
|
||||
install_node_deps
|
||||
install_uv
|
||||
install_browser_use_cli
|
||||
install_computer_use_driver
|
||||
;;
|
||||
path)
|
||||
detect_os
|
||||
@@ -3357,6 +3412,7 @@ main() {
|
||||
install_deps
|
||||
install_node_deps
|
||||
install_browser_use_cli
|
||||
install_computer_use_driver
|
||||
setup_path
|
||||
copy_config_templates
|
||||
run_setup_wizard
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Install-on-enable for toolset toggles (dashboard/desktop PUT endpoint).
|
||||
|
||||
When a toolset is toggled ON via ``PUT /api/tools/toolsets/{name}`` and its
|
||||
provider carries a post_setup hook with a registered, UNSATISFIED
|
||||
install-state predicate (``_POST_SETUP_INSTALLED`` — today: cua-driver),
|
||||
the endpoint spawns the same background ``hermes tools post-setup <key>``
|
||||
action the interactive CLI flow runs. Without this, a GUI toggle "saves"
|
||||
but the tool never appears in the schema because its check_fn can't find
|
||||
the binary.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestToggleToolsetInstallOnEnable:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, monkeypatch, _isolate_hermes_home):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
|
||||
)
|
||||
self.client = TestClient(app)
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def _spawn_recorder(self, monkeypatch):
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
calls = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4242
|
||||
|
||||
def _fake_spawn(subcommand, name, **kwargs):
|
||||
calls.append((tuple(subcommand), name))
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(web_server, "_spawn_hermes_action", _fake_spawn)
|
||||
return calls
|
||||
|
||||
def test_enable_computer_use_spawns_cua_install_when_binary_missing(
|
||||
self, monkeypatch
|
||||
):
|
||||
import hermes_cli.tools_config as tools_config
|
||||
|
||||
calls = self._spawn_recorder(monkeypatch)
|
||||
# Binary missing → the cua_driver predicate reports unsatisfied.
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_resolved_cua_driver_cmd", lambda: None
|
||||
)
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/computer_use", json={"enabled": True}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["post_setup_started"] == "cua_driver"
|
||||
assert calls, "expected a background post-setup spawn"
|
||||
subcommand, action_name = calls[0]
|
||||
assert subcommand[-3:] == ("tools", "post-setup", "cua_driver")
|
||||
assert action_name == "tools-post-setup"
|
||||
|
||||
def test_enable_computer_use_skips_install_when_binary_present(
|
||||
self, monkeypatch
|
||||
):
|
||||
import hermes_cli.tools_config as tools_config
|
||||
|
||||
calls = self._spawn_recorder(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_resolved_cua_driver_cmd", lambda: "/usr/bin/cua-driver"
|
||||
)
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/computer_use", json={"enabled": True}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["post_setup_started"] is None
|
||||
assert calls == []
|
||||
|
||||
def test_disable_never_spawns_install(self, monkeypatch):
|
||||
import hermes_cli.tools_config as tools_config
|
||||
|
||||
calls = self._spawn_recorder(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_resolved_cua_driver_cmd", lambda: None
|
||||
)
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/computer_use", json={"enabled": False}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["post_setup_started"] is None
|
||||
assert calls == []
|
||||
|
||||
def test_spawn_failure_does_not_fail_the_toggle(self, monkeypatch):
|
||||
import hermes_cli.tools_config as tools_config
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
tools_config, "_resolved_cua_driver_cmd", lambda: None
|
||||
)
|
||||
|
||||
def _boom(subcommand, name, **kwargs):
|
||||
raise RuntimeError("spawn exploded")
|
||||
|
||||
monkeypatch.setattr(web_server, "_spawn_hermes_action", _boom)
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/computer_use", json={"enabled": True}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
assert data["enabled"] is True
|
||||
assert data["post_setup_started"] is None
|
||||
|
||||
def test_toolsets_without_pending_post_setup_unchanged(self, monkeypatch):
|
||||
"""A toolset with no registered install predicate keeps today's
|
||||
contract — no spawn, post_setup_started stays None."""
|
||||
calls = self._spawn_recorder(monkeypatch)
|
||||
|
||||
resp = self.client.put(
|
||||
"/api/tools/toolsets/web", json={"enabled": True}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["post_setup_started"] is None
|
||||
assert calls == []
|
||||
@@ -2307,6 +2307,8 @@ class TestNewEndpoints:
|
||||
"name": "discord",
|
||||
"platform": "discord",
|
||||
"enabled": True,
|
||||
# Install-on-enable: no provider post_setup pending for discord.
|
||||
"post_setup_started": None,
|
||||
}
|
||||
|
||||
config = load_config()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Regression tests: installers provision cua-driver (Computer Use).
|
||||
|
||||
Policy: choosing Computer Use should be a config flip, not a surprise
|
||||
multi-minute binary fetch. The installers pre-install cua-driver
|
||||
(best-effort, skippable), and the dashboard toggle auto-installs when the
|
||||
binary is still missing (see test_web_routers_tools_install_on_enable.py).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
|
||||
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
|
||||
|
||||
|
||||
class TestInstallSh:
|
||||
def test_has_skip_flag(self) -> None:
|
||||
text = INSTALL_SH.read_text()
|
||||
assert "--skip-computer-use)" in text
|
||||
assert "SKIP_COMPUTER_USE=true" in text
|
||||
assert "SKIP_COMPUTER_USE=false" in text # default off
|
||||
assert "--skip-computer-use Skip the cua-driver" in text
|
||||
|
||||
def test_install_function_wired_into_main_and_stage(self) -> None:
|
||||
text = INSTALL_SH.read_text()
|
||||
assert "install_computer_use_driver() {" in text
|
||||
# main() flow and the node-deps stage both run it.
|
||||
assert text.count("install_computer_use_driver\n") >= 2
|
||||
|
||||
def test_install_is_timeboxed_above_upstream_lock_window(self) -> None:
|
||||
"""The upstream installer serializes on a lock with a 600s stale
|
||||
window; a ceiling below that reintroduces the self-perpetuating
|
||||
wedge (#58762). Must stay >= 660."""
|
||||
text = INSTALL_SH.read_text()
|
||||
assert "run_with_timeout 660 /bin/bash -c" in text
|
||||
|
||||
def test_install_is_best_effort(self) -> None:
|
||||
text = INSTALL_SH.read_text()
|
||||
assert "Computer Use driver install failed" in text
|
||||
assert "hermes computer-use install" in text
|
||||
|
||||
def test_skips_unwritable_applications_dir(self) -> None:
|
||||
"""Non-admin macOS accounts can't receive CuaDriver.app (#47865
|
||||
class) — skip cleanly instead of failing every install."""
|
||||
text = INSTALL_SH.read_text()
|
||||
assert '[ -d /Applications ] && [ ! -w /Applications ]' in text
|
||||
|
||||
|
||||
class TestInstallPs1:
|
||||
def test_has_skip_switch(self) -> None:
|
||||
text = INSTALL_PS1.read_text()
|
||||
assert "[switch]$SkipComputerUse," in text
|
||||
assert "if ($SkipComputerUse)" in text
|
||||
|
||||
def test_install_function_wired_into_node_deps(self) -> None:
|
||||
text = INSTALL_PS1.read_text()
|
||||
assert "function Install-CuaDriver {" in text
|
||||
assert " Install-CuaDriver\n" in text
|
||||
|
||||
def test_install_is_timeboxed_above_upstream_lock_window(self) -> None:
|
||||
text = INSTALL_PS1.read_text()
|
||||
assert "Wait-Job $job -Timeout 660" in text
|
||||
|
||||
def test_install_is_best_effort(self) -> None:
|
||||
text = INSTALL_PS1.read_text()
|
||||
assert "Computer Use driver install timed out" in text
|
||||
assert "hermes computer-use install" in text
|
||||
@@ -132,6 +132,8 @@ Running Hermes as a dedicated unprivileged user (e.g. a `hermes` systemd service
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-browser
|
||||
```
|
||||
|
||||
The installer also pre-installs [`cua-driver`](../user-guide/features/computer-use.md) so the Computer Use toolset works the moment you enable it; pass `--skip-computer-use` to opt out (it will then install on demand when you enable the tool).
|
||||
|
||||
3. **Make `hermes` available to the service user's shells.** The installer writes the launcher to `~/.local/bin/hermes`. System service accounts often have a minimal PATH that doesn't include `~/.local/bin`. Either add it to the user's environment, or symlink the launcher into a system location:
|
||||
```bash
|
||||
# Option A — add to the service user's profile
|
||||
|
||||
@@ -40,10 +40,18 @@ no-foreground invariant, click-dispatch internals — see
|
||||
|
||||
## Enabling
|
||||
|
||||
Pick whichever path is most convenient — both run the same upstream
|
||||
installer:
|
||||
**Fresh installs already have the driver.** The Hermes installer
|
||||
(`install.sh` / `install.ps1`) pre-installs `cua-driver` (best-effort;
|
||||
pass `--skip-computer-use` / `-SkipComputerUse` to opt out), so enabling
|
||||
Computer Use is just a config flip:
|
||||
|
||||
**Option 1: dedicated CLI command (most direct).**
|
||||
- **`hermes tools`** → pick `🖱️ Computer Use` — installs the driver
|
||||
automatically if it's still missing.
|
||||
- **Dashboard / desktop app** → toggle the Computer Use toolset — if the
|
||||
driver is missing, the toggle kicks off the install in the background
|
||||
automatically (watch progress in the toolset panel).
|
||||
|
||||
**Manual fallback (older installs, skipped installer step):**
|
||||
|
||||
```
|
||||
hermes computer-use install
|
||||
@@ -53,11 +61,6 @@ This fetches and runs the upstream cua-driver installer — `install.sh`
|
||||
on macOS/Linux, `install.ps1` on Windows. Use `hermes computer-use
|
||||
status` to verify the install.
|
||||
|
||||
**Option 2: enable the toolset interactively.**
|
||||
|
||||
1. Run `hermes tools`, pick `🖱️ Computer Use (macOS/Windows/Linux)`.
|
||||
2. The setup runs the upstream installer (same as Option 1).
|
||||
|
||||
After installing, regardless of which path you took, grant the
|
||||
platform-appropriate prereqs:
|
||||
|
||||
|
||||
Reference in new issue
Block a user