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

2376 lines
86 KiB
Python

"""Tests for the host connect process (launch/stop handling)."""
from __future__ import annotations
import asyncio
import logging
import subprocess
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosedError, InvalidStatus, InvalidURI
from websockets.http11 import Response
from omnigent.host.connect import (
HostConnectError,
HostProcess,
_build_runner_env,
_RunnerHandle,
run_host_process,
)
from omnigent.host.frames import (
HARNESS_NOT_CONFIGURED_ERROR_CODE,
HostCreateDirFrame,
HostCreateDirResultFrame,
HostHelloFrame,
HostLaunchRunnerFrame,
HostLaunchRunnerResultFrame,
HostListDirFrame,
HostListDirResultFrame,
HostRunnerExitedFrame,
HostStatFrame,
HostStatResultFrame,
HostStopRunnerFrame,
HostStopRunnerResultFrame,
decode_host_frame,
)
from omnigent.host.identity import HostIdentity
from omnigent.runner.identity import (
RUNNER_ID_ENV_VAR,
RUNNER_PARENT_PID_ENV_VAR,
RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR,
RUNNER_WORKSPACE_ENV_VAR,
token_bound_runner_id,
)
pytestmark = pytest.mark.asyncio
def _make_host_process() -> HostProcess:
"""Create a HostProcess with a test identity.
:returns: A :class:`HostProcess` for testing.
"""
identity = HostIdentity(
host_id="host_test_connect",
name="test-laptop",
)
return HostProcess(
identity=identity,
server_url="http://localhost:8000",
)
def _cleanup_host(host: HostProcess) -> None:
"""Terminate a test host's spawned runners and exit watchers.
``_cleanup_runners`` pops every handle before terminating, so the
watcher tasks see an intentional stop and never report; cancelling
them afterwards just avoids "task was destroyed but pending"
warnings at loop teardown.
:param host: The host process under test.
"""
host._cleanup_runners()
for task in host._watcher_tasks:
task.cancel()
async def test_handle_launch_spawns_subprocess(
tmp_path: Path,
) -> None:
"""
Verify that _handle_launch spawns a subprocess with the correct
binding token and workspace, and returns status='launched'.
If the result status is not 'launched', the subprocess spawn
failed or the result frame construction is wrong.
"""
host = _make_host_process()
workspace = tmp_path / "project"
workspace.mkdir()
frame = HostLaunchRunnerFrame(
request_id="req_001",
binding_token="test_token_abc",
workspace=str(workspace),
)
spawned_env: dict[str, str] = {}
spawned_kwargs: dict[str, object] = {}
original_popen = subprocess.Popen
def _fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
"""Capture the env vars and spawn a no-op process.
:param args: Command args (ignored — we spawn a real sleep).
:param kwargs: Popen kwargs including env and stdin.
:returns: A real subprocess handle.
"""
env = kwargs.get("env", {})
spawned_env.update(env)
spawned_kwargs.update(kwargs)
# Spawn a real process that sleeps briefly so poll() returns None.
return original_popen(
["sleep", "10"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
with patch("omnigent.host.connect.subprocess.Popen", side_effect=_fake_popen):
result = await host._handle_launch(frame)
assert isinstance(result, HostLaunchRunnerResultFrame)
# Status should be 'launched' since the process started.
assert result.status == "launched", (
f"Expected 'launched', got {result.status!r}. Error: {result.error}"
)
assert result.request_id == "req_001"
expected_runner_id = token_bound_runner_id("test_token_abc")
# runner_id should be derived from the binding token.
assert result.runner_id == expected_runner_id, (
"runner_id should be token_bound_runner_id(binding_token)"
)
# Verify env vars passed to the subprocess.
assert spawned_env.get("RUNNER_SERVER_URL") == "http://localhost:8000"
assert spawned_env.get("OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN") == "test_token_abc"
assert spawned_env.get("OMNIGENT_RUNNER_WORKSPACE") == str(workspace)
# Runners must get a clean /dev/null stdin, not the daemon's inherited fd:
# a long-lived (e.g. nohup'd) daemon can end up with a closed/recycled
# stdin, and an inherited bad fd crashes the runner at interpreter startup
# ("init_sys_streams: Bad file descriptor") so it never connects. If this
# regresses, long-lived daemons intermittently fail to start sessions.
assert spawned_kwargs.get("stdin") == subprocess.DEVNULL, (
"runner subprocess must be spawned with stdin=subprocess.DEVNULL"
)
# Clean up the spawned sleep process (and its exit watcher).
_cleanup_host(host)
async def test_handle_launch_fails_for_bad_workspace() -> None:
"""
Verify that _handle_launch returns status='failed' when the
workspace path does not exist.
If it returns 'launched', the path validation is missing and
the runner would start in a nonexistent directory.
"""
host = _make_host_process()
frame = HostLaunchRunnerFrame(
request_id="req_002",
binding_token="token_xyz",
workspace="/nonexistent/path/that/does/not/exist",
)
result = await host._handle_launch(frame)
assert isinstance(result, HostLaunchRunnerResultFrame)
assert result.status == "failed", "Should fail for nonexistent workspace"
assert "does not exist" in (result.error or ""), (
f"Error should mention path doesn't exist, got: {result.error!r}"
)
assert result.runner_id is None
async def test_handle_launch_refuses_unconfigured_harness(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Verify _handle_launch refuses to spawn when the frame's harness is
not configured, with the structured error_code and a message that
names the harness, the host, and the `omnigent setup` fix.
If this regresses, an unconfigured launch spawns a runner whose
first turn dies inside the executor — the exact dead-session UX
this check exists to prevent — and the server's 412 mapping (keyed
on error_code) never fires.
"""
host = _make_host_process()
workspace = tmp_path / "project"
workspace.mkdir()
# Patch the symbol connect.py imported, with the real function's
# signature; the workspace exists so ONLY the harness check can fail.
monkeypatch.setattr(
"omnigent.host.connect.harness_is_configured",
lambda harness: False,
)
frame = HostLaunchRunnerFrame(
request_id="req_unconfigured",
binding_token="token_abc",
workspace=str(workspace),
harness="codex",
)
result = await host._handle_launch(frame)
assert isinstance(result, HostLaunchRunnerResultFrame)
assert result.status == "failed"
# The structured code is what the server's 412 mapping keys on.
assert result.error_code == HARNESS_NOT_CONFIGURED_ERROR_CODE
# The message is shown verbatim to the user — it must name the
# harness, the host, and the remediation command.
assert "'codex'" in (result.error or "")
assert "test-laptop" in (result.error or "")
assert "omnigent setup" in (result.error or "")
assert result.runner_id is None
# No runner subprocess may exist after a refusal.
assert host._runners == {}
async def test_handle_launch_native_cursor_message_points_at_cursor_installer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
A native-Cursor refusal must name the ``cursor-agent`` installer and
login, not ``omnigent setup`` — which only configures the SDK ``cursor``
harness and never installs the ``cursor-agent`` CLI ``omni cursor`` boots.
Here ``harness_setup_hint`` is the real function (only the readiness check
is forced False), so this exercises the connect.py → hint wiring end to end.
"""
host = _make_host_process()
workspace = tmp_path / "project"
workspace.mkdir()
monkeypatch.setattr(
"omnigent.host.connect.harness_is_configured",
lambda harness: False,
)
frame = HostLaunchRunnerFrame(
request_id="req_cursor_native",
binding_token="token_abc",
workspace=str(workspace),
harness="cursor-native",
)
result = await host._handle_launch(frame)
assert result.status == "failed"
assert result.error_code == HARNESS_NOT_CONFIGURED_ERROR_CODE
message = result.error or ""
assert "'cursor-native'" in message
assert "test-laptop" in message
assert "cursor.com/install" in message
assert "cursor-agent login" in message
assert "omnigent setup" not in message
assert host._runners == {}
async def test_handle_launch_configured_harness_proceeds_to_spawn(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Verify a configured harness passes the check and the launch
proceeds to the normal spawn path.
If this fails with status='failed', the readiness check is
refusing configured harnesses — every launch on the host would
break, not just unconfigured ones.
"""
host = _make_host_process()
workspace = tmp_path / "project"
workspace.mkdir()
monkeypatch.setattr(
"omnigent.host.connect.harness_is_configured",
lambda harness: True,
)
original_popen = subprocess.Popen
def _fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
"""Spawn a no-op process so poll() returns None.
:param args: Command args (ignored — we spawn a real sleep).
:param kwargs: Popen kwargs (ignored).
:returns: A real subprocess handle.
"""
return original_popen(
["sleep", "10"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
frame = HostLaunchRunnerFrame(
request_id="req_configured",
binding_token="token_def",
workspace=str(workspace),
harness="claude-sdk",
)
with patch("omnigent.host.connect.subprocess.Popen", side_effect=_fake_popen):
result = await host._handle_launch(frame)
assert result.status == "launched", (
f"Configured harness must launch, got {result.status!r}: {result.error}"
)
assert result.error_code is None
# Clean up the spawned sleep process (and its exit watcher).
_cleanup_host(host)
async def test_handle_launch_without_harness_skips_check(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Verify a launch frame with harness=None (an older server) never
consults the readiness check — fail open across version skew.
If the check ran anyway, upgrading hosts before servers would
break launches for any session whose harness the host considers
unconfigured, even though the server never asked for the check.
"""
host = _make_host_process()
workspace = tmp_path / "project"
workspace.mkdir()
def _must_not_be_called(harness: str) -> bool:
"""Fail the test if the readiness check runs for harness=None.
:param harness: The harness the production code passed.
:returns: Never returns.
"""
raise AssertionError("harness_is_configured must not be called when frame.harness is None")
monkeypatch.setattr(
"omnigent.host.connect.harness_is_configured",
_must_not_be_called,
)
original_popen = subprocess.Popen
def _fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
"""Spawn a no-op process so poll() returns None.
:param args: Command args (ignored — we spawn a real sleep).
:param kwargs: Popen kwargs (ignored).
:returns: A real subprocess handle.
"""
return original_popen(
["sleep", "10"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
frame = HostLaunchRunnerFrame(
request_id="req_no_harness",
binding_token="token_ghi",
workspace=str(workspace),
)
with patch("omnigent.host.connect.subprocess.Popen", side_effect=_fake_popen):
result = await host._handle_launch(frame)
assert result.status == "launched"
# Clean up the spawned sleep process (and its exit watcher).
_cleanup_host(host)
async def test_handle_launch_prints_exact_runner_log_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The launch banner names the exact runner log file, not just the dir.
A foreground host's terminal shows the lifecycle line, but the runner's
real output — the agent turn, tracebacks — lands only in the per-runner
log file. The user needs that precise path to tail it, so the launch
print must include it. We repoint ``Path.home`` so the log lands under
tmp (no write to the developer's real ``~/.omnigent``).
"""
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path))
host = _make_host_process()
workspace = tmp_path / "project"
workspace.mkdir()
frame = HostLaunchRunnerFrame(
request_id="req_log",
binding_token="tok_log",
workspace=str(workspace),
session_id="conv_log",
)
original_popen = subprocess.Popen
def _fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
"""Spawn a harmless sleep so poll() reports the runner as alive.
:param args: Command args (ignored — a real sleep is spawned).
:param kwargs: Popen kwargs (ignored).
:returns: A real subprocess handle.
"""
return original_popen(
["sleep", "10"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
with patch("omnigent.host.connect.subprocess.Popen", side_effect=_fake_popen):
result = await host._handle_launch(frame)
assert result.status == "launched", result.error
# Exactly one runner-*.log was created under the host-runner dir.
runner_log_dir = tmp_path / ".omnigent" / "logs" / "host-runner"
log_files = list(runner_log_dir.glob("runner-*.log"))
assert len(log_files) == 1
out = capsys.readouterr().out
assert "↑ Runner started:" in out
# The exact file path is printed, home-collapsed to ``~`` for readability.
assert f"log: ~/.omnigent/logs/host-runner/{log_files[0].name}" in out
assert "session: conv_log" in out
_cleanup_host(host)
class _FakeTunnel:
"""In-memory stand-in for the host's WebSocket tunnel connection.
Captures frames sent by the host process so tests can assert on
outbound reports; ``recv`` immediately raises so ``_serve_frames``
exits after its post-hello work.
:param sent: Encoded frames sent via ``send``, in order.
"""
def __init__(self) -> None:
"""Initialize with an empty sent-frame log."""
self.sent: list[str] = []
async def send(self, data: str) -> None:
"""Record an outbound frame.
:param data: Encoded frame text.
"""
self.sent.append(data)
async def recv(self) -> str:
"""Simulate an immediate disconnect.
:returns: Never returns.
:raises ConnectionError: Always — ends the serve loop.
"""
raise ConnectionError("test disconnect")
async def test_handle_launch_immediate_exit_reports_exit_code_and_log_tail(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An immediate runner death fails the launch with the actual cause.
Before this, the daemon answered only "runner exited immediately
with code N" — the real error (a traceback, a missing module)
stayed in a log file on the host. The launch failure must now
carry the exit code, the log path, and the log tail so the server
can surface the cause to the user verbatim.
"""
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path))
host = _make_host_process()
workspace = tmp_path / "project"
workspace.mkdir()
original_popen = subprocess.Popen
def _fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
"""Spawn a process that writes to the real captured log and dies.
Passes through the production-opened log handles so the error
output lands exactly where the daemon will read the tail from,
and waits for exit so ``poll()`` reports the death immediately.
:param args: Command args (ignored — a failing sh is spawned).
:param kwargs: Popen kwargs from production, including the log
file handles.
:returns: A finished subprocess handle with returncode 7.
"""
proc = original_popen(
["sh", "-c", "echo 'RuntimeError: boom-traceback' >&2; exit 7"],
stdin=subprocess.DEVNULL,
stdout=kwargs["stdout"],
stderr=kwargs["stderr"],
)
proc.wait()
return proc
frame = HostLaunchRunnerFrame(
request_id="req_dead",
binding_token="tok_dead",
workspace=str(workspace),
)
with patch("omnigent.host.connect.subprocess.Popen", side_effect=_fake_popen):
result = await host._handle_launch(frame)
assert result.status == "failed"
error = result.error or ""
# The exit code identifies the failure class without log-reading.
assert "code 7" in error
# The log path lets the user fetch the full log on the host.
assert "~/.omnigent/logs/host-runner/runner-" in error
# The tail carries the actual cause — the whole point of the report.
assert "RuntimeError: boom-traceback" in error
async def test_watch_runner_reports_unexpected_exit(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A runner that dies after launch is reported via host.runner_exited.
This is the launch-succeeded-then-crashed path (auth rejection, bad
env, import error) — the daemon already told the server "launched",
so only the watcher can carry the cause. Without the report, the
client polls its full timeout and the user is sent to a log
directory on the host.
"""
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path))
monkeypatch.setattr("omnigent.host.connect._RUNNER_WATCH_INTERVAL_S", 0.01)
host = _make_host_process()
tunnel = _FakeTunnel()
host._ws = tunnel # type: ignore[assignment] — duck-typed send
workspace = tmp_path / "project"
workspace.mkdir()
original_popen = subprocess.Popen
def _fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
"""Spawn a runner that lives briefly, logs a cause, then dies.
The initial sleep keeps ``poll()`` None at launch time so the
launch itself succeeds; the process then writes the cause to
the production log handles and exits 3.
:param args: Command args (ignored).
:param kwargs: Popen kwargs from production, including the log
file handles.
:returns: A live subprocess handle.
"""
return original_popen(
["sh", "-c", "echo 'tunnel rejected: crash-cause' >&2; sleep 0.2; exit 3"],
stdin=subprocess.DEVNULL,
stdout=kwargs["stdout"],
stderr=kwargs["stderr"],
)
frame = HostLaunchRunnerFrame(
request_id="req_watch",
binding_token="tok_watch",
workspace=str(workspace),
)
with patch("omnigent.host.connect.subprocess.Popen", side_effect=_fake_popen):
result = await host._handle_launch(frame)
assert result.status == "launched", result.error
# Wait for the watcher to observe the death and send its report
# (bounded — a hang means the watcher never fired).
await asyncio.wait_for(asyncio.gather(*host._watcher_tasks), timeout=5.0)
# Exactly one report; a second would double-record server-side.
assert len(tunnel.sent) == 1
report = decode_host_frame(tunnel.sent[0])
assert isinstance(report, HostRunnerExitedFrame)
assert report.runner_id == token_bound_runner_id("tok_watch")
# The report carries the exit code and the log tail with the cause.
assert "code 3" in report.error
assert "tunnel rejected: crash-cause" in report.error
async def test_watch_runner_silent_on_intentional_stop(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A host.stop_runner termination is NOT reported as a crash.
``_handle_stop`` pops the handle before terminating; the watcher
must read that as intentional and send nothing. A false report
here would attach a scary "runner process exited" error to every
cleanly stopped session.
"""
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path))
monkeypatch.setattr("omnigent.host.connect._RUNNER_WATCH_INTERVAL_S", 0.01)
host = _make_host_process()
tunnel = _FakeTunnel()
host._ws = tunnel # type: ignore[assignment] — duck-typed send
workspace = tmp_path / "project"
workspace.mkdir()
original_popen = subprocess.Popen
def _fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
"""Spawn a long-lived stand-in runner.
:param args: Command args (ignored).
:param kwargs: Popen kwargs (ignored beyond stdio defaults).
:returns: A live subprocess handle.
"""
return original_popen(
["sleep", "60"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
launch = HostLaunchRunnerFrame(
request_id="req_stop",
binding_token="tok_stop",
workspace=str(workspace),
)
with patch("omnigent.host.connect.subprocess.Popen", side_effect=_fake_popen):
result = await host._handle_launch(launch)
assert result.status == "launched", result.error
runner_id = token_bound_runner_id("tok_stop")
stop_result = host._handle_stop(
HostStopRunnerFrame(request_id="req_stop_2", runner_id=runner_id)
)
assert stop_result.status == "stopped"
# Let the watcher observe the (intentional) death and finish.
await asyncio.wait_for(asyncio.gather(*host._watcher_tasks), timeout=5.0)
# No runner_exited report and nothing parked for a reconnect —
# either would mark a clean stop as a crash.
assert tunnel.sent == []
assert host._unreported_exits == {}
async def test_unreported_exit_flushes_after_reconnect(
capsys: pytest.CaptureFixture[str],
) -> None:
"""An exit report that raced a disconnect is sent after the next hello.
A runner can die while the host tunnel is down (server restart,
network blip). The report parks in ``_unreported_exits`` and must
flush right after the reconnect hello — otherwise the death is
never reported and the waiting client polls to its timeout.
"""
host = _make_host_process()
host._unreported_exits["runner_parked"] = "runner process exited with code 1"
tunnel = _FakeTunnel()
# _serve_frames sends hello, flushes parked reports, then hits the
# fake recv's immediate disconnect.
with pytest.raises(ConnectionError, match="test disconnect"):
await host._serve_frames(tunnel) # type: ignore[arg-type] — duck-typed ws
assert len(tunnel.sent) == 2
hello = decode_host_frame(tunnel.sent[0])
assert isinstance(hello, HostHelloFrame)
report = decode_host_frame(tunnel.sent[1])
assert isinstance(report, HostRunnerExitedFrame)
assert report.runner_id == "runner_parked"
assert report.error == "runner process exited with code 1"
# The queue drained — a retained entry would re-send on every
# reconnect forever.
assert host._unreported_exits == {}
async def test_hello_advertises_installed_version() -> None:
"""The ``host.hello`` frame reports the omnigent version, not a placeholder.
A hard-coded placeholder would make every host look like the same
stale build in the server's version popover. The hello must carry the
shared resolved version this host is actually running.
"""
from omnigent.version import VERSION
host = _make_host_process()
tunnel = _FakeTunnel()
with pytest.raises(ConnectionError, match="test disconnect"):
await host._serve_frames(tunnel) # type: ignore[arg-type] — duck-typed ws
hello = decode_host_frame(tunnel.sent[0])
assert isinstance(hello, HostHelloFrame)
assert hello.version == VERSION
# Guard against the old hard-coded literal creeping back.
assert hello.version != "0.1.0"
def test_handle_stop_terminates_process(tmp_path: Path) -> None:
"""
Verify that _handle_stop terminates a tracked runner and
returns status='stopped'.
If the process isn't terminated, the runner keeps consuming
resources after the server requested a stop.
"""
host = _make_host_process()
proc = subprocess.Popen(
["sleep", "60"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
host._runners["runner_aaa"] = _RunnerHandle(proc=proc, log_path=tmp_path / "runner-a.log")
frame = HostStopRunnerFrame(
request_id="req_003",
runner_id="runner_aaa",
)
result = host._handle_stop(frame)
assert isinstance(result, HostStopRunnerResultFrame)
assert result.status == "stopped"
# Process should be terminated.
assert proc.poll() is not None, "Runner process should be terminated after stop"
# Runner should be removed from tracking.
assert "runner_aaa" not in host._runners
def test_handle_stop_unknown_runner() -> None:
"""
Verify that _handle_stop returns status='failed' for an
unknown runner_id.
If it returns 'stopped', the stop is silently succeeding for
a runner that doesn't exist, which could mask bugs.
"""
host = _make_host_process()
frame = HostStopRunnerFrame(
request_id="req_004",
runner_id="runner_nonexistent",
)
result = host._handle_stop(frame)
assert isinstance(result, HostStopRunnerResultFrame)
assert result.status == "failed"
assert "unknown runner" in (result.error or "")
def test_alive_runner_ids_cleans_dead(tmp_path: Path) -> None:
"""
Verify that _alive_runner_ids removes dead processes and returns
only alive ones.
If dead runners persist, reconnect reconciliation would report
runners that are no longer running.
"""
host = _make_host_process()
alive_proc = subprocess.Popen(
["sleep", "60"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
dead_proc = subprocess.Popen(
["true"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
dead_proc.wait()
host._runners["runner_alive"] = _RunnerHandle(
proc=alive_proc, log_path=tmp_path / "runner-alive.log"
)
host._runners["runner_dead"] = _RunnerHandle(
proc=dead_proc, log_path=tmp_path / "runner-dead.log"
)
alive_ids = host._alive_runner_ids()
assert "runner_alive" in alive_ids
assert "runner_dead" not in alive_ids, "Dead runner should be cleaned up"
assert "runner_dead" not in host._runners, "Dead runner should be removed from tracking dict"
alive_proc.terminate()
alive_proc.wait()
def test_cleanup_runners_terminates_all(tmp_path: Path) -> None:
"""
Verify that _cleanup_runners terminates every tracked runner.
This is the graceful shutdown path (Ctrl-C / finally block).
If any runner survives, the host leaves orphaned processes.
"""
host = _make_host_process()
procs = []
for name in ("runner_a", "runner_b", "runner_c"):
proc = subprocess.Popen(
["sleep", "60"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
host._runners[name] = _RunnerHandle(proc=proc, log_path=tmp_path / f"{name}.log")
procs.append(proc)
host._cleanup_runners()
# All processes should be terminated.
for proc in procs:
assert proc.poll() is not None, f"Runner pid={proc.pid} should be dead after cleanup"
# Tracking dict should be empty.
assert host._runners == {}
def test_reap_orphans_reaps_orphaned_children(tmp_path: Path) -> None:
"""Regression for #1782: orphaned children are reaped, not leaked.
In production the leak is a harness tool subprocess (node/chromium/tmux)
whose runner parent died: it is orphaned and reparented to the host
(PID 1 in a container, or a subreaper). Once reparented it is a *direct*
child of the host that nothing ``wait()``s — so it lingers as a
``<defunct>`` zombie forever. This test models that end state directly by
forking a child the host does not track and never waits: ``os.fork`` is
portable (no ``PR_SET_CHILD_SUBREAPER``, which macOS lacks), and a
reparented orphan is indistinguishable from a plain unwaited child to the
reaper. ``_reap_orphans_once`` must drain it.
"""
import errno
import os
host = _make_host_process()
# Fork a bare child (NOT a tracked runner, NOT wrapped in Popen) that
# exits immediately — the faithful model of an orphan reparented to us.
pid = os.fork()
if pid == 0: # pragma: no cover — child leg never returns to pytest
os._exit(0)
# Let it exit and become a zombie parented to this process.
deadline = time.monotonic() + 5.0
reaped_total = 0
while time.monotonic() < deadline:
reaped_total += host._reap_orphans_once()
if reaped_total >= 1:
break
time.sleep(0.05)
assert reaped_total >= 1, "orphaned child was not reaped (zombie leak — #1782)"
# It is truly reaped: a direct waitpid now raises ECHILD (no such child).
with pytest.raises(OSError) as exc_info:
os.waitpid(pid, 0)
assert exc_info.value.errno == errno.ECHILD
# A second sweep with no orphans left is a clean no-op.
assert host._reap_orphans_once() == 0
def test_reap_orphans_never_steals_tracked_runner_exit_code(tmp_path: Path) -> None:
"""The reaper must not consume a tracked runner's exit status (#1782).
A naive ``waitpid(-1)`` reaper would reap a just-exited tracked runner
behind ``Popen``'s back, making ``_watch_runner``'s ``poll()`` report a
bogus exit 0 for a crash. ``_reap_orphans_once`` peeks with ``WNOWAIT``
and skips tracked pids, so the runner's real exit code survives for the
``host.runner_exited`` report.
"""
host = _make_host_process()
# A tracked runner that exits non-zero (a "crash").
runner = subprocess.Popen(["python3", "-c", "import sys; sys.exit(42)"])
host._runners["runner_crash"] = _RunnerHandle(
proc=runner, log_path=tmp_path / "runner-crash.log"
)
# Wait until the OS reports it as exited (zombie), WITHOUT Popen.wait().
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline and runner.poll() is None:
# poll() would itself reap; instead peek via the reaper repeatedly.
host._reap_orphans_once()
time.sleep(0.05)
# The reaper ran while the crashed runner was reapable; it must NOT have
# stolen it. Popen must still see the true exit code.
assert runner.poll() == 42, "reaper corrupted the tracked runner's exit code"
runner.wait()
def test_reaper_does_not_steal_host_owned_subprocess_exit_code(tmp_path: Path) -> None:
"""The reaper must not reap a host-owned subprocess's child (#1782).
Regression for the Polly-flagged race: the host also spawns *direct*
children that are not tracked runners — the ``git`` commands in
:mod:`omnigent.host.git_worktree`, run via ``subprocess.run`` under
``asyncio.to_thread`` from the worktree handlers. If the 2s reaper sweep
fires after such a git child has exited but before ``subprocess``'s own
``wait()`` collects it, a blind reaper would ``waitpid`` it — and CPython
then swallows the resulting ``ECHILD`` and reports ``returncode == 0``,
silently turning a *failed* ``git worktree`` op into a success.
``_host_subprocess_op`` pauses the reaper for exactly that window. Here a
``sh -c 'exit 42'`` stands in for a failing git command: while the op is
marked in flight, ``_reap_orphans_once`` must be a no-op and must NOT
consume the child, so the owner still reads the true exit code 42.
"""
host = _make_host_process()
# A host-owned subprocess (git stand-in) that FAILS with a distinctive
# code. NOT a tracked runner — indistinguishable from an orphan to a naive
# reaper.
proc = subprocess.Popen(["sh", "-c", "exit 42"])
# Let it exit so it is reapable (the dangerous window subprocess.run has
# between the child exiting and its internal wait()).
time.sleep(0.3)
with host._host_subprocess_op():
# Every sweep during the op must be a no-op — the reaper is paused.
for _ in range(5):
assert host._reap_orphans_once() == 0, "reaper ran during a host-owned op"
time.sleep(0.02)
# The owner still collects its child's TRUE exit code — not corrupted to 0.
assert proc.poll() == 42, "reaper stole the host-owned subprocess's exit code (#1782)"
proc.wait()
def test_host_subprocess_op_guard_is_reentrant_and_balanced(tmp_path: Path) -> None:
"""``_host_subprocess_op`` nests correctly and always rebalances (#1782).
The reaper resumes only when the counter returns to 0, and the decrement
must survive an exception in the guarded body (``finally``), or a raising
worktree op would wedge the reaper off permanently.
"""
host = _make_host_process()
assert host._owned_subprocess_ops == 0
with host._host_subprocess_op():
assert host._owned_subprocess_ops == 1
with host._host_subprocess_op(): # re-entrant
assert host._owned_subprocess_ops == 2
assert host._owned_subprocess_ops == 1
assert host._owned_subprocess_ops == 0
# An exception inside the guarded body must still rebalance the counter.
with pytest.raises(RuntimeError):
with host._host_subprocess_op():
assert host._owned_subprocess_ops == 1
raise RuntimeError("worktree op blew up")
assert host._owned_subprocess_ops == 0, "guard leaked a ref on exception — reaper wedged"
def test_install_child_subreaper_is_safe_to_call() -> None:
"""``_install_child_subreaper`` never raises and reports a bool.
``True`` on Linux where ``prctl`` set the bit; ``False`` on non-Linux or
when ``prctl`` is unavailable — both are acceptable, non-fatal outcomes.
"""
import sys
from omnigent.host.connect import _install_child_subreaper
result = _install_child_subreaper()
assert isinstance(result, bool)
if sys.platform != "linux":
assert result is False
def test_host_spawned_runner_has_parent_pid_env(
tmp_path: Path,
) -> None:
"""
Verify that runners spawned by the host have
OMNIGENT_RUNNER_PARENT_PID set to the host's PID.
The runner's parent-PID watchdog uses this to auto-exit when
the host dies. If the env var is missing or wrong, runners
would become orphans on hard kill.
"""
host = _make_host_process()
workspace = tmp_path / "project"
workspace.mkdir()
frame = HostLaunchRunnerFrame(
request_id="req_pid",
binding_token="token_for_pid_test",
workspace=str(workspace),
)
import os
spawned_env: dict[str, str] = {}
original_popen = subprocess.Popen
def _capture_env(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
"""Capture env vars from the subprocess spawn.
:param args: Command args.
:param kwargs: Popen kwargs including env.
:returns: A real subprocess.
"""
env = kwargs.get("env", {})
spawned_env.update(env)
return original_popen(
["sleep", "10"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
with patch("omnigent.host.connect.subprocess.Popen", side_effect=_capture_env):
import asyncio
result = asyncio.run(host._handle_launch(frame))
assert result.status == "launched"
# RUNNER_PARENT_PID should be the host's own PID.
parent_pid = spawned_env.get("OMNIGENT_RUNNER_PARENT_PID")
assert parent_pid == str(os.getpid()), (
f"Expected RUNNER_PARENT_PID={os.getpid()}, got {parent_pid}. "
"Without this, the runner watchdog can't detect host death."
)
# Clean up.
_cleanup_host(host)
# ── host.stat handler ───────────────────────────────────
def test_handle_stat_returns_directory_for_existing_dir(
tmp_path: Path,
) -> None:
"""
Verify that ``_handle_stat`` reports ``exists: true,
type: "directory"`` for an existing directory and returns its
realpath as ``canonical_path``.
These three fields drive the server-side validation contract
(path exists, is a directory, canonical_path is what gets
stored). If any one is wrong, every host-launched session
would either be rejected or land in the wrong directory.
"""
host = _make_host_process()
target = tmp_path / "project"
target.mkdir()
result = host._handle_stat(HostStatFrame(request_id="r_dir", path=str(target)))
assert isinstance(result, HostStatResultFrame)
assert result.status == "ok"
assert result.exists is True
assert result.type == "directory"
# Compare via os.path.realpath because tmp_path on macOS goes
# through /var → /private/var symlinks; the design says the
# stored canonical_path is the realpath.
import os
assert result.canonical_path == os.path.realpath(target)
assert result.error is None
def test_handle_stat_returns_file_for_existing_file(
tmp_path: Path,
) -> None:
"""
Verify that ``_handle_stat`` reports ``type: "file"`` for a
regular file. The validator rejects non-directories at session
create — without ``type``, it would happily store a file path
as the workspace and the runner would fail on ``os.chdir``.
"""
host = _make_host_process()
target = tmp_path / "README.md"
target.write_text("hi")
result = host._handle_stat(HostStatFrame(request_id="r_file", path=str(target)))
assert result.exists is True
assert result.type == "file"
def test_handle_stat_follows_symlink_to_directory(
tmp_path: Path,
) -> None:
"""
Verify symlinks are followed: symlink to a directory returns
``type: "directory"`` and ``canonical_path`` is the target's
realpath (not the symlink itself).
This is the load-bearing case for the "symlinks cannot smuggle
a workspace out of the agent's boundary" guarantee. If the
canonical_path were the symlink path (not the target), a
``cwd: ~/foo`` boundary check would pass for ``~/foo/link →
/etc``, and the runner would end up in /etc.
"""
host = _make_host_process()
real_dir = tmp_path / "real"
real_dir.mkdir()
link = tmp_path / "link"
link.symlink_to(real_dir)
result = host._handle_stat(HostStatFrame(request_id="r_sym", path=str(link)))
assert result.exists is True
assert result.type == "directory"
import os
assert result.canonical_path == os.path.realpath(real_dir)
def test_handle_stat_dangling_symlink_returns_not_exists(
tmp_path: Path,
) -> None:
"""
Verify a symlink pointing at a non-existent target returns
``exists: false``.
Without this collapse, the runner would later fail on chdir
with a confusing error and no useful surface for the user.
The design defines this as part of the "exists/not exists"
contract — see HostStatResultFrame docstring.
"""
host = _make_host_process()
dangling = tmp_path / "dangling"
dangling.symlink_to(tmp_path / "does_not_exist")
result = host._handle_stat(HostStatFrame(request_id="r_dangling", path=str(dangling)))
assert result.status == "ok"
assert result.exists is False
assert result.canonical_path is None
def test_handle_stat_missing_path_returns_not_exists(
tmp_path: Path,
) -> None:
"""
Verify a missing path returns ``exists: false`` (not status
"failed"). The design treats non-existence as a normal answer,
not an error — so the route can return a 400 with a clean
"doesn't exist" message instead of a 500.
"""
host = _make_host_process()
missing = tmp_path / "does_not_exist"
result = host._handle_stat(HostStatFrame(request_id="r_missing", path=str(missing)))
assert result.status == "ok"
assert result.exists is False
assert result.canonical_path is None
assert result.error is None
def test_handle_stat_permission_denied_returns_not_exists(
tmp_path: Path,
) -> None:
"""
Verify a path the host process can't access (EACCES) collapses
to ``exists: false`` rather than surfacing the EACCES.
Per the design: v1 collapses ENOENT and EACCES into
a single answer for simplicity. If we ever distinguish them,
the wire shape changes (a ``readable`` field gets added);
this test pins the v1 contract and would fail loud on a
silent regression.
"""
host = _make_host_process()
locked = tmp_path / "locked"
locked.mkdir()
# Set permissions to 0 — even the owner cannot stat children.
# We then ask for a child path to force EACCES rather than
# ENOENT (the directory itself is still stat-able by its owner).
import stat as stat_mod
locked.chmod(0)
try:
child = locked / "child"
result = host._handle_stat(HostStatFrame(request_id="r_eacces", path=str(child)))
# Many filesystems / kernels return EACCES from stat
# on the child of a 0-permission dir; some ultra-permissive
# setups (e.g. macOS as root, certain CI sandboxes) may
# short-circuit and return ENOENT. Both collapse to
# exists:false per the design — so we accept either route.
assert result.status == "ok"
assert result.exists is False
finally:
# Restore so tmp_path cleanup doesn't fail.
locked.chmod(stat_mod.S_IRWXU)
def test_handle_stat_expands_tilde(tmp_path: Path, monkeypatch) -> None:
"""
Verify that ``~`` in the input path is expanded to the host
process owner's home directory.
The host (not the server) is the source of truth for ``~`` —
designs/SESSION_WORKSPACE_SELECTION.md. If the host
handler skipped expansion, agent specs with ``cwd: ~/foo``
would never resolve and validation would fail on every host.
"""
# Point HOME at our tmp_path so ~ resolves predictably.
monkeypatch.setenv("HOME", str(tmp_path))
target = tmp_path / "subdir"
target.mkdir()
host = _make_host_process()
result = host._handle_stat(HostStatFrame(request_id="r_tilde", path="~/subdir"))
import os
assert result.exists is True
assert result.type == "directory"
assert result.canonical_path == os.path.realpath(target)
def test_build_runner_env_allowlists_host_env_and_strips_secrets() -> None:
"""
A spawned runner inherits only allowlisted host env vars — process
essentials pass through, the host owner's NON-HARNESS secrets do
not — plus the runner wiring vars. Harness credentials
(HARNESS_CREDENTIAL_ENV_VARS) are the deliberate exception: the
host owner provisions those precisely so runners can use them.
Guards against unrelated host secrets (cloud creds, workspace
tokens) leaking into every runner subprocess.
"""
base = {
"PATH": "/usr/bin:/bin",
"HOME": "/home/alice",
"LANG": "en_US.UTF-8",
"LC_CTYPE": "UTF-8",
"DATABRICKS_CONFIG_PROFILE": "ambient",
"DATABRICKS_CONFIG_FILE": "/tmp/databrickscfg",
"DATABRICKS_AUTH_STORAGE": "plaintext",
"ANTHROPIC_API_KEY": "sk-harness",
"IS_SANDBOX": "1",
"DATABRICKS_TOKEN": "dapi-secret",
"AWS_SECRET_ACCESS_KEY": "aws-secret",
"SOME_RANDOM_VAR": "x",
"OMNIGENT_CLAUDE_SDK_NO_SANDBOX": "1",
"KUBECONFIG": "/home/alice/.kube/config",
"CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1",
"OMNIGENT_DATABRICKS_EXTRA_HEADERS": '{"x-databricks-route-hint": "instance-abc"}',
}
env = _build_runner_env(
base,
server_url="http://server",
runner_id="runner_abc",
binding_token="tok",
workspace="/ws",
parent_pid=42,
)
# Process essentials + the locale family pass through.
assert env["PATH"] == "/usr/bin:/bin"
assert env["HOME"] == "/home/alice"
assert env["LANG"] == "en_US.UTF-8"
assert env["LC_CTYPE"] == "UTF-8"
# Databricks config selectors are allowlisted ambient passthrough —
# the ambient value reaches the runner unmodified (no flag override).
assert env["DATABRICKS_CONFIG_PROFILE"] == "ambient"
assert env["DATABRICKS_CONFIG_FILE"] == "/tmp/databrickscfg"
# The token-storage backend selector forwards too — without it the runner
# falls back to the ~/.databrickscfg default and can read a different token
# store than the host/daemon, failing to mint a token (runner tunnel 401).
assert env["DATABRICKS_AUTH_STORAGE"] == "plaintext"
# Harness credentials forward — they exist FOR the runner's
# harnesses (laptop: exported keys; managed sandbox: the
# deployment's injected provider secrets).
assert env["ANTHROPIC_API_KEY"] == "sk-harness"
# The sandbox-image environment descriptor forwards: Claude Code
# needs it to allow --dangerously-skip-permissions under root in
# sandbox containers. Only the baked host image ever sets it.
assert env["IS_SANDBOX"] == "1"
# The claude-sdk sandbox bypass flag forwards — it is read inside the
# harness, so a bare ``OMNIGENT_CLAUDE_SDK_NO_SANDBOX=1 omnigent run …``
# must reach the runner without also forcing
# ``OMNIGENT_RUNNER_ENV_PASSTHROUGH=OMNIGENT_CLAUDE_SDK_NO_SANDBOX``.
assert env["OMNIGENT_CLAUDE_SDK_NO_SANDBOX"] == "1"
# KUBECONFIG is a filesystem path (not a secret) — kubectl, helm, k9s
# need it to resolve the user's cluster contexts and namespaces.
assert env["KUBECONFIG"] == "/home/alice/.kube/config"
# CLAUDE_CODE_SKIP_BEDROCK_AUTH disables AWS SigV4 auth for LiteLLM
# proxies — a non-secret boolean, same rationale as CLAUDE_CODE_USE_BEDROCK.
assert env["CLAUDE_CODE_SKIP_BEDROCK_AUTH"] == "1"
# Opaque request-routing headers forward host→runner so the runner's tunnel
# and server callbacks reach the same server instance the host registered on
# (without the operator also listing it in OMNIGENT_RUNNER_ENV_PASSTHROUGH).
assert (
env["OMNIGENT_DATABRICKS_EXTRA_HEADERS"] == '{"x-databricks-route-hint": "instance-abc"}'
)
# Non-harness secrets are stripped — the point of the allowlist.
assert "DATABRICKS_TOKEN" not in env
assert "AWS_SECRET_ACCESS_KEY" not in env
# Non-allowlisted vars are dropped (allowlist, not denylist).
assert "SOME_RANDOM_VAR" not in env
# Runner wiring is layered on.
assert env["RUNNER_SERVER_URL"] == "http://server"
assert env[RUNNER_ID_ENV_VAR] == "runner_abc"
assert env[RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR] == "tok"
assert env[RUNNER_WORKSPACE_ENV_VAR] == "/ws"
assert env[RUNNER_PARENT_PID_ENV_VAR] == "42"
def test_build_runner_env_forwards_harness_credentials_and_endpoints() -> None:
"""
Every var in HARNESS_CREDENTIAL_ENV_VARS forwards when present —
keys AND endpoint wiring (base URLs travel with their credentials
or gateway setups break in confusing ways). Absent vars are simply
not set rather than defaulted.
"""
from omnigent.host.connect import HARNESS_CREDENTIAL_ENV_VARS
base = {
"PATH": "/usr/bin",
"HOME": "/root",
"ANTHROPIC_API_KEY": "sk-a",
"ANTHROPIC_BASE_URL": "https://gateway.example.com/anthropic",
"ANTHROPIC_MODEL": "gateway-served-claude",
"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat-sub",
"CODEX_ACCESS_TOKEN": "codex-workspace-token",
"OPENAI_API_KEY": "sk-o",
"OPENAI_BASE_URL": "https://gateway.example.com/openai",
"GEMINI_API_KEY": "g-key",
"AWS_BEARER_TOKEN_BEDROCK": "absk-fwd",
"ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com",
# An unrelated secret must never ride the credential set.
"MY_UNRELATED_SECRET": "leak-me-not",
}
env = _build_runner_env(
base,
server_url="http://server",
runner_id="runner_abc",
binding_token="tok",
workspace="/ws",
parent_pid=42,
)
for name in (
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
# The gateway model pin travels with the key/endpoint — without it a
# LiteLLM-style gateway session launches Claude Code model-less and
# the gateway rejects Claude Code's own default model.
"ANTHROPIC_MODEL",
"CLAUDE_CODE_OAUTH_TOKEN",
"CODEX_ACCESS_TOKEN",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"GEMINI_API_KEY",
"AWS_BEARER_TOKEN_BEDROCK",
"ANTHROPIC_BEDROCK_BASE_URL",
):
# Pins each conventional name into the default set — dropping
# one breaks that harness's credentials on managed sandboxes.
assert name in HARNESS_CREDENTIAL_ENV_VARS
assert env[name] == base[name]
# ANTHROPIC_AUTH_TOKEN wasn't in base: present in the default set
# but never invented into the env.
assert "ANTHROPIC_AUTH_TOKEN" in HARNESS_CREDENTIAL_ENV_VARS
assert "ANTHROPIC_AUTH_TOKEN" not in env
# A secret not on the credential set / allowlist is not forwarded.
assert "MY_UNRELATED_SECRET" not in env
def test_build_runner_env_forwards_omnigent_prefixed_harness_credentials() -> None:
"""Prefixed harness credential aliases forward without creating raw names."""
from omnigent.host.connect import HARNESS_CREDENTIAL_ENV_VARS
base = {
"PATH": "/usr/bin",
"HOME": "/root",
"OMNIGENT_ANTHROPIC_API_KEY": "sk-prefixed",
}
env = _build_runner_env(
base,
server_url="http://server",
runner_id="runner_abc",
binding_token="tok",
workspace="/ws",
parent_pid=42,
)
assert "OMNIGENT_ANTHROPIC_API_KEY" in HARNESS_CREDENTIAL_ENV_VARS
assert env["OMNIGENT_ANTHROPIC_API_KEY"] == "sk-prefixed"
assert "ANTHROPIC_API_KEY" not in env
def test_build_runner_env_passthrough_extends_forwarded_set() -> None:
"""
OMNIGENT_RUNNER_ENV_PASSTHROUGH names EXTRA vars to forward (for
`providers:`-config `env:` refs and custom gateway wiring) without
opening the allowlist to anything unnamed.
"""
base = {
"PATH": "/usr/bin",
"HOME": "/root",
"OMNIGENT_RUNNER_ENV_PASSTHROUGH": "MY_GATEWAY_TOKEN, MY_GATEWAY_URL",
"MY_GATEWAY_TOKEN": "tok-123",
"MY_GATEWAY_URL": "https://llm.internal.example.com",
"UNLISTED_SECRET": "nope",
}
env = _build_runner_env(
base,
server_url="http://server",
runner_id="runner_abc",
binding_token="tok",
workspace="/ws",
parent_pid=42,
)
# Named extras forward (whitespace around commas tolerated).
assert env["MY_GATEWAY_TOKEN"] == "tok-123"
assert env["MY_GATEWAY_URL"] == "https://llm.internal.example.com"
# Anything unnamed stays behind the allowlist.
assert "UNLISTED_SECRET" not in env
def test_build_runner_env_preserves_ambient_databricks_profile() -> None:
"""
Ambient Databricks profile/config-file selectors reach host runners.
Databricks SDK resolution honors ``DATABRICKS_CONFIG_PROFILE`` and
``DATABRICKS_CONFIG_FILE``. If the host strips either selector, a
native Codex runner can fail to resolve a profile that works in the
user's shell.
"""
env = _build_runner_env(
{
"PATH": "/usr/bin:/bin",
"DATABRICKS_CONFIG_PROFILE": "oss",
"DATABRICKS_CONFIG_FILE": "/tmp/databrickscfg",
},
server_url="http://server",
runner_id="runner_abc",
binding_token="tok",
workspace="/ws",
parent_pid=42,
)
assert env["DATABRICKS_CONFIG_PROFILE"] == "oss"
assert env["DATABRICKS_CONFIG_FILE"] == "/tmp/databrickscfg"
def test_build_runner_env_propagates_data_dir_paths_not_db_uri() -> None:
"""
The runtime data/config-dir PATH vars propagate to runners so the whole
local chain agrees on where config + data live, but the DB URI (which may
embed a password) does not.
Regression guard: ``OMNIGENT_CONFIG_HOME`` was absent from the
allowlist, so the daemon/runner used ``~/.omnigent`` while a CLI run
under an isolated config home read the local-server pidfile elsewhere —
discovery then timed out (the e2e ``OMNIGENT_CONFIG_HOME`` isolation
case). A failure of the first two asserts means that regression is back;
a failure of the third means a DB secret can now leak into a (possibly
hosted) runner.
"""
base = {
"PATH": "/usr/bin:/bin",
"OMNIGENT_CONFIG_HOME": "/tmp/iso-home",
"OMNIGENT_DATA_DIR": "/tmp/iso-data",
"OMNIGENT_DATABASE_URI": "postgresql://user:pw@host/db",
}
env = _build_runner_env(
base,
server_url="http://server",
runner_id="runner_abc",
binding_token="tok",
workspace="/ws",
parent_pid=42,
)
# Path vars propagate — they're how the runner finds the same config/data
# dir the CLI + daemon + local server use.
assert env["OMNIGENT_CONFIG_HOME"] == "/tmp/iso-home"
assert env["OMNIGENT_DATA_DIR"] == "/tmp/iso-data"
# The DB URI is NOT propagated — it may carry credentials and a runner
# (hosted or local) has no business holding the server's DB connection.
assert "OMNIGENT_DATABASE_URI" not in env
def test_build_runner_env_propagates_disable_keyring() -> None:
"""``OMNIGENT_DISABLE_KEYRING`` propagates so the runner resolves
``keychain:`` secret refs against the SAME backend the CLI configured.
Regression guard: with the flag set, ``configure harnesses`` stores pasted
API keys via the FILE backend (``secrets.json``) and writes
``keychain:<name>`` refs. If the flag didn't reach the runner it would fall
back to the OS keyring and fail with "no stored secret named …" for a key
the CLI just saved — the headless / file-backend deploy case (and the exact
failure hit while dogfooding the first-run flow).
"""
base = {"PATH": "/usr/bin:/bin", "OMNIGENT_DISABLE_KEYRING": "1"}
env = _build_runner_env(
base,
server_url="http://server",
runner_id="runner_abc",
binding_token="tok",
workspace="/ws",
parent_pid=42,
)
assert env["OMNIGENT_DISABLE_KEYRING"] == "1"
# ── host.list_dir handler ───────────────────────────────
def test_handle_list_dir_returns_sorted_entries(tmp_path: Path) -> None:
"""
Verify ``_handle_list_dir`` returns entries sorted by name with
correct types and sizes.
Sorted order matters for the Web UI tree (stable rendering)
and for cursor-based pagination across pages.
"""
host = _make_host_process()
(tmp_path / "src").mkdir()
(tmp_path / "README.md").write_text("hello")
(tmp_path / "build").mkdir()
result = host._handle_list_dir(HostListDirFrame(request_id="r1", path=str(tmp_path)))
assert isinstance(result, HostListDirResultFrame)
assert result.status == "ok"
assert result.error is None
names = [e.name for e in result.entries]
# Sorted by name — order pinned so cursor pagination is stable.
assert names == ["README.md", "build", "src"]
# Type classification.
by_name = {e.name: e for e in result.entries}
assert by_name["src"].type == "directory"
assert by_name["build"].type == "directory"
assert by_name["README.md"].type == "file"
# Size only set for files.
assert by_name["README.md"].bytes == 5 # len("hello")
assert by_name["src"].bytes is None
assert by_name["build"].bytes is None
def test_handle_list_dir_missing_path_returns_error(tmp_path: Path) -> None:
"""
Verify a missing path returns ``status: "ok"`` with an error
message rather than ``status: "failed"``.
The design treats missing as a normal answer (so the route
layer maps it to a 404 with the descriptive error). If we
surfaced ``status: "failed"`` instead, every missing-directory
browse would 500.
"""
host = _make_host_process()
missing = tmp_path / "does_not_exist"
result = host._handle_list_dir(HostListDirFrame(request_id="r2", path=str(missing)))
assert result.status == "ok"
assert result.error == "path does not exist"
assert result.entries == []
def test_handle_list_dir_on_file_returns_error(tmp_path: Path) -> None:
"""
Verify list_dir on a regular file returns "not a directory"
rather than crashing.
Without this guard, a user clicking on a file in the picker
would get a 500 instead of a clear "not a directory" message.
"""
host = _make_host_process()
target = tmp_path / "file.txt"
target.write_text("hi")
result = host._handle_list_dir(HostListDirFrame(request_id="r3", path=str(target)))
assert result.status == "ok"
assert "not a directory" in (result.error or "")
assert result.entries == []
def test_handle_list_dir_follows_symlink_to_directory(tmp_path: Path) -> None:
"""
Verify list_dir on a symlinked directory follows the link.
Same posture as host.stat: symlinks are followed, type
classification reflects the target. Without this, the picker
would silently fail to enter symlinked directories.
"""
host = _make_host_process()
real_dir = tmp_path / "real"
real_dir.mkdir()
(real_dir / "child.txt").write_text("data")
link = tmp_path / "link"
link.symlink_to(real_dir)
result = host._handle_list_dir(HostListDirFrame(request_id="r4", path=str(link)))
assert result.status == "ok"
assert len(result.entries) == 1
assert result.entries[0].name == "child.txt"
def test_handle_list_dir_skips_dangling_symlink_per_entry(
tmp_path: Path,
) -> None:
"""
Verify per-entry ``OSError`` (e.g. dangling symlink) is silently
skipped rather than failing the whole listing.
Matches the runner's ``list_dir`` posture: a single broken
symlink in a large directory shouldn't prevent the user from
seeing the rest. Without the per-entry try/except in the
handler, a dangling symlink in a project's tree would 500
every browse of that directory.
"""
host = _make_host_process()
good_file = tmp_path / "good.txt"
good_file.write_text("ok")
dangling = tmp_path / "dangling"
dangling.symlink_to(tmp_path / "missing_target")
result = host._handle_list_dir(HostListDirFrame(request_id="r5", path=str(tmp_path)))
assert result.status == "ok"
names = [e.name for e in result.entries]
# Good entry survived; dangling was silently skipped.
assert "good.txt" in names
assert "dangling" not in names
def test_handle_list_dir_expands_tilde(tmp_path: Path, monkeypatch) -> None:
"""
Verify ``~`` in the input path is expanded against the host
process owner's home.
The host owns ``~`` resolution; the server passes tildes
through unchanged (designs/SESSION_WORKSPACE_SELECTION.md).
Without expansion, ``~/projects`` would become a
literal subdir named ``~`` and fail with ENOENT.
"""
monkeypatch.setenv("HOME", str(tmp_path))
(tmp_path / "subdir").mkdir()
(tmp_path / "subdir" / "x.txt").write_text("data")
host = _make_host_process()
result = host._handle_list_dir(HostListDirFrame(request_id="r6", path="~/subdir"))
assert result.status == "ok"
assert len(result.entries) == 1
assert result.entries[0].name == "x.txt"
def test_handle_list_dir_pagination_after_cursor(tmp_path: Path) -> None:
"""
Verify that ``after`` cursor returns entries strictly after
the cursor.
Cursors are entry paths so they survive concurrent directory
mutations between calls. Without this, the Web UI's "next
page" button would silently return the same page or skip
entries.
"""
host = _make_host_process()
for name in ("a.txt", "b.txt", "c.txt", "d.txt", "e.txt"):
(tmp_path / name).write_text("x")
result = host._handle_list_dir(
HostListDirFrame(
request_id="r7",
path=str(tmp_path),
limit=2,
after=str(tmp_path / "b.txt"),
)
)
assert result.status == "ok"
names = [e.name for e in result.entries]
# After "b.txt", next two are "c.txt" and "d.txt".
assert names == ["c.txt", "d.txt"]
assert result.has_more is True # "e.txt" still remains
def test_handle_list_dir_pagination_before_cursor_returns_previous_page(
tmp_path: Path,
) -> None:
"""
Verify that ``before`` returns the page immediately before the cursor.
Backward pagination should take the tail of the bounded slice, not the
first entries in the directory. Otherwise a request for the page before
"e.txt" returns "a.txt", "b.txt" instead of "c.txt", "d.txt".
"""
host = _make_host_process()
for name in ("a.txt", "b.txt", "c.txt", "d.txt", "e.txt"):
(tmp_path / name).write_text("x")
middle = host._handle_list_dir(
HostListDirFrame(
request_id="r_before_middle",
path=str(tmp_path),
limit=2,
before=str(tmp_path / "e.txt"),
)
)
assert middle.status == "ok"
assert [e.name for e in middle.entries] == ["c.txt", "d.txt"]
assert middle.has_more is True
first = host._handle_list_dir(
HostListDirFrame(
request_id="r_before_first",
path=str(tmp_path),
limit=2,
before=str(tmp_path / "c.txt"),
)
)
assert first.status == "ok"
assert [e.name for e in first.entries] == ["a.txt", "b.txt"]
assert first.has_more is False
def test_handle_list_dir_pagination_last_page_has_more_false(
tmp_path: Path,
) -> None:
"""
Verify ``has_more=False`` on the last page.
The Web UI uses ``has_more`` to decide whether to render the
"next page" button. If we always returned ``True``, the user
would click forever past the end of the listing.
"""
host = _make_host_process()
for name in ("a.txt", "b.txt"):
(tmp_path / name).write_text("x")
result = host._handle_list_dir(HostListDirFrame(request_id="r8", path=str(tmp_path), limit=10))
assert result.status == "ok"
assert len(result.entries) == 2
assert result.has_more is False
# ── host.create_dir handler ─────────────────────────────
def test_handle_create_dir_creates_directory(tmp_path: Path) -> None:
"""
Verify ``_handle_create_dir`` makes the directory and returns its
absolute path.
This is the picker's "New folder" happy path — the returned path
is what the picker navigates into afterward.
"""
host = _make_host_process()
target = tmp_path / "new-app"
result = host._handle_create_dir(HostCreateDirFrame(request_id="m1", path=str(target)))
assert isinstance(result, HostCreateDirResultFrame)
assert result.status == "ok"
assert result.error is None
assert result.path == str(target)
assert target.is_dir()
def test_handle_create_dir_creates_missing_parents(tmp_path: Path) -> None:
"""
Verify missing parent directories are created (``os.makedirs``).
Lets the picker accept a nested name like ``a/b/c`` in one go
rather than forcing the user to create each level.
"""
host = _make_host_process()
target = tmp_path / "a" / "b" / "c"
result = host._handle_create_dir(HostCreateDirFrame(request_id="m2", path=str(target)))
assert result.status == "ok"
assert target.is_dir()
def test_handle_create_dir_existing_returns_error_not_failed(tmp_path: Path) -> None:
"""
Verify creating an existing directory returns ``status: "ok"`` with
an "already exists" error rather than ``status: "failed"``.
The route maps a non-empty ``error`` to a 409 so the picker shows
"directory already exists" inline; surfacing ``failed`` would 502
instead.
"""
host = _make_host_process()
existing = tmp_path / "dup"
existing.mkdir()
result = host._handle_create_dir(HostCreateDirFrame(request_id="m3", path=str(existing)))
assert result.status == "ok"
assert result.error == "directory already exists"
assert result.path is None
def test_handle_create_dir_leaf_is_file_reports_file_not_directory(tmp_path: Path) -> None:
"""
Verify a regular file at the target path reports a file, not a
directory.
``os.makedirs`` raises ``FileExistsError`` for both an existing
directory and an existing file; the handler must distinguish them
so the picker doesn't mislabel "a file is in the way" as
"directory already exists".
"""
host = _make_host_process()
a_file = tmp_path / "taken"
a_file.write_text("hi")
result = host._handle_create_dir(HostCreateDirFrame(request_id="m3b", path=str(a_file)))
assert result.status == "ok"
assert result.error == "a file already exists at that path"
assert result.path is None
def test_handle_create_dir_parent_is_file_returns_error(tmp_path: Path) -> None:
"""
Verify creating under a path whose parent is a regular file returns
a clean error rather than crashing.
"""
host = _make_host_process()
a_file = tmp_path / "file.txt"
a_file.write_text("hi")
target = a_file / "child"
result = host._handle_create_dir(HostCreateDirFrame(request_id="m4", path=str(target)))
assert result.status == "ok"
assert "not a directory" in (result.error or "")
assert result.path is None
def test_handle_create_dir_expands_tilde(tmp_path: Path, monkeypatch) -> None:
"""
Verify ``~`` expands against the host process owner's home.
The host owns ``~`` resolution; without expansion ``~/scratch``
would become a literal ``~`` subdir of the process cwd.
"""
monkeypatch.setenv("HOME", str(tmp_path))
host = _make_host_process()
result = host._handle_create_dir(HostCreateDirFrame(request_id="m5", path="~/scratch"))
assert result.status == "ok"
assert (tmp_path / "scratch").is_dir()
assert result.path == str(tmp_path / "scratch")
# --- Fail-loud on permanent tunnel failures ----------------------------
#
# Before the fix, HostProcess.run() caught every connection exception and
# reconnected forever, so an auth/authorization/outdated-server failure
# looked like a silent hang. These tests drive the public run() /
# run_host_process entry points with a stubbed websockets connect whose
# handshake (__aenter__) raises the upgrade rejection, and assert that
# permanent failures fail loud while transient ones still reconnect.
class _HandshakeFailingConnect:
"""Async-CM stand-in for ``websockets.asyncio.client.connect`` whose
handshake raises a preset exception.
Models the real library: ``connect(...)`` returns an async context
manager and the upgrade rejection surfaces from ``__aenter__``.
:param exc: Exception to raise from ``__aenter__``, e.g. an
:class:`~websockets.exceptions.InvalidStatus` carrying a 403.
"""
def __init__(self, exc: BaseException) -> None:
"""Store the exception the handshake will raise.
:param exc: Exception raised on ``__aenter__``.
"""
self._exc = exc
async def __aenter__(self) -> object:
"""Raise the preset handshake exception.
:returns: Never returns — always raises.
:raises BaseException: The exception passed at construction.
"""
raise self._exc
async def __aexit__(self, *exc_info: object) -> bool:
"""No-op async-CM exit.
:param exc_info: Standard ``__aexit__`` triple (unused).
:returns: ``False`` so any exception propagates.
"""
return False
class _DroppedTunnel:
"""Fake accepted tunnel whose receive loop drops immediately.
Lets ``_serve_frames`` send the ``host.hello`` frame (proving the
upgrade was accepted), then fails the first ``recv()`` like an
abruptly closed connection, returning control to the reconnect loop.
"""
async def send(self, data: str | bytes) -> None:
"""Accept any outbound frame (the ``host.hello``) silently.
:param data: Encoded frame payload (ignored).
:returns: None.
"""
async def recv(self) -> str:
"""Fail like a connection that closed without a close frame.
:returns: Never returns — always raises.
:raises ConnectionClosedError: Always, with no close frames.
"""
raise ConnectionClosedError(None, None)
class _AcceptingConnect:
"""Async-CM stand-in for a *successful* WS upgrade.
``__aenter__`` hands back a :class:`_DroppedTunnel`, so the host
marks the connection authenticated and then immediately loses it —
the minimal scripted "connected once" event.
"""
async def __aenter__(self) -> _DroppedTunnel:
"""Complete the handshake with a tunnel that drops on first recv.
:returns: A :class:`_DroppedTunnel`.
"""
return _DroppedTunnel()
async def __aexit__(self, *exc_info: object) -> bool:
"""No-op async-CM exit.
:param exc_info: Standard ``__aexit__`` triple (unused).
:returns: ``False`` so any exception propagates.
"""
return False
class _ConnectSpy:
"""Stub for ``websockets.asyncio.client.connect`` that records calls
and scripts each handshake with the next queued entry.
An exception entry fails that handshake; a ``None`` entry accepts it
with a tunnel that drops on first ``recv()`` (so the reconnect loop
regains control). The last queued entry repeats for any further
calls, so a single fatal exception covers the "fails on first
attempt" case and a ``[transient, CancelledError]`` pair covers
"retried once, then stop".
:param exceptions: Per-call handshake script, e.g.
``[None, InvalidStatus(resp_503), asyncio.CancelledError()]``.
"""
def __init__(self, exceptions: list[BaseException | None]) -> None:
"""Initialize the spy with a handshake script.
:param exceptions: Exception to raise (or ``None`` to accept) on
each successive call; the final entry repeats.
"""
self._exceptions = exceptions
self.call_count = 0
def __call__(self, url: str, **kwargs: object) -> _HandshakeFailingConnect | _AcceptingConnect:
"""Return an async-CM scripting the handshake for this call.
:param url: Tunnel URL passed by production (ignored).
:param kwargs: Connect kwargs passed by production (ignored).
:returns: A context manager whose ``__aenter__`` raises the
queued exception, or completes the handshake for a ``None``
entry.
"""
exc = self._exceptions[min(self.call_count, len(self._exceptions) - 1)]
self.call_count += 1
if exc is None:
return _AcceptingConnect()
return _HandshakeFailingConnect(exc)
def _invalid_status(status_code: int) -> InvalidStatus:
"""Build a real :class:`InvalidStatus` for a rejected WS upgrade.
Matches what ``websockets`` raises when the server answers the
upgrade with a non-101 status.
:param status_code: HTTP status on the upgrade response, e.g.
``403``.
:returns: An ``InvalidStatus`` whose ``response.status_code`` is
*status_code*.
"""
return InvalidStatus(Response(status_code, "", Headers()))
def _patch_connect(monkeypatch: pytest.MonkeyPatch, spy: _ConnectSpy) -> None:
"""Stub the WS connect and silence the auth-token factory.
Patches ``websockets.asyncio.client.connect`` (the module attribute
production resolves at call time) with *spy*, and forces
``_make_auth_token_factory`` to return ``None`` so no real Databricks
credentials/network are touched.
:param monkeypatch: The pytest monkeypatch fixture.
:param spy: Connect stub to install.
:returns: None.
"""
import websockets.asyncio.client as ws_client
import omnigent.runner._entry as entry_mod
monkeypatch.setattr(entry_mod, "_make_auth_token_factory", lambda *, server_url=None: None)
monkeypatch.setattr(ws_client, "connect", spy)
def _host(
server_url: str = "https://app.example.databricks.com",
) -> HostProcess:
"""Build a HostProcess for the fail-loud tests.
:param server_url: Server URL the host connects to, e.g.
``"https://app.example.databricks.com"``.
:returns: A configured :class:`HostProcess`.
"""
identity = HostIdentity(host_id="host_test_connect", name="test-laptop")
return HostProcess(identity, server_url)
def test_build_connect_headers_adds_org_header(monkeypatch: pytest.MonkeyPatch) -> None:
"""A recorded ?o= selector rides the tunnel handshake.
The WS upgrade must name the workspace via ``X-Databricks-Org-Id`` or it
routes to the account. The header rides alongside the Origin sentinel,
independent of the bearer/managed-token branch.
:param monkeypatch: The pytest monkeypatch fixture.
:returns: None.
"""
import omnigent.runner._entry as entry_mod
# No managed token + no real Databricks creds: isolate the bearer
# branch so only the routing header is under test.
monkeypatch.delenv("OMNIGENT_HOST_TOKEN", raising=False)
monkeypatch.setattr(entry_mod, "_make_auth_token_factory", lambda *, server_url=None: None)
monkeypatch.setattr(
"omnigent.cli_auth.load_databricks_org_id", lambda _url: "2850744067564480"
)
headers = _host("https://acme.databricks.com/api/2.0/omnigent")._build_connect_headers()
assert headers["X-Databricks-Org-Id"] == "2850744067564480"
async def test_run_retries_on_login_redirect(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A login-redirect upgrade failure retries with a warning.
The Databricks Apps OAuth proxy bounces an unauthenticated ``wss://``
upgrade to an ``https://.../authorize`` URL; ``websockets`` then raises
``InvalidURI`` because the redirect scheme isn't ws/wss. This can
happen transiently during server restarts, so the host must retry
with backoff rather than dying. The warning still surfaces the single
``omnigent login`` remediation so the operator can act if the cause
is persistent.
"""
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
spy = _ConnectSpy(
[
InvalidURI("https://w/oidc/authorize", "scheme isn't ws or wss"),
asyncio.CancelledError(),
]
)
_patch_connect(monkeypatch, spy)
host = _host()
with caplog.at_level(logging.WARNING, logger="omnigent.host.connect"):
await host.run()
# 2 = redirect attempt + cancel attempt → it genuinely reconnected.
assert spy.call_count == 2
# The warning surfaces the login-page cause and the credentials hint —
# the single remediation message recommending `omnigent login <url>`.
assert any("login page" in r.message for r in caplog.records)
assert any(
"omnigent login https://app.example.databricks.com" in r.message for r in caplog.records
)
async def test_login_redirect_prints_warning_to_terminal(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""The first login redirect of a streak warns on stderr, not just the log.
``_logger.warning`` goes to the CLI log file, so before this fix a
not-logged-in ``omnigent host`` sat completely silent on the terminal
while retrying (the user's only signal was Ctrl-C and reading the log).
The terminal warning must name the cause and the copy-pasteable
``omnigent login <url>`` remedy.
"""
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
spy = _ConnectSpy(
[
InvalidURI("https://w/oidc/authorize", "scheme isn't ws or wss"),
asyncio.CancelledError(),
]
)
_patch_connect(monkeypatch, spy)
host = _host()
await host.run()
err = capsys.readouterr().err
# The cause reached the terminal — if missing, the silent-hang
# regression is back (warning only in the log file).
assert "login page" in err
# The exact remedy command, URL included, so the user can copy-paste.
assert "omnigent login https://app.example.databricks.com" in err
async def test_fresh_host_fails_loud_after_persistent_login_redirects(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Persistent login redirects on a never-connected host fail loud.
A host that has never completed an upgrade and keeps getting bounced
to the login page is unauthenticated, not watching a server restart —
it must raise HostConnectError (→ exit 1 with the fix printed) instead
of retrying forever with the terminal silent (the silent-hang regression
could resurface once the redirect was made retryable).
"""
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
# A single queued redirect repeats forever — the streak only ends
# because the host gives up.
spy = _ConnectSpy([InvalidURI("https://w/oidc/authorize", "scheme isn't ws or wss")])
_patch_connect(monkeypatch, spy)
host = _host()
with pytest.raises(HostConnectError) as excinfo:
await host.run()
message = str(excinfo.value)
# The fatal message identifies the auth cause and the exact remedy.
assert "login page" in message
assert "omnigent login https://app.example.databricks.com" in message
# 3 = _LOGIN_REDIRECT_FATAL_ATTEMPTS: enough retries to absorb a
# one-off proxy blip, then fail. 1 would mean the blip
# tolerance regressed; more (or no raise at all) would mean the
# fail-loud threshold is broken and the host loops silently again.
assert spy.call_count == 3
async def test_login_redirect_streak_resets_on_other_transient_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Only CONSECUTIVE login redirects count toward the fatal threshold.
A messy server restart can interleave login redirects with other
transient failures (5xx bounces). Those must reset the streak —
otherwise a fresh host riding out a restart would die from redirects
accumulated across unrelated errors instead of three in a row.
"""
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
redirect = InvalidURI("https://w/oidc/authorize", "scheme isn't ws or wss")
# Two redirects, then a 503 (must reset the streak), then redirects
# repeating forever until the host gives up.
spy = _ConnectSpy([redirect, redirect, _invalid_status(503), redirect])
_patch_connect(monkeypatch, spy)
host = _host()
with pytest.raises(HostConnectError):
await host.run()
# 6 = 2 redirects + the streak-resetting 503 + 3 consecutive
# redirects that hit the fatal threshold. 5 would mean the 503 did
# NOT reset the streak (the pre-restart redirects were counted),
# killing fresh hosts during messy restarts.
assert spy.call_count == 6
async def test_connected_host_retries_login_redirects_indefinitely(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Login redirects after a successful connect never turn fatal.
A host that already authenticated and then gets login redirects is
watching a server restart behind the Apps OAuth proxy —
killing it would drop its live runners. It must keep retrying past
the fresh-host fatal threshold.
"""
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
redirect = InvalidURI("https://w/oidc/authorize", "scheme isn't ws or wss")
# Accepted upgrade first (None), then MORE redirects than the
# fresh-host fatal threshold of 3, then a cancel to end the test.
spy = _ConnectSpy([None, redirect, redirect, redirect, redirect, asyncio.CancelledError()])
_patch_connect(monkeypatch, spy)
host = _host()
# Returns normally: if the post-connect redirects were misclassified
# as fatal this would raise HostConnectError after the 3rd redirect
# (call_count 4) instead.
await host.run()
# 6 = accepted connect + 4 retried redirects + the ending cancel.
# Fewer means the host died mid-streak (restart killed a live host).
assert spy.call_count == 6
@pytest.mark.parametrize(
"status,expected",
[
(401, "HTTP 401"),
(403, "HTTP 403"),
(404, "permanent"),
],
)
async def test_run_fails_loud_on_permanent_4xx(
monkeypatch: pytest.MonkeyPatch, status: int, expected: str
) -> None:
"""A permanent 4xx upgrade rejection fails loud on the first attempt.
401/403/other-4xx mean unauthenticated / unauthorized / wrong-or-old
server — reconnecting can never succeed, so run() must raise
HostConnectError immediately rather than backing off.
"""
spy = _ConnectSpy([_invalid_status(status)])
_patch_connect(monkeypatch, spy)
host = _host()
with pytest.raises(HostConnectError) as excinfo:
await host.run()
# Message identifies the specific permanent failure.
assert expected in str(excinfo.value)
# Exactly one attempt → no silent reconnect/backoff. If >1, the 4xx
# was misclassified as transient and the loop kept retrying.
assert spy.call_count == 1
@pytest.mark.parametrize("status", [401, 403])
async def test_auth_rejection_suggests_omnigent_login(
monkeypatch: pytest.MonkeyPatch, status: int
) -> None:
"""401/403 rejections point the user at ``omnigent login``.
Both statuses are auth failures the user can resolve by logging in to
an accounts/OIDC-mode server (a Databricks profile token may
authenticate at the proxy yet be rejected by the server itself). The
fatal message must name the exact remedy command, including the
server URL, so the user can copy-paste it.
"""
server_url = "https://app.example.databricks.com"
spy = _ConnectSpy([_invalid_status(status)])
_patch_connect(monkeypatch, spy)
host = _host(server_url=server_url)
with pytest.raises(HostConnectError) as excinfo:
await host.run()
# The exact, copy-pasteable command — URL included — must be present,
# not just the bare word "login".
assert f"omnigent login {server_url}" in str(excinfo.value)
async def test_non_auth_permanent_4xx_omits_login_hint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A non-auth permanent 4xx (404) does NOT suggest ``omnigent login``.
The login remedy is specific to credential/authorization failures;
surfacing it on a 404 ("wrong URL / route missing") would misdirect
the user. This pins the hint to the 401/403 paths only.
"""
spy = _ConnectSpy([_invalid_status(404)])
_patch_connect(monkeypatch, spy)
host = _host()
with pytest.raises(HostConnectError) as excinfo:
await host.run()
message = str(excinfo.value)
# Confirm we got the actual 404 fatal message (not some unrelated
# error that happens to lack "login") before asserting the absence.
assert "HTTP 404" in message
assert "omnigent login" not in message
@pytest.mark.parametrize("status", [408, 429, 500, 503])
async def test_run_reconnects_on_transient_upgrade_failure(
monkeypatch: pytest.MonkeyPatch, status: int
) -> None:
"""Transient upgrade failures reconnect instead of failing loud.
Retryable 4xx (408/429) and any 5xx are transient (server bounce,
rate limit) and must stay on the reconnect path. A clean
``CancelledError`` on the second attempt ends the loop so the test
terminates; with backoff zeroed the retry is immediate.
"""
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
spy = _ConnectSpy([_invalid_status(status), asyncio.CancelledError()])
_patch_connect(monkeypatch, spy)
host = _host()
# Returns normally (no HostConnectError): the transient failure was
# retried, and the 2nd-attempt CancelledError broke the loop. If the
# transient status had been treated as fatal, this would raise
# HostConnectError instead and call_count would be 1.
await host.run()
# 2 = transient attempt + cancel attempt → it genuinely reconnected.
assert spy.call_count == 2
def test_run_host_process_exits_nonzero_on_fatal(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""run_host_process surfaces a fatal tunnel failure as exit code 1.
The CLI entry point must print the cause + fix and exit non-zero, not
hang silently. Driven through the real ``asyncio.run``
path, so this is a sync test.
"""
_patch_connect(monkeypatch, _ConnectSpy([_invalid_status(403)]))
with pytest.raises(SystemExit) as excinfo:
run_host_process(
server_url="https://app.example.databricks.com",
config_path=tmp_path / "config.yaml",
)
# Non-zero exit so callers/CI see the failure.
assert excinfo.value.code == 1
err = capsys.readouterr().err
# The actionable message reached stderr (banner + the 403 cause).
assert "Could not connect" in err
assert "HTTP 403" in err
def test_run_host_process_announces_session_log_dir_on_start(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""``omnigent host`` names the session-log dir on start (was silent).
The reported regression: ``omnigent host`` ran completely silently, so a
quiet/stuck host gave no hint where to look. The startup banner now points
at the per-session runner log dir up front; the exact ``runner-*.log`` is
printed later when each runner launches. We stub the tunnel to a clean
cancel so ``run()`` returns immediately, and repoint ``Path.home`` so the
advertised dir resolves under tmp.
"""
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path))
# A single CancelledError ends the connect loop cleanly (no fatal exit),
# so run_host_process returns after printing the startup banner.
_patch_connect(monkeypatch, _ConnectSpy([asyncio.CancelledError()]))
run_host_process(
server_url="https://app.example.databricks.com",
config_path=tmp_path / "config.yaml",
)
out = capsys.readouterr().out
assert "Session logs: ~/.omnigent/logs/host-runner/" in out