Skip to main content

vtcode_webmcp/
protocol.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use vtcode_exec_events::VersionedThreadEvent;
4
5/// Current browser/server protocol version.
6pub const PROTOCOL_VERSION: &str = "1";
7
8pub(crate) const MAX_REQUEST_ID_BYTES: usize = 256;
9
10pub(crate) fn is_valid_request_id(request_id: &str) -> bool {
11    !request_id.is_empty() && request_id.len() <= MAX_REQUEST_ID_BYTES
12}
13
14pub(crate) fn response_request_id(request_id: &str) -> &str {
15    if is_valid_request_id(request_id) {
16        request_id
17    } else {
18        "unknown"
19    }
20}
21
22/// A structured browser edit proposal.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct FileChange {
25    /// Workspace-relative path.
26    pub path: String,
27    /// SHA-256 digest of the file the browser edited.
28    pub base_digest: String,
29    /// Complete proposed file content.
30    pub content: String,
31}
32
33/// Browser requests accepted by the WebMCP server.
34#[derive(Debug, Clone, Deserialize)]
35#[serde(tag = "type")]
36pub enum BridgeRequest {
37    /// Consume the one-time pairing code.
38    #[serde(rename = "pair")]
39    Pair {
40        /// Correlates the response with the browser request.
41        request_id: String,
42        /// One-time code displayed by VT Code. Omit when resuming an in-memory session.
43        #[serde(default)]
44        code: String,
45        /// Existing in-memory session token used only to resume a dropped socket.
46        #[serde(default)]
47        resume_token: Option<String>,
48        /// Optional browser-provided origin, checked against the HTTP header.
49        #[serde(default)]
50        origin: Option<String>,
51        /// Replay events after this bridge sequence when reconnecting.
52        #[serde(default)]
53        after_sequence: Option<u64>,
54    },
55    /// Return bridge and runtime status.
56    #[serde(rename = "status")]
57    Status {
58        /// Correlates the response with the browser request.
59        request_id: String,
60        /// In-memory pairing token.
61        token: String,
62    },
63    /// List bounded workspace files.
64    #[serde(rename = "workspace.list_files", alias = "list_files")]
65    ListFiles {
66        /// Correlates the response with the browser request.
67        request_id: String,
68        /// In-memory pairing token.
69        token: String,
70    },
71    /// Read a workspace file and its digest.
72    #[serde(rename = "workspace.read_file", alias = "read_file")]
73    ReadFile {
74        /// Correlates the response with the browser request.
75        request_id: String,
76        /// In-memory pairing token.
77        token: String,
78        /// Workspace-relative path.
79        path: String,
80    },
81    /// Validate and stage structured file changes.
82    #[serde(rename = "patch.propose", alias = "propose_changes")]
83    ProposeChanges {
84        /// Correlates the response with the browser request.
85        request_id: String,
86        /// In-memory pairing token.
87        token: String,
88        /// Proposed file changes.
89        changes: Vec<FileChange>,
90    },
91    /// Ask the terminal runtime to approve and apply a staged proposal.
92    #[serde(rename = "patch.apply", alias = "apply_proposal")]
93    ApplyProposal {
94        /// Correlates the response with the browser request.
95        request_id: String,
96        /// In-memory pairing token.
97        token: String,
98        /// Proposal identifier returned by `patch.propose`.
99        proposal_id: String,
100    },
101    /// Run a safe, runtime-approved check command.
102    #[serde(rename = "checks.run", alias = "run_checks")]
103    RunChecks {
104        /// Correlates the response with the browser request.
105        request_id: String,
106        /// In-memory pairing token.
107        token: String,
108        /// Command text parsed without invoking a shell.
109        command: String,
110    },
111    /// Revert the most recent applied bridge change.
112    #[serde(rename = "patch.revert", alias = "revert_last_change")]
113    RevertLastChange {
114        /// Correlates the response with the browser request.
115        request_id: String,
116        /// In-memory pairing token.
117        token: String,
118        /// Change identity returned by `patch.apply`.
119        change_id: String,
120    },
121    /// Send a prompt to the active VT Code runtime.
122    #[serde(rename = "turn.request", alias = "request_turn")]
123    RequestTurn {
124        /// Correlates the response with the browser request.
125        request_id: String,
126        /// In-memory pairing token.
127        token: String,
128        /// Optional server-validated proposal to include in the active turn handoff.
129        #[serde(default)]
130        proposal_id: Option<String>,
131        /// Prompt to submit.
132        prompt: String,
133    },
134    /// Cancel an active request or turn.
135    #[serde(rename = "cancel")]
136    Cancel {
137        /// Correlates the response with the browser request.
138        request_id: String,
139        /// In-memory pairing token.
140        token: String,
141        /// Request or turn identifier to cancel.
142        target_id: String,
143    },
144}
145
146impl BridgeRequest {
147    /// Returns the request correlation identifier.
148    pub fn request_id(&self) -> &str {
149        match self {
150            Self::Pair { request_id, .. }
151            | Self::Status { request_id, .. }
152            | Self::ListFiles { request_id, .. }
153            | Self::ReadFile { request_id, .. }
154            | Self::ProposeChanges { request_id, .. }
155            | Self::ApplyProposal { request_id, .. }
156            | Self::RunChecks { request_id, .. }
157            | Self::RevertLastChange { request_id, .. }
158            | Self::RequestTurn { request_id, .. }
159            | Self::Cancel { request_id, .. } => request_id,
160        }
161    }
162
163    /// Returns the session token for authenticated requests.
164    pub fn token(&self) -> Option<&str> {
165        match self {
166            Self::Pair { .. } => None,
167            Self::Status { token, .. }
168            | Self::ListFiles { token, .. }
169            | Self::ReadFile { token, .. }
170            | Self::ProposeChanges { token, .. }
171            | Self::ApplyProposal { token, .. }
172            | Self::RunChecks { token, .. }
173            | Self::RevertLastChange { token, .. }
174            | Self::RequestTurn { token, .. }
175            | Self::Cancel { token, .. } => Some(token),
176        }
177    }
178}
179
180/// A JSON response envelope sent to the browser.
181#[derive(Debug, Clone, Serialize)]
182pub struct BridgeResponse {
183    /// Response discriminator.
184    #[serde(rename = "type")]
185    pub kind: &'static str,
186    /// Request correlation identifier.
187    pub request_id: String,
188    /// Whether the operation succeeded.
189    pub ok: bool,
190    /// Successful operation payload.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub payload: Option<Value>,
193    /// Structured error payload.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub error: Option<BridgeErrorPayload>,
196}
197
198/// Error information returned without exposing secrets.
199#[derive(Debug, Clone, Serialize)]
200pub struct BridgeErrorPayload {
201    /// Stable error code.
202    pub code: &'static str,
203    /// User-safe message.
204    pub message: String,
205}
206
207impl BridgeResponse {
208    /// Construct a successful response.
209    pub fn success(request_id: impl Into<String>, payload: impl Serialize) -> Self {
210        Self {
211            kind: "response",
212            request_id: request_id.into(),
213            ok: true,
214            payload: serde_json::to_value(payload).ok(),
215            error: None,
216        }
217    }
218
219    /// Construct a failed response.
220    pub fn failure(request_id: impl Into<String>, code: &'static str, message: impl Into<String>) -> Self {
221        Self {
222            kind: "response",
223            request_id: request_id.into(),
224            ok: false,
225            payload: None,
226            error: Some(BridgeErrorPayload { code, message: message.into() }),
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn request_ids_are_bounded_and_invalid_ids_are_not_echoed() {
237        assert!(!is_valid_request_id(""));
238        assert!(is_valid_request_id("browser-1"));
239        assert!(is_valid_request_id(&"x".repeat(MAX_REQUEST_ID_BYTES)));
240        assert!(!is_valid_request_id(&"x".repeat(MAX_REQUEST_ID_BYTES + 1)));
241        assert_eq!(response_request_id(""), "unknown");
242        assert_eq!(response_request_id(&"x".repeat(MAX_REQUEST_ID_BYTES + 1)), "unknown");
243    }
244
245    #[test]
246    fn turn_request_can_reference_a_staged_proposal() {
247        let request: BridgeRequest = serde_json::from_value(serde_json::json!({
248            "type": "turn.request",
249            "request_id": "browser-1",
250            "token": "session-token",
251            "proposal_id": "proposal-1",
252            "prompt": "Implement the staged change"
253        }))
254        .expect("turn request should deserialize");
255
256        assert!(matches!(
257            request,
258            BridgeRequest::RequestTurn { proposal_id: Some(proposal_id), prompt, .. }
259                if proposal_id == "proposal-1" && prompt == "Implement the staged change"
260        ));
261    }
262
263    #[test]
264    fn bridge_settings_are_non_secret() {
265        let settings = BridgeSettings {
266            host: "127.0.0.1".to_string(),
267            port: 4321,
268            pairing_ttl_secs: 300,
269            max_frame_bytes: 1_048_576,
270            max_in_flight_requests: 8,
271            remote_enabled: false,
272        };
273        let serialized = serde_json::to_string(&settings).expect("settings should serialize");
274        assert!(serialized.contains("pairing_ttl_secs"));
275        assert!(!serialized.contains("token"));
276        assert!(!serialized.contains("code"));
277    }
278}
279
280/// Pairing response payload.
281#[derive(Debug, Clone, Serialize)]
282pub struct PairPayload {
283    /// In-memory token used on subsequent messages.
284    pub token: String,
285    /// Protocol version negotiated by the server.
286    pub protocol_version: &'static str,
287    /// Seconds until the session inactivity lease expires.
288    pub expires_in_secs: u64,
289}
290
291/// Non-secret bridge settings returned to an authenticated browser.
292///
293/// The origin is returned separately in [`StatusPayload`] because the server
294/// may allow more than one configured origin while each session is bound to
295/// exactly one request origin. Pairing codes and session tokens are never
296/// included in this structure.
297#[derive(Debug, Clone, Serialize)]
298pub struct BridgeSettings {
299    /// Literal address configured for the listener.
300    pub host: String,
301    /// Configured listener port; zero means that the operating system chooses one.
302    pub port: u16,
303    /// Pairing-code lifetime and authenticated-session inactivity lease.
304    pub pairing_ttl_secs: u64,
305    /// Maximum accepted WebSocket frame size.
306    pub max_frame_bytes: usize,
307    /// Maximum concurrent bridge operations.
308    pub max_in_flight_requests: usize,
309    /// Whether the server was configured for a TLS-terminating remote proxy.
310    pub remote_enabled: bool,
311}
312
313/// Status response payload.
314#[derive(Debug, Clone, Serialize)]
315pub struct StatusPayload {
316    /// Protocol version.
317    pub protocol_version: &'static str,
318    /// Whether this process has a connected runtime adapter.
319    pub connected: bool,
320    /// Runtime status.
321    pub runtime: crate::runtime::RuntimeStatus,
322    /// Origin authenticated for this browser session.
323    pub authenticated_origin: String,
324    /// Terminal-owned, non-secret bridge configuration.
325    pub settings: BridgeSettings,
326    /// Latest bridge event sequence.
327    pub latest_sequence: u64,
328}
329
330/// Event envelope sent after a browser pairs or reconnects.
331#[derive(Debug, Clone, Serialize)]
332pub struct BridgeEventMessage {
333    /// Event discriminator.
334    #[serde(rename = "type")]
335    pub kind: &'static str,
336    /// Monotonic bridge sequence.
337    pub sequence: u64,
338    /// Canonical VT Code runtime event.
339    pub event: VersionedThreadEvent,
340}