agentmux_srv\backend\wshutil/
osc.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! OSC (Operating System Command) encoding/decoding for terminal RPC messages.
6//! Port of Go's `pkg/wshutil/wshutil.go` — OSC escape sequence handling.
7
8
9use base64::Engine;
10
11/// OSC number for AgentMux client messages.
12pub const WAVE_OSC: &str = "23198";
13/// OSC number for AgentMux server responses.
14pub const WAVE_SERVER_OSC: &str = "23199";
15
16/// BEL character (OSC terminator).
17pub const BEL: u8 = 0x07;
18/// String Terminator.
19pub const ST: u8 = 0x9C;
20/// Escape character.
21pub const ESC: u8 = 0x1B;
22
23/// Default channel buffer sizes.
24pub const DEFAULT_OUTPUT_CH_SIZE: usize = 32;
25pub const DEFAULT_INPUT_CH_SIZE: usize = 32;
26
27/// Hex characters for encoding.
28const HEX_CHARS: &[u8] = b"0123456789ABCDEF";
29
30/// Make an OSC prefix: ESC ] <oscnum> ;
31pub fn make_osc_prefix(osc_num: &str) -> Vec<u8> {
32    let mut prefix = Vec::with_capacity(3 + osc_num.len());
33    prefix.push(ESC);
34    prefix.push(b']');
35    prefix.extend_from_slice(osc_num.as_bytes());
36    prefix.push(b';');
37    prefix
38}
39
40/// Encode bytes as a AgentMux OSC escape sequence.
41///
42/// Format: ESC ] <oscnum> ; <payload> BEL
43///
44/// If the payload contains control characters, it's base64 encoded.
45/// Otherwise, sent as raw JSON.
46pub fn encode_wave_osc_bytes(osc_num: &str, data: &[u8]) -> Result<Vec<u8>, String> {
47    if osc_num.len() != 5 {
48        return Err("osc_num must be 5 characters".to_string());
49    }
50
51    let needs_base64 = data.iter().any(|&b| b < 0x20 || b == 0x7F);
52
53    let prefix = make_osc_prefix(osc_num);
54    let payload = if needs_base64 {
55        base64::engine::general_purpose::STANDARD.encode(data).into_bytes()
56    } else {
57        data.to_vec()
58    };
59
60    let mut result = Vec::with_capacity(prefix.len() + payload.len() + 1);
61    result.extend_from_slice(&prefix);
62    result.extend_from_slice(&payload);
63    result.push(BEL);
64    Ok(result)
65}
66
67/// Decode a AgentMux OSC escape sequence, returning the payload bytes.
68///
69/// Strips the OSC prefix and terminator (BEL or ST).
70/// If payload starts with '{', it's JSON. Otherwise, base64 decode.
71pub fn decode_wave_osc_bytes(data: &[u8]) -> Result<Vec<u8>, String> {
72    if data.len() < 9 {
73        return Err("data too short for OSC message".to_string());
74    }
75
76    // Verify ESC ]
77    if data[0] != ESC || data[1] != b']' {
78        return Err("invalid OSC prefix".to_string());
79    }
80
81    // Find the semicolon separator
82    let sep_pos = data.iter().position(|&b| b == b';')
83        .ok_or("missing semicolon in OSC message")?;
84
85    // Find the terminator (BEL or ST)
86    let end_pos = data.len() - 1;
87    let terminator = data[end_pos];
88    if terminator != BEL && terminator != ST {
89        return Err(format!("invalid OSC terminator: 0x{:02X}", terminator));
90    }
91
92    let payload = &data[sep_pos + 1..end_pos];
93
94    // If starts with '{', it's raw JSON
95    if !payload.is_empty() && payload[0] == b'{' {
96        return Ok(payload.to_vec());
97    }
98
99    // Otherwise, base64 decode
100    base64::engine::general_purpose::STANDARD
101        .decode(payload)
102        .map_err(|e| format!("base64 decode error: {}", e))
103}
104
105/// Check if bytes represent a AgentMux OSC message.
106pub fn is_wave_osc(data: &[u8]) -> bool {
107    if data.len() < 9 {
108        return false;
109    }
110    data[0] == ESC && data[1] == b']' && (
111        data[2..7] == *WAVE_OSC.as_bytes() ||
112        data[2..7] == *WAVE_SERVER_OSC.as_bytes()
113    )
114}
115
116/// Get the OSC number from an OSC message.
117pub fn get_osc_num(data: &[u8]) -> Option<&str> {
118    if data.len() < 8 || data[0] != ESC || data[1] != b']' {
119        return None;
120    }
121    std::str::from_utf8(&data[2..7]).ok()
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_encode_decode_json() {
130        let json = b"{\"command\":\"test\"}";
131        let encoded = encode_wave_osc_bytes(WAVE_OSC, json).unwrap();
132
133        assert_eq!(encoded[0], ESC);
134        assert_eq!(encoded[1], b']');
135        assert_eq!(*encoded.last().unwrap(), BEL);
136        assert!(is_wave_osc(&encoded));
137
138        let decoded = decode_wave_osc_bytes(&encoded).unwrap();
139        assert_eq!(decoded, json);
140    }
141
142    #[test]
143    fn test_encode_decode_base64() {
144        let data = b"\x00\x01\x02binary data";
145        let encoded = encode_wave_osc_bytes(WAVE_OSC, data).unwrap();
146        let decoded = decode_wave_osc_bytes(&encoded).unwrap();
147        assert_eq!(decoded, data);
148    }
149
150    #[test]
151    fn test_osc_prefix() {
152        let prefix = make_osc_prefix(WAVE_OSC);
153        assert_eq!(prefix.len(), 8); // ESC + ] + 5 digits + ;
154        assert_eq!(prefix[0], ESC);
155    }
156
157    #[test]
158    fn test_get_osc_num() {
159        let encoded = encode_wave_osc_bytes(WAVE_OSC, b"{}").unwrap();
160        assert_eq!(get_osc_num(&encoded), Some(WAVE_OSC));
161    }
162
163    #[test]
164    fn test_invalid_osc_num_length() {
165        let result = encode_wave_osc_bytes("123", b"data");
166        assert!(result.is_err());
167    }
168}