Refactor owner scope across channels and fix default routing fallback (#1151)

* refactor: add explicit owner scope across channels

* fix: tighten routine owner target routing

* fix: address owner scope review feedback

* Fix owner-scope onboarding and event trigger isolation

* Tighten routing fallback and wizard owner validation

* fix: address owner-scope follow-up review

* fix: tighten owner-scope follow-up details

* fix: import Channel trait in telegram test

* fix: normalize http webhook sender ids

* fix: address remaining owner-scope review issues

* fix: reconcile config rebase fallout

* fix: reconcile extension manager rebase drift

* fix: address current copilot review regressions

* fix: restore clippy matrix after rebase
This commit is contained in:
Henry Park
2026-03-16 13:31:03 -07:00
committed by GitHub
parent 971b4c2ef4
commit 878a67cdb6
50 changed files with 2767 additions and 1071 deletions
+61 -4
View File
@@ -15,7 +15,13 @@ from pathlib import Path
import pytest
from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready
from helpers import (
AUTH_TOKEN,
HTTP_WEBHOOK_SECRET,
OWNER_SCOPE_ID,
wait_for_port_line,
wait_for_ready,
)
# Project root (two levels up from tests/e2e/)
ROOT = Path(__file__).resolve().parent.parent.parent
@@ -92,6 +98,21 @@ def _find_free_port() -> int:
return s.getsockname()[1]
def _reserve_loopback_sockets(count: int) -> list[socket.socket]:
"""Bind loopback sockets and keep them open until the server starts."""
sockets: list[socket.socket] = []
try:
while len(sockets) < count:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
sockets.append(sock)
return sockets
except Exception:
for sock in sockets:
sock.close()
raise
@pytest.fixture(scope="session")
def ironclaw_binary():
"""Ensure ironclaw binary is built. Returns the binary path."""
@@ -108,6 +129,21 @@ def ironclaw_binary():
return str(binary)
@pytest.fixture(scope="session")
def server_ports():
"""Reserve dynamic ports for the gateway and HTTP webhook channel."""
reserved = _reserve_loopback_sockets(2)
try:
yield {
"gateway": reserved[0].getsockname()[1],
"http": reserved[1].getsockname()[1],
"sockets": reserved,
}
finally:
for sock in reserved:
sock.close()
@pytest.fixture(scope="session")
async def mock_llm_server():
"""Start the mock LLM server. Yields the base URL."""
@@ -177,10 +213,19 @@ def _wasm_build_symlinks():
@pytest.fixture(scope="session")
async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
async def ironclaw_server(
ironclaw_binary,
mock_llm_server,
wasm_tools_dir,
server_ports,
):
"""Start the ironclaw gateway. Yields the base URL."""
gateway_port = _find_free_port()
home_dir = _HOME_TMPDIR.name
gateway_port = server_ports["gateway"]
http_port = server_ports["http"]
for sock in server_ports["sockets"]:
if sock.fileno() != -1:
sock.close()
env = {
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
@@ -188,11 +233,15 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
"IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
"RUST_LOG": "ironclaw=info",
"RUST_BACKTRACE": "1",
"IRONCLAW_OWNER_ID": OWNER_SCOPE_ID,
"GATEWAY_ENABLED": "true",
"GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
"GATEWAY_USER_ID": "e2e-tester",
"GATEWAY_USER_ID": "e2e-web-sender",
"HTTP_HOST": "127.0.0.1",
"HTTP_PORT": str(http_port),
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
"CLI_ENABLED": "false",
"LLM_BACKEND": "openai_compatible",
"LLM_BASE_URL": mock_llm_server,
@@ -261,6 +310,14 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
proc.kill()
@pytest.fixture(scope="session")
async def http_channel_server(ironclaw_server, server_ports):
"""HTTP webhook channel base URL."""
base_url = f"http://127.0.0.1:{server_ports['http']}"
await wait_for_ready(f"{base_url}/health", timeout=30)
return base_url
@pytest.fixture(scope="session")
async def ironclaw_server_with_webhook_secret(ironclaw_binary, mock_llm_server, wasm_tools_dir):
"""Start ironclaw with HTTP_WEBHOOK_SECRET configured for webhook tests.
+24
View File
@@ -1,6 +1,8 @@
"""Shared helpers for E2E tests."""
import asyncio
import hashlib
import hmac
import re
import time
@@ -95,12 +97,21 @@ SEL = {
"toast_success": ".toast.toast-success",
"toast_error": ".toast.toast-error",
"toast_info": ".toast.toast-info",
# Jobs / routines
"jobs_tbody": "#jobs-tbody",
"job_row": "#jobs-tbody .job-row",
"jobs_empty": "#jobs-empty",
"routines_tbody": "#routines-tbody",
"routine_row": "#routines-tbody .routine-row",
"routines_empty": "#routines-empty",
}
TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
# Auth token used across all tests
AUTH_TOKEN = "e2e-test-token"
OWNER_SCOPE_ID = "e2e-owner-scope"
HTTP_WEBHOOK_SECRET = "e2e-http-webhook-secret"
async def wait_for_ready(url: str, *, timeout: float = 60, interval: float = 0.5):
@@ -162,3 +173,16 @@ async def api_post(base_url: str, path: str, **kwargs) -> httpx.Response:
timeout=kwargs.pop("timeout", 10),
**kwargs,
)
def signed_http_webhook_headers(body: bytes) -> dict[str, str]:
"""Return headers for the owner-scoped HTTP webhook channel."""
digest = hmac.new(
HTTP_WEBHOOK_SECRET.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return {
"Content-Type": "application/json",
"X-Hub-Signature-256": f"sha256={digest}",
}
+34
View File
@@ -26,6 +26,40 @@ DEFAULT_RESPONSE = "I understand your request."
TOOL_CALL_PATTERNS = [
(re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}),
(re.compile(r"what time|current time", re.IGNORECASE), "time", lambda _: {"operation": "now"}),
(
re.compile(
r"create lightweight owner routine (?P<name>[a-z0-9][a-z0-9_-]*)",
re.IGNORECASE,
),
"routine_create",
lambda m: {
"name": m.group("name"),
"description": f"Owner-scope routine {m.group('name')}",
"trigger_type": "manual",
"prompt": f"Confirm that {m.group('name')} executed.",
"action_type": "lightweight",
"use_tools": False,
},
),
(
re.compile(
r"create full[- ]job owner routine (?P<name>[a-z0-9][a-z0-9_-]*)",
re.IGNORECASE,
),
"routine_create",
lambda m: {
"name": m.group("name"),
"description": f"Owner-scope full-job routine {m.group('name')}",
"trigger_type": "manual",
"prompt": f"Complete the routine job for {m.group('name')}.",
"action_type": "full_job",
},
),
(
re.compile(r"list owner routines", re.IGNORECASE),
"routine_list",
lambda _: {},
),
]
+226
View File
@@ -0,0 +1,226 @@
"""Owner-scope end-to-end scenarios.
These tests exercise the explicit owner model across:
- the web gateway chat UI
- the owner-scoped HTTP webhook channel
- routine tools / routines tab
- job creation via routine execution / jobs tab
"""
import asyncio
import json
import uuid
import httpx
from helpers import SEL, AUTH_TOKEN, signed_http_webhook_headers
async def _send_and_get_response(
page,
message: str,
*,
expected_fragment: str,
timeout: int = 30000,
) -> str:
"""Send a chat message and return the newest assistant response text."""
chat_input = page.locator(SEL["chat_input"])
await chat_input.wait_for(state="visible", timeout=5000)
assistant_sel = SEL["message_assistant"]
before_count = await page.locator(assistant_sel).count()
await chat_input.fill(message)
await chat_input.press("Enter")
expected = before_count + 1
await page.wait_for_function(
"""({ assistantSelector, expectedCount, expectedFragment }) => {
const messages = document.querySelectorAll(assistantSelector);
if (messages.length < expectedCount) return false;
const text = (messages[messages.length - 1].innerText || '').trim().toLowerCase();
return text.includes(expectedFragment.toLowerCase());
}""",
arg={
"assistantSelector": assistant_sel,
"expectedCount": expected,
"expectedFragment": expected_fragment,
},
timeout=timeout,
)
return await page.locator(assistant_sel).last.inner_text()
async def _post_http_webhook(
http_channel_server: str,
*,
content: str,
sender_id: str,
thread_id: str,
) -> str:
"""Send a signed request to the owner-scoped HTTP webhook channel."""
payload = {
"user_id": sender_id,
"thread_id": thread_id,
"content": content,
"wait_for_response": True,
}
body = json.dumps(payload).encode("utf-8")
async with httpx.AsyncClient() as client:
response = await client.post(
f"{http_channel_server}/webhook",
content=body,
headers=signed_http_webhook_headers(body),
timeout=90,
)
assert response.status_code == 200, (
f"HTTP webhook failed: {response.status_code} {response.text[:400]}"
)
data = response.json()
assert data["status"] == "accepted", f"Unexpected webhook response: {data}"
assert data["response"], f"Expected synchronous response body, got: {data}"
return data["response"]
async def _open_tab(page, tab: str) -> None:
btn = page.locator(SEL["tab_button"].format(tab=tab))
await btn.click()
await page.locator(SEL["tab_panel"].format(tab=tab)).wait_for(
state="visible",
timeout=5000,
)
async def _wait_for_routine(base_url: str, name: str, timeout: float = 20.0) -> dict:
"""Poll the routines API until the named routine exists."""
async with httpx.AsyncClient() as client:
for _ in range(int(timeout * 2)):
response = await client.get(
f"{base_url}/api/routines",
headers={"Authorization": f"Bearer {AUTH_TOKEN}"},
timeout=10,
)
response.raise_for_status()
routines = response.json()["routines"]
for routine in routines:
if routine["name"] == name:
return routine
await _poll_sleep()
raise AssertionError(f"Routine '{name}' was not created within {timeout}s")
async def _wait_for_job(base_url: str, title: str, timeout: float = 30.0) -> dict:
"""Poll the jobs API until the named job exists."""
async with httpx.AsyncClient() as client:
for _ in range(int(timeout * 2)):
response = await client.get(
f"{base_url}/api/jobs",
headers={"Authorization": f"Bearer {AUTH_TOKEN}"},
timeout=10,
)
response.raise_for_status()
jobs = response.json()["jobs"]
for job in jobs:
if job["title"] == title:
return job
await _poll_sleep()
raise AssertionError(f"Job '{title}' was not created within {timeout}s")
async def _poll_sleep() -> None:
"""Small shared backoff for API polling loops."""
await asyncio.sleep(0.5)
async def test_http_channel_created_routine_is_visible_in_web_routines_tab(
page,
ironclaw_server,
http_channel_server,
):
"""A routine created from the HTTP channel is visible in the web owner UI."""
routine_name = f"owner-http-{uuid.uuid4().hex[:8]}"
response_text = await _post_http_webhook(
http_channel_server,
content=f"create lightweight owner routine {routine_name}",
sender_id="external-sender-alpha",
thread_id="http-owner-routine-thread",
)
assert routine_name in response_text
await _wait_for_routine(ironclaw_server, routine_name)
await _open_tab(page, "routines")
await page.locator(SEL["routine_row"]).filter(has_text=routine_name).first.wait_for(
state="visible",
timeout=15000,
)
async def test_web_created_routine_is_listed_from_http_channel_across_senders(
page,
ironclaw_server,
http_channel_server,
):
"""Routines created in web chat remain owner-global across HTTP senders/threads."""
routine_name = f"owner-web-{uuid.uuid4().hex[:8]}"
assistant_text = await _send_and_get_response(
page,
f"create lightweight owner routine {routine_name}",
expected_fragment=routine_name,
)
assert routine_name in assistant_text
await _wait_for_routine(ironclaw_server, routine_name)
first_sender_text = await _post_http_webhook(
http_channel_server,
content="list owner routines",
sender_id="http-sender-one",
thread_id="owner-list-thread-a",
)
second_sender_text = await _post_http_webhook(
http_channel_server,
content="list owner routines",
sender_id="http-sender-two",
thread_id="owner-list-thread-b",
)
assert routine_name in first_sender_text, first_sender_text
assert routine_name in second_sender_text, second_sender_text
async def test_http_created_full_job_routine_can_be_run_from_web_and_shows_in_jobs(
page,
ironclaw_server,
http_channel_server,
):
"""A full-job routine created via HTTP can be run from the web UI and create a job."""
routine_name = f"owner-job-{uuid.uuid4().hex[:8]}"
response_text = await _post_http_webhook(
http_channel_server,
content=f"create full-job owner routine {routine_name}",
sender_id="http-job-sender",
thread_id="owner-job-thread",
)
assert routine_name in response_text
await _wait_for_routine(ironclaw_server, routine_name)
await _open_tab(page, "routines")
routine_row = page.locator(SEL["routine_row"]).filter(has_text=routine_name).first
await routine_row.wait_for(state="visible", timeout=15000)
await routine_row.locator('button[data-action="trigger-routine"]').click()
await _wait_for_job(ironclaw_server, routine_name, timeout=45.0)
await _open_tab(page, "jobs")
await page.locator(SEL["job_row"]).filter(has_text=routine_name).first.wait_for(
state="visible",
timeout=20000,
)