use anyhow::{anyhow, Context, Result};
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::{header, HeaderMap, Request, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use clap::Parser;
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, PtySize};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::ffi::c_char;
use std::io::{ErrorKind, Read, Write};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::net::TcpListener;
use uuid::Uuid;
pub const PLUGIN_ID: &str = "aev-web";
pub const PLUGIN_NAME: &str = "aev-web";
pub const CRATE_NAME: &str = "aev-web";
pub const ENTRYPOINT: &str = "aev_plugin_init";
pub const PROTOCOL: &str = "aev.plugin.v1";
pub const DEFAULT_BIND: &str = "127.0.0.1:8765";
const OUTPUT_LIMIT_BYTES: usize = 1024 * 1024;
const MAX_INPUT_BYTES: usize = 64 * 1024;
pub 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"]}"#;
const 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";
#[no_mangle]
pub extern "C" fn aev_plugin_init() -> *const c_char {
PLUGIN_INIT_JSON_NUL.as_ptr().cast()
}
#[derive(Parser, Debug, Clone)]
#[command(name = "aev-web", about = "Start the aev-web local terminal server.")]
pub struct Cli {
#[arg(long, env = "AEV_WEB_BIND", default_value = DEFAULT_BIND)]
pub bind: SocketAddr,
#[arg(long, env = "AEV_WEB_TOKEN")]
pub token: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub bind: SocketAddr,
pub token: String,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: DEFAULT_BIND.parse().expect("default bind address is valid"),
token: generate_token(),
}
}
}
impl From<Cli> for ServerConfig {
fn from(cli: Cli) -> Self {
Self {
bind: cli.bind,
token: normalize_token(cli.token),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PluginManifest {
pub id: &'static str,
pub name: &'static str,
pub package: &'static str,
pub entrypoint: &'static str,
pub protocol: &'static str,
pub capabilities: Vec<&'static str>,
pub default_bind: &'static str,
}
pub fn plugin_manifest() -> PluginManifest {
PluginManifest {
id: PLUGIN_ID,
name: PLUGIN_NAME,
package: CRATE_NAME,
entrypoint: ENTRYPOINT,
protocol: PROTOCOL,
capabilities: vec!["tool", "terminal", "web", "local-http", "bearer-auth"],
default_bind: DEFAULT_BIND,
}
}
pub async fn serve(config: ServerConfig) -> Result<()> {
let token = Arc::<str>::from(config.token);
let state = AppState::new(token.clone());
let app = router(state.clone());
let listener = TcpListener::bind(config.bind)
.await
.with_context(|| format!("failed to bind aev-web to {}", config.bind))?;
let local_addr = listener
.local_addr()
.context("failed to read aev-web listener address")?;
eprintln!("aev-web listening on http://{local_addr}");
eprintln!("open http://{local_addr}/?token={token}");
let result = axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await;
state.terminate_all();
result.context("aev-web server failed")
}
pub fn router(state: AppState) -> Router {
let auth_token = state.token.clone();
let api = Router::new()
.route("/manifest", get(manifest_handler))
.route(
"/sessions",
get(list_sessions_handler).post(create_session_handler),
)
.route(
"/sessions/{id}",
get(get_session_handler).delete(delete_session_handler),
)
.route("/sessions/{id}/input", post(write_input_handler))
.route("/sessions/{id}/output", get(read_output_handler))
.route_layer(middleware::from_fn_with_state(auth_token, require_bearer));
Router::new()
.route("/", get(index_handler))
.route("/health", get(health_handler))
.nest("/api", api)
.with_state(state)
}
#[derive(Clone)]
pub struct AppState {
sessions: Arc<RwLock<BTreeMap<Uuid, Arc<TerminalSession>>>>,
token: Arc<str>,
output_limit: usize,
}
impl AppState {
pub fn new(token: Arc<str>) -> Self {
Self {
sessions: Arc::new(RwLock::new(BTreeMap::new())),
token,
output_limit: OUTPUT_LIMIT_BYTES,
}
}
fn insert_session(&self, session: Arc<TerminalSession>) -> Result<()> {
self.sessions
.write()
.map_err(|_| anyhow!("session registry lock poisoned"))?
.insert(session.id, session);
Ok(())
}
fn get_session(&self, id: &Uuid) -> Result<Option<Arc<TerminalSession>>> {
Ok(self
.sessions
.read()
.map_err(|_| anyhow!("session registry lock poisoned"))?
.get(id)
.cloned())
}
fn remove_session(&self, id: &Uuid) -> Result<Option<Arc<TerminalSession>>> {
Ok(self
.sessions
.write()
.map_err(|_| anyhow!("session registry lock poisoned"))?
.remove(id))
}
fn session_infos(&self) -> Result<Vec<SessionInfo>> {
Ok(self
.sessions
.read()
.map_err(|_| anyhow!("session registry lock poisoned"))?
.values()
.map(|session| session.info())
.collect())
}
fn terminate_all(&self) {
let sessions = match self.sessions.write() {
Ok(mut sessions) => std::mem::take(&mut *sessions),
Err(_) => return,
};
for session in sessions.values() {
let _ = session.terminate();
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SessionInfo {
pub id: Uuid,
pub pid: Option<u32>,
pub command: String,
pub cwd: Option<String>,
pub alive: bool,
pub created_unix: u64,
pub updated_unix: u64,
pub output_cursor: u64,
}
struct TerminalSession {
id: Uuid,
pid: Option<u32>,
command: String,
cwd: Option<PathBuf>,
writer: Mutex<Box<dyn Write + Send>>,
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
output: Mutex<OutputBuffer>,
alive: AtomicBool,
created_unix: u64,
updated_unix: AtomicU64,
}
impl TerminalSession {
fn info(&self) -> SessionInfo {
let output_cursor = self
.output
.lock()
.map(|output| output.next_cursor())
.unwrap_or_default();
SessionInfo {
id: self.id,
pid: self.pid,
command: self.command.clone(),
cwd: self
.cwd
.as_ref()
.map(|path| path.to_string_lossy().to_string()),
alive: self.alive.load(Ordering::Acquire),
created_unix: self.created_unix,
updated_unix: self.updated_unix.load(Ordering::Acquire),
output_cursor,
}
}
fn append_output(&self, data: &[u8]) -> Result<()> {
self.output
.lock()
.map_err(|_| anyhow!("session output lock poisoned"))?
.append(data);
self.touch();
Ok(())
}
fn output_since(&self, since: u64) -> Result<SessionOutputResponse> {
let snapshot = self
.output
.lock()
.map_err(|_| anyhow!("session output lock poisoned"))?
.read_since(since);
Ok(SessionOutputResponse {
id: self.id,
cursor: snapshot.cursor,
next: snapshot.next,
data: snapshot.data,
dropped: snapshot.dropped,
alive: self.alive.load(Ordering::Acquire),
})
}
fn write_input(&self, data: &str) -> Result<usize> {
if data.len() > MAX_INPUT_BYTES {
return Err(anyhow!("input exceeds {MAX_INPUT_BYTES} bytes"));
}
let mut writer = self
.writer
.lock()
.map_err(|_| anyhow!("session input lock poisoned"))?;
writer.write_all(data.as_bytes())?;
writer.flush()?;
self.touch();
Ok(data.len())
}
fn terminate(&self) -> Result<()> {
self.alive.store(false, Ordering::Release);
self.touch();
self.killer
.lock()
.map_err(|_| anyhow!("session killer lock poisoned"))?
.kill()
.context("failed to terminate session process")
}
fn mark_exited(&self) {
self.alive.store(false, Ordering::Release);
self.touch();
}
fn touch(&self) {
self.updated_unix.store(unix_now(), Ordering::Release);
}
}
#[derive(Debug, Clone)]
struct OutputBuffer {
base: u64,
bytes: Vec<u8>,
limit: usize,
}
impl OutputBuffer {
fn with_limit(limit: usize) -> Self {
Self {
base: 0,
bytes: Vec::new(),
limit,
}
}
fn append(&mut self, data: &[u8]) {
self.bytes.extend_from_slice(data);
if self.bytes.len() > self.limit {
let overflow = self.bytes.len() - self.limit;
self.bytes.drain(..overflow);
self.base = self.base.saturating_add(overflow as u64);
}
}
fn next_cursor(&self) -> u64 {
self.base.saturating_add(self.bytes.len() as u64)
}
fn read_since(&self, since: u64) -> OutputSnapshot {
let dropped = since < self.base;
let start = if dropped {
0
} else {
since.saturating_sub(self.base).min(self.bytes.len() as u64) as usize
};
OutputSnapshot {
cursor: self.base.saturating_add(start as u64),
next: self.next_cursor(),
data: String::from_utf8_lossy(&self.bytes[start..]).into_owned(),
dropped,
}
}
}
#[derive(Debug, Clone)]
struct OutputSnapshot {
cursor: u64,
next: u64,
data: String,
dropped: bool,
}
#[derive(Debug, Deserialize)]
pub struct CreateSessionRequest {
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
pub cwd: Option<String>,
pub rows: Option<u16>,
pub cols: Option<u16>,
}
#[derive(Debug, Serialize)]
pub struct CreateSessionResponse {
pub session: SessionInfo,
}
#[derive(Debug, Serialize)]
pub struct ListSessionsResponse {
pub sessions: Vec<SessionInfo>,
}
#[derive(Debug, Deserialize)]
pub struct InputRequest {
#[serde(default)]
pub data: String,
}
#[derive(Debug, Serialize)]
pub struct InputResponse {
pub id: Uuid,
pub accepted: bool,
pub bytes: usize,
}
#[derive(Debug, Deserialize)]
pub struct OutputQuery {
pub since: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct SessionOutputResponse {
pub id: Uuid,
pub cursor: u64,
pub next: u64,
pub data: String,
pub dropped: bool,
pub alive: bool,
}
#[derive(Debug, Serialize)]
pub struct DeleteSessionResponse {
pub id: Uuid,
pub terminated: bool,
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
struct HealthResponse {
id: &'static str,
status: &'static str,
protocol: &'static str,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: String,
}
#[derive(Debug)]
struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.into(),
}
}
fn internal(error: anyhow::Error) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: error.to_string(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorResponse {
error: self.message,
}),
)
.into_response()
}
}
async fn index_handler() -> Html<&'static str> {
Html(INDEX_HTML)
}
async fn health_handler() -> Json<HealthResponse> {
Json(HealthResponse {
id: PLUGIN_ID,
status: "ok",
protocol: PROTOCOL,
})
}
async fn manifest_handler() -> Json<PluginManifest> {
Json(plugin_manifest())
}
async fn list_sessions_handler(
State(state): State<AppState>,
) -> Result<Json<ListSessionsResponse>, ApiError> {
Ok(Json(ListSessionsResponse {
sessions: state.session_infos().map_err(ApiError::internal)?,
}))
}
async fn create_session_handler(
State(state): State<AppState>,
Json(request): Json<CreateSessionRequest>,
) -> Result<Json<CreateSessionResponse>, ApiError> {
let session =
create_terminal_session(request, state.output_limit).map_err(ApiError::internal)?;
let info = session.info();
state.insert_session(session).map_err(ApiError::internal)?;
Ok(Json(CreateSessionResponse { session: info }))
}
async fn get_session_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<SessionInfo>, ApiError> {
let session = state
.get_session(&id)
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("session not found"))?;
Ok(Json(session.info()))
}
async fn write_input_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(request): Json<InputRequest>,
) -> Result<Json<InputResponse>, ApiError> {
let session = state
.get_session(&id)
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("session not found"))?;
let bytes = session
.write_input(&request.data)
.map_err(|error| ApiError::bad_request(error.to_string()))?;
Ok(Json(InputResponse {
id,
accepted: true,
bytes,
}))
}
async fn read_output_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(query): Query<OutputQuery>,
) -> Result<Json<SessionOutputResponse>, ApiError> {
let session = state
.get_session(&id)
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("session not found"))?;
Ok(Json(
session
.output_since(query.since.unwrap_or_default())
.map_err(ApiError::internal)?,
))
}
async fn delete_session_handler(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<DeleteSessionResponse>, ApiError> {
let session = state
.remove_session(&id)
.map_err(ApiError::internal)?
.ok_or_else(|| ApiError::not_found("session not found"))?;
let error = session.terminate().err().map(|error| error.to_string());
Ok(Json(DeleteSessionResponse {
id,
terminated: error.is_none(),
error,
}))
}
async fn require_bearer(State(token): State<Arc<str>>, req: Request<Body>, next: Next) -> Response {
if bearer_token_matches(req.headers(), token.as_ref()) {
return next.run(req).await;
}
(
StatusCode::UNAUTHORIZED,
Json(ErrorResponse {
error: "missing or invalid bearer token".to_string(),
}),
)
.into_response()
}
pub fn bearer_token_matches(headers: &HeaderMap, token: &str) -> bool {
let Some(value) = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
else {
return false;
};
let Some(candidate) = value.strip_prefix("Bearer ") else {
return false;
};
constant_time_eq(candidate.as_bytes(), token.as_bytes())
}
fn create_terminal_session(
request: CreateSessionRequest,
output_limit: usize,
) -> Result<Arc<TerminalSession>> {
let id = Uuid::now_v7();
let now = unix_now();
let rows = request.rows.unwrap_or(24).clamp(8, 200);
let cols = request.cols.unwrap_or(80).clamp(20, 400);
let command = request
.command
.as_deref()
.map(str::trim)
.filter(|command| !command.is_empty())
.map(str::to_string)
.unwrap_or_else(default_shell);
let cwd = request
.cwd
.as_deref()
.map(str::trim)
.filter(|cwd| !cwd.is_empty())
.map(PathBuf::from);
if let Some(cwd) = &cwd {
if !cwd.is_dir() {
return Err(anyhow!("cwd is not a directory: {}", cwd.display()));
}
}
let mut cmd = CommandBuilder::new(&command);
cmd.args(&request.args);
if let Some(cwd) = &cwd {
cmd.cwd(cwd.as_os_str());
}
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("failed to open pty")?;
let mut child = pair
.slave
.spawn_command(cmd)
.with_context(|| format!("failed to spawn terminal command: {command}"))?;
let pid = child.process_id();
let killer = child.clone_killer();
let mut reader = pair
.master
.try_clone_reader()
.context("failed to clone pty reader")?;
let writer = pair
.master
.take_writer()
.context("failed to open pty writer")?;
let command_line = command_line_for_display(&command, &request.args);
let session = Arc::new(TerminalSession {
id,
pid,
command: command_line,
cwd,
writer: Mutex::new(writer),
killer: Mutex::new(killer),
output: Mutex::new(OutputBuffer::with_limit(output_limit)),
alive: AtomicBool::new(true),
created_unix: now,
updated_unix: AtomicU64::new(now),
});
let read_session = session.clone();
thread::Builder::new()
.name(format!("aev-web-read-{id}"))
.spawn(move || read_pty_output(read_session, &mut reader))
.context("failed to spawn pty reader thread")?;
let wait_session = session.clone();
thread::Builder::new()
.name(format!("aev-web-wait-{id}"))
.spawn(move || {
let message = match child.wait() {
Ok(status) => format!("\r\n[aev-web: process exited: {status:?}]\r\n"),
Err(error) => format!("\r\n[aev-web: wait failed: {error}]\r\n"),
};
let _ = wait_session.append_output(message.as_bytes());
wait_session.mark_exited();
})
.context("failed to spawn pty wait thread")?;
Ok(session)
}
fn read_pty_output(session: Arc<TerminalSession>, reader: &mut dyn Read) {
let mut buffer = [0_u8; 8192];
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
let _ = session.append_output(&buffer[..n]);
}
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(error) => {
let message = format!("\r\n[aev-web: read failed: {error}]\r\n");
let _ = session.append_output(message.as_bytes());
break;
}
}
}
session.mark_exited();
}
fn default_shell() -> String {
std::env::var("SHELL")
.ok()
.filter(|shell| !shell.trim().is_empty())
.unwrap_or_else(|| "/bin/sh".to_string())
}
fn command_line_for_display(command: &str, args: &[String]) -> String {
let mut line = command.to_string();
for arg in args {
line.push(' ');
line.push_str(arg);
}
line
}
fn normalize_token(token: Option<String>) -> String {
token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
.unwrap_or_else(generate_token)
}
fn generate_token() -> String {
Uuid::now_v7().to_string()
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0_u8, |diff, (left, right)| diff | (left ^ right))
== 0
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
}
const INDEX_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>aev-web</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #0d1117; color: #e6edf3; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at top left, #1f6feb44, transparent 32rem), #0d1117; }
main { width: min(1100px, calc(100vw - 32px)); display: grid; gap: 14px; }
.panel { border: 1px solid #30363d; border-radius: 18px; background: #161b22ee; box-shadow: 0 24px 80px #0008; }
header { padding: 22px 24px; display: flex; gap: 16px; align-items: center; justify-content: space-between; }
h1 { margin: 0; font-size: clamp(26px, 5vw, 46px); letter-spacing: -0.04em; }
.subtle { color: #8b949e; }
.controls { padding: 0 24px 20px; display: grid; grid-template-columns: 1fr auto auto; gap: 10px; }
input, textarea, button { border: 1px solid #30363d; border-radius: 12px; background: #0d1117; color: #e6edf3; font: inherit; }
input, textarea { padding: 12px 14px; }
button { padding: 12px 16px; cursor: pointer; background: #238636; border-color: #2ea043; font-weight: 700; }
button.secondary { background: #21262d; border-color: #30363d; }
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; }
footer { padding: 14px 24px 22px; display: grid; grid-template-columns: 1fr auto; gap: 10px; }
textarea { min-height: 52px; resize: vertical; }
@media (max-width: 760px) { .controls, footer { grid-template-columns: 1fr; } header { display: grid; } }
</style>
</head>
<body>
<main class="panel">
<header>
<div>
<h1>aev-web</h1>
<div class="subtle">Private local terminal bridge for aev.</div>
</div>
<div id="status" class="subtle">not connected</div>
</header>
<section class="controls">
<input id="token" type="password" autocomplete="off" placeholder="Bearer token">
<button id="start">Start shell</button>
<button id="ctrlc" class="secondary">Ctrl-C</button>
</section>
<pre id="screen"></pre>
<footer>
<textarea id="input" placeholder="Type a command, then Enter. Use Shift+Enter for a newline."></textarea>
<button id="send">Send</button>
</footer>
</main>
<script>
const statusEl = document.querySelector('#status');
const screenEl = document.querySelector('#screen');
const tokenEl = document.querySelector('#token');
const inputEl = document.querySelector('#input');
let sessionId = null;
let cursor = 0;
let pollTimer = null;
const queryToken = new URLSearchParams(location.search).get('token');
tokenEl.value = queryToken || localStorage.getItem('aevWebToken') || '';
function token() {
const value = tokenEl.value.trim();
if (value) localStorage.setItem('aevWebToken', value);
return value;
}
async function api(path, options = {}) {
const headers = Object.assign({ 'Authorization': `Bearer ${token()}` }, options.headers || {});
if (options.body) headers['Content-Type'] = 'application/json';
const response = await fetch(`/api${path}`, Object.assign({}, options, { headers }));
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || response.statusText);
return body;
}
async function start() {
if (!token()) {
statusEl.textContent = 'token required';
return;
}
const result = await api('/sessions', { method: 'POST', body: JSON.stringify({}) });
sessionId = result.session.id;
cursor = 0;
screenEl.textContent = '';
statusEl.textContent = `session ${sessionId}`;
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(poll, 350);
poll();
}
async function poll() {
if (!sessionId) return;
try {
const result = await api(`/sessions/${sessionId}/output?since=${cursor}`);
cursor = result.next;
if (result.dropped) screenEl.textContent += '\n[output truncated]\n';
if (result.data) {
screenEl.textContent += result.data;
screenEl.scrollTop = screenEl.scrollHeight;
}
if (!result.alive) statusEl.textContent = `session ${sessionId} exited`;
} catch (error) {
statusEl.textContent = error.message;
}
}
async function send(data) {
if (!sessionId) await start();
if (!sessionId || !data) return;
await api(`/sessions/${sessionId}/input`, { method: 'POST', body: JSON.stringify({ data }) });
}
document.querySelector('#start').addEventListener('click', () => start().catch(error => statusEl.textContent = error.message));
document.querySelector('#send').addEventListener('click', () => {
const data = inputEl.value;
inputEl.value = '';
send(data).catch(error => statusEl.textContent = error.message);
});
document.querySelector('#ctrlc').addEventListener('click', () => send('\u0003').catch(error => statusEl.textContent = error.message));
inputEl.addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
const data = `${inputEl.value}\r`;
inputEl.value = '';
send(data).catch(error => statusEl.textContent = error.message);
}
});
</script>
</body>
</html>"#;
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
use std::ffi::CStr;
use std::path::Path;
use std::time::Duration;
#[test]
fn manifest_matches_plugin_contract() {
let manifest = plugin_manifest();
assert_eq!(manifest.id, "aev-web");
assert_eq!(manifest.package, "aev-web");
assert_eq!(manifest.entrypoint, "aev_plugin_init");
assert_eq!(manifest.protocol, "aev.plugin.v1");
assert!(manifest.capabilities.contains(&"terminal"));
}
#[test]
fn output_buffer_tracks_absolute_cursor_and_truncation() {
let mut buffer = OutputBuffer::with_limit(5);
buffer.append(b"hello");
assert_eq!(buffer.read_since(0).data, "hello");
assert_eq!(buffer.next_cursor(), 5);
buffer.append(b" world");
let snapshot = buffer.read_since(0);
assert!(snapshot.dropped);
assert_eq!(snapshot.cursor, 6);
assert_eq!(snapshot.next, 11);
assert_eq!(snapshot.data, "world");
}
#[test]
fn bearer_auth_requires_matching_token() {
let mut headers = HeaderMap::new();
assert!(!bearer_token_matches(&headers, "secret"));
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_static("Bearer secret"),
);
assert!(bearer_token_matches(&headers, "secret"));
assert!(!bearer_token_matches(&headers, "different"));
}
#[test]
fn ffi_entrypoint_returns_static_manifest_json() {
assert!(!aev_plugin_init().is_null());
let json = unsafe { CStr::from_ptr(aev_plugin_init()) }
.to_str()
.expect("plugin manifest is utf-8");
assert_eq!(json, PLUGIN_INIT_JSON);
assert!(PLUGIN_INIT_JSON.starts_with("{\"id\":\"aev-web\""));
}
#[test]
fn router_constructs_with_axum_capture_syntax() {
let _ = router(AppState::new(Arc::<str>::from("secret")));
}
#[test]
fn terminal_session_captures_process_output() {
if !Path::new("/bin/sh").is_file() {
return;
}
let session = create_terminal_session(
CreateSessionRequest {
command: Some("/bin/sh".to_string()),
args: vec!["-lc".to_string(), "printf aev-web-ok".to_string()],
cwd: None,
rows: Some(24),
cols: Some(80),
},
1024,
)
.expect("session starts");
for _ in 0..100 {
let output = session.output_since(0).expect("output reads");
if output.data.contains("aev-web-ok") {
let _ = session.terminate();
return;
}
std::thread::sleep(Duration::from_millis(20));
}
let output = session.output_since(0).expect("output reads after timeout");
let _ = session.terminate();
panic!("expected terminal output, got: {:?}", output.data);
}
}