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