Terminal: a shell on the agent chat's computer, before the first message too
ci / check (push) Failing after 2s
ci / start (push) Failing after 2s

The Terminal panel opens a shell on the device an agent chat works on, in its
folder: for a new agent chat as soon as the device and folder are chosen (the
server's /api/v2/devices/{device}/terminal/ws?directory=, which the chat's first
message takes over -- the same socket carries on), and for an open agent chat
from the header (/api/v2/chats/{chat}/terminal/ws, the web page's shell).
Offered where GET /devices says the device opens a terminal.

The socket is Rust's (src-tauri/src/terminal.rs, tungstenite on the rustls
ureq already brings): the bearer token goes in the handshake and never reaches
the page, which gets the frames as `terminal` events and sends keys, sizes and
close through commands. 4401 signs the app out as a 401 does.

xterm and its fit addon are the server's vendored build, pinned through
fetch-vendor.sh like CodeMirror and loaded on the first open. Send to chat puts
the selection, the last marked command and its output, or the screen into the
composer, never sent. The terminal and the activity panel take turns in the
panel column. Continue in a terminal stays in the chat's menu.

tests/ui: seven B3 cases with a mocked socket, at 1280 and 390 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 09:06:01 +00:00
1 parent ec32bca644
commit ac0d4ce623
21 files changed
+1989 -24

No files matched your search

