Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion deployer/windows_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,53 @@ def _npm_registry_candidates(self) -> list[str]:
registries.append(registry)
return registries

def _reachable_npm_registries(self, candidates: list[str], timeout: float = 2.5) -> list[str]:
"""Filter ``candidates`` down to registries that answer a quick probe.

Each candidate's ``/-/ping`` endpoint is probed in parallel with a
short per-registry ``timeout``. A registry counts as reachable if the
host responds at all — even a 4xx/5xx is fine, since that still proves
the TCP+TLS handshake completed. Registries that time out or fail to
connect (DNS, SSL, or a corporate ``NPM URL Block`` network policy)
are dropped so we never hand them to ``npm install``, which would
otherwise hang for up to 15 minutes retrying a blocked host.

Returns the reachable subset ordered fastest-first; unreachable
registries are omitted entirely.
"""
import concurrent.futures as _cf

def _measure(registry: str) -> tuple[str, float]:
url = registry.rstrip("/") + "/-/ping"
req = urllib.request.Request(url, headers={"User-Agent": "OpenClawDeployer/1.0"})
start = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp.read(64)
return registry, time.monotonic() - start
except urllib.error.HTTPError:
# A 404/403 still means the host is reachable.
return registry, time.monotonic() - start
except Exception:
return registry, float("inf")

if not candidates:
return []

results: list[tuple[str, float]] = []
try:
with _cf.ThreadPoolExecutor(max_workers=len(candidates)) as pool:
futures = [pool.submit(_measure, r) for r in candidates]
for f in _cf.as_completed(futures, timeout=timeout + 1.0):
results.append(f.result())
except Exception as e:
self.log.debug(f"npm registry reachability probe failed: {e}")

reachable = sorted((r for r in results if r[1] != float("inf")), key=lambda x: x[1])
for registry, latency in reachable:
self.log.debug(f" npm registry {registry}: {int(latency * 1000)} ms")
return [registry for registry, _ in reachable]

def _install_openclaw_from_registry(
self, install_prefix: Path, registry: str
) -> OpenClawInstallAttempt:
Expand Down Expand Up @@ -1366,7 +1413,18 @@ def _install_openclaw_with_registry_fallback(self, install_prefix: Path) -> bool
)
channel = self.cfg.get("openclaw.channel", "stable")
expected_version = OPENCLAW_TARGET_VERSION if channel == "stable" else None
for registry in self._npm_registry_candidates():
candidates = self._npm_registry_candidates()
registries = self._reachable_npm_registries(candidates)
if not registries:
self.log.error(
"Cannot reach any npm registry to install OpenClaw. Every candidate "
"registry failed a quick reachability probe, which usually means they "
"are blocked by your network or IT policy (e.g. a corporate "
"'NPM URL Block'). Tried: " + ", ".join(candidates) + ". "
"Configure an allowed npm registry via 'npm.registry' and retry."
)
return False
for registry in registries:
self.log.info(f" npm registry attempt: {registry}")
attempt = self._install_openclaw_from_registry(install_prefix, registry)
installed = attempt.installed_version
Expand Down
26 changes: 16 additions & 10 deletions desktop/renderer/src/views/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -418,11 +418,14 @@
</div>
<span class="skill-desc">{{ skill.description }}</span>
</div>
<el-tooltip v-if="!skill.eligible" :content="missingTooltip(skill)" placement="top">
<el-button class="ask-agent-btn" size="small" @click="askAgentAboutSkill(skill)">
{{ t("settings.askAgent") }}
</el-button>
</el-tooltip>
<el-button
v-if="!skill.eligible"
class="ask-agent-btn"
size="small"
@click="askAgentAboutSkill(skill)"
>
{{ t("settings.askAgent") }}
</el-button>
<el-switch
v-else
:model-value="skill.enabled && skill.eligible"
Expand Down Expand Up @@ -460,11 +463,14 @@
</div>
<span class="skill-desc">{{ skill.description }}</span>
</div>
<el-tooltip v-if="!skill.eligible" :content="missingTooltip(skill)" placement="top">
<el-button class="ask-agent-btn" size="small" @click="askAgentAboutSkill(skill)">
{{ t("settings.askAgent") }}
</el-button>
</el-tooltip>
<el-button
v-if="!skill.eligible"
class="ask-agent-btn"
size="small"
@click="askAgentAboutSkill(skill)"
>
{{ t("settings.askAgent") }}
</el-button>
<el-switch
v-else
:model-value="skill.enabled && skill.eligible"
Expand Down
11 changes: 11 additions & 0 deletions tests/test_windows_setup_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ def test_prepare_rechecks_gateway_after_acquiring_upgrade_lock(self):

def test_registry_tls_failure_retries_next_registry(self):
self.ws.cfg = _Config({"npm.registry": "https://registry.npmmirror.com"})
self.ws._reachable_npm_registries = lambda candidates, **_kw: list(candidates)
self.ws._install_openclaw_from_registry = unittest.mock.Mock(
side_effect=[
SimpleNamespace(
Expand All @@ -326,6 +327,7 @@ def test_registry_tls_failure_retries_next_registry(self):

def test_registry_policy_or_missing_package_retries_next_registry(self):
self.ws.cfg = _Config({"npm.registry": "https://packagefeedproxy.microsoft.io/npm/"})
self.ws._reachable_npm_registries = lambda candidates, **_kw: list(candidates)

for output in (
"npm error code E404\nnpm error 404 Not Found - GET",
Expand Down Expand Up @@ -354,6 +356,7 @@ def test_registry_policy_or_missing_package_retries_next_registry(self):

def test_non_network_npm_failure_does_not_change_registry(self):
self.ws.cfg = _Config()
self.ws._reachable_npm_registries = lambda candidates, **_kw: list(candidates)
self.ws._install_openclaw_from_registry = unittest.mock.Mock(
return_value=SimpleNamespace(
installed_version=None,
Expand All @@ -365,6 +368,14 @@ def test_non_network_npm_failure_does_not_change_registry(self):
self.assertFalse(self.ws._install_openclaw_with_registry_fallback(self.appdata / "npm"))
self.assertEqual(self.ws._install_openclaw_from_registry.call_count, 1)

def test_all_registries_unreachable_fails_fast_without_install(self):
self.ws.cfg = _Config()
self.ws._reachable_npm_registries = lambda candidates, **_kw: []
self.ws._install_openclaw_from_registry = unittest.mock.Mock()

self.assertFalse(self.ws._install_openclaw_with_registry_fallback(self.appdata / "npm"))
self.ws._install_openclaw_from_registry.assert_not_called()

def test_automatic_registry_fallbacks_are_https(self):
self.ws.cfg = _Config()

Expand Down
Loading