Files
2026-07-13 13:12:33 +08:00

1376 lines
48 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
import platform
import signal
import sys
import tomllib
from pathlib import Path
from types import SimpleNamespace
from urllib.error import URLError
import pytest
from typer.testing import CliRunner
from opensquilla.cli import gateway_cmd, gateway_lifecycle
from opensquilla.cli.gateway_cmd import gateway_startup_guidance
from opensquilla.cli.main import app
from opensquilla.paths import default_opensquilla_home
runner = CliRunner()
Manager = gateway_lifecycle.GatewayLifecycleManager
def _env_command(env_key: str) -> str:
# Mirrors next_steps.set_env_command: recovery ``command`` fields are the
# bare command on every platform (no "PowerShell:" label).
if platform.system().lower().startswith("win"):
return f'$env:{env_key} = "<your-key>"'
return f'export {env_key}="<your-key>"'
def _payload(result):
return json.loads(result.stdout)
def _write_pidfile(record: dict) -> None:
path = gateway_lifecycle.gateway_pidfile_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(record), encoding="utf-8")
def _record(pid: int = 1234, *, port: int = 18791) -> dict:
return {
"pid": pid,
"host": "127.0.0.1",
"port": port,
"url": f"http://127.0.0.1:{port}",
"healthUrl": f"http://127.0.0.1:{port}/health",
"logPath": str(gateway_lifecycle.gateway_log_path()),
"startedAt": "2026-05-04T00:00:00Z",
"argv": [
sys.executable,
"-m",
"opensquilla.cli.main",
"gateway",
"run",
"--listen",
"127.0.0.1",
"--port",
str(port),
],
}
def _patch_health(monkeypatch, value: bool) -> None:
monkeypatch.setattr(Manager, "_probe_health", lambda self: value)
def _patch_wait_for_health(monkeypatch, value: bool) -> None:
monkeypatch.setattr(Manager, "_wait_for_health", lambda self: value)
def _patch_pid_running(monkeypatch, value: bool) -> None:
monkeypatch.setattr(Manager, "_pid_running", lambda self, pid: value)
def _profile_tree_snapshot(home: Path) -> dict[str, tuple[str, bytes]]:
snapshot: dict[str, tuple[str, bytes]] = {}
for path in sorted(home.rglob("*")):
relative = path.relative_to(home).as_posix()
snapshot[relative] = ("directory", b"") if path.is_dir() else ("file", path.read_bytes())
return snapshot
def _unsafe_desktop_profile(home: Path, *, port: int = 0) -> None:
state = home / "state"
lifecycle = state / "gateway"
lifecycle.mkdir(parents=True)
missing_workspace = home.parent / "missing-workspace"
(home / "config.toml").write_text(
f"state_dir = {json.dumps(str(state))}\n"
f"workspace_dir = {json.dumps(str(missing_workspace))}\n",
encoding="utf-8",
)
(lifecycle / "gateway.json").write_text(
json.dumps(_record(pid=999_999, port=port)),
encoding="utf-8",
)
logs = home / "logs"
logs.mkdir()
(logs / "gateway.log").write_bytes(b"synthetic-existing-log\n")
def _safe_desktop_profile(home: Path) -> None:
state = home / "state"
workspace = home / "workspace"
state.mkdir(parents=True)
workspace.mkdir()
(workspace / "SOUL.md").write_text("synthetic-safe-profile\n", encoding="utf-8")
(home / "config.toml").write_text(
f"state_dir = {json.dumps(str(state))}\n"
f"workspace_dir = {json.dumps(str(workspace))}\n",
encoding="utf-8",
)
class _FakeHealthResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
return None
def test_gateway_startup_guidance_shows_operator_next_steps() -> None:
guidance = gateway_startup_guidance("127.0.0.1", 18791)
assert "[bold]Web UI:[/bold] http://127.0.0.1:18791/control/" in guidance
assert "[bold]API base:[/bold] http://127.0.0.1:18791" in guidance
debug_log = default_opensquilla_home() / "logs" / "debug.log"
assert f"[bold]Debug log:[/bold] {debug_log}" in guidance
assert "[dim]Keep this terminal open. Press Ctrl+C to stop.[/dim]" in guidance
def test_gateway_run_turns_missing_onboarding_env_into_recovery_hint(
tmp_path,
monkeypatch,
) -> None:
target = tmp_path / "custom.toml"
target.write_text(
'[llm]\n'
'provider = "openrouter"\n'
'model = "deepseek/deepseek-v4-flash"\n'
'api_key = "sk-or"\n'
'\n'
'[memory.embedding]\n'
'provider = "openai"\n'
'\n'
'[memory.embedding.remote]\n'
'api_key_env = "OPENAI_EMBEDDINGS_API_KEY"\n',
encoding="utf-8",
)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
monkeypatch.delenv("OPENAI_EMBEDDINGS_API_KEY", raising=False)
async def fail_start_gateway_server(**_kwargs):
raise ValueError(
"memory.embedding.provider='openai' requires "
"memory.embedding.remote.api_key"
)
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
monkeypatch.setattr(gateway_cmd, "start_gateway_server", fail_start_gateway_server)
result = runner.invoke(app, ["gateway", "run", "--config", str(target)])
assert result.exit_code == 1
output = result.stdout + (result.stderr or "")
compact = "".join(output.split())
assert "Gateway could not start" in output
assert (
f"Set memory key: {_env_command('OPENAI_EMBEDDINGS_API_KEY')}".replace(" ", "")
in compact
)
expected_config = str(target).replace("\\", "/")
normalized = compact.replace("\\", "/")
assert "opensquillaonboardstatus--config" in normalized
assert expected_config in normalized
assert normalized.index("opensquillaonboardstatus--config") < normalized.index(
expected_config
)
assert "Traceback" not in output
def test_gateway_run_memory_recovery_command_is_bare_on_windows(
tmp_path,
monkeypatch,
) -> None:
"""The recovery ``command`` field is machine-shaped on every surface: the
gateway-run fallback entry must carry the bare set-env command on Windows
(no "PowerShell:" label), matching env_recovery_commands."""
from opensquilla.onboarding import next_steps
target = tmp_path / "custom.toml"
target.write_text(
'[llm]\n'
'provider = "openrouter"\n'
'model = "dummy/model"\n'
'api_key = "sk-dummy"\n'
'\n'
'[memory.embedding.remote]\n'
'api_key_env = "DUMMY_UNSET_EMBED_KEY"\n',
encoding="utf-8",
)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
monkeypatch.delenv("DUMMY_UNSET_EMBED_KEY", raising=False)
monkeypatch.setattr(next_steps, "_is_windows", lambda: True)
async def fail_start_gateway_server(**_kwargs):
raise ValueError(
"memory.embedding.provider='openai' requires "
"memory.embedding.remote.api_key"
)
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
monkeypatch.setattr(gateway_cmd, "start_gateway_server", fail_start_gateway_server)
result = runner.invoke(app, ["gateway", "run", "--config", str(target)])
assert result.exit_code == 1
output = result.stdout + (result.stderr or "")
compact = "".join(output.split())
expected = 'Set memory key: $env:DUMMY_UNSET_EMBED_KEY = "<your-key>"'
assert expected.replace(" ", "") in compact
assert "PowerShell" not in output
def test_gateway_lifecycle_paths_use_state_root(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
assert gateway_lifecycle.gateway_pidfile_path() == (
tmp_path / "home" / "state" / "gateway" / "gateway.json"
)
assert gateway_lifecycle.gateway_log_path() == tmp_path / "home" / "logs" / "gateway.log"
def test_safe_desktop_gateway_start_uses_external_lifecycle_state(
tmp_path: Path,
monkeypatch,
) -> None:
home = tmp_path / "opensquilla"
user_state = tmp_path / "user-state"
_safe_desktop_profile(home)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
monkeypatch.setenv("OPENSQUILLA_TEST", "1")
monkeypatch.setenv("OPENSQUILLA_USER_STATE_DIR", str(user_state))
calls = []
def fake_popen(argv, **kwargs):
calls.append((argv, kwargs))
return SimpleNamespace(pid=4242)
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fake_popen)
_patch_health(monkeypatch, False)
_patch_wait_for_health(monkeypatch, True)
result = runner.invoke(app, ["gateway", "start", "--port", "0", "--json"])
assert result.exit_code == 0, result.stdout
payload = _payload(result)
pidfile = Path(payload["pidfile"])
log_path = Path(payload["logPath"])
assert payload["state"] == "running"
assert calls
assert home not in pidfile.parents
assert home not in log_path.parents
assert user_state in pidfile.parents
assert user_state in log_path.parents
@pytest.mark.parametrize("action", ["start", "restart"])
def test_unsafe_desktop_gateway_lifecycle_blocks_before_spawn_or_write(
action: str,
tmp_path: Path,
monkeypatch,
) -> None:
home = tmp_path / "opensquilla"
user_state = tmp_path / "user-state"
_unsafe_desktop_profile(home)
before = _profile_tree_snapshot(home)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
monkeypatch.setenv("OPENSQUILLA_TEST", "1")
monkeypatch.setenv("OPENSQUILLA_USER_STATE_DIR", str(user_state))
def fail_spawn(self, argv):
raise AssertionError("unsafe Desktop profile must not spawn a gateway")
monkeypatch.setattr(Manager, "_spawn_gateway", fail_spawn)
result = getattr(Manager(port=0, health_timeout=0), action)()
assert result.ok is False
assert result.state == "recovery_required"
assert result.code == "DESKTOP_PROFILE_RECOVERY_REQUIRED"
assert result.details["stableCode"] == "effective_workspace_missing"
assert _profile_tree_snapshot(home) == before
assert not user_state.exists()
@pytest.mark.parametrize("action", ["start", "restart"])
def test_desktop_lifecycle_rejects_config_outside_profile_before_write(
action: str,
tmp_path: Path,
monkeypatch,
) -> None:
home = tmp_path / "opensquilla"
user_state = tmp_path / "user-state"
_safe_desktop_profile(home)
outside = tmp_path / "outside.toml"
outside.write_text("synthetic = true\n", encoding="utf-8")
before = _profile_tree_snapshot(home)
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
monkeypatch.setenv("OPENSQUILLA_TEST", "1")
monkeypatch.setenv("OPENSQUILLA_USER_STATE_DIR", str(user_state))
result = getattr(
Manager(config_path=str(outside), port=0, health_timeout=0),
action,
)()
assert result.ok is False
assert result.code == "DESKTOP_PROFILE_RECOVERY_REQUIRED"
assert result.details["stableCode"] == "desktop_config_outside_profile"
assert _profile_tree_snapshot(home) == before
assert not user_state.exists()
def test_desktop_gateway_run_rejects_config_outside_profile_before_loading_it(
tmp_path: Path,
monkeypatch,
) -> None:
home = tmp_path / "opensquilla"
_safe_desktop_profile(home)
outside = tmp_path / "outside.toml"
outside.write_text("synthetic = true\n", encoding="utf-8")
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
def fail_load(*_args, **_kwargs):
raise AssertionError("out-of-profile config must be rejected before load")
monkeypatch.setattr(gateway_cmd.GatewayConfig, "load", fail_load)
result = runner.invoke(app, ["gateway", "run", "--config", str(outside)])
assert result.exit_code == 1
assert "DESKTOP_CONFIG_OUTSIDE_PROFILE" in result.stdout
def test_gateway_help_lists_lifecycle_commands() -> None:
result = runner.invoke(app, ["gateway", "--help"])
assert result.exit_code == 0
assert "run" in result.stdout
assert "start" in result.stdout
assert "status" in result.stdout
assert "stop" in result.stdout
assert "restart" in result.stdout
def test_gateway_subapp_disables_pretty_exceptions() -> None:
from opensquilla.cli.main import gateway_app
assert gateway_app.pretty_exceptions_enable is False
def test_gateway_start_help_explains_config_backed_target_defaults() -> None:
result = runner.invoke(app, ["gateway", "start", "--help"])
assert result.exit_code == 0
assert "Port to bind (default: config port, usually 18791)" in result.stdout
assert "Host to bind (default: config host, usually 127.0.0.1)" in result.stdout
def test_gateway_status_json_reports_not_started(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_patch_health(monkeypatch, False)
result = runner.invoke(app, ["gateway", "status", "--json"])
assert result.exit_code == 0
payload = _payload(result)
assert payload["ok"] is True
assert payload["state"] == "not_started"
assert payload["managed"] is False
def test_gateway_status_gateway_url_probes_remote_https_health(monkeypatch) -> None:
urls = []
def fake_urlopen(request, timeout):
urls.append(request.full_url)
assert timeout == 0.5
return _FakeHealthResponse()
monkeypatch.setattr(gateway_lifecycle, "urlopen", fake_urlopen)
result = runner.invoke(
app,
["gateway", "status", "--gateway", "https://squilla.example.com", "--json"],
)
assert result.exit_code == 0, result.stdout
payload = _payload(result)
assert payload["ok"] is True
assert payload["remote"] is True
assert payload["managed"] is False
assert payload["state"] == "running"
assert payload["gatewayUrl"] == "wss://squilla.example.com/ws"
assert payload["url"] == "https://squilla.example.com"
assert payload["healthUrl"] == "https://squilla.example.com/health"
assert urls == ["https://squilla.example.com/health"]
def test_gateway_status_gateway_url_reports_remote_unavailable(monkeypatch) -> None:
urls = []
def fake_urlopen(request, timeout):
urls.append(request.full_url)
assert timeout == 0.5
raise OSError("offline")
monkeypatch.setattr(gateway_lifecycle, "urlopen", fake_urlopen)
result = runner.invoke(
app,
["gateway", "status", "--gateway", "wss://squilla.example.com/ws", "--json"],
)
assert result.exit_code == 1, result.stdout
payload = _payload(result)
assert payload["ok"] is False
assert payload["remote"] is True
assert payload["managed"] is False
assert payload["state"] == "unavailable"
assert payload["code"] == "REMOTE_GATEWAY_UNAVAILABLE"
assert payload["gatewayUrl"] == "wss://squilla.example.com/ws"
assert payload["url"] == "https://squilla.example.com"
assert payload["healthUrl"] == "https://squilla.example.com/health"
assert urls == [
"https://squilla.example.com/health",
"https://squilla.example.com/healthz",
]
assert [attempt["errorType"] for attempt in payload["details"]["attempts"]] == [
"OSError",
"OSError",
]
def test_gateway_status_reports_stale_without_mutating_pidfile(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_write_pidfile(_record(pid=9999))
before = gateway_lifecycle.gateway_pidfile_path().read_text(encoding="utf-8")
_patch_pid_running(monkeypatch, False)
_patch_health(monkeypatch, False)
result = runner.invoke(app, ["gateway", "status", "--json"])
assert result.exit_code == 0
assert _payload(result)["state"] == "stale"
assert gateway_lifecycle.gateway_pidfile_path().read_text(encoding="utf-8") == before
def test_gateway_start_refuses_unmanaged_healthy_gateway(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_patch_health(monkeypatch, True)
result = runner.invoke(app, ["gateway", "start", "--json"])
assert result.exit_code == 3
payload = _payload(result)
assert payload["ok"] is False
assert payload["state"] == "unmanaged"
assert payload["code"] == "UNMANAGED_GATEWAY_RUNNING"
assert "http://127.0.0.1:18791" in payload["message"]
assert "host=127.0.0.1" in payload["message"]
assert "port=18791" in payload["message"]
assert not gateway_lifecycle.gateway_pidfile_path().exists()
def test_gateway_start_uses_same_interpreter_cli_boundary(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
calls = []
def fake_popen(argv, **kwargs):
calls.append((argv, kwargs))
return SimpleNamespace(pid=4242)
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fake_popen)
_patch_health(monkeypatch, False)
_patch_wait_for_health(monkeypatch, True)
result = runner.invoke(
app,
["gateway", "start", "--listen", "127.0.0.2", "--port", "18888", "--json"],
)
assert result.exit_code == 0, result.stdout
payload = _payload(result)
assert payload["state"] == "running"
assert payload["pid"] == 4242
argv, kwargs = calls[0]
assert argv[:5] == [sys.executable, "-m", "opensquilla.cli.main", "gateway", "run"]
assert "--listen" in argv
assert argv[argv.index("--listen") + 1] == "127.0.0.2"
assert kwargs["shell"] is False
def test_gateway_start_frozen_binary_invokes_subcommand_directly(tmp_path, monkeypatch) -> None:
# In a PyInstaller-frozen desktop bundle sys.executable already is the CLI
# entrypoint, so "-m opensquilla.cli.main" would be handed to Typer as
# arguments and the child would exit on a usage error.
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
monkeypatch.setattr(sys, "frozen", True, raising=False)
calls = []
def fake_popen(argv, **kwargs):
calls.append((argv, kwargs))
return SimpleNamespace(pid=4243)
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fake_popen)
_patch_health(monkeypatch, False)
_patch_wait_for_health(monkeypatch, True)
result = runner.invoke(
app,
["gateway", "start", "--listen", "127.0.0.2", "--port", "18888", "--json"],
)
assert result.exit_code == 0, result.stdout
argv, _ = calls[0]
assert argv[:3] == [sys.executable, "gateway", "run"]
assert "-m" not in argv
assert "opensquilla.cli.main" not in argv
assert argv[argv.index("--listen") + 1] == "127.0.0.2"
def test_gateway_start_uses_explicit_config_path(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
default_config = tmp_path / "default.toml"
custom_config = tmp_path / "custom.toml"
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(default_config))
calls = []
def fake_popen(argv, **kwargs):
calls.append((argv, kwargs))
return SimpleNamespace(pid=4245)
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fake_popen)
_patch_health(monkeypatch, False)
_patch_wait_for_health(monkeypatch, True)
result = runner.invoke(
app,
["gateway", "start", "--config", str(custom_config), "--json"],
)
assert result.exit_code == 0, result.stdout
argv, kwargs = calls[0]
assert argv[argv.index("--config") + 1] == str(custom_config)
assert kwargs["env"]["OPENSQUILLA_GATEWAY_CONFIG_PATH"] == str(custom_config)
record = json.loads(gateway_lifecycle.gateway_pidfile_path().read_text(encoding="utf-8"))
assert record["configPath"] == str(custom_config)
def test_gateway_start_uses_config_host_port_when_flags_are_omitted(
tmp_path, monkeypatch
) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
custom_config = tmp_path / "custom.toml"
custom_config.write_text('host = "127.0.0.2"\nport = 19999\n', encoding="utf-8")
calls = []
def fake_popen(argv, **kwargs):
calls.append((argv, kwargs))
return SimpleNamespace(pid=4246)
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fake_popen)
_patch_health(monkeypatch, False)
_patch_wait_for_health(monkeypatch, True)
result = runner.invoke(
app,
["gateway", "start", "--config", str(custom_config), "--json"],
)
assert result.exit_code == 0, result.stdout
argv, _kwargs = calls[0]
assert argv[argv.index("--listen") + 1] == "127.0.0.2"
assert argv[argv.index("--port") + 1] == "19999"
payload = _payload(result)
assert payload["url"] == "http://127.0.0.2:19999"
def test_gateway_status_uses_config_host_port_when_flags_are_omitted(
tmp_path, monkeypatch
) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
custom_config = tmp_path / "custom.toml"
custom_config.write_text('host = "127.0.0.2"\nport = 19999\n', encoding="utf-8")
probes = []
def fake_probe(self):
probes.append((self.host, self.port))
return False
monkeypatch.setattr(Manager, "_probe_health", fake_probe)
result = runner.invoke(
app,
["gateway", "status", "--config", str(custom_config), "--json"],
)
assert result.exit_code == 0, result.stdout
assert probes == [("127.0.0.2", 19999)]
payload = _payload(result)
assert payload["url"] == "http://127.0.0.2:19999"
def test_gateway_run_uses_config_host_port_when_flags_are_omitted(
tmp_path, monkeypatch
) -> None:
custom_config = tmp_path / "custom.toml"
custom_config.write_text('host = "127.0.0.2"\nport = 19999\n', encoding="utf-8")
captured = {}
class FakeServer:
def __init__(self, task):
self._task = task
async def close(self, _reason):
return None
async def fake_start_gateway_server(*, config, subscription_manager, run):
captured["config"] = config
async def done():
return None
import asyncio
return FakeServer(asyncio.create_task(done()))
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
monkeypatch.setattr(gateway_cmd, "start_gateway_server", fake_start_gateway_server)
gateway_cmd.run_gateway(
port=None,
bind=None,
listen="",
debug=False,
config_path=str(custom_config),
)
assert captured["config"].host == "127.0.0.2"
assert captured["config"].port == 19999
def test_gateway_run_records_cli_flags_as_runtime_overrides(
tmp_path, monkeypatch
) -> None:
"""Boot-time --listen/--port/--debug are runtime state, not operator
config edits: run_gateway must record runtime provenance for the fields
it mutates so the sparse persister can keep them out of config.toml."""
custom_config = tmp_path / "custom.toml"
custom_config.write_text('host = "127.0.0.1"\nport = 18791\n', encoding="utf-8")
captured = {}
class FakeServer:
def __init__(self, task):
self._task = task
async def close(self, _reason):
return None
async def fake_start_gateway_server(*, config, subscription_manager, run):
captured["config"] = config
async def done():
return None
return FakeServer(asyncio.ensure_future(done()))
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
monkeypatch.setattr(gateway_cmd, "start_gateway_server", fake_start_gateway_server)
gateway_cmd.run_gateway(
port=18888,
bind=None,
listen="0.0.0.0",
debug=True,
config_path=str(custom_config),
)
overrides = captured["config"].runtime_field_overrides()
assert overrides["host"] == ("127.0.0.1", "0.0.0.0")
assert overrides["port"] == (18791, 18888)
assert overrides["debug"] == (False, True)
def test_gateway_run_flags_do_not_leak_into_config_via_unrelated_persist(
tmp_path, monkeypatch
) -> None:
"""F4 regression: `gateway run --listen 0.0.0.0 --debug` for a one-off
session followed by an onboarding-surface save of an unrelated section
must not bake host/debug into config.toml permanently."""
from opensquilla.onboarding.config_store import load_config, persist_config
from opensquilla.onboarding.mutations import upsert_search_provider
custom_config = tmp_path / "custom.toml"
custom_config.write_text('host = "127.0.0.1"\nport = 18791\n', encoding="utf-8")
captured = {}
class FakeServer:
def __init__(self, task):
self._task = task
async def close(self, _reason):
return None
async def fake_start_gateway_server(*, config, subscription_manager, run):
captured["config"] = config
async def done():
return None
return FakeServer(asyncio.ensure_future(done()))
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
monkeypatch.setattr(gateway_cmd, "start_gateway_server", fake_start_gateway_server)
gateway_cmd.run_gateway(
port=None,
bind=None,
listen="0.0.0.0",
debug=True,
config_path=str(custom_config),
)
boot_config = captured["config"]
assert boot_config.host == "0.0.0.0"
assert boot_config.debug is True
# Web-UI save of an unrelated onboarding section: the mutation clone
# inherits the boot config's provenance and is what the RPC layer
# persists (rpc_onboarding._persist -> persist_config).
res = upsert_search_provider(
boot_config, provider_id="tavily", api_key="tvly-synthetic-run"
)
persist_config(res.config, path=custom_config)
data = tomllib.loads(custom_config.read_text())
assert data["search_api_key"] == "tvly-synthetic-run"
assert data["host"] == "127.0.0.1" # transient --listen never lands
assert data.get("debug", False) is False # transient --debug never lands
assert data.get("port", 18791) == 18791
# The file is still what the operator wrote plus the search save: a
# fresh load must not come up publicly bound or in debug mode.
reloaded = load_config(custom_config)
assert reloaded.host == "127.0.0.1"
assert reloaded.debug is False
def test_gateway_run_keeps_missing_explicit_config_path_for_setup(
tmp_path,
monkeypatch,
) -> None:
custom_config = tmp_path / "first-run.toml"
captured = {}
class FakeServer:
def __init__(self, task):
self._task = task
async def close(self, _reason):
return None
async def fake_start_gateway_server(*, config, subscription_manager, run):
captured["config"] = config
async def done():
return None
import asyncio
return FakeServer(asyncio.create_task(done()))
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
monkeypatch.setattr(gateway_cmd, "start_gateway_server", fake_start_gateway_server)
gateway_cmd.run_gateway(
port=19876,
bind=None,
listen="",
debug=False,
config_path=str(custom_config),
)
assert captured["config"].config_path == str(custom_config)
assert not custom_config.exists()
def test_gateway_run_preflights_occupied_port_before_building_services(
tmp_path,
monkeypatch,
) -> None:
custom_config = tmp_path / "custom.toml"
custom_config.write_text('host = "127.0.0.2"\nport = 19999\n', encoding="utf-8")
async def fail_start_gateway_server(**_kwargs):
raise AssertionError("gateway services should not build when bind preflight fails")
monkeypatch.setattr(gateway_cmd, "start_gateway_server", fail_start_gateway_server)
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda host, port: False)
result = runner.invoke(
app,
["gateway", "run", "--config", str(custom_config)],
)
assert result.exit_code == 1
assert "127.0.0.2:19999 is already in use" in result.stdout
def test_gateway_start_waits_for_readiness_after_liveness(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
calls = []
health_checks = 0
ready_checks = []
def fake_popen(argv, **kwargs):
calls.append((argv, kwargs))
return SimpleNamespace(pid=4244)
def fake_health(self):
nonlocal health_checks
health_checks += 1
return health_checks > 1
def fake_ready(self):
ready_checks.append(True)
return len(ready_checks) > 1
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fake_popen)
monkeypatch.setattr(Manager, "_probe_health", fake_health)
monkeypatch.setattr(Manager, "_probe_ready", fake_ready, raising=False)
monkeypatch.setattr(gateway_lifecycle.time, "sleep", lambda _seconds: None)
result = runner.invoke(app, ["gateway", "start", "--json"])
assert result.exit_code == 0, result.stdout
assert _payload(result)["state"] == "running"
assert calls
assert len(ready_checks) == 2
def test_gateway_health_probe_uses_loopback_for_wildcard_bind(monkeypatch) -> None:
urls = []
def fake_urlopen(request, timeout):
urls.append(request.full_url)
assert timeout == 0.5
return _FakeHealthResponse()
monkeypatch.setattr(gateway_lifecycle, "urlopen", fake_urlopen)
manager = Manager(host="0.0.0.0", port=18888)
assert manager._probe_health() is True
assert urls == ["http://127.0.0.1:18888/health"]
def test_gateway_start_with_wildcard_listen_keeps_bind_and_reports_probe_host(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
calls = []
def fake_popen(argv, **kwargs):
calls.append((argv, kwargs))
return SimpleNamespace(pid=4243)
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fake_popen)
_patch_health(monkeypatch, False)
_patch_wait_for_health(monkeypatch, True)
result = runner.invoke(
app,
["gateway", "start", "--listen", "0.0.0.0", "--port", "18889", "--json"],
)
assert result.exit_code == 0, result.stdout
payload = _payload(result)
assert payload["host"] == "0.0.0.0"
assert payload["probeHost"] == "127.0.0.1"
assert payload["url"] == "http://0.0.0.0:18889"
assert payload["healthUrl"] == "http://127.0.0.1:18889/health"
record = json.loads(gateway_lifecycle.gateway_pidfile_path().read_text(encoding="utf-8"))
assert record["host"] == "0.0.0.0"
assert record["probeHost"] == "127.0.0.1"
assert record["url"] == "http://0.0.0.0:18889"
assert record["healthUrl"] == "http://127.0.0.1:18889/health"
argv, kwargs = calls[0]
assert argv[argv.index("--listen") + 1] == "0.0.0.0"
assert kwargs["shell"] is False
def test_gateway_start_does_not_spawn_duplicate_recorded_gateway(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_write_pidfile(_record(pid=321))
_patch_pid_running(monkeypatch, True)
_patch_health(monkeypatch, True)
def fail_popen(*args, **kwargs):
raise AssertionError("duplicate gateway should not be spawned")
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fail_popen)
result = runner.invoke(app, ["gateway", "start", "--json"])
assert result.exit_code == 0
payload = _payload(result)
assert payload["state"] == "running"
assert payload["pid"] == 321
def test_gateway_start_refuses_live_pidfile_for_different_target(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_write_pidfile(_record(pid=321, port=18791))
_patch_pid_running(monkeypatch, True)
_patch_health(monkeypatch, False)
def fail_popen(*args, **kwargs):
raise AssertionError("target mismatch must not spawn a second gateway")
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fail_popen)
result = runner.invoke(app, ["gateway", "start", "--port", "18792", "--json"])
assert result.exit_code == 3
payload = _payload(result)
assert payload["state"] == "target_mismatch"
assert payload["code"] == "MANAGED_GATEWAY_TARGET_MISMATCH"
assert "http://127.0.0.1:18791" in payload["message"]
assert "http://127.0.0.1:18792" in payload["message"]
assert gateway_lifecycle.gateway_pidfile_path().exists()
def test_gateway_status_reports_recorded_config_mismatch(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
record = _record(pid=321, port=18791)
record["configPath"] = str(tmp_path / "first.toml")
_write_pidfile(record)
_patch_pid_running(monkeypatch, True)
_patch_health(monkeypatch, False)
result = runner.invoke(
app,
[
"gateway",
"status",
"--config",
str(tmp_path / "second.toml"),
"--json",
],
)
assert result.exit_code == 0
payload = _payload(result)
assert payload["state"] == "target_mismatch"
assert payload["details"]["recordedConfigPath"] == str(tmp_path / "first.toml")
assert payload["details"]["requestedConfigPath"] == str(tmp_path / "second.toml")
def test_gateway_stop_clears_stale_pidfile(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_write_pidfile(_record(pid=9999))
_patch_pid_running(monkeypatch, False)
_patch_health(monkeypatch, False)
result = runner.invoke(app, ["gateway", "stop", "--json"])
assert result.exit_code == 0
assert _payload(result)["state"] == "cleared_stale"
assert not gateway_lifecycle.gateway_pidfile_path().exists()
def test_gateway_stop_refuses_unmanaged_healthy_gateway(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_patch_health(monkeypatch, True)
result = runner.invoke(app, ["gateway", "stop", "--json"])
assert result.exit_code == 3
payload = _payload(result)
assert payload["code"] == "UNMANAGED_GATEWAY_RUNNING"
assert payload["state"] == "unmanaged"
def test_gateway_stop_refuses_live_pidfile_for_different_target(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_write_pidfile(_record(pid=321, port=18791))
_patch_pid_running(monkeypatch, True)
_patch_health(monkeypatch, False)
def fail_terminate(self, pid):
raise AssertionError("target mismatch must not terminate another gateway")
monkeypatch.setattr(Manager, "_terminate_pid", fail_terminate)
result = runner.invoke(app, ["gateway", "stop", "--port", "18792", "--json"])
assert result.exit_code == 3
payload = _payload(result)
assert payload["state"] == "target_mismatch"
assert payload["code"] == "MANAGED_GATEWAY_TARGET_MISMATCH"
assert gateway_lifecycle.gateway_pidfile_path().exists()
def test_gateway_restart_refuses_live_pidfile_for_different_target(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_write_pidfile(_record(pid=321, port=18791))
_patch_pid_running(monkeypatch, True)
_patch_health(monkeypatch, False)
def fail_popen(*args, **kwargs):
raise AssertionError("target mismatch must not restart over another gateway")
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fail_popen)
result = runner.invoke(app, ["gateway", "restart", "--port", "18792", "--json"])
assert result.exit_code == 3
payload = _payload(result)
assert payload["state"] == "target_mismatch"
assert payload["code"] == "MANAGED_GATEWAY_TARGET_MISMATCH"
assert gateway_lifecycle.gateway_pidfile_path().exists()
def test_gateway_restart_stops_before_starting(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(tmp_path / "home"))
_write_pidfile(_record(pid=777))
events = []
def fake_popen(argv, **kwargs):
events.append("start")
return SimpleNamespace(pid=888)
def fake_terminate(self, pid):
events.append("stop")
return True
_patch_pid_running(monkeypatch, True)
_patch_health(monkeypatch, False)
monkeypatch.setattr(Manager, "_terminate_pid", fake_terminate)
_patch_wait_for_health(monkeypatch, True)
monkeypatch.setattr(gateway_lifecycle.subprocess, "Popen", fake_popen)
result = runner.invoke(app, ["gateway", "restart", "--json"])
assert result.exit_code == 0, result.stdout
assert events == ["stop", "start"]
assert _payload(result)["state"] == "running"
# ---------------------------------------------------------------------------
# Graceful shutdown signal handling (gateway run)
#
# Regression coverage for the SIGTERM drain bug: _run() must route SIGINT and
# SIGTERM through GatewayServer.close() (the only path that drains in-flight
# agent turns + background completions), not let uvicorn's default handler exit
# without the drain.
# ---------------------------------------------------------------------------
class _ShutdownProbeServer:
"""Fake GatewayServer whose serve task only ends when close() is called.
``fire`` is the shutdown reason to simulate once the loop is running; ``via``
selects how it is delivered — "signal" through the captured signal handler,
or "http" through ``app.state.request_shutdown`` (the HTTP endpoint path).
"""
def __init__(self, *, fire: str | None = None, via: str = "signal") -> None:
self.closed: list[str] = []
self._fire = fire
self._via = via
self._on_signal = None
self.app = SimpleNamespace(state=SimpleNamespace())
self._task: asyncio.Task | None = None
def spawn(self) -> None:
# Must run inside the event loop, so the fake start coroutine calls it.
self._task = asyncio.ensure_future(self._serve())
async def _serve(self) -> None:
if self._fire is not None:
await asyncio.sleep(0.01)
if self._via == "http":
self.app.state.request_shutdown(self._fire)
else:
assert self._on_signal is not None, "shutdown handler not captured"
self._on_signal(self._fire)
await asyncio.Event().wait()
async def close(self, reason: str) -> None:
self.closed.append(reason)
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
def _install_fake_start(server, holder, monkeypatch) -> None:
async def fake_start(*, config, subscription_manager, run):
server.spawn()
holder["server"] = server
return server
monkeypatch.setattr(gateway_cmd, "start_gateway_server", fake_start)
def test_gateway_run_drains_via_close_on_shutdown_signal(tmp_path, monkeypatch) -> None:
"""A delivered SIGTERM must trigger server.close() (the graceful drain)."""
config = tmp_path / "gw.toml"
config.write_text('host = "127.0.0.1"\nport = 18791\n', encoding="utf-8")
holder: dict = {}
server = _ShutdownProbeServer(fire="sigterm", via="signal")
# Capture the handler _run installs, and feed it back into the fake server so
# it can simulate signal delivery deterministically (no real os.kill).
real_install = gateway_cmd._install_shutdown_handlers
def capturing_install(loop, on_signal):
server._on_signal = on_signal
return real_install(loop, on_signal)
monkeypatch.setattr(gateway_cmd, "_install_shutdown_handlers", capturing_install)
_install_fake_start(server, holder, monkeypatch)
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
gateway_cmd.run_gateway(
port=None, bind=None, listen="", debug=False, config_path=str(config)
)
assert holder["server"].closed == ["sigterm"]
def test_gateway_run_drains_when_server_task_exits_on_its_own(
tmp_path, monkeypatch
) -> None:
"""If the serve task ends without a signal, close() still runs the drain."""
config = tmp_path / "gw.toml"
config.write_text('host = "127.0.0.1"\nport = 18791\n', encoding="utf-8")
class _SelfExitingServer:
def __init__(self) -> None:
self.closed: list[str] = []
self._task: asyncio.Task | None = None
def spawn(self) -> None:
async def done() -> None:
return None
self._task = asyncio.ensure_future(done())
async def close(self, reason: str) -> None:
self.closed.append(reason)
holder: dict = {}
_install_fake_start(_SelfExitingServer(), holder, monkeypatch)
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
gateway_cmd.run_gateway(
port=None, bind=None, listen="", debug=False, config_path=str(config)
)
assert holder["server"].closed == ["shutdown"]
@pytest.mark.skipif(
os.name == "nt", reason="loop.add_signal_handler is unsupported on Windows"
)
def test_install_shutdown_handlers_traps_real_sigterm() -> None:
"""The installed asyncio handler must trap a real SIGTERM (not let it kill)."""
async def main() -> list[str]:
loop = asyncio.get_running_loop()
got: list[str] = []
installed = gateway_cmd._install_shutdown_handlers(loop, got.append)
try:
# Only deliver the signal once we've confirmed our handler owns it,
# so a failed install can never fall through to the default (kill).
assert signal.SIGTERM in installed
os.kill(os.getpid(), signal.SIGTERM)
for _ in range(100):
if got:
break
await asyncio.sleep(0.01)
finally:
gateway_cmd._remove_shutdown_handlers(loop, installed)
return got
assert asyncio.run(main()) == ["sigterm"]
def test_gateway_shutdown_deadline_exceeds_graceful_budget(monkeypatch) -> None:
"""The kill deadline must always exceed the (two-phase) graceful drain."""
from opensquilla.gateway import boot
monkeypatch.delenv(boot.GATEWAY_GRACEFUL_TIMEOUT_ENV, raising=False)
assert boot.gateway_shutdown_deadline() > boot.gateway_graceful_timeout() * 2
monkeypatch.setenv(boot.GATEWAY_GRACEFUL_TIMEOUT_ENV, "5")
assert boot.gateway_graceful_timeout() == 5.0
assert boot.gateway_shutdown_deadline() > 5.0 * 2
# Bounded: absurd values clamp, junk falls back to the default.
monkeypatch.setenv(boot.GATEWAY_GRACEFUL_TIMEOUT_ENV, "100000")
assert boot.gateway_graceful_timeout() == 120.0
monkeypatch.setenv(boot.GATEWAY_GRACEFUL_TIMEOUT_ENV, "not-a-number")
assert boot.gateway_graceful_timeout() == 30.0
def test_gateway_run_drains_via_http_shutdown_trigger(tmp_path, monkeypatch) -> None:
"""_run must expose app.state.request_shutdown; calling it drains via close()."""
config = tmp_path / "gw.toml"
config.write_text('host = "127.0.0.1"\nport = 18791\n', encoding="utf-8")
holder: dict = {}
# via="http" makes the fake server fire through app.state.request_shutdown,
# which _run wires to the same graceful-drain trigger as the signal handlers.
server = _ShutdownProbeServer(fire="api_shutdown", via="http")
_install_fake_start(server, holder, monkeypatch)
monkeypatch.setattr(gateway_cmd, "_gateway_bind_available", lambda *_args: True)
gateway_cmd.run_gateway(
port=None, bind=None, listen="", debug=False, config_path=str(config)
)
assert holder["server"].closed == ["api_shutdown"]
# ---------------------------------------------------------------------------
# Lifecycle stop: graceful HTTP shutdown (Windows) + POSIX SIGTERM fallback
# ---------------------------------------------------------------------------
class _FakeShutdownResponse:
def __init__(self, status: int) -> None:
self.status = status
def __enter__(self):
return self
def __exit__(self, *_exc) -> None:
return None
def test_request_graceful_shutdown_posts_with_token(monkeypatch) -> None:
monkeypatch.setenv("OPENSQUILLA_GATEWAY_TOKEN", "secret-tok")
captured: dict = {}
def fake_urlopen(request, timeout):
captured["url"] = request.full_url
captured["method"] = request.method
captured["auth"] = request.headers.get("Authorization")
captured["timeout"] = timeout
return _FakeShutdownResponse(202)
monkeypatch.setattr(gateway_lifecycle, "urlopen", fake_urlopen)
mgr = Manager(host="127.0.0.1", port=18791)
assert mgr._request_graceful_shutdown() is True
assert captured["url"] == "http://127.0.0.1:18791/api/system/shutdown"
assert captured["method"] == "POST"
assert captured["auth"] == "Bearer secret-tok"
def test_request_graceful_shutdown_returns_false_when_unreachable(monkeypatch) -> None:
def fake_urlopen(request, timeout):
raise URLError("connection refused")
monkeypatch.setattr(gateway_lifecycle, "urlopen", fake_urlopen)
monkeypatch.delenv("OPENSQUILLA_GATEWAY_TOKEN", raising=False)
mgr = Manager(host="127.0.0.1", port=18791, config_path=None)
assert mgr._request_graceful_shutdown() is False
def test_terminate_pid_windows_prefers_graceful_endpoint(monkeypatch) -> None:
monkeypatch.setattr(gateway_lifecycle, "_running_on_windows", lambda: True)
monkeypatch.setattr(Manager, "_pid_running", lambda self, pid: True)
calls: list[str] = []
monkeypatch.setattr(
Manager,
"_request_graceful_shutdown",
lambda self: calls.append("graceful") or True,
)
monkeypatch.setattr(Manager, "_wait_for_exit", lambda self, pid, timeout: True)
killed: list = []
monkeypatch.setattr(gateway_lifecycle.os, "kill", lambda *a: killed.append(a))
mgr = Manager(host="127.0.0.1", port=18791, shutdown_timeout=1.0)
assert mgr._terminate_pid(4321) is True
assert calls == ["graceful"]
assert killed == [] # graceful exit means no hard kill
def test_terminate_pid_windows_falls_back_to_terminate(monkeypatch) -> None:
monkeypatch.setattr(gateway_lifecycle, "_running_on_windows", lambda: True)
monkeypatch.setattr(Manager, "_pid_running", lambda self, pid: True)
monkeypatch.setattr(Manager, "_request_graceful_shutdown", lambda self: False)
monkeypatch.setattr(Manager, "_wait_for_exit", lambda self, pid, timeout: True)
killed: list = []
monkeypatch.setattr(gateway_lifecycle.os, "kill", lambda pid, sig: killed.append((pid, sig)))
mgr = Manager(host="127.0.0.1", port=18791, shutdown_timeout=1.0)
assert mgr._terminate_pid(4321) is True
# Graceful rejected -> hard terminate via TerminateProcess (os.kill SIGTERM).
assert killed == [(4321, signal.SIGTERM)]
@pytest.mark.skipif(os.name == "nt", reason="SIGKILL is POSIX-only")
def test_terminate_pid_posix_uses_sigterm_then_sigkill(monkeypatch) -> None:
monkeypatch.setattr(gateway_lifecycle, "_running_on_windows", lambda: False)
monkeypatch.setattr(Manager, "_pid_running", lambda self, pid: True)
sent: list = []
monkeypatch.setattr(gateway_lifecycle.os, "kill", lambda pid, sig: sent.append((pid, sig)))
# First wait (after SIGTERM) times out, forcing the SIGKILL hard-kill path.
monkeypatch.setattr(Manager, "_wait_for_exit", lambda self, pid, timeout: False)
# Never reach the HTTP path on POSIX.
monkeypatch.setattr(
Manager,
"_request_graceful_shutdown",
lambda self: (_ for _ in ()).throw(AssertionError("HTTP path used on POSIX")),
)
mgr = Manager(host="127.0.0.1", port=18791, shutdown_timeout=0.0)
mgr._terminate_pid(4321)
assert (4321, signal.SIGTERM) in sent
assert any(sig == getattr(signal, "SIGKILL", None) for _pid, sig in sent)
def test_hard_kill_backstop_does_not_reuse_full_shutdown_timeout(monkeypatch) -> None:
"""Windows accepted-but-slow path must not wait the full graceful budget twice."""
monkeypatch.setattr(gateway_lifecycle, "_running_on_windows", lambda: True)
monkeypatch.setattr(Manager, "_pid_running", lambda self, pid: True)
monkeypatch.setattr(Manager, "_request_graceful_shutdown", lambda self: True)
waits: list[float] = []
def fake_wait(self, pid, timeout):
waits.append(timeout)
return len(waits) > 1 # graceful wait times out; hard-kill wait succeeds
monkeypatch.setattr(Manager, "_wait_for_exit", fake_wait)
monkeypatch.setattr(gateway_lifecycle.os, "kill", lambda *a: None)
mgr = Manager(host="127.0.0.1", port=18791, shutdown_timeout=75.0)
assert mgr._terminate_pid(4321) is True
# First wait uses the graceful budget; the hard-kill backstop is short, not 75s again.
assert waits == [75.0, gateway_lifecycle._HARD_KILL_BACKSTOP_S]