feat: agor-pty crate — standalone PTY multiplexer daemon (Phase 1 WIP)
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)
This commit is contained in:
parent
e8132b7dc6
commit
4b5583430d
6 changed files with 861 additions and 0 deletions
166
agor-pty/src/protocol.rs
Normal file
166
agor-pty/src/protocol.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue