275 lines
12 KiB
Python
275 lines
12 KiB
Python
"""End-to-end proof of the managed-sandbox runner HTTP-auth fix (#357 HTTP half).
|
|
|
|
A server-managed sandbox runner has no user credential of its own — only its
|
|
tunnel binding token. Under accounts/OIDC auth every runner->server HTTP
|
|
callback gates on ``require_user``, so before this fix those callbacks went out
|
|
bare and 401'd (the runner connected its tunnel but could never fetch its own
|
|
agent spec). The Option-C fix has the runner mint a short-lived owner JWT from
|
|
``POST /v1/runners/{id}/token`` (authenticated by its binding token) and present
|
|
it as ``Authorization: Bearer`` on every callback.
|
|
|
|
This test proves that fix works against a **real** ``omnigent server``
|
|
subprocess with accounts auth enabled, driving the runner's **real** outbound
|
|
code over a **real** TCP socket — no transports are stubbed:
|
|
|
|
* ``_make_auth_token_factory`` -> ``_make_managed_mint_factory`` ->
|
|
``_mint_managed_owner_token`` (a real ``httpx`` POST to the mint endpoint), and
|
|
* ``_RunnerDatabricksAuth`` on a real ``httpx.AsyncClient`` GET.
|
|
|
|
The only thing simulated is the managed-sandbox *condition* — no ``omnigent
|
|
login`` token and no Databricks config on disk — which is exactly what makes the
|
|
fix necessary (and what a fresh sandbox actually looks like).
|
|
|
|
The differential is asserted in one test, so the mint is provably the cause of
|
|
the flip:
|
|
|
|
* WITHOUT a minted token (``_RunnerDatabricksAuth(None)`` — precisely what
|
|
``_make_auth_token_factory`` returns for a managed sandbox on ``main``):
|
|
``GET /v1/sessions/{id}/agent/contents`` -> **401**.
|
|
* WITH the fix: the same GET -> **200**, returning the agent bundle.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from omnigent.runner._entry import _make_auth_token_factory, _RunnerDatabricksAuth
|
|
from omnigent.runner.identity import (
|
|
OMNIGENT_INTERNAL_WS_ORIGIN,
|
|
RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR,
|
|
token_bound_runner_id,
|
|
)
|
|
from omnigent.server.oidc import mint_session_cookie
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from tests._helpers.compat import apply_server_env, compat_server_cwd, server_executable
|
|
from tests._helpers.live_server import find_free_port
|
|
from tests.server.helpers import build_agent_bundle
|
|
|
|
# Repo root — this file lives at tests/e2e/<name>.py.
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
# 32-byte cookie secret (64 hex chars), shared between this test process and the
|
|
# server subprocess so (a) the accounts cookie we mint for the owner validates
|
|
# server-side and (b) the JWT the mint endpoint signs validates the same way.
|
|
_COOKIE_SECRET_HEX = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
|
|
_OWNER = "alice@example.com"
|
|
_BINDING_TOKEN = "e2e-managed-sandbox-binding-token"
|
|
_SERVER_HEALTH_TIMEOUT_S = 40.0
|
|
|
|
|
|
def _await_health(base_url: str, log_path: Path) -> None:
|
|
"""Poll ``/health`` until the server answers 200, or fail with the log tail.
|
|
|
|
:param base_url: Server base URL, e.g. ``"http://localhost:58123"``.
|
|
:param log_path: Server stdout/stderr log, tailed into the failure message.
|
|
:returns: None.
|
|
:raises RuntimeError: If the server doesn't answer within the deadline.
|
|
"""
|
|
deadline = time.monotonic() + _SERVER_HEALTH_TIMEOUT_S
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
if httpx.get(f"{base_url}/health", timeout=2).status_code == 200:
|
|
return
|
|
except httpx.HTTPError:
|
|
# Expected while the server is still booting (connection refused /
|
|
# reset); keep polling until the deadline.
|
|
pass
|
|
time.sleep(0.5)
|
|
tail = log_path.read_text()[-3000:] if log_path.exists() else "(no log)"
|
|
raise RuntimeError(f"accounts server did not become healthy. Log:\n{tail}")
|
|
|
|
|
|
@pytest.fixture()
|
|
def accounts_server(tmp_path: Path) -> Iterator[tuple[str, str]]:
|
|
"""Run a real ``omnigent server`` subprocess with accounts auth enabled.
|
|
|
|
Accounts mode is selected by ``OMNIGENT_AUTH_PROVIDER=accounts`` plus a
|
|
shared cookie secret; the subprocess handles the full runtime lifecycle
|
|
(migrations, DBOS, auth provider, permission store) exactly as a deployed
|
|
server does. ``_maybe_prompt_first_admin`` no-ops without a TTY, so the
|
|
boot is non-interactive.
|
|
|
|
Deliberately independent of the session-scoped ``live_server`` fixture
|
|
(which spawns a server + runner pair for the harness matrix): this test
|
|
needs an accounts-auth server and no LLM, so it owns its own subprocess.
|
|
|
|
:param tmp_path: Per-test temp dir for the DB, artifacts, and server log.
|
|
:returns: ``(base_url, db_uri)`` — the running server's URL and the SQLite
|
|
URI the test opens directly to bind the managed runner id.
|
|
"""
|
|
port = find_free_port()
|
|
db_path = tmp_path / "e2e.db"
|
|
db_uri = f"sqlite:///{db_path}"
|
|
artifact_dir = tmp_path / "artifacts"
|
|
artifact_dir.mkdir()
|
|
log_path = tmp_path / "server.log"
|
|
base_url = f"http://localhost:{port}"
|
|
|
|
env = {**os.environ}
|
|
env["OMNIGENT_AUTH_PROVIDER"] = "accounts"
|
|
env["OMNIGENT_ACCOUNTS_COOKIE_SECRET"] = _COOKIE_SECRET_HEX
|
|
env["OMNIGENT_ACCOUNTS_BASE_URL"] = base_url
|
|
# Force the accounts branch of the auth-source switch (an ambient OIDC
|
|
# issuer in the environment would otherwise select oidc mode).
|
|
env.pop("OMNIGENT_OIDC_ISSUER", None)
|
|
# Import the server package from this worktree, not an installed copy.
|
|
apply_server_env(env, _REPO_ROOT)
|
|
|
|
log_handle = open(log_path, "w") # noqa: SIM115 — handle lives for the subprocess
|
|
proc = subprocess.Popen(
|
|
[
|
|
server_executable(),
|
|
"-m",
|
|
"omnigent.cli",
|
|
"server",
|
|
"--port",
|
|
str(port),
|
|
"--database-uri",
|
|
db_uri,
|
|
"--artifact-location",
|
|
str(artifact_dir),
|
|
],
|
|
env=env,
|
|
cwd=compat_server_cwd(),
|
|
stdout=log_handle,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
try:
|
|
_await_health(base_url, log_path)
|
|
yield base_url, db_uri
|
|
finally:
|
|
proc.send_signal(signal.SIGTERM)
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=5)
|
|
log_handle.close()
|
|
|
|
|
|
async def _get_agent_contents(
|
|
base_url: str,
|
|
path: str,
|
|
auth: _RunnerDatabricksAuth,
|
|
) -> httpx.Response:
|
|
"""Drive the runner's real callback client for one ``GET``.
|
|
|
|
Builds the same ``httpx.AsyncClient`` the runner uses for its server
|
|
callbacks (``auth=_RunnerDatabricksAuth(...)``, sentinel ``Origin``,
|
|
redirects off) and issues a single request over a real socket.
|
|
|
|
:param base_url: Live server base URL.
|
|
:param path: Request path, e.g. ``"/v1/sessions/<id>/agent/contents"``.
|
|
:param auth: The runner's httpx auth — ``_RunnerDatabricksAuth(None)`` for
|
|
the bare (pre-fix) leg, or one wired to the managed-mint factory.
|
|
:returns: The HTTP response.
|
|
"""
|
|
async with httpx.AsyncClient(
|
|
base_url=base_url,
|
|
auth=auth,
|
|
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
|
|
follow_redirects=False,
|
|
timeout=30.0,
|
|
) as client:
|
|
return await client.get(path)
|
|
|
|
|
|
def test_managed_runner_callback_authenticates_end_to_end(
|
|
accounts_server: tuple[str, str],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A managed runner's HTTP callback 401s bare and 200s with a minted token.
|
|
|
|
End-to-end against a live accounts-auth server, driving the runner's real
|
|
outbound auth code over a real socket. Reverting the runner-side managed
|
|
mint tier (or the server-side mint endpoint) turns the 200 assertion back
|
|
into a 401 — that is the exact gap this change closes.
|
|
|
|
:param accounts_server: ``(base_url, db_uri)`` from the live-server fixture.
|
|
:param monkeypatch: Puts this process into the managed-sandbox posture
|
|
(binding token + server URL present; no user credential resolvable).
|
|
:returns: None.
|
|
"""
|
|
base_url, db_uri = accounts_server
|
|
|
|
# 1. Alice owns a real session. Her identity comes from a directly-minted
|
|
# accounts cookie signed with the server's shared secret — the same JWT
|
|
# the password login flow issues; only the password dance is skipped.
|
|
# The session-create, agent registration, and owner grant are all real.
|
|
owner_cookie = mint_session_cookie(_OWNER, bytes.fromhex(_COOKIE_SECRET_HEX), 8, "accounts")
|
|
bundle = build_agent_bundle(name="e2e-managed-runner-agent")
|
|
with httpx.Client(base_url=base_url, timeout=30.0) as http:
|
|
create = http.post(
|
|
"/v1/sessions",
|
|
headers={
|
|
"Authorization": f"Bearer {owner_cookie}",
|
|
"Origin": OMNIGENT_INTERNAL_WS_ORIGIN,
|
|
},
|
|
data={"metadata": "{}"},
|
|
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
|
|
)
|
|
assert create.status_code in (200, 201), (create.status_code, create.text)
|
|
session_id = create.json()["session_id"]
|
|
|
|
# 2. Bind a managed runner id to Alice's session — what the managed-launch
|
|
# path does at spawn time via replace_runner_id. WAL journaling + a 20s
|
|
# busy_timeout make this cross-process write safe against the running
|
|
# server, which then resolves runner_id -> owner from this row.
|
|
runner_id = token_bound_runner_id(_BINDING_TOKEN)
|
|
SqlAlchemyConversationStore(db_uri).replace_runner_id(session_id, runner_id)
|
|
|
|
# 3. Put this process in a managed-sandbox posture: the runner holds ONLY
|
|
# its binding token and the server URL — no omnigent-login token, no
|
|
# Databricks config. Forcing both credential sources to miss is what a
|
|
# fresh sandbox actually is, and it routes _make_auth_token_factory to
|
|
# the managed-mint tier under test.
|
|
from omnigent.inner.databricks_executor import DatabricksAuthError
|
|
|
|
def _no_databricks_creds(*args: object, **kwargs: object) -> tuple[object, str]:
|
|
"""Stand in for _resolve_databricks_auth in a credential-less sandbox."""
|
|
raise DatabricksAuthError("managed sandbox has no Databricks config")
|
|
|
|
monkeypatch.setenv("RUNNER_SERVER_URL", base_url)
|
|
monkeypatch.setenv(RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR, _BINDING_TOKEN)
|
|
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url: None)
|
|
monkeypatch.setattr(
|
|
"omnigent.inner.databricks_executor._resolve_databricks_auth",
|
|
_no_databricks_creds,
|
|
)
|
|
|
|
contents_path = f"/v1/sessions/{session_id}/agent/contents"
|
|
|
|
# 4a. Pre-fix behavior: a managed sandbox on main resolves no credential,
|
|
# so _make_auth_token_factory returns None and the callback goes out
|
|
# with no bearer. require_user rejects it.
|
|
bare = asyncio.run(_get_agent_contents(base_url, contents_path, _RunnerDatabricksAuth(None)))
|
|
assert bare.status_code == 401, (bare.status_code, bare.text)
|
|
|
|
# 4b. With the fix: the factory installs the managed-mint tier, mints a
|
|
# short-lived owner JWT from the binding token (a real POST to the mint
|
|
# endpoint at construction), and presents it on the callback.
|
|
factory = _make_auth_token_factory()
|
|
assert factory is not None, (
|
|
"managed-mint factory should install for a managed sandbox "
|
|
"(binding token + server URL present, no user credential)"
|
|
)
|
|
authed = asyncio.run(
|
|
_get_agent_contents(base_url, contents_path, _RunnerDatabricksAuth(factory))
|
|
)
|
|
assert authed.status_code == 200, (authed.status_code, authed.text)
|
|
# The minted owner token resolved to Alice, who owns the session, so the
|
|
# real agent bundle comes back.
|
|
assert authed.headers.get("X-Agent-Name")
|
|
assert authed.content[:2] == b"\x1f\x8b", "expected a gzip agent bundle body"
|