Files
omnigent-ai--omnigent/tests/test_claude_native_hook.py
2026-07-13 13:12:00 +08:00

2476 lines
93 KiB
Python

"""Tests for the native Claude Code hook command."""
from __future__ import annotations
import io
import json
import re
import sys
from pathlib import Path
import httpx
import pytest
from omnigent import claude_native_hook, native_policy_hook
from omnigent.claude_native_bridge import (
build_hook_settings,
prepare_bridge_dir,
read_transcript_path,
record_hook_event,
write_active_session_id,
)
from tests.native_hook_helpers import make_failing_client
@pytest.fixture(autouse=True)
def _trust_tmp_bridge_root(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""
Treat each test's temp dir as the Claude bridge root.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Per-test temp directory.
:returns: None.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path)
def test_session_start_hook_records_transcript_state_without_output(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
SessionStart records Claude state without printing hook output.
This fails if the ``omnigent claude`` hook reintroduces
``systemMessage`` output, which Claude renders with the noisy
``SessionStart:startup says:`` prefix.
"""
bridge_dir = tmp_path / "bridge"
transcript_path = tmp_path / "session.jsonl"
payload = {
"hook_event_name": "SessionStart",
"transcript_path": str(transcript_path),
}
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(
[
"--bridge-dir",
str(bridge_dir),
]
)
captured = capsys.readouterr()
assert exit_code == 0
assert captured.out == ""
assert captured.err == ""
assert read_transcript_path(bridge_dir) == transcript_path
def test_session_start_hook_emits_conversation_url_system_message(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
SessionStart emits Claude hook output when a conversation URL exists.
This fails if ``omnigent claude`` stops routing the web URL
through Claude's hook output path, leaving users with no startup
pointer back to the Omnigent conversation.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_abc",
bridge_id="bridge_shared",
workspace=tmp_path,
)
transcript_path = tmp_path / "session.jsonl"
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
payload = {
"hook_event_name": "SessionStart",
"transcript_path": str(transcript_path),
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(
[
"--bridge-dir",
str(bridge_dir),
]
)
captured = capsys.readouterr()
assert exit_code == 0
assert json.loads(captured.out) == {
"systemMessage": "Open this session in Omnigent: http://127.0.0.1:8787/c/conv_abc"
}
assert captured.err == ""
assert read_transcript_path(bridge_dir) == transcript_path
def test_session_start_hook_maps_workspace_hosted_server_to_ui_mount(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
SessionStart links to the SPA mount for workspace-hosted servers.
``ap_server_url`` is the API proxy base (``/api/2.0/omnigent``);
pointing the "Open this session" message there returns JSON, not
the web UI. The message must land on the ``/omnigent`` SPA mount
with the ``?o=<org>`` selector — matching the CLI's ``Web UI:``
line and the tmux status bar.
"""
from omnigent.cli_auth import store_databricks_auth
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(
"omnigent.cli_auth._token_file_path",
lambda: tmp_path / "auth_tokens.json",
)
server = "https://example.databricks.com/api/2.0/omnigent"
store_databricks_auth(
server,
"https://example.databricks.com",
org_id="2850744067564480",
)
bridge_dir = prepare_bridge_dir(
"conv_abc",
bridge_id="bridge_shared",
workspace=tmp_path,
)
build_hook_settings(bridge_dir, ap_server_url=server)
payload = {
"hook_event_name": "SessionStart",
"transcript_path": str(tmp_path / "session.jsonl"),
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
assert json.loads(captured.out) == {
"systemMessage": (
"Open this session in Omnigent: "
"https://example.databricks.com/omnigent/c/conv_abc?o=2850744067564480"
)
}
def test_clear_session_start_hook_rotates_before_printing_conversation_url(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
``/clear`` SessionStart prints the URL for the replacement Omnigent session.
Claude renders hook stdout immediately, before the background
forwarder can poll the hook log. This test fails if the banner
regresses to the launch conversation URL after ``/clear``.
"""
requests: list[tuple[str, str, dict[str, object] | None]] = []
class _FakeHttpxClient:
"""
Minimal sync HTTP client stub for clear-session rotation.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
captured_timeouts: list[object] = []
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Capture constructor inputs.
:param headers: HTTP headers for AP.
:param timeout: HTTP timeout object.
:returns: None.
"""
del headers
self.captured_timeouts.append(timeout)
def __enter__(self) -> _FakeHttpxClient:
"""
Enter the context manager.
:returns: This fake client.
"""
return self
def __exit__(self, *args: object) -> None:
"""
Exit the context manager.
:param args: Exception details.
:returns: None.
"""
del args
def get(self, url: str) -> object:
"""
Return the old session snapshot.
:param url: Target Omnigent URL.
:returns: HTTP response object.
"""
import httpx
requests.append(("GET", url, None))
return httpx.Response(
200,
json={
"id": "conv_old",
"agent_id": "ag_claude",
"runner_id": "runner_one",
"labels": {"omnigent.claude_native.bridge_id": "bridge_shared"},
},
request=httpx.Request("GET", url),
)
def post(self, url: str, *, json: dict[str, object]) -> object:
"""
Create the replacement session or transfer the terminal.
:param url: Target Omnigent URL.
:param json: Request JSON body.
:returns: HTTP response object.
"""
import httpx
requests.append(("POST", url, json))
if url == "http://127.0.0.1:8787/v1/sessions":
return httpx.Response(
201,
json={"id": "conv_new"},
request=httpx.Request("POST", url),
)
return httpx.Response(
200,
json={"id": "terminal_claude_main"},
request=httpx.Request("POST", url),
)
def patch(self, url: str, *, json: dict[str, object]) -> object:
"""
Bind the new session or clear the old runner binding.
:param url: Target Omnigent URL.
:param json: Request JSON body.
:returns: HTTP response object.
"""
import httpx
requests.append(("PATCH", url, json))
return httpx.Response(
200,
json={"id": "patched"},
request=httpx.Request("PATCH", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FakeHttpxClient)
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
build_hook_settings(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
ap_auth_headers={"Authorization": "Bearer xyz"},
)
payload = {
"hook_event_name": "SessionStart",
"source": "clear",
"transcript_path": str(tmp_path / "session.jsonl"),
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
assert json.loads(captured.out) == {
"systemMessage": "Open this session in Omnigent: http://127.0.0.1:8787/c/conv_new"
}
assert captured.err == ""
assert requests == [
("GET", "http://127.0.0.1:8787/v1/sessions/conv_old", None),
(
"POST",
"http://127.0.0.1:8787/v1/sessions",
{
"agent_id": "ag_claude",
"labels": {"omnigent.claude_native.bridge_id": "bridge_shared"},
},
),
("PATCH", "http://127.0.0.1:8787/v1/sessions/conv_new", {"runner_id": "runner_one"}),
(
"POST",
(
"http://127.0.0.1:8787/v1/sessions/conv_old/resources/"
"terminals/terminal_claude_main/transfer"
),
{"target_session_id": "conv_new"},
),
(
"PATCH",
"http://127.0.0.1:8787/v1/sessions/conv_old",
{
"runner_id": "",
"labels": {"omnigent.claude_native.bridge_id": "conv_old-cleared"},
},
),
]
recorded = (bridge_dir / "hooks.jsonl").read_text(encoding="utf-8")
assert '"omnigent_clear_rotated_to":"conv_new"' in recorded
# The /clear rotation gates Claude's welcome banner and must fail
# fast — it uses _SESSION_ROTATION_TIMEOUT_S, NOT the day-long
# permission long-poll budget. If this regresses to
# _PERMISSION_TIMEOUT_S (86400) an unresponsive Omnigent server would hang
# the banner for a full day instead of returning None so the
# background forwarder can rotate.
rotation_timeout = _FakeHttpxClient.captured_timeouts[0]
assert isinstance(rotation_timeout, httpx.Timeout)
assert rotation_timeout.read == claude_native_hook._SESSION_ROTATION_TIMEOUT_S
def test_fork_session_start_hook_forks_before_printing_conversation_url(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
Claude ``/fork`` SessionStart prints the URL for the forked Omnigent session.
Claude reports ``/fork``/``/branch`` as ``SessionStart`` with
``source=resume``. This test fails if the hook no longer detects
the branch marker, forks AP, transfers the terminal, and points the
welcome banner at the forked session.
"""
requests: list[tuple[str, str, dict[str, object] | None]] = []
class _FakeHttpxClient:
"""
Minimal sync HTTP client stub for fork-session rotation.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
captured_timeouts: list[object] = []
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Capture constructor inputs.
:param headers: HTTP headers for AP.
:param timeout: HTTP timeout object.
:returns: None.
"""
del headers
self.captured_timeouts.append(timeout)
def __enter__(self) -> _FakeHttpxClient:
"""
Enter the context manager.
:returns: This fake client.
"""
return self
def __exit__(self, *args: object) -> None:
"""
Exit the context manager.
:param args: Exception details.
:returns: None.
"""
del args
def get(self, url: str) -> object:
"""
Return the old session snapshot.
:param url: Target Omnigent URL.
:returns: HTTP response object.
"""
import httpx
requests.append(("GET", url, None))
return httpx.Response(
200,
json={
"id": "conv_old",
"agent_id": "ag_claude",
"runner_id": "runner_one",
"labels": {"omnigent.claude_native.bridge_id": "bridge_shared"},
},
request=httpx.Request("GET", url),
)
def post(self, url: str, *, json: dict[str, object]) -> object:
"""
Fork the Omnigent session or transfer the terminal.
:param url: Target Omnigent URL.
:param json: Request JSON body.
:returns: HTTP response object.
"""
import httpx
requests.append(("POST", url, json))
if url == "http://127.0.0.1:8787/v1/sessions/conv_old/fork":
return httpx.Response(
201,
json={"id": "conv_fork"},
request=httpx.Request("POST", url),
)
return httpx.Response(
200,
json={"id": "terminal_claude_main"},
request=httpx.Request("POST", url),
)
def patch(self, url: str, *, json: dict[str, object]) -> object:
"""
Bind the forked session or clear the old runner binding.
:param url: Target Omnigent URL.
:param json: Request JSON body.
:returns: HTTP response object.
"""
import httpx
requests.append(("PATCH", url, json))
return httpx.Response(
200,
json={"id": "patched"},
request=httpx.Request("PATCH", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FakeHttpxClient)
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
build_hook_settings(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
ap_auth_headers={"Authorization": "Bearer xyz"},
)
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude_old",
},
)
transcript_path = tmp_path / "fork.jsonl"
transcript_path.write_text(
json.dumps(
{
"type": "attachment",
"timestamp": "2026-05-27T22:53:13.245Z",
"sessionId": "claude_fork",
"forkedFrom": {"sessionId": "claude_old"},
}
)
+ "\n",
encoding="utf-8",
)
monkeypatch.setattr(claude_native_hook.time, "time", lambda: 1779922393.245)
payload = {
"hook_event_name": "SessionStart",
"source": "resume",
"session_id": "claude_fork",
"session_title": "hello",
"transcript_path": str(transcript_path),
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
assert json.loads(captured.out) == {
"systemMessage": "Open this session in Omnigent: http://127.0.0.1:8787/c/conv_fork"
}
assert captured.err == ""
assert requests == [
("GET", "http://127.0.0.1:8787/v1/sessions/conv_old", None),
("POST", "http://127.0.0.1:8787/v1/sessions/conv_old/fork", {}),
("PATCH", "http://127.0.0.1:8787/v1/sessions/conv_fork", {"runner_id": "runner_one"}),
(
"POST",
(
"http://127.0.0.1:8787/v1/sessions/conv_old/resources/"
"terminals/terminal_claude_main/transfer"
),
{"target_session_id": "conv_fork"},
),
("PATCH", "http://127.0.0.1:8787/v1/sessions/conv_old", {"runner_id": ""}),
]
recorded = (bridge_dir / "hooks.jsonl").read_text(encoding="utf-8")
assert '"omnigent_previous_claude_session_id":"claude_old"' in recorded
assert '"omnigent_claude_session_was_seen":false' in recorded
assert '"omnigent_fork_detected":true' in recorded
assert '"omnigent_fork_rotated_to":"conv_fork"' in recorded
# The /fork rotation gates Claude's welcome banner and must fail
# fast — it uses _SESSION_ROTATION_TIMEOUT_S, NOT the day-long
# permission long-poll budget. If this regresses to
# _PERMISSION_TIMEOUT_S (86400) an unresponsive Omnigent server would hang
# the banner for a full day instead of returning None so the
# background forwarder can fork.
rotation_timeout = _FakeHttpxClient.captured_timeouts[0]
assert isinstance(rotation_timeout, httpx.Timeout)
assert rotation_timeout.read == claude_native_hook._SESSION_ROTATION_TIMEOUT_S
def test_resume_session_start_without_branch_marker_does_not_fork(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
Ordinary Claude resumes do not create Omnigent forks.
This fails if every ``SessionStart source=resume`` starts forking
Omnigent sessions, which would break normal Claude resume flows.
"""
class _FailingHttpxClient:
"""
HTTP client stub that fails if fork detection makes Omnigent calls.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Capture constructor inputs.
:param headers: HTTP headers for AP.
:param timeout: HTTP timeout object.
:returns: None.
"""
del headers, timeout
def __enter__(self) -> _FailingHttpxClient:
"""
Enter the context manager.
:returns: This fake client.
"""
raise AssertionError("ordinary resume should not call AP")
def __exit__(self, *args: object) -> None:
"""
Exit the context manager.
:param args: Exception details.
:returns: None.
"""
del args
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FailingHttpxClient)
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
record_hook_event(
bridge_dir,
{
"hook_event_name": "SessionStart",
"session_id": "claude_old",
},
)
payload = {
"hook_event_name": "SessionStart",
"source": "resume",
"session_id": "claude_other",
"transcript_path": str(tmp_path / "resume.jsonl"),
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
assert json.loads(captured.out) == {
"systemMessage": "Open this session in Omnigent: http://127.0.0.1:8787/c/conv_old"
}
recorded = (bridge_dir / "hooks.jsonl").read_text(encoding="utf-8")
assert "omnigent_fork_detected" not in recorded
assert "omnigent_fork_rotated_to" not in recorded
def test_non_session_start_hook_does_not_emit_conversation_url_context(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
Non-startup hooks remain observational and do not write Claude output.
This fails if Stop/UserPromptSubmit hooks start producing stdout,
which Claude could interpret as hook output for events that are only
supposed to update Omnigent bridge state.
"""
bridge_dir = tmp_path / "bridge"
payload = {"hook_event_name": "Stop"}
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(
[
"--bridge-dir",
str(bridge_dir),
"--conversation-url",
"http://127.0.0.1:8787/c/conv_abc",
]
)
captured = capsys.readouterr()
assert exit_code == 0
assert captured.out == ""
assert captured.err == ""
def test_permission_request_hook_posts_to_active_session_from_bridge_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
Permission command hook routes to the current active Omnigent session.
This fails if the hook bakes in the launch conversation id: after
Claude ``/clear`` rotates the bridge to a new Omnigent session, approval
requests would still appear on the old conversation.
"""
posted: dict[str, object] = {}
class _FakeHttpxClient:
"""
Minimal sync HTTP client stub for the permission hook.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Capture constructor inputs.
:param headers: HTTP headers for AP.
:param timeout: HTTP timeout object.
:returns: None.
"""
posted["headers"] = headers
posted["timeout"] = timeout
def __enter__(self) -> _FakeHttpxClient:
"""
Enter the context manager.
:returns: This fake client.
"""
return self
def __exit__(self, *args: object) -> None:
"""
Exit the context manager.
:param args: Exception details.
:returns: None.
"""
del args
def post(self, url: str, *, json: dict[str, object]) -> object:
"""
Record the outgoing Omnigent request.
:param url: Target Omnigent URL.
:param json: Request JSON body.
:returns: HTTP response object.
"""
import httpx
posted["url"] = url
posted["json"] = json
return httpx.Response(
200,
text='{"hookSpecificOutput":{"hookEventName":"PermissionRequest","permissionDecision":"allow"}}',
request=httpx.Request("POST", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FakeHttpxClient)
bridge_dir = prepare_bridge_dir(
"conv_old",
bridge_id="bridge_shared",
workspace=tmp_path,
)
write_active_session_id(bridge_dir, "conv_new")
build_hook_settings(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
ap_auth_headers={"Authorization": "Bearer xyz"},
)
payload = {"hook_event_name": "PermissionRequest", "tool_name": "Bash"}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(
[
"permission-request",
"--bridge-dir",
str(bridge_dir),
]
)
captured = capsys.readouterr()
assert exit_code == 0
assert posted["url"] == ("http://127.0.0.1:8787/v1/sessions/conv_new/hooks/permission-request")
sent = posted["json"]
assert isinstance(sent, dict)
# The hook payload is forwarded verbatim, plus the minted re-attach
# id the server uses to re-park the prompt across severed polls.
assert {k: v for k, v in sent.items() if k != "_omnigent_elicitation_id"} == payload
assert re.fullmatch(r"elicit_claude_[0-9a-f]{32}", sent["_omnigent_elicitation_id"]), (
f"re-attach id outside the claude-hook namespace: {sent.get('_omnigent_elicitation_id')!r}"
)
assert posted["headers"] == {"Authorization": "Bearer xyz"}
assert json.loads(captured.out)["hookSpecificOutput"]["permissionDecision"] == "allow"
assert captured.err == ""
def _prepare_permission_bridge(tmp_path: Path, session_id: str) -> Path:
"""
Stand up a bridge dir wired for the permission-request hook.
:param tmp_path: Test-scoped temp directory (already patched as the
trusted bridge parent by the caller).
:param session_id: Active Omnigent session id, e.g. ``"conv_x"``.
:returns: The prepared bridge directory.
"""
bridge_dir = prepare_bridge_dir(
session_id,
bridge_id="bridge_retry",
workspace=tmp_path,
)
build_hook_settings(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
ap_auth_headers={"Authorization": "Bearer xyz"},
)
return bridge_dir
def test_permission_request_hook_retries_transport_cut_with_same_id(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
A severed long-poll is re-POSTed with the SAME re-attach id.
This is the proxy-cut path that used to fail-ask into an invisible
terminal prompt for headless sub-agents: one transport error ended
the hook. The retry must reuse the minted
``_omnigent_elicitation_id`` — a fresh id per attempt would park a
NEW elicitation and orphan the card the server kept alive through
the re-park grace.
"""
attempts: list[dict[str, object]] = []
class _FlakyHttpxClient:
"""
Client stub: first POST raises a transport error, second succeeds.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Accept and discard constructor inputs.
:param headers: HTTP headers for AP.
:param timeout: HTTP timeout object.
:returns: None.
"""
del headers, timeout
def __enter__(self) -> _FlakyHttpxClient:
"""
Enter the context manager.
:returns: This fake client.
"""
return self
def __exit__(self, *args: object) -> None:
"""
Exit the context manager.
:param args: Exception details.
:returns: None.
"""
del args
def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
"""
Fail the first attempt at the transport layer, then succeed.
:param url: Target Omnigent URL.
:param json: Request JSON body.
:returns: HTTP 200 with a decision on the second attempt.
:raises httpx.ReadError: On the first attempt.
"""
attempts.append(dict(json))
if len(attempts) == 1:
raise httpx.ReadError(
"proxy severed the long-poll",
request=httpx.Request("POST", url),
)
return httpx.Response(
200,
text=(
'{"hookSpecificOutput":{"hookEventName":"PermissionRequest",'
'"decision":{"behavior":"allow"}}}'
),
request=httpx.Request("POST", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FlakyHttpxClient)
# Zero backoff keeps the retry loop instant in tests; production
# waits between attempts.
monkeypatch.setattr(claude_native_hook, "_PERMISSION_RETRY_INITIAL_BACKOFF_S", 0.0)
bridge_dir = _prepare_permission_bridge(tmp_path, "conv_retry")
payload = {"hook_event_name": "PermissionRequest", "tool_name": "Bash"}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["permission-request", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
# 2 = one severed attempt + one successful retry. 1 would mean the
# transport error fail-asked without retrying (the production bug);
# 3+ would mean a success was retried.
assert len(attempts) == 2, f"expected 2 attempts, got {len(attempts)}"
first_id = attempts[0]["_omnigent_elicitation_id"]
assert re.fullmatch(r"elicit_claude_[0-9a-f]{32}", str(first_id))
# Same id on the retry is the whole re-attach contract — a new id
# would orphan the elicitation the server kept pending.
assert attempts[1]["_omnigent_elicitation_id"] == first_id
# The verdict from the successful retry reaches Claude on stdout.
decision = json.loads(captured.out)["hookSpecificOutput"]["decision"]
assert decision == {"behavior": "allow"}
def test_permission_request_hook_does_not_retry_rejections(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
A 4xx from the Omnigent server is a deliberate answer — no retry.
Retrying a rejection (bad payload, foreign elicitation id) would
hammer the server with a request it already refused; the hook must
fail-ask immediately instead.
"""
attempts: list[str] = []
class _RejectingHttpxClient:
"""
Client stub that always returns HTTP 400.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Accept and discard constructor inputs.
:param headers: HTTP headers for AP.
:param timeout: HTTP timeout object.
:returns: None.
"""
del headers, timeout
def __enter__(self) -> _RejectingHttpxClient:
"""
Enter the context manager.
:returns: This fake client.
"""
return self
def __exit__(self, *args: object) -> None:
"""
Exit the context manager.
:param args: Exception details.
:returns: None.
"""
del args
def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
"""
Reject every attempt with HTTP 400.
:param url: Target Omnigent URL.
:param json: Request JSON body.
:returns: HTTP 400 response.
"""
del json
attempts.append(url)
return httpx.Response(
400,
text='{"error": "bad payload"}',
request=httpx.Request("POST", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", _RejectingHttpxClient)
monkeypatch.setattr(claude_native_hook, "_PERMISSION_RETRY_INITIAL_BACKOFF_S", 0.0)
bridge_dir = _prepare_permission_bridge(tmp_path, "conv_reject")
payload = {"hook_event_name": "PermissionRequest", "tool_name": "Bash"}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["permission-request", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
# Fail-ask contract: exit 0 with no stdout so Claude falls back to
# its TUI prompt.
assert exit_code == 0
assert captured.out == ""
# 1 = the 4xx was treated as final. 2+ means rejections are being
# retried, hammering the server with refused requests.
assert len(attempts) == 1, f"expected a single attempt, got {len(attempts)}"
assert "rejected" in captured.err
def test_build_hook_settings_registers_policy_hooks_when_omnigent_server_url_set(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``build_hook_settings`` includes PreToolUse and PostToolUse policy hooks.
Without these entries, Claude Code's native tools (Bash, Edit, Write,
etc.) bypass policy evaluation entirely — only relay/MCP tools would
be gated. This fails if the hook registration is dropped or guarded
behind a different condition than ``ap_server_url``.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_abc",
bridge_id="bridge_test",
workspace=tmp_path,
)
settings = build_hook_settings(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
)
hooks = settings["hooks"]
# PreToolUse and PostToolUse must be registered alongside PermissionRequest.
assert "PreToolUse" in hooks, (
"PreToolUse hook not registered — native tools bypass TOOL_CALL policy evaluation"
)
assert "PermissionRequest" in hooks
# PreToolUse has two entries: the AskUserQuestion-specific hook first,
# then the catch-all policy evaluation hook.
assert len(hooks["PreToolUse"]) == 2, (
f"Expected 2 PreToolUse entries (AskUserQuestion + catch-all policy), "
f"got {len(hooks['PreToolUse'])}"
)
# First entry: AskUserQuestion-specific hook with matcher.
ask_uq_entry = hooks["PreToolUse"][0]
assert ask_uq_entry.get("matcher") == "AskUserQuestion"
ask_uq_cmd = ask_uq_entry["hooks"][0]["command"]
assert "ask-user-question" in ask_uq_cmd
assert str(bridge_dir) in ask_uq_cmd
# Second entry: catch-all policy evaluation hook (no matcher).
policy_entry = hooks["PreToolUse"][1]
assert "matcher" not in policy_entry
pre_tool_use_cmd = policy_entry["hooks"][0]["command"]
assert "evaluate-policy" in pre_tool_use_cmd
assert str(bridge_dir) in pre_tool_use_cmd
# PostToolUse has observer hooks (TodoWrite, TaskUpdate) PLUS the policy
# evaluation hook appended as a catch-all entry.
post_tool_use_entries = hooks["PostToolUse"]
# At least 3 entries: TodoWrite matcher, TaskUpdate matcher, catch-all policy.
assert len(post_tool_use_entries) >= 3, (
f"Expected >= 3 PostToolUse entries (TodoWrite + TaskUpdate + policy), "
f"got {len(post_tool_use_entries)}"
)
# The last entry is the catch-all policy evaluation hook.
policy_entry_cmd = post_tool_use_entries[-1]["hooks"][0]["command"]
assert "evaluate-policy" in policy_entry_cmd
# UserPromptSubmit carries the forwarder's status hook PLUS the policy
# hook appended as a catch-all. For native sessions this is the sole
# REQUEST-phase gate (the server-level _evaluate_input_policy skips
# native message events), so a missing policy hook here means native
# prompts reach the model with no request-phase policy.
user_prompt_entries = hooks["UserPromptSubmit"]
user_prompt_cmds = [h["command"] for entry in user_prompt_entries for h in entry["hooks"]]
assert any("evaluate-policy" in cmd for cmd in user_prompt_cmds), (
f"UserPromptSubmit policy hook not registered; got {user_prompt_cmds!r}"
)
# The forwarder's status hook must survive (the policy hook is appended).
assert any("evaluate-policy" not in cmd for cmd in user_prompt_cmds)
def test_build_hook_settings_registers_message_display_hook(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``build_hook_settings`` wires ``MessageDisplay`` to the fast appender.
Without this entry, Claude never invokes the deltas-appender and live
token streaming silently does nothing (the web UI falls back to the
whole-message-on-completion behavior). It must route to the dedicated
stdlib-only module — NOT the heavier observer hook — so the per-chunk
hot path stays cheap, and it must NOT depend on ``ap_server_url``
(streaming works for local servers too). Fails if the registration
is dropped or pointed at the wrong module.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_test", workspace=tmp_path)
# No ap_server_url: streaming must still be registered.
settings = build_hook_settings(bridge_dir)
hooks = settings["hooks"]
assert "MessageDisplay" in hooks, (
"MessageDisplay hook not registered — live token streaming is dead"
)
command = hooks["MessageDisplay"][0]["hooks"][0]["command"]
# Routes to the dedicated lightweight module with this bridge dir...
assert "omnigent.claude_native_message_display_hook" in command
assert str(bridge_dir) in command
# ...and NOT through the heavier observer/policy subcommands (which
# would import claude_native_bridge on every streamed chunk).
assert "evaluate-policy" not in command
assert "permission-request" not in command
def test_build_hook_settings_omits_policy_hooks_without_omnigent_server_url(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``build_hook_settings`` omits policy hooks when no Omnigent URL is set.
Without an Omnigent server there are no policies to evaluate; registering
the hooks would cause no-op subprocesses on every tool call.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir(
"conv_abc",
bridge_id="bridge_test",
workspace=tmp_path,
)
settings = build_hook_settings(bridge_dir)
hooks = settings["hooks"]
assert "PreToolUse" not in hooks
assert "PermissionRequest" not in hooks
# PostToolUse still has the observer hooks (TodoWrite, TaskUpdate)
# but NOT the policy evaluation hook.
for entry in hooks.get("PostToolUse", []):
cmd = entry["hooks"][0]["command"]
assert "evaluate-policy" not in cmd, (
"Policy evaluation hook should not be registered without Omnigent URL"
)
def test_evaluate_policy_pre_tool_use_converts_and_returns_deny(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
PreToolUse payload is converted to proto schema and deny verdict is returned.
This fails if the subprocess doesn't convert the Claude payload to
the EvaluationRequest proto format, doesn't POST to the correct
``/policies/evaluate`` endpoint, or doesn't convert the
EvaluationResponse back to Claude's PreToolUse hook output.
"""
posted: dict[str, object] = {}
class _FakeHttpxClient:
"""
Minimal sync HTTP client stub for the evaluate-policy hook.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Capture constructor inputs.
:param headers: HTTP headers for AP.
:param timeout: HTTP timeout object.
:returns: None.
"""
posted["headers"] = headers
posted["timeout"] = timeout
def __enter__(self) -> _FakeHttpxClient:
"""
Enter the context manager.
:returns: This fake client.
"""
return self
def __exit__(self, *args: object) -> None:
"""
Exit the context manager.
:param args: Exception details.
:returns: None.
"""
del args
def post(self, url: str, *, json: dict[str, object]) -> object:
"""
Record the outgoing Omnigent request and return a DENY verdict.
:param url: Target Omnigent URL.
:param json: Request JSON body (EvaluationRequest).
:returns: HTTP response object with EvaluationResponse.
"""
import httpx
posted["url"] = url
posted["json"] = json
return httpx.Response(
200,
text=('{"result":"POLICY_ACTION_DENY","reason":"Blocked by policy"}'),
request=httpx.Request("POST", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(native_policy_hook.httpx, "Client", _FakeHttpxClient)
bridge_dir = prepare_bridge_dir(
"conv_abc",
bridge_id="bridge_shared",
workspace=tmp_path,
)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
ap_auth_headers={"Authorization": "Bearer test-token"},
)
payload = {
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /"},
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(
[
"evaluate-policy",
"--bridge-dir",
str(bridge_dir),
]
)
captured = capsys.readouterr()
assert exit_code == 0
# Verify the hook posted to the /policies/evaluate endpoint.
assert posted["url"] == ("http://127.0.0.1:8787/v1/sessions/conv_active/policies/evaluate")
# The payload is converted to proto EvaluationRequest format.
sent = posted["json"]
assert sent["event"]["type"] == "PHASE_TOOL_CALL"
assert sent["event"]["data"]["name"] == "Bash"
assert sent["event"]["data"]["arguments"] == {"command": "rm -rf /"}
# Auth headers from permission_hook.json are sent.
assert posted["headers"] == {"Authorization": "Bearer test-token"}
# The EvaluationResponse is converted back to Claude's PreToolUse format.
result = json.loads(captured.out)
assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
assert result["hookSpecificOutput"]["permissionDecisionReason"] == "Blocked by policy"
assert captured.err == ""
def test_evaluate_policy_stamps_live_model_from_context_json(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
The hook stamps the statusLine-captured live model into the request.
The statusLine wrapper writes the active model id into ``context.json``
on every render. The hook must stamp it (and ``harness``) onto the
evaluation request so the cost-budget gate sees the CURRENT model at gate
time — not the lagging ``model_override`` mirror. Regression guard for a
cheap-model session getting blocked over budget because the model was
unresolved (None) and the gate failed closed.
"""
posted: dict[str, object] = {}
class _FakeHttpxClient:
"""Sync HTTP client stub capturing the posted EvaluationRequest."""
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""Record constructor inputs. :returns: None."""
del headers, timeout
def __enter__(self) -> _FakeHttpxClient:
""":returns: This fake client."""
return self
def __exit__(self, *args: object) -> None:
""":returns: None."""
del args
def post(self, url: str, *, json: dict[str, object]) -> object:
"""Record the request and return an ALLOW verdict. :returns: response."""
import httpx
posted["json"] = json
return httpx.Response(
200,
text='{"result":"POLICY_ACTION_ALLOW"}',
request=httpx.Request("POST", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FakeHttpxClient)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_shared", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
# The statusLine wrapper's capture: the live model the user is on.
(bridge_dir / "context.json").write_text(
json.dumps({"model": "claude-sonnet-4-6"}), encoding="utf-8"
)
payload = {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {}}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
assert exit_code == 0
context = posted["json"]["event"]["context"]
# The live model + harness must ride the request so the cost gate doesn't
# fail closed on an unresolved model.
assert context["model"] == "claude-sonnet-4-6"
assert context["harness"] == "claude-native"
def test_evaluate_policy_post_tool_use_converts_and_returns_context(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
PostToolUse payload is converted to PHASE_TOOL_RESULT and deny surfaces as context.
PostToolUse hooks are observational — they can't block the tool call.
A DENY verdict is surfaced as ``additionalContext`` so Claude sees
the policy warning alongside the tool result.
"""
posted: dict[str, object] = {}
class _FakeHttpxClient:
"""
Minimal sync HTTP client stub for PostToolUse evaluation.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Capture constructor inputs.
:param headers: HTTP headers for AP.
:param timeout: HTTP timeout object.
:returns: None.
"""
posted["headers"] = headers
def __enter__(self) -> _FakeHttpxClient:
"""
Enter the context manager.
:returns: This fake client.
"""
return self
def __exit__(self, *args: object) -> None:
"""
Exit the context manager.
:param args: Exception details.
:returns: None.
"""
del args
def post(self, url: str, *, json: dict[str, object]) -> object:
"""
Record the outgoing Omnigent request and return a DENY verdict.
:param url: Target Omnigent URL.
:param json: Request JSON body (EvaluationRequest).
:returns: HTTP response with EvaluationResponse.
"""
import httpx
posted["url"] = url
posted["json"] = json
return httpx.Response(
200,
text='{"result":"POLICY_ACTION_DENY","reason":"Sensitive data in output"}',
request=httpx.Request("POST", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FakeHttpxClient)
bridge_dir = prepare_bridge_dir(
"conv_abc",
bridge_id="bridge_shared",
workspace=tmp_path,
)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
)
payload = {
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_input": {"command": "cat /etc/passwd"},
"tool_output": "root:x:0:0:root:/root:/bin/bash",
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(
[
"evaluate-policy",
"--bridge-dir",
str(bridge_dir),
]
)
captured = capsys.readouterr()
assert exit_code == 0
# Verify the proto request is PHASE_TOOL_RESULT with request_data.
sent = posted["json"]
assert sent["event"]["type"] == "PHASE_TOOL_RESULT"
assert sent["event"]["data"]["result"] == "root:x:0:0:root:/root:/bin/bash"
assert sent["event"]["request_data"]["name"] == "Bash"
assert sent["event"]["request_data"]["arguments"] == {"command": "cat /etc/passwd"}
# PostToolUse DENY surfaces as additionalContext warning.
result = json.loads(captured.out)
assert "Policy violation" in result["hookSpecificOutput"]["additionalContext"]
assert "Sensitive data in output" in result["hookSpecificOutput"]["additionalContext"]
assert captured.err == ""
def test_ask_user_question_hook_noop_in_non_bypass_mode(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
``ask-user-question`` subcommand is a no-op when not in bypassPermissions mode.
In default / acceptEdits / plan modes the ``PermissionRequest`` hook fires
and owns the elicitation. The ``ask-user-question`` PreToolUse hook must
return empty output (no opinion) so the form is not shown twice.
This fails if the handler forwards the payload to Omnigent in non-bypass mode —
which would cause a duplicate elicitation card in the web UI and race for
the same answer.
"""
calls: list[str] = []
class _RaisesIfCalled:
"""HTTP client stub that fails the test if called unexpectedly."""
def __init__(self, **_kwargs: object) -> None:
"""
Record unexpected construction.
:param _kwargs: Ignored constructor args.
:returns: None.
"""
calls.append("constructed")
def __enter__(self) -> _RaisesIfCalled:
"""
Enter context — should not be reached.
:returns: self.
"""
return self
def __exit__(self, *_args: object) -> None:
"""
Exit context — should not be reached.
:param _args: Ignored exception args.
:returns: None.
"""
def post(self, *_args: object, **_kwargs: object) -> object:
"""
Fail if Omnigent is called — must not happen in non-bypass mode.
:param _args: Ignored.
:param _kwargs: Ignored.
:returns: Never.
:raises AssertionError: Always, so the test fails visibly.
"""
raise AssertionError(
"AP was called for ask-user-question in non-bypass mode — "
"PermissionRequest hook should own the elicitation instead"
)
monkeypatch.setattr(claude_native_hook.httpx, "Client", _RaisesIfCalled)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="b1", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_abc")
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
for mode in ("default", "acceptEdits", "plan", None):
payload: dict[str, object] = {
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": []},
}
if mode is not None:
payload["permission_mode"] = mode
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["ask-user-question", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
# No Omnigent call, no output — "no opinion" so PermissionRequest takes over.
assert exit_code == 0, f"Non-zero exit for mode={mode!r}"
assert captured.out == "", f"Unexpected output for mode={mode!r}: {captured.out!r}"
assert calls == [], f"AP client was constructed for mode={mode!r}"
def test_ask_user_question_hook_posts_and_returns_pre_tool_use_output_in_bypass_mode(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
In bypassPermissions mode the hook posts to Omnigent and returns PreToolUse output.
In bypass mode ``PermissionRequest`` never fires, so this PreToolUse hook
is the only opportunity to surface ``AskUserQuestion`` in the web UI. It
must POST the payload to the Omnigent session's permission-request endpoint, then
convert the ``PermissionRequest``-format response to ``PreToolUse`` format
(lifting ``decision.updatedInput`` to the top-level ``updatedInput`` field).
Fails if: Omnigent is not called in bypass mode, the URL targets the wrong session,
the response is not converted from PermissionRequest to PreToolUse format,
or the user's answers are not surfaced in ``updatedInput``.
"""
posted: dict[str, object] = {}
answers = {"q1": "Option A"}
server_response = {
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "allow",
"updatedInput": {
"questions": [{"question": "Pick one", "options": [{"label": "Option A"}]}],
"answers": answers,
},
},
}
}
class _FakeHttpxClient:
"""
Minimal sync HTTP client stub for the ask-user-question hook.
:param headers: Headers passed to :class:`httpx.Client`.
:param timeout: Timeout passed to :class:`httpx.Client`.
"""
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
"""
Capture constructor inputs.
:param headers: HTTP headers.
:param timeout: Request timeout.
:returns: None.
"""
posted["headers"] = headers
posted["timeout"] = timeout
def __enter__(self) -> _FakeHttpxClient:
"""
Enter context manager.
:returns: self.
"""
return self
def __exit__(self, *_args: object) -> None:
"""
Exit context manager.
:param _args: Ignored.
:returns: None.
"""
def post(self, url: str, *, json: dict[str, object]) -> object:
"""
Record the Omnigent request and return a canned PermissionRequest response.
:param url: Target URL.
:param json: Request body.
:returns: Fake HTTP response.
"""
import httpx as _httpx
posted["url"] = url
posted["json"] = json
import json as _json
return _httpx.Response(
200,
text=_json.dumps(server_response),
request=_httpx.Request("POST", url),
)
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FakeHttpxClient)
bridge_dir = prepare_bridge_dir("conv_bypass", bridge_id="b2", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_bypass")
build_hook_settings(
bridge_dir,
ap_server_url="http://127.0.0.1:8787",
ap_auth_headers={"Authorization": "Bearer token"},
)
payload = {
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {
"questions": [{"question": "Pick one", "options": [{"label": "Option A"}]}]
},
"permission_mode": "bypassPermissions",
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["ask-user-question", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
# Omnigent must be called with the active session's URL.
assert posted["url"] == (
"http://127.0.0.1:8787/v1/sessions/conv_bypass/hooks/permission-request"
)
# The full PreToolUse payload (including permission_mode) is
# forwarded verbatim, plus the minted re-attach id.
sent = posted["json"]
assert isinstance(sent, dict)
assert {k: v for k, v in sent.items() if k != "_omnigent_elicitation_id"} == payload
assert re.fullmatch(r"elicit_claude_[0-9a-f]{32}", sent["_omnigent_elicitation_id"])
# Auth headers from bridge config are forwarded.
assert posted["headers"] == {"Authorization": "Bearer token"}
# Output must be PreToolUse-format, NOT PermissionRequest-format.
result = json.loads(captured.out)
hs = result["hookSpecificOutput"]
assert hs["hookEventName"] == "PreToolUse", (
"Response was not converted from PermissionRequest to PreToolUse format"
)
assert hs["permissionDecision"] == "allow"
# User answers must be lifted into top-level updatedInput so Claude skips
# its TUI picker and uses the web form's selections.
assert hs["updatedInput"]["answers"] == answers, (
"User answers were not propagated in updatedInput — Claude will fall back "
"to its TUI picker and ignore the web form selection"
)
assert captured.err == ""
def test_ask_user_question_hook_returns_deny_without_updated_input(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
When the user denies AskUserQuestion in bypass mode, hook output is deny with no updatedInput.
A denial blocks the tool call entirely. There are no answers to inject, so
``updatedInput`` must be absent from the PreToolUse output.
Fails if ``updatedInput`` is included on a deny (which would produce a
malformed output and confuse Claude), or if the denial is not surfaced.
"""
server_response = {
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {"behavior": "deny"},
}
}
class _FakeHttpxClient:
"""Fake HTTP client returning a deny response."""
def __init__(self, **_kwargs: object) -> None:
"""
Accept constructor kwargs.
:param _kwargs: Ignored.
:returns: None.
"""
def __enter__(self) -> _FakeHttpxClient:
"""
Enter context.
:returns: self.
"""
return self
def __exit__(self, *_args: object) -> None:
"""
Exit context.
:param _args: Ignored.
:returns: None.
"""
def post(self, url: str, *, json: object) -> object:
"""
Return the canned deny response.
:param url: Ignored.
:param json: Ignored.
:returns: Fake HTTP response.
"""
import json as _json
import httpx as _httpx
return _httpx.Response(
200,
text=_json.dumps(server_response),
request=_httpx.Request("POST", url),
)
monkeypatch.setattr(claude_native_hook.httpx, "Client", _FakeHttpxClient)
bridge_dir = prepare_bridge_dir("conv_deny", bridge_id="b3", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_deny")
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
payload = {
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": []},
"permission_mode": "bypassPermissions",
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["ask-user-question", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
result = json.loads(captured.out)
hs = result["hookSpecificOutput"]
assert hs["hookEventName"] == "PreToolUse"
assert hs["permissionDecision"] == "deny"
# No updatedInput on deny — answers are meaningless when the tool is blocked.
assert "updatedInput" not in hs, (
"updatedInput must not appear on a deny response — there are no answers to inject"
)
@pytest.mark.parametrize("mode", ["connect_error", "non_2xx", "empty_body", "malformed_json"])
def test_evaluate_policy_pre_tool_use_fails_closed_when_verdict_unavailable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
mode: str,
) -> None:
"""
A governed PreToolUse call denies when no usable verdict is returned.
For native harnesses this hook is the sole TOOL_CALL enforcement point,
so a server outage / non-2xx / empty / malformed response must fail
CLOSED (deny) instead of "no opinion" — the bypass reported in #536.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", make_failing_client(mode))
monkeypatch.setattr(native_policy_hook, "_EVALUATE_POLICY_RETRY_BUDGET_S", 0.0)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_shared", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
payload = {
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "rm -rf /"},
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
result = json.loads(captured.out)
assert result["hookSpecificOutput"]["permissionDecision"] == "deny", result
assert result["hookSpecificOutput"]["permissionDecisionReason"]
def test_evaluate_policy_user_prompt_submit_fails_closed_on_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
A governed UserPromptSubmit blocks when no usable verdict is returned.
The request gate is the sole pre-turn enforcement point for native
sessions — a server outage must not let an over-budget or otherwise-
blocked request proceed. The output must be ``decision: "block"``.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", make_failing_client("connect_error"))
monkeypatch.setattr(native_policy_hook, "_EVALUATE_POLICY_RETRY_BUDGET_S", 0.0)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_shared", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
monkeypatch.setattr(
sys,
"stdin",
io.StringIO(json.dumps({"hook_event_name": "UserPromptSubmit", "prompt": "hello"})),
)
exit_code = claude_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
result = json.loads(captured.out)
assert result["decision"] == "block"
assert result["reason"]
def test_evaluate_policy_post_tool_use_fails_open_on_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
PostToolUse fails OPEN on a transport error — the tool already ran.
Mirroring the runner-side ``FAIL_CLOSED_PHASES``.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(claude_native_hook.httpx, "Client", make_failing_client("connect_error"))
monkeypatch.setattr(native_policy_hook, "_EVALUATE_POLICY_RETRY_BUDGET_S", 0.0)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_shared", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
payload = {
"hook_event_name": "PostToolUse",
"tool_name": "Bash",
"tool_input": {"command": "ls"},
"tool_output": "ok",
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
assert captured.out == ""
def test_build_hook_settings_omits_apikeyhelper_when_none(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A ``None`` api_key_helper writes no ``apiKeyHelper`` (the Bedrock path).
``ClaudeNativeUcodeConfig.api_key_helper`` is now Optional and the Bedrock
config returns ``None`` (Bedrock authenticates from AWS_BEARER_TOKEN_BEDROCK,
not an apiKeyHelper). The settings writer must omit the key for ``None`` and
never write the string ``"None"`` — a regression to an unconditional
assignment would also corrupt the existing key/gateway/local flows.
"""
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_test", workspace=tmp_path)
assert "apiKeyHelper" not in build_hook_settings(bridge_dir, api_key_helper=None)
with_helper = build_hook_settings(bridge_dir, api_key_helper="printf tok")
assert with_helper["apiKeyHelper"] == "printf tok"
def test_evaluate_policy_retries_5xx_and_succeeds(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
A transient 5xx from the policy server is retried and the eventual 200 is used.
Regression guard for DB-hosted deployments where brief server hiccups
previously caused a spurious fail-closed deny on every affected tool call.
"""
call_count = 0
class _FlakyThenOkClient:
def __init__(self, *, headers: object, timeout: object) -> None:
del headers, timeout
def __enter__(self) -> _FlakyThenOkClient:
return self
def __exit__(self, *args: object) -> None:
del args
def post(self, url: str, *, json: object = None) -> httpx.Response:
del json
nonlocal call_count
call_count += 1
req = httpx.Request("POST", url)
if call_count < 3:
return httpx.Response(503, text="upstream down", request=req)
return httpx.Response(
200,
text='{"result":"POLICY_ACTION_ALLOW"}',
request=req,
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
# Sleep is a no-op so retries are instant.
monkeypatch.setattr(native_policy_hook.time, "sleep", lambda _: None)
monkeypatch.setattr(native_policy_hook.httpx, "Client", _FlakyThenOkClient)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_shared", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(bridge_dir, ap_server_url="http://127.0.0.1:8787")
payload = {
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "ls"},
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
# ALLOW verdict → no hook output (hook defers to Claude's own permission system).
assert captured.out == ""
# Two 503s then one 200 = 3 total attempts.
assert call_count == 3
def test_evaluate_policy_reauths_on_expired_token_instead_of_failing_closed(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
An expired hook token self-heals: 302→/oidc re-mints and the tool is allowed.
End-to-end repro of the production bug — an "old" native session (token
past the ~1h Databricks OAuth lifetime) hits the Apps front-door
``302 → /oidc`` on every tool call and used to fail CLOSED ("policy
evaluation unavailable"). The hook must now re-mint through the token
factory and retry, returning the real ALLOW verdict (no deny output).
"""
attempts: list[dict[str, str]] = []
class _RedirectThenOkClient:
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
del timeout
self._headers = headers
def __enter__(self) -> _RedirectThenOkClient:
return self
def __exit__(self, *args: object) -> None:
del args
def post(self, url: str, *, json: object = None) -> httpx.Response:
del json
attempts.append(dict(self._headers))
req = httpx.Request("POST", url)
if len(attempts) == 1:
return httpx.Response(
302,
headers={"Location": "https://w.example.com/oidc/oauth2/v2.0/authorize"},
request=req,
)
return httpx.Response(200, text='{"result":"POLICY_ACTION_ALLOW"}', request=req)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(native_policy_hook.httpx, "Client", _RedirectThenOkClient)
# The hook re-mints through the runner's token factory; stub a fresh token.
monkeypatch.setattr(
"omnigent.runner._entry._make_auth_token_factory",
lambda server_url=None: lambda: "fresh-token",
)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_shared", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(
bridge_dir,
ap_server_url="https://omnigents.example.databricksapps.com",
ap_auth_headers={"Authorization": "Bearer stale-token", "X-Databricks-Org-Id": "o1"},
)
payload = {
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "ls"},
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
# Two attempts: first with the lapsed token, retry with the fresh one.
assert len(attempts) == 2
assert attempts[0]["Authorization"] == "Bearer stale-token"
assert attempts[1]["Authorization"] == "Bearer fresh-token"
# Routing header survives the re-mint.
assert attempts[1]["X-Databricks-Org-Id"] == "o1"
# ALLOW verdict → no hook output → the tool is NOT denied (no fail-closed).
assert captured.out == ""
assert "re-minted token and retrying" in captured.err
def test_evaluate_policy_reauths_on_403_invalid_token(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
A 403 "Invalid Token" self-heals: re-mints the bearer and the tool is allowed.
Databricks Apps returns 403 (not 401) for an expired bearer. End-to-end
regression guard for the fix that added 403 to the re-auth signal set.
The first attempt carries the stale token (403), the retry carries the
fresh token and gets the ALLOW verdict.
"""
attempts: list[dict[str, str]] = []
class _ForbiddenThenOkClient:
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
del timeout
self._headers = headers
def __enter__(self) -> _ForbiddenThenOkClient:
return self
def __exit__(self, *args: object) -> None:
del args
def post(self, url: str, *, json: object = None) -> httpx.Response:
del json
attempts.append(dict(self._headers))
req = httpx.Request("POST", url)
if len(attempts) == 1:
return httpx.Response(403, text="Invalid Token", request=req)
return httpx.Response(200, text='{"result":"POLICY_ACTION_ALLOW"}', request=req)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(native_policy_hook.httpx, "Client", _ForbiddenThenOkClient)
monkeypatch.setattr(
"omnigent.runner._entry._make_auth_token_factory",
lambda server_url=None: lambda: "fresh-token",
)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_shared", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(
bridge_dir,
ap_server_url="https://omnigents.example.databricksapps.com",
ap_auth_headers={"Authorization": "Bearer stale-token", "X-Databricks-Org-Id": "o1"},
)
monkeypatch.setattr(
sys,
"stdin",
io.StringIO(
json.dumps(
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "ls"},
}
)
),
)
exit_code = claude_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
assert len(attempts) == 2
assert attempts[0]["Authorization"] == "Bearer stale-token"
assert attempts[1]["Authorization"] == "Bearer fresh-token"
assert attempts[1]["X-Databricks-Org-Id"] == "o1"
assert captured.out == ""
assert "re-minted token and retrying" in captured.err
def test_evaluate_policy_fails_closed_when_reauth_unavailable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""
If the token can't be re-minted, the tool still fails CLOSED (safety net).
Re-auth is best-effort. When no refresh mechanism is available, the
authoritative PreToolUse gate must still DENY rather than let an
unevaluated tool through — preserving the fail-closed guarantee from #163.
"""
class _RedirectClient:
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
del headers, timeout
def __enter__(self) -> _RedirectClient:
return self
def __exit__(self, *args: object) -> None:
del args
def post(self, url: str, *, json: object = None) -> httpx.Response:
del json
return httpx.Response(
302,
headers={"Location": "https://w.example.com/oidc/x"},
request=httpx.Request("POST", url),
)
monkeypatch.setattr("omnigent.claude_native_bridge._TRUSTED_PARENT", tmp_path)
monkeypatch.setattr("omnigent.claude_native_bridge._BRIDGE_ROOT", tmp_path / "root")
monkeypatch.setattr(native_policy_hook.httpx, "Client", _RedirectClient)
monkeypatch.setattr(
"omnigent.runner._entry._make_auth_token_factory",
lambda server_url=None: None,
)
bridge_dir = prepare_bridge_dir("conv_abc", bridge_id="bridge_shared", workspace=tmp_path)
write_active_session_id(bridge_dir, "conv_active")
build_hook_settings(
bridge_dir,
ap_server_url="https://omnigents.example.databricksapps.com",
ap_auth_headers={"Authorization": "Bearer stale-token"},
)
payload = {
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {"command": "ls"},
}
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
exit_code = claude_native_hook.main(["evaluate-policy", "--bridge-dir", str(bridge_dir)])
captured = capsys.readouterr()
assert exit_code == 0
result = json.loads(captured.out)
assert result["hookSpecificOutput"]["permissionDecision"] == "deny"
assert result["hookSpecificOutput"]["permissionDecisionReason"].startswith(
native_policy_hook._EVAL_UNAVAILABLE_REASON
)
# ── #1782: bound the reattach spin-loop ────────────────────────────────────
#
# Before the fix, ``_post_hook_with_reattach`` re-POSTed on every 5xx/transport
# failure until a one-day wall-clock deadline. Against a persistently down
# server that meant re-driving the turn (and respawning harness/tool
# subprocesses) every <=30s for 24h — the spin half of the zombie pileup. The
# fix bounds CONSECUTIVE HARD failures (server down/sick), classified by
# EXCEPTION KIND + held time, while leaving a proxy-severed HELD poll (a slow
# human waiting) untouched. The Polly review flagged that a pure wall-clock
# threshold couldn't tell a 60s proxy-severed parked poll from a 60s connect
# failure — hence the kind-based classification exercised below.
def _scripted_client(
*,
script: list[tuple[str, float]],
monkeypatch: pytest.MonkeyPatch,
) -> type:
"""Build an httpx.Client stub whose POSTs fail per a scripted plan.
Each entry is ``(kind, held_s)``: ``kind`` selects the failure raised and
``held_s`` is how long that attempt "took" (advanced on a fake clock, so
tests run instantly). Past the end, the last entry repeats.
``kind`` values:
* ``"connect"`` — :class:`httpx.ConnectError` (server never reached: hard)
* ``"severed"`` — :class:`httpx.RemoteProtocolError` (established then
dropped: a held-poll sever iff ``held_s`` >= the floor)
* ``"5xx"`` — a 503 response (server sick: hard)
:param script: Per-attempt ``(kind, held_s)`` plan.
:param monkeypatch: Installs the fake clock + no-op sleep.
:returns: A drop-in ``httpx.Client`` class; ``.calls`` counts POSTs.
"""
clock = {"t": 0.0}
calls: list[str] = []
monkeypatch.setattr(claude_native_hook.time, "monotonic", lambda: clock["t"])
monkeypatch.setattr(claude_native_hook.time, "sleep", lambda _s: None)
class _ScriptedClient:
calls: list[str] = []
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
del headers, timeout
def __enter__(self) -> _ScriptedClient:
return self
def __exit__(self, *args: object) -> None:
del args
def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
del json
i = len(calls)
calls.append(url)
kind, held_s = script[i] if i < len(script) else script[-1]
clock["t"] += held_s # this attempt "took" held_s before failing
req = httpx.Request("POST", url)
if kind == "connect":
raise httpx.ConnectError("AP unreachable", request=req)
if kind == "severed":
raise httpx.RemoteProtocolError("server dropped the poll", request=req)
if kind == "5xx":
return httpx.Response(503, text="upstream down", request=req)
raise AssertionError(f"unknown scripted kind {kind!r}")
_ScriptedClient.calls = calls
return _ScriptedClient
def test_reattach_bounds_consecutive_hard_failures(monkeypatch: pytest.MonkeyPatch) -> None:
"""A down server no longer re-POSTs for a day (#1782 spin-loop).
Every attempt is an unreachable-server ``ConnectError``, so the loop must
give up after ``_PERMISSION_MAX_CONSECUTIVE_FAILURES`` attempts and return
``None`` (caller fails-ask) — not spin until the day-long budget.
"""
client = _scripted_client(script=[("connect", 0.0)], monkeypatch=monkeypatch)
monkeypatch.setattr(claude_native_hook.httpx, "Client", client)
resp = claude_native_hook._post_hook_with_reattach(
url="http://127.0.0.1:8787/v1/sessions/conv_x/hooks/permission-request",
headers={},
payload={"hook_event_name": "PreToolUse"},
hook_label="permission",
)
assert resp is None
assert len(client.calls) == claude_native_hook._PERMISSION_MAX_CONSECUTIVE_FAILURES, (
"reattach must stop after the consecutive-hard-failure cap, not spin"
)
def test_reattach_5xx_counts_as_hard_failure(monkeypatch: pytest.MonkeyPatch) -> None:
"""A server stuck returning 5xx is a hard failure and is bounded (#1782).
A sick server that keeps 500-ing is the spin just as much as an
unreachable one, so it must hit the same cap.
"""
client = _scripted_client(script=[("5xx", 0.0)], monkeypatch=monkeypatch)
monkeypatch.setattr(claude_native_hook.httpx, "Client", client)
resp = claude_native_hook._post_hook_with_reattach(
url="http://127.0.0.1:8787/v1/sessions/conv_x/hooks/permission-request",
headers={},
payload={"hook_event_name": "PreToolUse"},
hook_label="permission",
)
assert resp is None
assert len(client.calls) == claude_native_hook._PERMISSION_MAX_CONSECUTIVE_FAILURES
def test_reattach_cap_is_env_overridable(monkeypatch: pytest.MonkeyPatch) -> None:
"""``OMNIGENT_HOOK_MAX_RETRIES`` tunes the hard-failure cap.
Operators fronting a flaky proxy can widen the budget. Re-import the
module under the env override so the module-level constant is recomputed.
"""
import importlib
monkeypatch.setenv("OMNIGENT_HOOK_MAX_RETRIES", "3")
reloaded = importlib.reload(claude_native_hook)
try:
client = _scripted_client(script=[("connect", 0.0)], monkeypatch=monkeypatch)
monkeypatch.setattr(reloaded.httpx, "Client", client)
resp = reloaded._post_hook_with_reattach(
url="http://127.0.0.1:8787/v1/sessions/conv_x/hooks/permission-request",
headers={},
payload={"hook_event_name": "PreToolUse"},
hook_label="permission",
)
assert resp is None
assert reloaded._PERMISSION_MAX_CONSECUTIVE_FAILURES == 3
assert len(client.calls) == 3
finally:
# Restore the module for other tests (monkeypatch unsets the env var,
# but the reloaded constant would persist without this).
monkeypatch.delenv("OMNIGENT_HOOK_MAX_RETRIES", raising=False)
importlib.reload(claude_native_hook)
def test_reattach_bad_env_max_retries_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-integer ``OMNIGENT_HOOK_MAX_RETRIES`` must not crash the hook.
``int("banana")`` at import time would raise and defeat the terminal
fallback; the guarded parse keeps the default instead (#1782 review note).
"""
import importlib
monkeypatch.setenv("OMNIGENT_HOOK_MAX_RETRIES", "banana")
reloaded = importlib.reload(claude_native_hook)
try:
assert reloaded._PERMISSION_MAX_CONSECUTIVE_FAILURES == 8 # default preserved
finally:
monkeypatch.delenv("OMNIGENT_HOOK_MAX_RETRIES", raising=False)
importlib.reload(claude_native_hook)
def test_reattach_proxy_severed_held_poll_never_caps(monkeypatch: pytest.MonkeyPatch) -> None:
"""A proxy severing a HELD poll must never cap a slow human (#1782 review).
This is the exact scenario Polly flagged: a legitimately-parked approval
behind a proxy that severs the idle long-poll every ~60s. Each sever is an
established-then-dropped ``RemoteProtocolError`` held past the floor — a
held-poll sever, NOT a hard failure — so the counter resets every time and
the human is never fail-asked. We script far more severs than the cap and
assert the loop keeps retrying, then a real 2xx (the human answers) returns.
"""
cap = claude_native_hook._PERMISSION_MAX_CONSECUTIVE_FAILURES
held = claude_native_hook._PERMISSION_HELD_POLL_FLOOR_S + 50.0 # ~60s proxy idle
# 3x the cap in held-poll severs, then a success — if severs counted, it
# would have fail-asked long before reaching the success.
n_severs = cap * 3
clock = {"t": 0.0}
calls: list[str] = []
monkeypatch.setattr(claude_native_hook.time, "monotonic", lambda: clock["t"])
monkeypatch.setattr(claude_native_hook.time, "sleep", lambda _s: None)
class _SeverThenAnswerClient:
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
del headers, timeout
def __enter__(self) -> _SeverThenAnswerClient:
return self
def __exit__(self, *args: object) -> None:
del args
def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
del json
i = len(calls)
calls.append(url)
req = httpx.Request("POST", url)
if i < n_severs:
clock["t"] += held # poll was held for the proxy idle interval
raise httpx.RemoteProtocolError("proxy severed idle poll", request=req)
return httpx.Response(200, json={"ok": True}, request=req)
monkeypatch.setattr(claude_native_hook.httpx, "Client", _SeverThenAnswerClient)
resp = claude_native_hook._post_hook_with_reattach(
url="http://127.0.0.1:8787/v1/sessions/conv_x/hooks/permission-request",
headers={},
payload={"hook_event_name": "PreToolUse"},
hook_label="permission",
)
assert resp is not None and resp.status_code == 200, (
"a slow human behind a severing proxy was capped — the #1782 regression"
)
assert len(calls) == n_severs + 1 # retried through every sever, then answered
def test_reattach_fast_flapping_connection_is_hard_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An established connection that drops instantly is a flap, not a poll.
A crash-looping server that accepts then immediately resets the connection
raises the same ``RemoteProtocolError`` as a real held-poll sever — but
held for ~0s. The held-time floor classifies it as a hard failure so this
tight loop is still bounded (it must not masquerade as a parked poll).
"""
client = _scripted_client(script=[("severed", 0.0)], monkeypatch=monkeypatch)
monkeypatch.setattr(claude_native_hook.httpx, "Client", client)
resp = claude_native_hook._post_hook_with_reattach(
url="http://127.0.0.1:8787/v1/sessions/conv_x/hooks/permission-request",
headers={},
payload={"hook_event_name": "PreToolUse"},
hook_label="permission",
)
assert resp is None
assert len(client.calls) == claude_native_hook._PERMISSION_MAX_CONSECUTIVE_FAILURES, (
"an instant establish-drop flap must be bounded like any hard failure"
)
def test_held_poll_floor_is_env_overridable(monkeypatch: pytest.MonkeyPatch) -> None:
"""``OMNIGENT_HOOK_HELD_POLL_FLOOR_S`` tunes the flap-vs-held boundary.
Operators behind an aggressive proxy whose idle timeout is under the 10s
default can lower the floor so their legitimate slow-human severs stay
classified as held polls (reset), not flaps (counted) — closing the one
narrow human-capping edge. A malformed value falls back to the default.
"""
import importlib
monkeypatch.setenv("OMNIGENT_HOOK_HELD_POLL_FLOOR_S", "3.5")
reloaded = importlib.reload(claude_native_hook)
try:
assert reloaded._PERMISSION_HELD_POLL_FLOOR_S == 3.5
finally:
monkeypatch.delenv("OMNIGENT_HOOK_HELD_POLL_FLOOR_S", raising=False)
importlib.reload(claude_native_hook)
# Malformed / non-finite overrides must not crash the hook or silently
# disable flap detection — each falls back to the 10s default. "inf" would
# make every sever a held poll; "nan" makes held_s < floor always False.
for bad in ("not-a-number", "inf", "nan", "-inf"):
monkeypatch.setenv("OMNIGENT_HOOK_HELD_POLL_FLOOR_S", bad)
reloaded = importlib.reload(claude_native_hook)
try:
assert reloaded._PERMISSION_HELD_POLL_FLOOR_S == 10.0, (
f"bad floor {bad!r} not rejected"
)
finally:
monkeypatch.delenv("OMNIGENT_HOOK_HELD_POLL_FLOOR_S", raising=False)
importlib.reload(claude_native_hook)
def test_reattach_never_resolving_severs_are_bounded_by_deadline(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A sick backend behind a holding proxy still terminates (#1782 review).
The known residual: a dead backend behind a proxy that accepts then
silently severs a held connection (>= the floor) is transport-
indistinguishable from a proxy severing a genuinely-parked human poll, so
every sever RESETS the consecutive-hard-failure counter and the cap is
never reached. This must NOT be an infinite loop — the absolute
``_PERMISSION_TIMEOUT_S`` deadline has to bound it. Here every attempt is a
~15s held sever that never resolves; the loop must eventually return
``None`` (fail-ask) once the day-long budget elapses, not spin forever.
"""
# Each attempt: an established connection held ~15s (> floor) then severed,
# never a success. The counter resets every time, so only the deadline can
# stop it. Fake clock advances 15s per attempt + 30s backoff.
held = claude_native_hook._PERMISSION_HELD_POLL_FLOOR_S + 5.0 # 15s: a held sever
client = _scripted_client(script=[("severed", held)], monkeypatch=monkeypatch)
monkeypatch.setattr(claude_native_hook.httpx, "Client", client)
resp = claude_native_hook._post_hook_with_reattach(
url="http://127.0.0.1:8787/v1/sessions/conv_x/hooks/permission-request",
headers={},
payload={"hook_event_name": "PreToolUse"},
hook_label="permission",
)
assert resp is None, "a never-resolving held-sever spin must fail-ask, not loop forever"
# It resets the counter every time (never hits the cap of 8), so it ran far
# more than the cap and stopped only when the ~1-day deadline elapsed.
assert len(client.calls) > claude_native_hook._PERMISSION_MAX_CONSECUTIVE_FAILURES, (
"held severs must reset the cap; the deadline (not the cap) bounds this path"
)
# And it is genuinely bounded by the deadline. In this harness only post()
# advances the fake clock (by ``held``); the backoff sleep is a no-op, so
# the loop runs ~deadline/held times. (In production the real backoff sleep
# also elapses, so the real-world count is strictly lower.) Assert the
# count matches that ceiling — proving the deadline, not an accident, stops
# it — and stays comfortably below a runaway.
max_expected = claude_native_hook._PERMISSION_TIMEOUT_S / held + 2
assert len(client.calls) <= max_expected, "deadline did not bound the reset-forever path"
def test_reattach_returns_response_on_success(monkeypatch: pytest.MonkeyPatch) -> None:
"""A 2xx on the first try returns immediately (no regression).
The spin-loop bound must not perturb the happy path: one successful POST
returns its response without retry.
"""
monkeypatch.setattr(claude_native_hook.time, "sleep", lambda _s: None)
class _OkClient:
calls = 0
def __init__(self, *, headers: dict[str, str], timeout: object) -> None:
del headers, timeout
def __enter__(self) -> _OkClient:
return self
def __exit__(self, *args: object) -> None:
del args
def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
del json
type(self).calls += 1
return httpx.Response(200, json={"ok": True}, request=httpx.Request("POST", url))
monkeypatch.setattr(claude_native_hook.httpx, "Client", _OkClient)
resp = claude_native_hook._post_hook_with_reattach(
url="http://127.0.0.1:8787/v1/sessions/conv_x/hooks/permission-request",
headers={},
payload={"hook_event_name": "PreToolUse"},
hook_label="permission",
)
assert resp is not None
assert resp.status_code == 200
assert _OkClient.calls == 1