agent-orchestrator/agor-pty/src/protocol.rs
Hibryda 4b5583430d 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)
2026-03-20 03:04:36 +01:00

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)
}