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}