diff --git a/qualification/pre1-cases.json b/qualification/pre1-cases.json index dca967d..d208bcb 100644 --- a/qualification/pre1-cases.json +++ b/qualification/pre1-cases.json @@ -144,6 +144,16 @@ "tests/test_current_node_token.py::test_e050_client_credential_lifecycle_fixture" ] }, + "dynamic-public-route-readiness": { + "assertion": "test_heartbeat_self_heals_from_empty_initial_token", + "command": [ + "@python", + "-m", + "pytest", + "-q", + "tests/test_serve.py::test_heartbeat_self_heals_from_empty_initial_token" + ] + }, "minimum-version": { "assertion": "test_minimum_python_version_is_declared_and_candidate_remains_pre1", "command": [ diff --git a/scripts/check_mypy_baseline.py b/scripts/check_mypy_baseline.py index 000bf01..ec228e3 100644 --- a/scripts/check_mypy_baseline.py +++ b/scripts/check_mypy_baseline.py @@ -6,6 +6,7 @@ import json import re import subprocess +import sys from pathlib import Path ERROR = re.compile(r"^([^:]+):\d+: error: .*\[([^]]+)\]$") @@ -26,7 +27,11 @@ def main() -> int: args = parser.parse_args() baseline = json.loads(args.baseline.read_text(encoding="utf-8")) result = subprocess.run( - ["mypy", "src"], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False + [sys.executable, "-m", "mypy", "src"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, ) observed = observed_errors(result.stdout) allowed = collections.Counter(baseline["errors"]) diff --git a/src/iicp_client/cli.py b/src/iicp_client/cli.py index 5b1ec67..3753037 100644 --- a/src/iicp_client/cli.py +++ b/src/iicp_client/cli.py @@ -2150,7 +2150,12 @@ async def _serve(args: argparse.Namespace) -> int: # None is reserved for --skip-registration (no heartbeat by design). token: str | None = None if not args.skip_registration: - for attempt in range(1, 4): + # A dynamic public route becomes eligible only after the node is + # listening and the directory can dial it. One expected pre-listener + # attempt is enough; the empty-token heartbeat re-registers immediately + # after serve() binds. + registration_attempts = 1 if _tunnel is not None else 3 + for attempt in range(1, registration_attempts + 1): try: token = await node.register() logger.info("Registered as %s (token=%s…)", node_id, (token or "")[:8]) @@ -2172,7 +2177,7 @@ async def _serve(args: argparse.Namespace) -> int: _complete_handoff_for_node(args.node) break except Exception as exc: # noqa: BLE001 - if attempt >= 3: + if attempt >= registration_attempts: logger.warning( "Registration failed after %d attempts: %s — starting heartbeat loop " "anyway; it will re-register on the first 401", @@ -3213,6 +3218,13 @@ def run_actions() -> None: raise RuntimeError(f"service manager action failed ({completed.returncode}): {' '.join(action.argv)}") if cmd == "install": + from iicp_client.tunnel import cloudflared_path + + if os.environ.get("IICP_CLOUDFLARED_PATH") is None and cloudflared_path() is None: + sys.stderr.write( + "WARNING: Quick Tunnel fallback is unavailable under the supervisor because " + "cloudflared could not be resolved. Direct reachability remains supported.\n" + ) if dry_run: sys.stdout.write(f"# {unit.platform} service: {unit.name}\n# path: {unit.path}\n") sys.stdout.write(unit.content) diff --git a/src/iicp_client/node.py b/src/iicp_client/node.py index fb53580..a07e219 100644 --- a/src/iicp_client/node.py +++ b/src/iicp_client/node.py @@ -993,8 +993,13 @@ async def _heartbeat_loop(self, node_token: str) -> None: recovery_grace = env_grace_checks() recovery_check_every = env_check_every_heartbeats() recovery_supervised = supervised_recovery_enabled() + # Empty means startup registration was deferred until the listener + # existed. Recover immediately; registered nodes keep normal cadence. + wait_before_tick = bool(token) while True: - await asyncio.sleep(_HEARTBEAT_INTERVAL) + if wait_before_tick: + await asyncio.sleep(_HEARTBEAT_INTERVAL) + wait_before_tick = True self._runtime_health.advance_supervisor() seq += 1 try: diff --git a/src/iicp_client/service.py b/src/iicp_client/service.py index 45e495d..6e39260 100644 --- a/src/iicp_client/service.py +++ b/src/iicp_client/service.py @@ -123,6 +123,37 @@ def _env_value(key: str, default: str) -> str: return os.environ.get(key, default) +def _supervisor_tunnel_environment() -> dict[str, str]: + from iicp_client.tunnel import cloudflared_path + + configured_path = os.environ.get("IICP_CLOUDFLARED_PATH") + binary = cloudflared_path() + if configured_path is not None and binary is None: + raise ValueError("IICP_CLOUDFLARED_PATH must be an absolute path to an executable file") + + explicit = os.environ.get("IICP_TUNNEL") + normalized: str | None = None + if explicit is not None: + value = explicit.strip().lower() + if value in {"1", "true", "yes"}: + normalized = "1" + elif value in {"0", "false", "no"}: + normalized = "0" + else: + raise ValueError("IICP_TUNNEL must be one of 1/true/yes or 0/false/no") + if normalized == "1" and binary is None: + raise ValueError( + "IICP_TUNNEL=1 requires cloudflared; set IICP_CLOUDFLARED_PATH to its absolute executable path" + ) + + result: dict[str, str] = {} + if binary is not None: + result["IICP_CLOUDFLARED_PATH"] = binary + if normalized is not None: + result["IICP_TUNNEL"] = normalized + return result + + def detect_platform(requested: str = "auto") -> str: if requested != "auto": if requested not in {"launchd", "systemd"}: @@ -146,6 +177,7 @@ def render_launchd(node: str, *, name: str | None = None, executable: str = "iic "IICP_TUNNEL_DEAD_POLICY": _env_value("IICP_TUNNEL_DEAD_POLICY", "auto"), "IICP_LOG_DIR": str(log_dir), } + env.update(_supervisor_tunnel_environment()) env_xml = "\n".join(f" {escape(k)}{escape(v)}" for k, v in env.items()) content = f""" @@ -200,6 +232,7 @@ def render_systemd(node: str, *, name: str | None = None, executable: str = "iic "IICP_TUNNEL_DEAD_POLICY": _env_value("IICP_TUNNEL_DEAD_POLICY", "auto"), "IICP_LOG_DIR": str(log_dir), } + env.update(_supervisor_tunnel_environment()) env_lines = "\n".join(f"Environment={k}={shlex.quote(v)}" for k, v in env.items()) content = f"""[Unit] Description=IICP node {node} diff --git a/src/iicp_client/tunnel.py b/src/iicp_client/tunnel.py index 3b44cca..b86d7ee 100644 --- a/src/iicp_client/tunnel.py +++ b/src/iicp_client/tunnel.py @@ -439,8 +439,27 @@ def _wait_until_reachable( def cloudflared_path() -> str | None: - """Locate the cloudflared binary, or None (we never auto-install it).""" - return shutil.which("cloudflared") + """Resolve cloudflared for interactive and supervisor-managed execution. + + An explicit ``IICP_CLOUDFLARED_PATH`` is authoritative and must be an + absolute executable file. Invalid explicit configuration fails closed + instead of selecting another binary from ``PATH``. + """ + + configured = os.environ.get("IICP_CLOUDFLARED_PATH") + candidate = configured if configured is not None else shutil.which("cloudflared") + if not candidate: + return None + path = Path(candidate) + if configured is not None and not path.is_absolute(): + return None + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError): + return None + if not resolved.is_file() or not os.access(resolved, os.X_OK): + return None + return str(resolved) class QuickTunnel: diff --git a/tests/test_sdk_quality_contract.py b/tests/test_sdk_quality_contract.py index cf4d9bc..6d9ff99 100644 --- a/tests/test_sdk_quality_contract.py +++ b/tests/test_sdk_quality_contract.py @@ -34,3 +34,8 @@ def test_quality_runner_uses_the_shared_content_free_schema() -> None: def test_pull_request_quality_enforces_mypy_no_regression() -> None: workflow = (ROOT / ".github/workflows/quality.yml").read_text() assert "python scripts/check_mypy_baseline.py" in workflow + + +def test_mypy_baseline_uses_the_active_python_environment() -> None: + source = (ROOT / "scripts/check_mypy_baseline.py").read_text() + assert '[sys.executable, "-m", "mypy", "src"]' in source diff --git a/tests/test_serve.py b/tests/test_serve.py index 1a6eaab..debf36a 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -551,10 +551,20 @@ async def fake_reg(): monkeypatch.setattr(n, "heartbeat", fake_hb) monkeypatch.setattr(n, "register", fake_reg) + recovered_heartbeat = asyncio.Event() + + original_fake_hb = fake_hb + + async def observed_hb(tok): + await original_fake_hb(tok) + if tok == "recovered-token": + recovered_heartbeat.set() + + monkeypatch.setattr(n, "heartbeat", observed_hb) async def _run(): task = asyncio.create_task(n._heartbeat_loop("")) # started with empty token - await asyncio.sleep(0.06) + await asyncio.wait_for(recovered_heartbeat.wait(), timeout=1.0) task.cancel() with contextlib.suppress(asyncio.CancelledError): await task diff --git a/tests/test_service.py b/tests/test_service.py index 1ad03c2..3d6d67c 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -77,3 +77,39 @@ def test_no_start_omits_start(monkeypatch, tmp_path): unit = render_systemd("mynode") commands = [a.argv for a in manager_actions(unit, "install", no_start=True)] assert not any(command[:3] == ("systemctl", "--user", "start") for command in commands) + + +def test_service_preserves_only_explicit_tunnel_policy_and_resolved_binary(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + binary = tmp_path / "cloudflared" + binary.write_text("#!/bin/sh\nexit 0\n") + binary.chmod(0o700) + monkeypatch.setenv("IICP_CLOUDFLARED_PATH", str(binary)) + monkeypatch.setenv("IICP_TUNNEL", "yes") + + launchd = render_launchd("mynode") + systemd = render_systemd("mynode") + resolved = str(binary.resolve()) + assert f"IICP_CLOUDFLARED_PATH{resolved}" in launchd.content + assert "IICP_TUNNEL1" in launchd.content + assert f"Environment=IICP_CLOUDFLARED_PATH={resolved}" in systemd.content + assert "Environment=IICP_TUNNEL=1" in systemd.content + + monkeypatch.delenv("IICP_TUNNEL") + automatic = render_launchd("mynode") + assert "IICP_TUNNEL" not in automatic.content + + +def test_service_refuses_invalid_or_unavailable_forced_tunnel(monkeypatch, tmp_path): + import pytest + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("IICP_CLOUDFLARED_PATH", "relative/cloudflared") + with pytest.raises(ValueError, match="absolute path"): + render_launchd("mynode") + + monkeypatch.delenv("IICP_CLOUDFLARED_PATH") + monkeypatch.setenv("PATH", "") + monkeypatch.setenv("IICP_TUNNEL", "1") + with pytest.raises(ValueError, match="requires cloudflared"): + render_systemd("mynode") diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py index 5382e78..50fc35f 100644 --- a/tests/test_tunnel.py +++ b/tests/test_tunnel.py @@ -52,6 +52,22 @@ """ +def test_cloudflared_override_is_absolute_executable_and_authoritative(monkeypatch, tmp_path): + binary = tmp_path / "cloudflared" + binary.write_text("#!/bin/sh\nexit 0\n") + binary.chmod(0o700) + + monkeypatch.setenv("IICP_CLOUDFLARED_PATH", str(binary)) + assert cloudflared_path() == str(binary.resolve()) + + monkeypatch.setenv("IICP_CLOUDFLARED_PATH", "relative/cloudflared") + monkeypatch.setenv("PATH", str(tmp_path)) + assert cloudflared_path() is None + + monkeypatch.delenv("IICP_CLOUDFLARED_PATH") + assert cloudflared_path() == str(binary.resolve()) + + def _fake_bin(tmp_path, template: str, name: str = "fake-fox-1234", lifetime: float = 60.0) -> str: p = tmp_path / "cloudflared" p.write_text(template.format(python=sys.executable, name=name, lifetime=lifetime))