Skip to main content

aev_web/
lib.rs

1use anyhow::{anyhow, Context, Result};
2use axum::body::Body;
3use axum::extract::{Path, Query, State};
4use axum::http::{header, HeaderMap, Request, StatusCode};
5use axum::middleware::{self, Next};
6use axum::response::{Html, IntoResponse, Response};
7use axum::routing::{get, post};
8use axum::{Json, Router};
9use clap::Parser;
10use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, PtySize};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::ffi::c_char;
14use std::io::{ErrorKind, Read, Write};
15use std::net::SocketAddr;
16use std::path::PathBuf;
17use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
18use std::sync::{Arc, Mutex, RwLock};
19use std::thread;
20use std::time::{SystemTime, UNIX_EPOCH};
21use tokio::net::TcpListener;
22use uuid::Uuid;
23
24pub const PLUGIN_ID: &str = "lios-web";
25pub const PLUGIN_NAME: &str = "lios-web";
26pub const CRATE_NAME: &str = "aev-web";
27pub const ENTRYPOINT: &str = "aev_plugin_init";
28pub const PROTOCOL: &str = "aev.plugin.v1";
29pub const DEFAULT_BIND: &str = "127.0.0.1:8765";
30const OUTPUT_LIMIT_BYTES: usize = 1024 * 1024;
31const MAX_INPUT_BYTES: usize = 64 * 1024;
32pub const PLUGIN_INIT_JSON: &str = r#"{"id":"lios-web","name":"lios-web","package":"aev-web","entrypoint":"aev_plugin_init","protocol":"aev.plugin.v1","capabilities":["tool","terminal","web","local-http","bearer-auth"]}"#;
33const PLUGIN_INIT_JSON_NUL: &str = "{\"id\":\"lios-web\",\"name\":\"lios-web\",\"package\":\"aev-web\",\"entrypoint\":\"aev_plugin_init\",\"protocol\":\"aev.plugin.v1\",\"capabilities\":[\"tool\",\"terminal\",\"web\",\"local-http\",\"bearer-auth\"]}\0";
34
35#[no_mangle]
36pub extern "C" fn aev_plugin_init() -> *const c_char {
37    PLUGIN_INIT_JSON_NUL.as_ptr().cast()
38}
39
40#[derive(Parser, Debug, Clone)]
41#[command(
42    name = "aev-web",
43    about = "Start the private lios-web local terminal server."
44)]
45pub struct Cli {
46    #[arg(long, env = "LIOS_WEB_BIND", default_value = DEFAULT_BIND)]
47    pub bind: SocketAddr,
48    #[arg(long, env = "LIOS_WEB_TOKEN")]
49    pub token: Option<String>,
50}
51
52#[derive(Debug, Clone)]
53pub struct ServerConfig {
54    pub bind: SocketAddr,
55    pub token: String,
56}
57
58impl Default for ServerConfig {
59    fn default() -> Self {
60        Self {
61            bind: DEFAULT_BIND.parse().expect("default bind address is valid"),
62            token: generate_token(),
63        }
64    }
65}
66
67impl From<Cli> for ServerConfig {
68    fn from(cli: Cli) -> Self {
69        Self {
70            bind: cli.bind,
71            token: normalize_token(cli.token),
72        }
73    }
74}
75
76#[derive(Debug, Clone, Serialize)]
77pub struct PluginManifest {
78    pub id: &'static str,
79    pub name: &'static str,
80    pub package: &'static str,
81    pub entrypoint: &'static str,
82    pub protocol: &'static str,
83    pub capabilities: Vec<&'static str>,
84    pub default_bind: &'static str,
85}
86
87pub fn plugin_manifest() -> PluginManifest {
88    PluginManifest {
89        id: PLUGIN_ID,
90        name: PLUGIN_NAME,
91        package: CRATE_NAME,
92        entrypoint: ENTRYPOINT,
93        protocol: PROTOCOL,
94        capabilities: vec!["tool", "terminal", "web", "local-http", "bearer-auth"],
95        default_bind: DEFAULT_BIND,
96    }
97}
98
99pub async fn serve(config: ServerConfig) -> Result<()> {
100    let token = Arc::<str>::from(config.token);
101    let state = AppState::new(token.clone());
102    let app = router(state.clone());
103    let listener = TcpListener::bind(config.bind)
104        .await
105        .with_context(|| format!("failed to bind lios-web to {}", config.bind))?;
106    let local_addr = listener
107        .local_addr()
108        .context("failed to read lios-web listener address")?;
109
110    eprintln!("lios-web listening on http://{local_addr}");
111    eprintln!("open http://{local_addr}/?token={token}");
112
113    let result = axum::serve(listener, app)
114        .with_graceful_shutdown(shutdown_signal())
115        .await;
116    state.terminate_all();
117    result.context("lios-web server failed")
118}
119
120pub fn router(state: AppState) -> Router {
121    let auth_token = state.token.clone();
122    let api = Router::new()
123        .route("/manifest", get(manifest_handler))
124        .route(
125            "/sessions",
126            get(list_sessions_handler).post(create_session_handler),
127        )
128        .route(
129            "/sessions/{id}",
130            get(get_session_handler).delete(delete_session_handler),
131        )
132        .route("/sessions/{id}/input", post(write_input_handler))
133        .route("/sessions/{id}/output", get(read_output_handler))
134        .route_layer(middleware::from_fn_with_state(auth_token, require_bearer));
135
136    Router::new()
137        .route("/", get(index_handler))
138        .route("/health", get(health_handler))
139        .nest("/api", api)
140        .with_state(state)
141}
142
143#[derive(Clone)]
144pub struct AppState {
145    sessions: Arc<RwLock<BTreeMap<Uuid, Arc<TerminalSession>>>>,
146    token: Arc<str>,
147    output_limit: usize,
148}
149
150impl AppState {
151    pub fn new(token: Arc<str>) -> Self {
152        Self {
153            sessions: Arc::new(RwLock::new(BTreeMap::new())),
154            token,
155            output_limit: OUTPUT_LIMIT_BYTES,
156        }
157    }
158
159    fn insert_session(&self, session: Arc<TerminalSession>) -> Result<()> {
160        self.sessions
161            .write()
162            .map_err(|_| anyhow!("session registry lock poisoned"))?
163            .insert(session.id, session);
164        Ok(())
165    }
166
167    fn get_session(&self, id: &Uuid) -> Result<Option<Arc<TerminalSession>>> {
168        Ok(self
169            .sessions
170            .read()
171            .map_err(|_| anyhow!("session registry lock poisoned"))?
172            .get(id)
173            .cloned())
174    }
175
176    fn remove_session(&self, id: &Uuid) -> Result<Option<Arc<TerminalSession>>> {
177        Ok(self
178            .sessions
179            .write()
180            .map_err(|_| anyhow!("session registry lock poisoned"))?
181            .remove(id))
182    }
183
184    fn session_infos(&self) -> Result<Vec<SessionInfo>> {
185        Ok(self
186            .sessions
187            .read()
188            .map_err(|_| anyhow!("session registry lock poisoned"))?
189            .values()
190            .map(|session| session.info())
191            .collect())
192    }
193
194    fn terminate_all(&self) {
195        let sessions = match self.sessions.write() {
196            Ok(mut sessions) => std::mem::take(&mut *sessions),
197            Err(_) => return,
198        };
199        for session in sessions.values() {
200            let _ = session.terminate();
201        }
202    }
203}
204
205#[derive(Debug, Clone, Serialize)]
206pub struct SessionInfo {
207    pub id: Uuid,
208    pub pid: Option<u32>,
209    pub command: String,
210    pub cwd: Option<String>,
211    pub alive: bool,
212    pub created_unix: u64,
213    pub updated_unix: u64,
214    pub output_cursor: u64,
215}
216
217struct TerminalSession {
218    id: Uuid,
219    pid: Option<u32>,
220    command: String,
221    cwd: Option<PathBuf>,
222    writer: Mutex<Box<dyn Write + Send>>,
223    killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
224    output: Mutex<OutputBuffer>,
225    alive: AtomicBool,
226    created_unix: u64,
227    updated_unix: AtomicU64,
228}
229
230impl TerminalSession {
231    fn info(&self) -> SessionInfo {
232        let output_cursor = self
233            .output
234            .lock()
235            .map(|output| output.next_cursor())
236            .unwrap_or_default();
237        SessionInfo {
238            id: self.id,
239            pid: self.pid,
240            command: self.command.clone(),
241            cwd: self
242                .cwd
243                .as_ref()
244                .map(|path| path.to_string_lossy().to_string()),
245            alive: self.alive.load(Ordering::Acquire),
246            created_unix: self.created_unix,
247            updated_unix: self.updated_unix.load(Ordering::Acquire),
248            output_cursor,
249        }
250    }
251
252    fn append_output(&self, data: &[u8]) -> Result<()> {
253        self.output
254            .lock()
255            .map_err(|_| anyhow!("session output lock poisoned"))?
256            .append(data);
257        self.touch();
258        Ok(())
259    }
260
261    fn output_since(&self, since: u64) -> Result<SessionOutputResponse> {
262        let snapshot = self
263            .output
264            .lock()
265            .map_err(|_| anyhow!("session output lock poisoned"))?
266            .read_since(since);
267        Ok(SessionOutputResponse {
268            id: self.id,
269            cursor: snapshot.cursor,
270            next: snapshot.next,
271            data: snapshot.data,
272            dropped: snapshot.dropped,
273            alive: self.alive.load(Ordering::Acquire),
274        })
275    }
276
277    fn write_input(&self, data: &str) -> Result<usize> {
278        if data.len() > MAX_INPUT_BYTES {
279            return Err(anyhow!("input exceeds {MAX_INPUT_BYTES} bytes"));
280        }
281        let mut writer = self
282            .writer
283            .lock()
284            .map_err(|_| anyhow!("session input lock poisoned"))?;
285        writer.write_all(data.as_bytes())?;
286        writer.flush()?;
287        self.touch();
288        Ok(data.len())
289    }
290
291    fn terminate(&self) -> Result<()> {
292        self.alive.store(false, Ordering::Release);
293        self.touch();
294        self.killer
295            .lock()
296            .map_err(|_| anyhow!("session killer lock poisoned"))?
297            .kill()
298            .context("failed to terminate session process")
299    }
300
301    fn mark_exited(&self) {
302        self.alive.store(false, Ordering::Release);
303        self.touch();
304    }
305
306    fn touch(&self) {
307        self.updated_unix.store(unix_now(), Ordering::Release);
308    }
309}
310
311#[derive(Debug, Clone)]
312struct OutputBuffer {
313    base: u64,
314    bytes: Vec<u8>,
315    limit: usize,
316}
317
318impl OutputBuffer {
319    fn with_limit(limit: usize) -> Self {
320        Self {
321            base: 0,
322            bytes: Vec::new(),
323            limit,
324        }
325    }
326
327    fn append(&mut self, data: &[u8]) {
328        self.bytes.extend_from_slice(data);
329        if self.bytes.len() > self.limit {
330            let overflow = self.bytes.len() - self.limit;
331            self.bytes.drain(..overflow);
332            self.base = self.base.saturating_add(overflow as u64);
333        }
334    }
335
336    fn next_cursor(&self) -> u64 {
337        self.base.saturating_add(self.bytes.len() as u64)
338    }
339
340    fn read_since(&self, since: u64) -> OutputSnapshot {
341        let dropped = since < self.base;
342        let start = if dropped {
343            0
344        } else {
345            since.saturating_sub(self.base).min(self.bytes.len() as u64) as usize
346        };
347        OutputSnapshot {
348            cursor: self.base.saturating_add(start as u64),
349            next: self.next_cursor(),
350            data: String::from_utf8_lossy(&self.bytes[start..]).into_owned(),
351            dropped,
352        }
353    }
354}
355
356#[derive(Debug, Clone)]
357struct OutputSnapshot {
358    cursor: u64,
359    next: u64,
360    data: String,
361    dropped: bool,
362}
363
364#[derive(Debug, Deserialize)]
365pub struct CreateSessionRequest {
366    pub command: Option<String>,
367    #[serde(default)]
368    pub args: Vec<String>,
369    pub cwd: Option<String>,
370    pub rows: Option<u16>,
371    pub cols: Option<u16>,
372}
373
374#[derive(Debug, Serialize)]
375pub struct CreateSessionResponse {
376    pub session: SessionInfo,
377}
378
379#[derive(Debug, Serialize)]
380pub struct ListSessionsResponse {
381    pub sessions: Vec<SessionInfo>,
382}
383
384#[derive(Debug, Deserialize)]
385pub struct InputRequest {
386    #[serde(default)]
387    pub data: String,
388}
389
390#[derive(Debug, Serialize)]
391pub struct InputResponse {
392    pub id: Uuid,
393    pub accepted: bool,
394    pub bytes: usize,
395}
396
397#[derive(Debug, Deserialize)]
398pub struct OutputQuery {
399    pub since: Option<u64>,
400}
401
402#[derive(Debug, Serialize)]
403pub struct SessionOutputResponse {
404    pub id: Uuid,
405    pub cursor: u64,
406    pub next: u64,
407    pub data: String,
408    pub dropped: bool,
409    pub alive: bool,
410}
411
412#[derive(Debug, Serialize)]
413pub struct DeleteSessionResponse {
414    pub id: Uuid,
415    pub terminated: bool,
416    pub error: Option<String>,
417}
418
419#[derive(Debug, Serialize)]
420struct HealthResponse {
421    id: &'static str,
422    status: &'static str,
423    protocol: &'static str,
424}
425
426#[derive(Debug, Serialize)]
427struct ErrorResponse {
428    error: String,
429}
430
431#[derive(Debug)]
432struct ApiError {
433    status: StatusCode,
434    message: String,
435}
436
437impl ApiError {
438    fn bad_request(message: impl Into<String>) -> Self {
439        Self {
440            status: StatusCode::BAD_REQUEST,
441            message: message.into(),
442        }
443    }
444
445    fn not_found(message: impl Into<String>) -> Self {
446        Self {
447            status: StatusCode::NOT_FOUND,
448            message: message.into(),
449        }
450    }
451
452    fn internal(error: anyhow::Error) -> Self {
453        Self {
454            status: StatusCode::INTERNAL_SERVER_ERROR,
455            message: error.to_string(),
456        }
457    }
458}
459
460impl IntoResponse for ApiError {
461    fn into_response(self) -> Response {
462        (
463            self.status,
464            Json(ErrorResponse {
465                error: self.message,
466            }),
467        )
468            .into_response()
469    }
470}
471
472async fn index_handler() -> Html<&'static str> {
473    Html(INDEX_HTML)
474}
475
476async fn health_handler() -> Json<HealthResponse> {
477    Json(HealthResponse {
478        id: PLUGIN_ID,
479        status: "ok",
480        protocol: PROTOCOL,
481    })
482}
483
484async fn manifest_handler() -> Json<PluginManifest> {
485    Json(plugin_manifest())
486}
487
488async fn list_sessions_handler(
489    State(state): State<AppState>,
490) -> Result<Json<ListSessionsResponse>, ApiError> {
491    Ok(Json(ListSessionsResponse {
492        sessions: state.session_infos().map_err(ApiError::internal)?,
493    }))
494}
495
496async fn create_session_handler(
497    State(state): State<AppState>,
498    Json(request): Json<CreateSessionRequest>,
499) -> Result<Json<CreateSessionResponse>, ApiError> {
500    let session =
501        create_terminal_session(request, state.output_limit).map_err(ApiError::internal)?;
502    let info = session.info();
503    state.insert_session(session).map_err(ApiError::internal)?;
504    Ok(Json(CreateSessionResponse { session: info }))
505}
506
507async fn get_session_handler(
508    State(state): State<AppState>,
509    Path(id): Path<Uuid>,
510) -> Result<Json<SessionInfo>, ApiError> {
511    let session = state
512        .get_session(&id)
513        .map_err(ApiError::internal)?
514        .ok_or_else(|| ApiError::not_found("session not found"))?;
515    Ok(Json(session.info()))
516}
517
518async fn write_input_handler(
519    State(state): State<AppState>,
520    Path(id): Path<Uuid>,
521    Json(request): Json<InputRequest>,
522) -> Result<Json<InputResponse>, ApiError> {
523    let session = state
524        .get_session(&id)
525        .map_err(ApiError::internal)?
526        .ok_or_else(|| ApiError::not_found("session not found"))?;
527    let bytes = session
528        .write_input(&request.data)
529        .map_err(|error| ApiError::bad_request(error.to_string()))?;
530    Ok(Json(InputResponse {
531        id,
532        accepted: true,
533        bytes,
534    }))
535}
536
537async fn read_output_handler(
538    State(state): State<AppState>,
539    Path(id): Path<Uuid>,
540    Query(query): Query<OutputQuery>,
541) -> Result<Json<SessionOutputResponse>, ApiError> {
542    let session = state
543        .get_session(&id)
544        .map_err(ApiError::internal)?
545        .ok_or_else(|| ApiError::not_found("session not found"))?;
546    Ok(Json(
547        session
548            .output_since(query.since.unwrap_or_default())
549            .map_err(ApiError::internal)?,
550    ))
551}
552
553async fn delete_session_handler(
554    State(state): State<AppState>,
555    Path(id): Path<Uuid>,
556) -> Result<Json<DeleteSessionResponse>, ApiError> {
557    let session = state
558        .remove_session(&id)
559        .map_err(ApiError::internal)?
560        .ok_or_else(|| ApiError::not_found("session not found"))?;
561    let error = session.terminate().err().map(|error| error.to_string());
562    Ok(Json(DeleteSessionResponse {
563        id,
564        terminated: error.is_none(),
565        error,
566    }))
567}
568
569async fn require_bearer(State(token): State<Arc<str>>, req: Request<Body>, next: Next) -> Response {
570    if bearer_token_matches(req.headers(), token.as_ref()) {
571        return next.run(req).await;
572    }
573    (
574        StatusCode::UNAUTHORIZED,
575        Json(ErrorResponse {
576            error: "missing or invalid bearer token".to_string(),
577        }),
578    )
579        .into_response()
580}
581
582pub fn bearer_token_matches(headers: &HeaderMap, token: &str) -> bool {
583    let Some(value) = headers
584        .get(header::AUTHORIZATION)
585        .and_then(|value| value.to_str().ok())
586    else {
587        return false;
588    };
589    let Some(candidate) = value.strip_prefix("Bearer ") else {
590        return false;
591    };
592    constant_time_eq(candidate.as_bytes(), token.as_bytes())
593}
594
595fn create_terminal_session(
596    request: CreateSessionRequest,
597    output_limit: usize,
598) -> Result<Arc<TerminalSession>> {
599    let id = Uuid::now_v7();
600    let now = unix_now();
601    let rows = request.rows.unwrap_or(24).clamp(8, 200);
602    let cols = request.cols.unwrap_or(80).clamp(20, 400);
603    let command = request
604        .command
605        .as_deref()
606        .map(str::trim)
607        .filter(|command| !command.is_empty())
608        .map(str::to_string)
609        .unwrap_or_else(default_shell);
610    let cwd = request
611        .cwd
612        .as_deref()
613        .map(str::trim)
614        .filter(|cwd| !cwd.is_empty())
615        .map(PathBuf::from);
616
617    if let Some(cwd) = &cwd {
618        if !cwd.is_dir() {
619            return Err(anyhow!("cwd is not a directory: {}", cwd.display()));
620        }
621    }
622
623    let mut cmd = CommandBuilder::new(&command);
624    cmd.args(&request.args);
625    if let Some(cwd) = &cwd {
626        cmd.cwd(cwd.as_os_str());
627    }
628
629    let pty_system = native_pty_system();
630    let pair = pty_system
631        .openpty(PtySize {
632            rows,
633            cols,
634            pixel_width: 0,
635            pixel_height: 0,
636        })
637        .context("failed to open pty")?;
638    let mut child = pair
639        .slave
640        .spawn_command(cmd)
641        .with_context(|| format!("failed to spawn terminal command: {command}"))?;
642    let pid = child.process_id();
643    let killer = child.clone_killer();
644    let mut reader = pair
645        .master
646        .try_clone_reader()
647        .context("failed to clone pty reader")?;
648    let writer = pair
649        .master
650        .take_writer()
651        .context("failed to open pty writer")?;
652    let command_line = command_line_for_display(&command, &request.args);
653    let session = Arc::new(TerminalSession {
654        id,
655        pid,
656        command: command_line,
657        cwd,
658        writer: Mutex::new(writer),
659        killer: Mutex::new(killer),
660        output: Mutex::new(OutputBuffer::with_limit(output_limit)),
661        alive: AtomicBool::new(true),
662        created_unix: now,
663        updated_unix: AtomicU64::new(now),
664    });
665
666    let read_session = session.clone();
667    thread::Builder::new()
668        .name(format!("lios-web-read-{id}"))
669        .spawn(move || read_pty_output(read_session, &mut reader))
670        .context("failed to spawn pty reader thread")?;
671
672    let wait_session = session.clone();
673    thread::Builder::new()
674        .name(format!("lios-web-wait-{id}"))
675        .spawn(move || {
676            let message = match child.wait() {
677                Ok(status) => format!("\r\n[lios-web: process exited: {status:?}]\r\n"),
678                Err(error) => format!("\r\n[lios-web: wait failed: {error}]\r\n"),
679            };
680            let _ = wait_session.append_output(message.as_bytes());
681            wait_session.mark_exited();
682        })
683        .context("failed to spawn pty wait thread")?;
684
685    Ok(session)
686}
687
688fn read_pty_output(session: Arc<TerminalSession>, reader: &mut dyn Read) {
689    let mut buffer = [0_u8; 8192];
690    loop {
691        match reader.read(&mut buffer) {
692            Ok(0) => break,
693            Ok(n) => {
694                let _ = session.append_output(&buffer[..n]);
695            }
696            Err(error) if error.kind() == ErrorKind::Interrupted => continue,
697            Err(error) => {
698                let message = format!("\r\n[lios-web: read failed: {error}]\r\n");
699                let _ = session.append_output(message.as_bytes());
700                break;
701            }
702        }
703    }
704    session.mark_exited();
705}
706
707fn default_shell() -> String {
708    std::env::var("SHELL")
709        .ok()
710        .filter(|shell| !shell.trim().is_empty())
711        .unwrap_or_else(|| "/bin/sh".to_string())
712}
713
714fn command_line_for_display(command: &str, args: &[String]) -> String {
715    let mut line = command.to_string();
716    for arg in args {
717        line.push(' ');
718        line.push_str(arg);
719    }
720    line
721}
722
723fn normalize_token(token: Option<String>) -> String {
724    token
725        .map(|token| token.trim().to_string())
726        .filter(|token| !token.is_empty())
727        .unwrap_or_else(generate_token)
728}
729
730fn generate_token() -> String {
731    Uuid::now_v7().to_string()
732}
733
734fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
735    if left.len() != right.len() {
736        return false;
737    }
738    left.iter()
739        .zip(right)
740        .fold(0_u8, |diff, (left, right)| diff | (left ^ right))
741        == 0
742}
743
744fn unix_now() -> u64 {
745    SystemTime::now()
746        .duration_since(UNIX_EPOCH)
747        .unwrap_or_default()
748        .as_secs()
749}
750
751async fn shutdown_signal() {
752    let _ = tokio::signal::ctrl_c().await;
753}
754
755const INDEX_HTML: &str = r#"<!doctype html>
756<html lang="en">
757<head>
758  <meta charset="utf-8">
759  <meta name="viewport" content="width=device-width, initial-scale=1">
760  <title>lios-web</title>
761  <style>
762    :root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #0d1117; color: #e6edf3; }
763    body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top left, #1f6feb44, transparent 32rem), #0d1117; }
764    main { width: min(1100px, calc(100vw - 32px)); display: grid; gap: 14px; }
765    .panel { border: 1px solid #30363d; border-radius: 18px; background: #161b22ee; box-shadow: 0 24px 80px #0008; }
766    header { padding: 22px 24px; display: flex; gap: 16px; align-items: center; justify-content: space-between; }
767    h1 { margin: 0; font-size: clamp(26px, 5vw, 46px); letter-spacing: -0.04em; }
768    .subtle { color: #8b949e; }
769    .controls { padding: 0 24px 20px; display: grid; grid-template-columns: 1fr auto auto; gap: 10px; }
770    input, textarea, button { border: 1px solid #30363d; border-radius: 12px; background: #0d1117; color: #e6edf3; font: inherit; }
771    input, textarea { padding: 12px 14px; }
772    button { padding: 12px 16px; cursor: pointer; background: #238636; border-color: #2ea043; font-weight: 700; }
773    button.secondary { background: #21262d; border-color: #30363d; }
774    pre { margin: 0; padding: 18px; min-height: 54vh; max-height: 64vh; overflow: auto; white-space: pre-wrap; word-break: break-word; font: 14px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #d1f5d3; background: #05080c; border-top: 1px solid #30363d; border-bottom: 1px solid #30363d; }
775    footer { padding: 14px 24px 22px; display: grid; grid-template-columns: 1fr auto; gap: 10px; }
776    textarea { min-height: 52px; resize: vertical; }
777    @media (max-width: 760px) { .controls, footer { grid-template-columns: 1fr; } header { display: grid; } }
778  </style>
779</head>
780<body>
781  <main class="panel">
782    <header>
783      <div>
784        <h1>lios-web</h1>
785        <div class="subtle">Private local terminal bridge for aev.</div>
786      </div>
787      <div id="status" class="subtle">not connected</div>
788    </header>
789    <section class="controls">
790      <input id="token" type="password" autocomplete="off" placeholder="Bearer token">
791      <button id="start">Start shell</button>
792      <button id="ctrlc" class="secondary">Ctrl-C</button>
793    </section>
794    <pre id="screen"></pre>
795    <footer>
796      <textarea id="input" placeholder="Type a command, then Enter. Use Shift+Enter for a newline."></textarea>
797      <button id="send">Send</button>
798    </footer>
799  </main>
800  <script>
801    const statusEl = document.querySelector('#status');
802    const screenEl = document.querySelector('#screen');
803    const tokenEl = document.querySelector('#token');
804    const inputEl = document.querySelector('#input');
805    let sessionId = null;
806    let cursor = 0;
807    let pollTimer = null;
808
809    const queryToken = new URLSearchParams(location.search).get('token');
810    tokenEl.value = queryToken || localStorage.getItem('liosWebToken') || '';
811
812    function token() {
813      const value = tokenEl.value.trim();
814      if (value) localStorage.setItem('liosWebToken', value);
815      return value;
816    }
817
818    async function api(path, options = {}) {
819      const headers = Object.assign({ 'Authorization': `Bearer ${token()}` }, options.headers || {});
820      if (options.body) headers['Content-Type'] = 'application/json';
821      const response = await fetch(`/api${path}`, Object.assign({}, options, { headers }));
822      const body = await response.json().catch(() => ({}));
823      if (!response.ok) throw new Error(body.error || response.statusText);
824      return body;
825    }
826
827    async function start() {
828      if (!token()) {
829        statusEl.textContent = 'token required';
830        return;
831      }
832      const result = await api('/sessions', { method: 'POST', body: JSON.stringify({}) });
833      sessionId = result.session.id;
834      cursor = 0;
835      screenEl.textContent = '';
836      statusEl.textContent = `session ${sessionId}`;
837      if (pollTimer) clearInterval(pollTimer);
838      pollTimer = setInterval(poll, 350);
839      poll();
840    }
841
842    async function poll() {
843      if (!sessionId) return;
844      try {
845        const result = await api(`/sessions/${sessionId}/output?since=${cursor}`);
846        cursor = result.next;
847        if (result.dropped) screenEl.textContent += '\n[output truncated]\n';
848        if (result.data) {
849          screenEl.textContent += result.data;
850          screenEl.scrollTop = screenEl.scrollHeight;
851        }
852        if (!result.alive) statusEl.textContent = `session ${sessionId} exited`;
853      } catch (error) {
854        statusEl.textContent = error.message;
855      }
856    }
857
858    async function send(data) {
859      if (!sessionId) await start();
860      if (!sessionId || !data) return;
861      await api(`/sessions/${sessionId}/input`, { method: 'POST', body: JSON.stringify({ data }) });
862    }
863
864    document.querySelector('#start').addEventListener('click', () => start().catch(error => statusEl.textContent = error.message));
865    document.querySelector('#send').addEventListener('click', () => {
866      const data = inputEl.value;
867      inputEl.value = '';
868      send(data).catch(error => statusEl.textContent = error.message);
869    });
870    document.querySelector('#ctrlc').addEventListener('click', () => send('\u0003').catch(error => statusEl.textContent = error.message));
871    inputEl.addEventListener('keydown', event => {
872      if (event.key === 'Enter' && !event.shiftKey) {
873        event.preventDefault();
874        const data = `${inputEl.value}\r`;
875        inputEl.value = '';
876        send(data).catch(error => statusEl.textContent = error.message);
877      }
878    });
879  </script>
880</body>
881</html>"#;
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886    use axum::http::HeaderValue;
887    use std::ffi::CStr;
888    use std::path::Path;
889    use std::time::Duration;
890
891    #[test]
892    fn manifest_matches_plugin_contract() {
893        let manifest = plugin_manifest();
894
895        assert_eq!(manifest.id, "lios-web");
896        assert_eq!(manifest.package, "aev-web");
897        assert_eq!(manifest.entrypoint, "aev_plugin_init");
898        assert_eq!(manifest.protocol, "aev.plugin.v1");
899        assert!(manifest.capabilities.contains(&"terminal"));
900    }
901
902    #[test]
903    fn output_buffer_tracks_absolute_cursor_and_truncation() {
904        let mut buffer = OutputBuffer::with_limit(5);
905        buffer.append(b"hello");
906        assert_eq!(buffer.read_since(0).data, "hello");
907        assert_eq!(buffer.next_cursor(), 5);
908
909        buffer.append(b" world");
910        let snapshot = buffer.read_since(0);
911
912        assert!(snapshot.dropped);
913        assert_eq!(snapshot.cursor, 6);
914        assert_eq!(snapshot.next, 11);
915        assert_eq!(snapshot.data, "world");
916    }
917
918    #[test]
919    fn bearer_auth_requires_matching_token() {
920        let mut headers = HeaderMap::new();
921        assert!(!bearer_token_matches(&headers, "secret"));
922
923        headers.insert(
924            header::AUTHORIZATION,
925            HeaderValue::from_static("Bearer secret"),
926        );
927        assert!(bearer_token_matches(&headers, "secret"));
928        assert!(!bearer_token_matches(&headers, "different"));
929    }
930
931    #[test]
932    fn ffi_entrypoint_returns_static_manifest_json() {
933        assert!(!aev_plugin_init().is_null());
934        let json = unsafe { CStr::from_ptr(aev_plugin_init()) }
935            .to_str()
936            .expect("plugin manifest is utf-8");
937        assert_eq!(json, PLUGIN_INIT_JSON);
938        assert!(PLUGIN_INIT_JSON.starts_with("{\"id\":\"lios-web\""));
939    }
940
941    #[test]
942    fn router_constructs_with_axum_capture_syntax() {
943        let _ = router(AppState::new(Arc::<str>::from("secret")));
944    }
945
946    #[test]
947    fn terminal_session_captures_process_output() {
948        if !Path::new("/bin/sh").is_file() {
949            return;
950        }
951
952        let session = create_terminal_session(
953            CreateSessionRequest {
954                command: Some("/bin/sh".to_string()),
955                args: vec!["-lc".to_string(), "printf lios-web-ok".to_string()],
956                cwd: None,
957                rows: Some(24),
958                cols: Some(80),
959            },
960            1024,
961        )
962        .expect("session starts");
963
964        for _ in 0..100 {
965            let output = session.output_since(0).expect("output reads");
966            if output.data.contains("lios-web-ok") {
967                let _ = session.terminate();
968                return;
969            }
970            std::thread::sleep(Duration::from_millis(20));
971        }
972
973        let output = session.output_since(0).expect("output reads after timeout");
974        let _ = session.terminate();
975        panic!("expected terminal output, got: {:?}", output.data);
976    }
977}