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