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}