mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
feat: verify telegram owner during hot activation (#1157)
* feat(telegram): verify owner during hot activation * fix(ci): satisfy no-panics and clippy checks * fix(web): preserve relay activation status * fix(telegram): redact setup errors * fix(telegram): require owner verification code * fix(telegram): allow code in conversational dm
This commit is contained in:
+43
-2
@@ -39,6 +39,9 @@ except Exception:
|
||||
# Temp directory for the libSQL database file (cleaned up automatically)
|
||||
_DB_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-")
|
||||
|
||||
# Temp HOME so pairing/allowFrom state never touches the developer's real ~/.ironclaw
|
||||
_HOME_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-home-")
|
||||
|
||||
# Temp directories for WASM extensions. These start empty and are populated by
|
||||
# the install pipeline during tests; fixtures do not pre-populate dev build
|
||||
# artifacts into them.
|
||||
@@ -46,6 +49,42 @@ _WASM_TOOLS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-tools
|
||||
_WASM_CHANNELS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-channels-")
|
||||
|
||||
|
||||
def _latest_mtime(path: Path) -> float:
|
||||
"""Return the newest mtime under a file or directory."""
|
||||
if not path.exists():
|
||||
return 0.0
|
||||
if path.is_file():
|
||||
return path.stat().st_mtime
|
||||
|
||||
latest = path.stat().st_mtime
|
||||
for root, dirnames, filenames in os.walk(path):
|
||||
dirnames[:] = [dirname for dirname in dirnames if dirname != "target"]
|
||||
for name in filenames:
|
||||
child = Path(root) / name
|
||||
try:
|
||||
latest = max(latest, child.stat().st_mtime)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return latest
|
||||
|
||||
|
||||
def _binary_needs_rebuild(binary: Path) -> bool:
|
||||
"""Rebuild when the binary is missing or older than embedded sources."""
|
||||
if not binary.exists():
|
||||
return True
|
||||
|
||||
binary_mtime = binary.stat().st_mtime
|
||||
inputs = [
|
||||
ROOT / "Cargo.toml",
|
||||
ROOT / "Cargo.lock",
|
||||
ROOT / "build.rs",
|
||||
ROOT / "providers.json",
|
||||
ROOT / "src",
|
||||
ROOT / "channels-src",
|
||||
]
|
||||
return any(_latest_mtime(path) > binary_mtime for path in inputs)
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
"""Bind to port 0 and return the OS-assigned port."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
@@ -57,7 +96,7 @@ def _find_free_port() -> int:
|
||||
def ironclaw_binary():
|
||||
"""Ensure ironclaw binary is built. Returns the binary path."""
|
||||
binary = ROOT / "target" / "debug" / "ironclaw"
|
||||
if not binary.exists():
|
||||
if _binary_needs_rebuild(binary):
|
||||
print("Building ironclaw (this may take a while)...")
|
||||
subprocess.run(
|
||||
["cargo", "build", "--no-default-features", "--features", "libsql"],
|
||||
@@ -141,10 +180,12 @@ def _wasm_build_symlinks():
|
||||
async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
|
||||
"""Start the ironclaw gateway. Yields the base URL."""
|
||||
gateway_port = _find_free_port()
|
||||
home_dir = _HOME_TMPDIR.name
|
||||
env = {
|
||||
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
"HOME": os.environ.get("HOME", "/tmp"),
|
||||
"HOME": home_dir,
|
||||
"IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
|
||||
"RUST_LOG": "ironclaw=info",
|
||||
"RUST_BACKTRACE": "1",
|
||||
"GATEWAY_ENABLED": "true",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Telegram hot-activation UI coverage."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from helpers import SEL
|
||||
|
||||
_CONFIGURE_SECRET_INPUT = "input[type='password']"
|
||||
_CONFIGURE_SAVE_BUTTON = ".configure-actions button.btn-ext.activate"
|
||||
|
||||
|
||||
_TELEGRAM_INSTALLED = {
|
||||
"name": "telegram",
|
||||
"display_name": "Telegram",
|
||||
"kind": "wasm_channel",
|
||||
"description": "Telegram Bot API channel",
|
||||
"url": None,
|
||||
"active": False,
|
||||
"authenticated": False,
|
||||
"has_auth": False,
|
||||
"needs_setup": True,
|
||||
"tools": [],
|
||||
"activation_status": "installed",
|
||||
"activation_error": None,
|
||||
}
|
||||
|
||||
_TELEGRAM_ACTIVE = {
|
||||
**_TELEGRAM_INSTALLED,
|
||||
"active": True,
|
||||
"authenticated": True,
|
||||
"needs_setup": False,
|
||||
"activation_status": "active",
|
||||
}
|
||||
|
||||
|
||||
async def go_to_extensions(page):
|
||||
await page.locator(SEL["tab_button"].format(tab="extensions")).click()
|
||||
await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for(
|
||||
state="visible", timeout=5000
|
||||
)
|
||||
await page.locator(
|
||||
f"{SEL['extensions_list']} .empty-state, {SEL['ext_card_installed']}"
|
||||
).first.wait_for(state="visible", timeout=8000)
|
||||
|
||||
|
||||
async def mock_extension_lists(page, ext_handler):
|
||||
async def handle_ext_list(route):
|
||||
path = route.request.url.split("?")[0]
|
||||
if path.endswith("/api/extensions"):
|
||||
await ext_handler(route)
|
||||
else:
|
||||
await route.continue_()
|
||||
|
||||
async def handle_tools(route):
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"tools": []}),
|
||||
)
|
||||
|
||||
async def handle_registry(route):
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"entries": []}),
|
||||
)
|
||||
|
||||
# Register the broad route first so the specific endpoints below win.
|
||||
await page.route("**/api/extensions*", handle_ext_list)
|
||||
await page.route("**/api/extensions/tools", handle_tools)
|
||||
await page.route("**/api/extensions/registry", handle_registry)
|
||||
|
||||
|
||||
async def wait_for_toast(page, text: str, *, timeout: int = 5000):
|
||||
await page.locator(SEL["toast"], has_text=text).wait_for(
|
||||
state="visible", timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
async def test_telegram_setup_modal_shows_bot_token_field(page):
|
||||
async def handle_ext_list(route):
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"extensions": [_TELEGRAM_INSTALLED]}),
|
||||
)
|
||||
|
||||
async def handle_setup(route):
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"secrets": [
|
||||
{
|
||||
"name": "telegram_bot_token",
|
||||
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||
"provided": False,
|
||||
"optional": False,
|
||||
"auto_generate": False,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
await mock_extension_lists(page, handle_ext_list)
|
||||
await page.route("**/api/extensions/telegram/setup", handle_setup)
|
||||
await go_to_extensions(page)
|
||||
|
||||
card = page.locator(SEL["ext_card_installed"]).first
|
||||
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
|
||||
|
||||
modal = page.locator(SEL["configure_modal"])
|
||||
await modal.wait_for(state="visible", timeout=5000)
|
||||
assert "Telegram Bot API token" in await modal.text_content()
|
||||
assert "IronClaw will show a one-time code" in (
|
||||
await modal.text_content()
|
||||
)
|
||||
input_el = modal.locator(_CONFIGURE_SECRET_INPUT)
|
||||
assert await input_el.count() == 1
|
||||
|
||||
|
||||
async def test_telegram_hot_activation_transitions_installed_to_active(page):
|
||||
phase = {"value": "installed"}
|
||||
captured_setup_payloads = []
|
||||
post_count = {"value": 0}
|
||||
|
||||
async def handle_ext_list(route):
|
||||
extensions = {
|
||||
"installed": [_TELEGRAM_INSTALLED],
|
||||
"active": [_TELEGRAM_ACTIVE],
|
||||
}[phase["value"]]
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"extensions": extensions}),
|
||||
)
|
||||
|
||||
async def handle_setup(route):
|
||||
if route.request.method == "GET":
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"secrets": [
|
||||
{
|
||||
"name": "telegram_bot_token",
|
||||
"prompt": "Enter your Telegram Bot API token (from @BotFather)",
|
||||
"provided": False,
|
||||
"optional": False,
|
||||
"auto_generate": False,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
payload = json.loads(route.request.post_data or "{}")
|
||||
captured_setup_payloads.append(payload)
|
||||
post_count["value"] += 1
|
||||
await asyncio.sleep(0.05)
|
||||
if post_count["value"] == 1:
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"activated": False,
|
||||
"message": "Configuration saved for 'telegram'. Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner.",
|
||||
"verification": {
|
||||
"code": "iclaw-7qk2m9",
|
||||
"instructions": "Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner.",
|
||||
"deep_link": "https://t.me/test_hot_bot?start=iclaw-7qk2m9",
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
else:
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"activated": True,
|
||||
"message": "Configuration saved, Telegram owner verified, and 'telegram' activated. Hot-activated WASM channel",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
await mock_extension_lists(page, handle_ext_list)
|
||||
await page.route("**/api/extensions/telegram/setup", handle_setup)
|
||||
await go_to_extensions(page)
|
||||
|
||||
card = page.locator(SEL["ext_card_installed"]).first
|
||||
await card.locator(SEL["ext_configure_btn"], has_text="Setup").click()
|
||||
|
||||
modal = page.locator(SEL["configure_modal"])
|
||||
await modal.wait_for(state="visible", timeout=5000)
|
||||
await modal.locator(_CONFIGURE_SECRET_INPUT).fill("123456789:ABCdefGhI")
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON).click()
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON, has_text="Verify owner").wait_for(
|
||||
state="visible", timeout=5000
|
||||
)
|
||||
assert "Verify owner" in (
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON).text_content()
|
||||
)
|
||||
assert "iclaw-7qk2m9" in (await modal.text_content())
|
||||
assert await modal.locator(".configure-verification-link").count() == 1
|
||||
|
||||
await modal.locator(_CONFIGURE_SAVE_BUTTON).click()
|
||||
await page.locator(SEL["configure_overlay"]).wait_for(state="hidden", timeout=5000)
|
||||
|
||||
phase["value"] = "active"
|
||||
await page.evaluate(
|
||||
"""
|
||||
handleAuthCompleted({
|
||||
extension_name: 'telegram',
|
||||
success: true,
|
||||
message: "Configuration saved, Telegram owner verified, and 'telegram' activated. Hot-activated WASM channel",
|
||||
});
|
||||
"""
|
||||
)
|
||||
|
||||
await wait_for_toast(page, "Telegram owner verified")
|
||||
await card.locator(SEL["ext_active_label"]).wait_for(state="visible", timeout=5000)
|
||||
assert await card.locator(SEL["ext_pairing_label"]).count() == 0
|
||||
|
||||
assert captured_setup_payloads == [
|
||||
{"secrets": {"telegram_bot_token": "123456789:ABCdefGhI"}},
|
||||
{"secrets": {}},
|
||||
]
|
||||
@@ -40,8 +40,31 @@ macro_rules! require_telegram_wasm {
|
||||
|
||||
/// Path to the built Telegram WASM module
|
||||
fn telegram_wasm_path() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm")
|
||||
let local = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm");
|
||||
if local.exists() {
|
||||
return local;
|
||||
}
|
||||
|
||||
if let Ok(output) = std::process::Command::new("git")
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
&& output.status.success()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if let Some(path) = line.strip_prefix("worktree ") {
|
||||
let candidate = std::path::PathBuf::from(path).join(
|
||||
"channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm",
|
||||
);
|
||||
if candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
local
|
||||
}
|
||||
|
||||
/// Create a test runtime for WASM channel operations.
|
||||
|
||||
Reference in New Issue
Block a user