use std::time::Instant;
use agent_first_ui::{UiDeliveryMode, UiUpstream};
use serde::Serialize;
use crate::cli::connect::Connection;
use crate::cli::output;
use crate::sdk::takeover::TakeoverUiSession;
use crate::shared::error::{Error, ErrorCode};
#[derive(Debug)]
pub struct Args {
pub sub: UiSub,
pub delivery: UiDeliveryMode,
}
#[derive(Debug)]
pub enum UiSub {
Takeover(TakeoverArgs),
}
#[derive(Debug)]
pub enum TakeoverArgs {
Mint { connection: Connection },
Open { takeover_url_secret: String },
}
#[derive(Serialize)]
struct UiReady {
panel_url: String,
session: &'static str,
}
#[derive(Serialize)]
struct UiTakeoverResult {
panel_url: String,
session: &'static str,
mode: &'static str,
outcome: &'static str,
open_s: u64,
}
const SESSION_KIND: &str = "watch";
fn ready_event<T: Serialize>(
facts: &agent_first_ui::UiDeliveryFacts,
own: T,
) -> Result<serde_json::Value, Error> {
let own = serde_json::to_value(own).map_err(|error| {
Error::new(
ErrorCode::InternalError,
format!("build takeover UI readiness event: {error}"),
)
})?;
Ok(agent_first_ui::cli::ready_event_revealing_link(facts, own))
}
const PROVIDER_ID: &str = "afhttp";
const UI_KIND: &str = "takeover";
pub async fn run(args: Args) -> Result<(), Error> {
match args.sub {
UiSub::Takeover(takeover) => takeover_run(takeover, args.delivery).await,
}
}
async fn takeover_run(args: TakeoverArgs, delivery: UiDeliveryMode) -> Result<(), Error> {
let panel = resolve_panel(args).await?;
let panel_url = credential_free_panel_url(panel.lease.takeover_url_secret())?;
if let Err(reason) = window_only_upstream(&panel_url, delivery) {
let _revoked = panel.lease.revoke().await;
return Err(reason);
}
let upstream = UiUpstream::new(PROVIDER_ID, UI_KIND, panel.lease.takeover_url_secret())
.map_err(delivery_error)?
.with_subject(&panel_url);
let active = crate::cli::spec::PANEL_DELIVERY
.resolve(Some(delivery))
.map_err(delivery_error)?
.start_upstream(upstream)
.await
.map_err(delivery_error)?;
let ready = ready_event(
&active.facts(),
UiReady {
panel_url: panel_url.clone(),
session: SESSION_KIND,
},
)?;
output::emit_progress("ui_takeover", &ready)?;
let opened_at = Instant::now();
let ended = {
let delivery_wait = active.wait();
let keep_alive = panel.lease.keep_alive();
tokio::pin!(delivery_wait);
tokio::pin!(keep_alive);
tokio::select! {
result = &mut delivery_wait => result
.map(|outcome| outcome.ending())
.map_err(delivery_error),
() = stop_requested() => Ok("stopped"),
result = &mut keep_alive => match result {
Err(error) => Err(error),
Ok(()) => Err(Error::new(
ErrorCode::InternalError,
"takeover UI session keep-alive ended unexpectedly",
)),
},
}
};
let revoked = panel.lease.revoke().await;
let outcome = ended?;
revoked?;
output::emit(
"ui_takeover",
&UiTakeoverResult {
panel_url,
session: SESSION_KIND,
mode: delivery.as_str(),
outcome,
open_s: opened_at.elapsed().as_secs(),
},
)
}
fn window_only_upstream(panel_url: &str, delivery: UiDeliveryMode) -> Result<(), Error> {
if delivery == UiDeliveryMode::Window || !panel_url.starts_with("https://") {
return Ok(());
}
Err(Error::new(
ErrorCode::InvalidEndpoint,
format!(
"`{}` delivery cannot proxy the https panel at {panel_url}; use `--mode window`, \
which opens it directly and needs no proxy",
delivery.as_str()
),
))
}
fn delivery_error(error: agent_first_ui::Error) -> Error {
use agent_first_ui::UiErrorKind;
let error_code = match error.kind() {
UiErrorKind::WindowUnavailable | UiErrorKind::WindowWaitFailed => {
ErrorCode::BrowserLaunchFailed
}
UiErrorKind::InvalidArgument | UiErrorKind::UpstreamNotProxyable => {
ErrorCode::InvalidEndpoint
}
UiErrorKind::DeliveryModeInvalid
| UiErrorKind::DeliveryModeNotOffered
| UiErrorKind::LinkAddressUnavailable => ErrorCode::InvalidEndpoint,
UiErrorKind::FrontendUnreadable
| UiErrorKind::FrontendIncompatible
| UiErrorKind::FrontendUnsafe
| UiErrorKind::PageRender
| UiErrorKind::PageIncomplete
| UiErrorKind::RuntimeMisconfigured
| UiErrorKind::RuntimeClosed
| UiErrorKind::RuntimeBusy
| UiErrorKind::RuntimeMessageTooLarge
| UiErrorKind::RuntimeBlob
| UiErrorKind::RuntimePayload
| UiErrorKind::ConfigUnreadable
| UiErrorKind::Io => ErrorCode::InternalError,
};
Error::new(error_code, error.to_string())
}
async fn stop_requested() {
let interrupt = async {
let _ignored = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut signal) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
signal.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = interrupt => {},
() = terminate => {},
}
}
struct Panel {
lease: TakeoverUiSession,
}
async fn resolve_panel(args: TakeoverArgs) -> Result<Panel, Error> {
match args {
TakeoverArgs::Open {
takeover_url_secret,
} => Ok(Panel {
lease: TakeoverUiSession::exchange(&takeover_url_secret).await?,
}),
TakeoverArgs::Mint { connection } => {
let client = connection.client().await?;
Ok(Panel {
lease: client.takeover_ui_session().await?,
})
}
}
}
fn credential_free_panel_url(url: &str) -> Result<String, Error> {
let mut panel = url::Url::parse(url).map_err(|error| {
Error::new(
ErrorCode::InvalidEndpoint,
format!("--takeover-url-secret is not a valid URL: {error}"),
)
})?;
panel.set_query(None);
panel.set_fragment(None);
Ok(panel.to_string())
}
#[cfg(test)]
mod tests {
use agent_first_ui::UiAttentionPolicy;
use super::*;
#[test]
fn the_reported_panel_url_drops_the_credential() {
let panel = credential_free_panel_url(
"http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef#frag",
)
.unwrap();
assert_eq!(panel, "http://127.0.0.1:9222/takeover/panel");
}
#[test]
fn a_non_url_panel_is_an_argument_error_not_a_launch() {
let error = credential_free_panel_url("not a url").unwrap_err();
assert_eq!(error.error_code, ErrorCode::InvalidEndpoint);
}
const SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn ready_event(delivery: UiDeliveryMode, panel: &str) -> serde_json::Value {
let attention = (delivery == UiDeliveryMode::Link).then(UiAttentionPolicy::default);
let facts = agent_first_ui::UiDeliveryFacts {
mode: delivery,
session_id: "aabbccdd".to_string(),
link_url_secret: (delivery == UiDeliveryMode::Link)
.then(|| "http://192.168.1.20:9888/afui-capability/".to_string()),
idle_timeout_s: attention
.and_then(|policy| policy.idle_timeout())
.map(|duration| duration.as_secs()),
grace_period_s: attention.map(|policy| policy.grace_period().as_secs()),
};
super::ready_event(
&facts,
UiReady {
panel_url: panel.to_string(),
session: SESSION_KIND,
},
)
.unwrap()
}
#[test]
fn no_delivery_event_carries_the_upstream_secret() {
let url = format!("http://127.0.0.1:9222/takeover/panel?handoff_secret={SECRET}");
let panel = credential_free_panel_url(&url).unwrap();
for delivery in [
UiDeliveryMode::Window,
UiDeliveryMode::Link,
UiDeliveryMode::Session,
] {
let ready = serde_json::to_string(&ready_event(delivery, &panel)).unwrap();
let done = serde_json::to_string(&UiTakeoverResult {
panel_url: panel.clone(),
session: SESSION_KIND,
mode: delivery.as_str(),
outcome: "expired",
open_s: 12,
})
.unwrap();
assert!(!ready.contains(SECRET), "{ready}");
assert!(!ready.contains("handoff_secret"), "{ready}");
assert!(!done.contains(SECRET), "{done}");
assert!(!done.contains("handoff_secret"), "{done}");
}
}
#[test]
fn a_link_carries_only_the_afui_url() {
let value = serde_json::to_value(ready_event(
UiDeliveryMode::Link,
"http://127.0.0.1:9222/takeover/panel",
))
.unwrap();
assert_eq!(
value[agent_first_ui::cli::LINK_URL_FIELD],
serde_json::Value::String("http://192.168.1.20:9888/afui-capability/".to_string())
);
assert!(value.get("link_url_secret").is_none(), "{value}");
assert!(value.get("takeover_url_secret").is_none());
let policy = UiAttentionPolicy::default();
assert_eq!(
value["idle_timeout_s"],
policy.idle_timeout().unwrap().as_secs()
);
assert_eq!(value["grace_period_s"], policy.grace_period().as_secs());
}
#[test]
fn an_https_panel_is_refused_before_a_proxying_delivery_starts() {
for delivery in [UiDeliveryMode::Link, UiDeliveryMode::Session] {
let refused = window_only_upstream("https://takeover.example/takeover/panel", delivery)
.expect_err("a proxying delivery cannot reach an https panel");
assert_eq!(refused.error_code, ErrorCode::InvalidEndpoint);
assert!(refused.detail.contains("--mode window"), "{refused:?}");
}
assert!(
window_only_upstream(
"https://takeover.example/takeover/panel",
UiDeliveryMode::Window
)
.is_ok()
);
for delivery in [
UiDeliveryMode::Window,
UiDeliveryMode::Link,
UiDeliveryMode::Session,
] {
assert!(window_only_upstream("http://127.0.0.1:9222/takeover/panel", delivery).is_ok());
}
}
#[test]
fn the_panel_is_announced_under_identifiers_afui_accepts() {
let announced = UiUpstream::new(
PROVIDER_ID,
UI_KIND,
"http://127.0.0.1:9222/takeover/panel?handoff_secret=deadbeef",
);
assert!(announced.is_ok());
}
}