New crate at agor-pty/ (standalone, not in workspace — portable to Tauri/Electrobun): - Rust daemon (agor-ptyd) with Unix socket IPC, JSON-framed protocol - PTY session lifecycle: create, resize, write, close, output fanout - 256-bit token auth, multi-client support, session persistence - TypeScript IPC client at clients/ts/pty-client.ts (Bun + Node.js) - Protocol: 9 client messages, 7 daemon messages - Based on tribunal ruling (78% confidence, 4 rounds, 55 objections) WIP: Rust implementation in progress (protocol.rs + auth.rs done)
166 lines
4.4 KiB
Rust
166 lines
4.4 KiB
Rust
use std::collections::HashMap;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Messages sent from client → daemon.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum ClientMessage {
|
|
Auth {
|
|
token: String,
|
|
},
|
|
CreateSession {
|
|
id: String,
|
|
shell: Option<String>,
|
|
cwd: Option<String>,
|
|
env: Option<HashMap<String, String>>,
|
|
cols: u16,
|
|
rows: u16,
|
|
},
|
|
WriteInput {
|
|
session_id: String,
|
|
/// Raw bytes encoded as a base64 string.
|
|
data: String,
|
|
},
|
|
Resize {
|
|
session_id: String,
|
|
cols: u16,
|
|
rows: u16,
|
|
},
|
|
Subscribe {
|
|
session_id: String,
|
|
},
|
|
Unsubscribe {
|
|
session_id: String,
|
|
},
|
|
CloseSession {
|
|
session_id: String,
|
|
},
|
|
ListSessions,
|
|
Ping,
|
|
}
|
|
|
|
/// Messages sent from daemon → client.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum DaemonMessage {
|
|
AuthResult {
|
|
ok: bool,
|
|
},
|
|
SessionCreated {
|
|
session_id: String,
|
|
pid: u32,
|
|
},
|
|
/// PTY output bytes base64-encoded so they survive JSON transport.
|
|
SessionOutput {
|
|
session_id: String,
|
|
data: String,
|
|
},
|
|
SessionClosed {
|
|
session_id: String,
|
|
exit_code: Option<i32>,
|
|
},
|
|
SessionList {
|
|
sessions: Vec<SessionInfo>,
|
|
},
|
|
Pong,
|
|
Error {
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
/// Snapshot of a running or recently-exited session.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SessionInfo {
|
|
pub id: String,
|
|
pub pid: u32,
|
|
pub shell: String,
|
|
pub cwd: String,
|
|
pub cols: u16,
|
|
pub rows: u16,
|
|
/// Unix timestamp of session creation.
|
|
pub created_at: u64,
|
|
pub alive: bool,
|
|
}
|
|
|
|
/// Encode raw bytes as base64 for embedding in JSON.
|
|
pub fn encode_output(bytes: &[u8]) -> String {
|
|
use std::fmt::Write as FmtWrite;
|
|
// Manual base64 — avoids adding a new crate; the hex crate IS available but
|
|
// base64 better compresses binary PTY data (33% overhead vs 100% for hex).
|
|
// We use a simple lookup table implementation that stays within 300 lines.
|
|
const TABLE: &[u8; 64] =
|
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
|
|
let mut i = 0;
|
|
while i + 2 < bytes.len() {
|
|
let b0 = bytes[i] as usize;
|
|
let b1 = bytes[i + 1] as usize;
|
|
let b2 = bytes[i + 2] as usize;
|
|
let _ = write!(
|
|
out,
|
|
"{}{}{}{}",
|
|
TABLE[b0 >> 2] as char,
|
|
TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char,
|
|
TABLE[((b1 & 0xf) << 2) | (b2 >> 6)] as char,
|
|
TABLE[b2 & 0x3f] as char
|
|
);
|
|
i += 3;
|
|
}
|
|
let rem = bytes.len() - i;
|
|
if rem == 1 {
|
|
let b0 = bytes[i] as usize;
|
|
let _ = write!(
|
|
out,
|
|
"{}{}==",
|
|
TABLE[b0 >> 2] as char,
|
|
TABLE[(b0 & 3) << 4] as char
|
|
);
|
|
} else if rem == 2 {
|
|
let b0 = bytes[i] as usize;
|
|
let b1 = bytes[i + 1] as usize;
|
|
let _ = write!(
|
|
out,
|
|
"{}{}{}=",
|
|
TABLE[b0 >> 2] as char,
|
|
TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char,
|
|
TABLE[(b1 & 0xf) << 2] as char
|
|
);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Decode base64 string back to bytes. Returns error string on invalid input.
|
|
pub fn decode_input(s: &str) -> Result<Vec<u8>, String> {
|
|
fn val(c: u8) -> Result<u8, String> {
|
|
match c {
|
|
b'A'..=b'Z' => Ok(c - b'A'),
|
|
b'a'..=b'z' => Ok(c - b'a' + 26),
|
|
b'0'..=b'9' => Ok(c - b'0' + 52),
|
|
b'+' => Ok(62),
|
|
b'/' => Ok(63),
|
|
b'=' => Ok(0),
|
|
_ => Err(format!("invalid base64 char: {c}")),
|
|
}
|
|
}
|
|
let bytes = s.as_bytes();
|
|
if bytes.len() % 4 != 0 {
|
|
return Err("base64 length not a multiple of 4".into());
|
|
}
|
|
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
let v0 = val(bytes[i])?;
|
|
let v1 = val(bytes[i + 1])?;
|
|
let v2 = val(bytes[i + 2])?;
|
|
let v3 = val(bytes[i + 3])?;
|
|
out.push((v0 << 2) | (v1 >> 4));
|
|
if bytes[i + 2] != b'=' {
|
|
out.push((v1 << 4) | (v2 >> 2));
|
|
}
|
|
if bytes[i + 3] != b'=' {
|
|
out.push((v2 << 6) | v3);
|
|
}
|
|
i += 4;
|
|
}
|
|
Ok(out)
|
|
}
|