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:
Henry Park
2026-03-16 08:07:45 -07:00
committed by GitHub
parent 946c040fff
commit 63a23550d6
17 changed files with 2103 additions and 149 deletions
+43 -2
View File
@@ -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": {}},
]
+25 -2
View File
@@ -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.