+84 -3
View File
@@ -279,6 +279,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -511,6 +520,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.2"
@@ -623,6 +638,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.37.0"
@@ -686,6 +710,12 @@ dependencies = [
"syn 3.0.8",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -775,8 +805,19 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
@@ -1552,6 +1593,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.12.0"
@@ -2097,6 +2147,7 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tungstenite",
"ureq",
]
@@ -3488,6 +3539,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3496,7 +3558,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -4539,6 +4601,25 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"rustls",
"rustls-native-certs 0.8.5",
"rustls-pki-types",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "typeid"
version = "1.0.3"
+2
View File
@@ -26,6 +26,8 @@ tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
# The terminal panel's socket (`terminal.rs`): a blocking WebSocket on the TLS ureq already brings.
tungstenite = { version = "0.30", default-features = false, features = ["handshake", "rustls-tls-native-roots"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+74
View File
@@ -22,6 +22,7 @@ mod remote;
mod sealed;
mod server;
mod settings;
mod terminal;
mod watch;
use std::collections::HashMap;
@@ -115,6 +116,8 @@ struct App {
signing_in: Mutex<Option<Arc<AtomicBool>>>,
streams: Mutex<HashMap<u64, Arc<AtomicBool>>>,
next_stream: AtomicU64,
/// The terminal panel's sockets (`terminal.rs`), by the page's id for each.
terminals: terminal::Sessions,
sealed: sealed::Pages,
watch: watch::Watch,
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
@@ -271,6 +274,7 @@ async fn set_server(handle: AppHandle, address: String) -> Result<Status, String
if before.as_deref() != Some(d.base.as_str()) {
app.agent.stop();
app.watch.reset();
app.terminals.close_all();
}
*app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base);
*app.server.lock().unwrap() = Some(d);
@@ -380,6 +384,7 @@ fn sign_out(handle: AppHandle) {
let account = app.account.lock().unwrap().take();
server::forget_account(&app.config_dir);
app.watch.reset();
app.terminals.close_all();
if let Some(a) = account {
std::thread::spawn(move || server::sign_out(&a.server, &a.token));
}
@@ -391,6 +396,7 @@ fn token_refused(handle: &AppHandle) {
let app = handle.state::<App>();
if app.account.lock().unwrap().take().is_some() {
server::forget_account(&app.config_dir);
app.terminals.close_all();
announce(handle);
}
}
@@ -511,6 +517,69 @@ fn stream_close(app: tauri::State<'_, App>, id: u64) {
}
}
// --- The terminal panel ------------------------------------------------------------------------
/// Open a shell on a linked device, as the terminal panel asks: an agent chat's (`chat`), or the
/// one before the chat exists (`device` and `directory`). The socket is opened here, with the
/// app's token in its handshake; the page gets its frames as `terminal` events with this id --
/// `output` (base64), `control` (the server's JSON), and a last `end`.
#[tauri::command]
fn terminal_open(
handle: AppHandle,
chat: Option<String>,
device: Option<String>,
directory: Option<String>,
cols: u16,
rows: u16,
) -> Result<u64, ApiError> {
let app = handle.state::<App>();
let Some((app_url, token)) = app.door() else {
return Err(ApiError::new(401, "Not signed in."));
};
let target = match (chat, device) {
(Some(chat), None) => terminal::Target::Chat(chat),
(None, Some(device)) => terminal::Target::Device {
device,
directory: directory.unwrap_or_default(),
},
_ => return Err(ApiError::new(400, "A chat or a device, not both.")),
};
let url = terminal::url(&app_url, &target, cols, rows)?;
let h = handle.clone();
Ok(app.terminals.open(url, token, move |down| {
if down.kind == "end" {
h.state::<App>().terminals.forget(down.id);
let refused = down.data.get("code").and_then(Value::as_u64)
== Some(u64::from(terminal::CLOSE_UNAUTHORIZED))
|| down.data.get("status").and_then(Value::as_u64) == Some(401);
if refused {
token_refused(&h);
}
}
let _ = h.emit("terminal", down);
}))
}
/// Keys typed (or pasted) into a terminal, as text: sent to the shell as its bytes.
#[tauri::command]
fn terminal_input(app: tauri::State<'_, App>, id: u64, data: String) -> bool {
app.terminals
.send(id, terminal::Up::Keys(data.into_bytes()))
}
/// The terminal's size changed.
#[tauri::command]
fn terminal_resize(app: tauri::State<'_, App>, id: u64, cols: u16, rows: u16) -> bool {
app.terminals.send(id, terminal::Up::Resize(cols, rows))
}
/// The page let go of a terminal: its socket is closed (the shell on the device lives on, as
/// the server keeps it).
#[tauri::command]
fn terminal_close(app: tauri::State<'_, App>, id: u64) {
app.terminals.close(id);
}
// --- Attachments ----------------------------------------------------------------------------------
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
@@ -1560,6 +1629,10 @@ pub fn run() {
terminal_word,
stream_open,
stream_close,
terminal_open,
terminal_input,
terminal_resize,
terminal_close,
local_list,
local_read,
sealed_open,
@@ -1621,6 +1694,7 @@ pub fn run() {
signing_in: Mutex::new(None),
streams: Mutex::new(HashMap::new()),
next_stream: AtomicU64::new(0),
terminals: terminal::Sessions::default(),
sealed: sealed::Pages::default(),
watch: watch::Watch::default(),
dropped: Mutex::new(Vec::new()),
+437
View File
@@ -0,0 +1,437 @@
//! The terminal panel's socket: a shell on a linked device, through the server's app door.
//!
//! The server has two WebSockets for an app (`/api/v2`, behind the app's token):
//!
//! - `/chats/{chat}/terminal/ws` -- an agent chat's shell, the one the web page's panel opens
//! too, so the two share it;
//! - `/devices/{device}/terminal/ws?directory=` -- before the chat exists, on the device and in
//! the folder a new agent chat has chosen. The chat's first message takes that shell over.
//!
//! Both take `cols` and `rows`. Down: the shell's output as binary frames, and text frames
//! `{"t": "ready" | "closed" | "behind" | "error", ...}`. Up: keystrokes as binary,
//! `{"t": "resize", "cols", "rows"}` as text. A refused token closes with 4401.
//!
//! **The token stays here.** The socket is opened in Rust with `Authorization: Bearer` in the
//! handshake, and the page only ever gets the frames (as `terminal` events) and sends keys,
//! sizes and "close" through commands -- the same rule as every other request the page makes.
//! A page cannot open the socket itself: its content security policy allows it no connection
//! but the bridge.
//!
//! One thread per terminal owns its socket: it reads with a short timeout and, between reads,
//! sends what the page queued on its channel. Blocking sockets, no async runtime of its own.
use std::collections::HashMap;
use std::io::ErrorKind;
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::{json, Value};
use tungstenite::http::Uri;
use tungstenite::stream::MaybeTlsStream;
use tungstenite::{ClientRequestBuilder, Error as WsError, HandshakeError, Message, WebSocket};
use crate::server::{base64, ApiError};
/// The server's close code for a token it refused (`api/v2/terminal.py`).
pub const CLOSE_UNAUTHORIZED: u16 = 4401;
/// The most one keystroke message may be: the server's own limit (a paste, at most).
pub const MAX_INPUT: usize = 256 * 1024;
/// The longest directory the server takes (`api/terminal.py` `MAX_DIR_CHARS`).
const MAX_DIR: usize = 4096;
/// How long a read waits before the thread looks at what the page queued.
const POLL: Duration = Duration::from_millis(40);
/// How long a close waits for the server's answer before the socket is simply dropped.
const CLOSE_WAIT: Duration = Duration::from_secs(2);
type Socket = WebSocket<MaybeTlsStream<TcpStream>>;
/// Where the shell is: an agent chat's, or a device's folder before the chat exists.
#[derive(Clone, Debug, PartialEq)]
pub enum Target {
Chat(String),
Device { device: String, directory: String },
}
/// An id as the server makes them (a chat's, a device token's): letters, digits, `-` and `_`.
fn id_ok(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// A query value, percent-encoded: every byte but the unreserved ones (RFC 3986 §2.3).
pub fn encode(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for b in text.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
/// The socket's address on the app door (`https://host/api/v2` -> `wss://host/api/v2/...`).
pub fn url(app: &str, target: &Target, cols: u16, rows: u16) -> Result<String, ApiError> {
let door = if let Some(rest) = app.strip_prefix("https://") {
format!("wss://{rest}")
} else if let Some(rest) = app.strip_prefix("http://") {
format!("ws://{rest}")
} else {
return Err(ApiError::new(
400,
"The server's address is not http or https.",
));
};
let cols = cols.clamp(2, 1000);
let rows = rows.clamp(1, 500);
match target {
Target::Chat(chat) => {
if !id_ok(chat) {
return Err(ApiError::new(400, "Not a chat."));
}
Ok(format!(
"{door}/chats/{chat}/terminal/ws?cols={cols}&rows={rows}"
))
}
Target::Device { device, directory } => {
if !id_ok(device) {
return Err(ApiError::new(400, "Not a device."));
}
let directory = directory.trim();
if directory.is_empty() || directory.len() > MAX_DIR || directory.contains('\0') {
return Err(ApiError::new(400, "Not a folder."));
}
Ok(format!(
"{door}/devices/{device}/terminal/ws?directory={}&cols={cols}&rows={rows}",
encode(directory)
))
}
}
}
/// What the page asked the socket's thread to do.
pub enum Up {
Keys(Vec<u8>),
Resize(u16, u16),
Close,
}
/// One frame for the page, as the `terminal` event: `output` (the bytes, base64), `control`
/// (the server's JSON), or `end` (how it ended: `{code, reason, status, error}`).
#[derive(Clone, Serialize)]
pub struct Down {
pub id: u64,
pub kind: &'static str,
pub data: Value,
}
/// How a terminal's socket ended. `code` is the close frame's (4401: the token was refused),
/// `status` an HTTP status the handshake was answered with, `error` why it could not connect or
/// broke off.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct End {
pub code: Option<u16>,
pub reason: String,
pub status: Option<u16>,
pub error: Option<String>,
}
impl End {
fn failed(error: impl Into<String>) -> Self {
End {
error: Some(error.into()),
..End::default()
}
}
/// The server refused the app's token: the page signs in again.
pub fn unauthorized(&self) -> bool {
self.code == Some(CLOSE_UNAUTHORIZED) || self.status == Some(401)
}
}
/// The open terminals: what each one's thread listens to.
#[derive(Default)]
pub struct Sessions {
next: AtomicU64,
open: Mutex<HashMap<u64, Sender<Up>>>,
}
impl Sessions {
/// Open a terminal's socket on its own thread. `emit` gets every frame, the `end` last;
/// the id is the page's handle on it.
pub fn open(&self, url: String, token: String, emit: impl Fn(Down) + Send + 'static) -> u64 {
let id = self.next.fetch_add(1, Ordering::SeqCst) + 1;
let (tx, rx) = mpsc::channel();
self.open.lock().unwrap().insert(id, tx);
std::thread::spawn(move || {
let send = |kind: &'static str, data: Value| emit(Down { id, kind, data });
let end = run(&url, &token, &rx, &send);
send("end", serde_json::to_value(&end).unwrap_or(Value::Null));
});
id
}
/// Hand the page's keys, size or close to the terminal's thread. False: no such terminal.
pub fn send(&self, id: u64, up: Up) -> bool {
self.open
.lock()
.unwrap()
.get(&id)
.is_some_and(|tx| tx.send(up).is_ok())
}
/// Close one terminal (the page's panel let go of it).
pub fn close(&self, id: u64) {
if let Some(tx) = self.open.lock().unwrap().remove(&id) {
let _ = tx.send(Up::Close);
}
}
/// A terminal's thread has ended: forget it.
pub fn forget(&self, id: u64) {
self.open.lock().unwrap().remove(&id);
}
/// Every terminal closed (signed out, or another server chosen).
pub fn close_all(&self) {
for (_, tx) in self.open.lock().unwrap().drain() {
let _ = tx.send(Up::Close);
}
}
}
/// The TCP socket under the WebSocket, to set its read timeout.
fn tcp(ws: &Socket) -> Option<&TcpStream> {
match ws.get_ref() {
MaybeTlsStream::Plain(s) => Some(s),
MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
_ => None,
}
}
fn connect(url: &str, token: &str) -> Result<Socket, End> {
let uri: Uri = url
.parse()
.map_err(|e| End::failed(format!("Not an address: {e}")))?;
let host = uri
.host()
.ok_or_else(|| End::failed("The address has no host."))?
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
let port = uri
.port_u16()
.unwrap_or(if uri.scheme_str() == Some("wss") {
443
} else {
80
});
let addrs = (host.as_str(), port)
.to_socket_addrs()
.map_err(|e| End::failed(format!("Could not reach the server: {e}")))?;
let mut last = String::from("no address");
let mut stream = None;
for addr in addrs {
match TcpStream::connect_timeout(&addr, Duration::from_secs(10)) {
Ok(s) => {
stream = Some(s);
break;
}
Err(e) => last = e.to_string(),
}
}
let stream =
stream.ok_or_else(|| End::failed(format!("Could not reach the server: {last}")))?;
// The handshake may take its time; reads only poll once it is done.
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
let _ = stream.set_nodelay(true);
let request = ClientRequestBuilder::new(uri)
.with_header("Authorization", format!("Bearer {token}"))
.with_header(
"User-Agent",
format!("llembas-desktop/{}", env!("CARGO_PKG_VERSION")),
);
match tungstenite::client_tls(request, stream) {
Ok((ws, _)) => Ok(ws),
Err(HandshakeError::Failure(WsError::Http(res))) => Err(End {
status: Some(res.status().as_u16()),
..End::default()
}),
Err(HandshakeError::Failure(e)) => Err(End::failed(format!("Could not connect: {e}"))),
Err(HandshakeError::Interrupted(_)) => Err(End::failed("The server did not answer.")),
}
}
/// A read that only timed out: nothing came in this poll.
fn idle(e: &WsError) -> bool {
matches!(e, WsError::Io(io) if matches!(io.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut))
}
fn run(url: &str, token: &str, rx: &Receiver<Up>, send: &dyn Fn(&'static str, Value)) -> End {
let mut ws = match connect(url, token) {
Ok(ws) => ws,
Err(end) => return end,
};
match tcp(&ws) {
Some(s) => {
let _ = s.set_read_timeout(Some(POLL));
}
None => return End::failed("An unexpected kind of connection."),
}
let mut closed_by_server: Option<(u16, String)> = None;
let mut closing: Option<Instant> = None;
loop {
// What the page queued, until the server has said goodbye.
if closing.is_none() && closed_by_server.is_none() {
loop {
let sent = match rx.try_recv() {
Ok(Up::Keys(bytes)) if bytes.len() <= MAX_INPUT => {
ws.send(Message::binary(bytes))
}
Ok(Up::Keys(_)) => Ok(()),
Ok(Up::Resize(cols, rows)) => ws.send(Message::text(
json!({ "t": "resize", "cols": cols, "rows": rows }).to_string(),
)),
Ok(Up::Close) | Err(TryRecvError::Disconnected) => {
closing = Some(Instant::now());
let _ = ws.close(None);
break;
}
Err(TryRecvError::Empty) => break,
};
if let Err(e) = sent {
if !idle(&e) {
return End::failed(format!("The connection broke off: {e}"));
}
}
}
}
match ws.read() {
Ok(Message::Binary(bytes)) => send("output", Value::String(base64(&bytes))),
Ok(Message::Text(text)) => {
if let Ok(value) = serde_json::from_str::<Value>(text.as_str()) {
send("control", value);
}
}
Ok(Message::Close(frame)) => {
let (code, reason) = frame
.map(|f| (u16::from(f.code), f.reason.as_str().to_string()))
.unwrap_or((1005, String::new()));
closed_by_server = Some((code, reason));
}
Ok(_) => {}
Err(e) if idle(&e) => {
if closing.is_some_and(|at| at.elapsed() > CLOSE_WAIT) {
break;
}
}
Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => break,
Err(e) => {
if closing.is_some() || closed_by_server.is_some() {
break;
}
return End::failed(format!("The connection broke off: {e}"));
}
}
}
match closed_by_server {
Some((code, reason)) => End {
code: Some(code),
reason,
..End::default()
},
None => End::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_chats_terminal_is_on_the_app_door_with_its_size() {
let t = Target::Chat("chat_1-a".into());
assert_eq!(
url("https://ai.example/api/v2", &t, 80, 24).unwrap(),
"wss://ai.example/api/v2/chats/chat_1-a/terminal/ws?cols=80&rows=24"
);
assert_eq!(
url("http://ai.example:8080/sub/api/v2", &t, 0, 9999).unwrap(),
"ws://ai.example:8080/sub/api/v2/chats/chat_1-a/terminal/ws?cols=2&rows=500"
);
}
#[test]
fn a_terminal_before_the_chat_carries_its_folder_encoded() {
let t = Target::Device {
device: "token1".into(),
directory: " /home/dev/my project&x=1 ".into(),
};
assert_eq!(
url("https://ai.example/api/v2", &t, 100, 30).unwrap(),
"wss://ai.example/api/v2/devices/token1/terminal/ws?directory=%2Fhome%2Fdev%2Fmy%20project%26x%3D1&cols=100&rows=30"
);
assert_eq!(encode("C:\\Users\\Ádám"), "C%3A%5CUsers%5C%C3%81d%C3%A1m");
}
#[test]
fn nothing_but_an_id_and_a_folder_reaches_the_address() {
let door = "https://ai.example/api/v2";
for bad in ["", "../me", "a/b", "a?b", "a b", "x#y"] {
assert!(
url(door, &Target::Chat(bad.into()), 80, 24).is_err(),
"{bad}"
);
let dev = Target::Device {
device: bad.into(),
directory: "/tmp".into(),
};
assert!(url(door, &dev, 80, 24).is_err(), "{bad}");
}
for dir in ["", " ", "a\0b"] {
let dev = Target::Device {
device: "token1".into(),
directory: dir.into(),
};
assert!(url(door, &dev, 80, 24).is_err(), "{dir:?}");
}
assert!(url("ftp://ai.example", &Target::Chat("c".into()), 80, 24).is_err());
}
#[test]
fn a_refused_token_is_told_apart_from_a_dropped_line() {
let refused = End {
code: Some(CLOSE_UNAUTHORIZED),
..End::default()
};
assert!(refused.unauthorized());
assert!(End {
status: Some(401),
..End::default()
}
.unauthorized());
assert!(!End::failed("reset").unauthorized());
assert!(!End {
code: Some(1000),
..End::default()
}
.unauthorized());
}
#[test]
fn keys_for_a_closed_terminal_go_nowhere() {
let s = Sessions::default();
assert!(!s.send(7, Up::Keys(b"ls\n".to_vec())));
s.close(7);
s.close_all();
}
}