nomoreide_remote_protocol/version.rs
1//! Version negotiation, capabilities, and what a phone does when the machine at
2//! the other end is out of date.
3//!
4//! This answers the question the original relay plan left open. It matters more
5//! than it looks: this project's own development machine ran a v0.1.103 daemon
6//! against a v0.3.0 client for days, and the only signal was one warning line.
7//! People do not upgrade daemons promptly, so "the versions differ" is the
8//! normal case, not the exception, and it needs a designed answer rather than a
9//! failure mode.
10//!
11//! Three rules, and they are deliberately different from each other:
12//!
13//! 1. **The envelope is invariant.** `v`, `id`, `type`, `deviceId`, `sentAt`,
14//! `replyTo`, `payload` are fixed for the life of the protocol. `v` versions
15//! the *payload union*, not the frame. That is what lets two peers with no
16//! version in common still exchange a hello, a rejection and a heartbeat
17//! instead of staring at each other.
18//! 2. **An unknown command is an error.** Fail closed: a name this peer does
19//! not know is refused, never ignored. See [`super::device_bound`].
20//! 3. **An unknown capability is an omission.** A feature the other end has not
21//! got is a thing to *say*, not a thing to fail on — the phone renders
22//! "your machine is running an older NoMoreIDE", and the rest of the session
23//! keeps working.
24//!
25//! Across a major gap the session still opens, in [`SessionMode::Degraded`]:
26//! presence and read-only commands route, and every mutating command is refused
27//! with [`super::errors::ErrorCode::UnsupportedProtocolVersion`]. Refusing the
28//! whole session instead would leave the user a dead screen with no way to be
29//! told what to do about it.
30
31use serde::{Deserialize, Serialize};
32use std::collections::BTreeSet;
33
34/// The major version this build speaks.
35pub const PROTOCOL_VERSION: u32 = 2;
36
37/// The oldest major version worth talking to at all.
38///
39/// Below this the platform rejects the socket outright rather than degrading:
40/// there is a point where "read-only and please upgrade" stops being reachable
41/// because the frames themselves have changed.
42///
43/// Still 1 now that 2 exists, which is the whole point of the number: v2 added
44/// frames and took none away, so a v1 daemon is not broken, only smaller. It
45/// keeps every v1 capability and simply never advertises the terminal ones.
46pub const MINIMUM_SPEAKABLE_VERSION: u32 = 1;
47
48/// Every major version this build can serve, newest last.
49pub const SUPPORTED_VERSIONS: &[u32] = &[1, 2];
50
51/// A floor above what this build speaks would reject every peer, including
52/// itself. Checked at compile time, for the same reason the limits are.
53const _: () = assert!(MINIMUM_SPEAKABLE_VERSION <= PROTOCOL_VERSION);
54
55/// A named, additively-shipped feature.
56///
57/// Capabilities exist so that adding a remote feature does not need a version
58/// bump — which in a world of stale daemons would mean every new feature
59/// degrading every old machine. The daemon advertises what it has; the platform
60/// asks for nothing it was not offered.
61///
62/// The name is the wire value. It is a plain string type rather than an enum
63/// because the *reading* side must tolerate names invented after it was built:
64/// an unrecognised capability is one this peer will simply never use.
65#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct Capability(pub String);
68
69impl Capability {
70 pub fn new(name: &str) -> Self {
71 Self(name.to_string())
72 }
73
74 pub fn as_str(&self) -> &str {
75 &self.0
76 }
77}
78
79/// The capabilities a v1 daemon advertises. One per allowlisted area, not one
80/// per command — a phone needs to know whether agent turns are available, not
81/// whether `agent.turn.cancel` specifically is.
82pub mod capabilities {
83 /// Sanitized device snapshot and presence. Always present; a daemon that
84 /// cannot do this has nothing to offer.
85 pub const DEVICE_SNAPSHOT: &str = "device.snapshot";
86 /// Listing registered services with their runtime state.
87 pub const SERVICE_LIST: &str = "service.list";
88 /// `start`, `stop`, `restart` on an exact registered service.
89 pub const SERVICE_ACTION: &str = "service.action";
90 /// Bounded, redacted recent logs.
91 pub const SERVICE_LOGS: &str = "service.logs";
92 /// Listing registered bundles with their state. Read-only: the allowlist
93 /// has no bundle mutation.
94 pub const BUNDLE_LIST: &str = "bundle.list";
95 /// Reporting which agent providers are installed.
96 pub const AGENT_PROVIDERS: &str = "agent.providers";
97 /// Starting, resuming and cancelling one agent turn.
98 pub const AGENT_TURNS: &str = "agent.turns";
99 /// Answering a pending mutating tool request.
100 pub const AGENT_APPROVALS: &str = "agent.approvals";
101
102 /// Listing the agent terminals running on the machine. **v2.**
103 pub const TERMINAL_SESSIONS: &str = "terminal.sessions";
104 /// Starting an agent terminal. **v2.**
105 ///
106 /// Its own capability, separate from mirroring, because they are different
107 /// permissions: watching an agent somebody started is not the same as
108 /// starting one. A machine can offer either without the other.
109 pub const TERMINAL_SPAWN: &str = "terminal.spawn";
110 /// Starting a **shell** terminal, and mirroring one. **v2.**
111 ///
112 /// Its own capability, and the only one here that is a genuine widening: a
113 /// shell is arbitrary command execution. It is separate so a machine can
114 /// offer agents from a phone without offering a shell, and so that turning
115 /// it off is a thing a user can actually do.
116 ///
117 /// Kept honest: while this is advertised, "remote control cannot run
118 /// arbitrary commands" is false, and the pairing copy says so.
119 pub const TERMINAL_SHELL: &str = "terminal.shell";
120 /// **Ending** an agent terminal, rather than only stopping the mirror.
121 ///
122 /// Its own name because it is the one thing on the terminal surface that
123 /// destroys work. `terminal.attach` covers detaching, which leaves the
124 /// agent running on purpose; this stops it. A machine can offer the whole
125 /// rest of the surface — spawn, shell, mirror, type — and not this, which
126 /// is the point of it being separate rather than folded into any of them.
127 pub const TERMINAL_KILL: &str = "terminal.kill";
128 /// Starting an agent terminal **in a named registered repository**, rather
129 /// than only in the one the machine has selected.
130 ///
131 /// The one capability here that gates a *field* rather than a command:
132 /// `terminal.spawn.request` is routable without it, and simply has no
133 /// `repository` on it. It needs a name of its own because
134 /// `TerminalSpawnRequest` denies unknown fields — a daemon built before the
135 /// field existed refuses the entire frame rather than ignoring the key, so
136 /// "try it and see" is not available to a phone the way an unknown
137 /// capability normally is. A phone that is not offered this name sends what
138 /// it always sent, and the agent starts in the selected repository.
139 pub const TERMINAL_SPAWN_REPOSITORY: &str = "terminal.spawn.repository";
140 /// Mirroring one agent terminal: its output, and typing into it. **v2.**
141 ///
142 /// Deliberately separate from [`TERMINAL_SESSIONS`], so a machine can show
143 /// a phone *that* an agent is running without handing over its screen.
144 pub const TERMINAL_ATTACH: &str = "terminal.attach";
145
146 // --- The read-only inspection surface -----------------------------------
147 //
148 // Everything below answers "what is going on?" and nothing below changes
149 // anything. They are separate capabilities rather than one `inspect`
150 // because they need different things of the machine — two of them need a
151 // GitHub account, one needs an agent to have run at least once, and two
152 // need only the daemon itself. A phone must be able to tell "this machine
153 // has no GitHub connected" from "this build is too old", and one
154 // capability covering all five could not say either.
155
156 /// Listing GitHub Actions workflow runs, and one run's jobs.
157 ///
158 /// Read-only: there is no re-run, no cancel, and no dispatch. Watching CI
159 /// from a phone is a different permission from steering it, and only the
160 /// first is here.
161 /// The machine's registered repositories, so a phone can say which one it
162 /// is asking about. Its own capability rather than folded into
163 /// `github.actions`: a daemon that predates the picker does not advertise
164 /// it, and a phone that sees it missing keeps asking about the selected
165 /// repository exactly as it did before.
166 pub const REPOSITORIES: &str = "repositories.list";
167
168 pub const GITHUB_ACTIONS: &str = "github.actions";
169 /// Listing pull requests, and reading one.
170 ///
171 /// Read-only in the same sense: no merge, no create, no review. Those live
172 /// in `nomoreide-actions` locally and have no frame here.
173 pub const GITHUB_PULLS: &str = "github.pulls";
174 /// Linear tasks: reading them, and — unlike the two above — writing them.
175 ///
176 /// The exception to the read-only rule the rest of this list follows, and
177 /// deliberately: binding a repository, creating an issue, changing a state
178 /// and commenting are classified as mutations in `linear.rs`, so a degraded
179 /// session refuses them and nothing ever retries one. The key itself stays
180 /// on the host — it is never sent to a device, and a scanned guest link
181 /// does not carry this capability at all.
182 pub const LINEAR: &str = "linear.tasks";
183 /// What Claude and Codex have spent, and how close their rate-limit
184 /// windows are.
185 ///
186 /// The one thing a phone can answer that a laptop cannot: whether starting
187 /// a turn now is worth it. Reported without the working directory or the
188 /// session id the local panel shows — see [`super::snapshot`].
189 pub const AGENT_USAGE: &str = "agent.usage";
190 /// The deduped error inbox: what is broken, in which service, how often.
191 pub const DEVICE_ERRORS: &str = "device.errors";
192 /// The runtime timeline: what the daemon did, across every service.
193 pub const DEVICE_TIMELINE: &str = "device.timeline";
194
195 /// Everything a fully-featured v1 daemon offers.
196 pub const V1: &[&str] = &[
197 DEVICE_SNAPSHOT,
198 SERVICE_LIST,
199 SERVICE_ACTION,
200 SERVICE_LOGS,
201 BUNDLE_LIST,
202 AGENT_PROVIDERS,
203 AGENT_TURNS,
204 AGENT_APPROVALS,
205 ];
206
207 /// Everything a fully-featured v2 daemon offers: v1, plus the terminal,
208 /// plus the read-only inspection surface.
209 ///
210 /// Additive by construction — v2 removed nothing, so this is `V1` with the
211 /// new names appended rather than a second list to keep in step.
212 ///
213 /// **This list grows without a version bump, and that is the design.** A
214 /// capability is what *this machine* offers, not what its protocol era
215 /// defined; `PROTOCOL_VERSION` versions the payload union's frame rules,
216 /// and a name a peer has not heard of is an omission rather than a failure.
217 /// So a daemon built today advertises more than one built when v2 shipped,
218 /// both are v2, and an older phone simply never asks for the extra names.
219 pub const V2: &[&str] = &[
220 DEVICE_SNAPSHOT,
221 SERVICE_LIST,
222 SERVICE_ACTION,
223 SERVICE_LOGS,
224 BUNDLE_LIST,
225 AGENT_PROVIDERS,
226 AGENT_TURNS,
227 AGENT_APPROVALS,
228 TERMINAL_SESSIONS,
229 TERMINAL_ATTACH,
230 TERMINAL_SPAWN,
231 TERMINAL_SPAWN_REPOSITORY,
232 TERMINAL_SHELL,
233 TERMINAL_KILL,
234 REPOSITORIES,
235 GITHUB_ACTIONS,
236 GITHUB_PULLS,
237 LINEAR,
238 AGENT_USAGE,
239 DEVICE_ERRORS,
240 DEVICE_TIMELINE,
241 ];
242
243 /// `V2` must extend `V1` rather than diverge from it. Checked here because
244 /// the two lists are written out separately for readability.
245 const _: () = {
246 assert!(V2.len() == V1.len() + 13);
247 };
248}
249
250/// What a daemon advertises, and what the platform holds about it.
251///
252/// A `BTreeSet` rather than a `Vec` so two daemons advertising the same
253/// capabilities in different orders compare equal, and so the JSON is stable
254/// enough to be a golden fixture.
255#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(transparent)]
257pub struct CapabilitySet(pub BTreeSet<Capability>);
258
259impl CapabilitySet {
260 /// Everything this build offers.
261 pub fn current() -> Self {
262 Self(
263 capabilities::V2
264 .iter()
265 .map(|name| Capability::new(name))
266 .collect(),
267 )
268 }
269
270 pub fn contains(&self, name: &str) -> bool {
271 self.0.iter().any(|capability| capability.as_str() == name)
272 }
273
274 pub fn from_names<'a>(names: impl IntoIterator<Item = &'a str>) -> Self {
275 Self(names.into_iter().map(Capability::new).collect())
276 }
277}
278
279/// How much of the protocol a negotiated session may use.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(rename_all = "lowercase")]
282pub enum SessionMode {
283 /// Both ends agreed on a version this build serves. Everything the
284 /// capability set allows is routable.
285 Full,
286 /// No shared version. Presence and read-only commands only; every mutation
287 /// is refused, and the phone is told to update the machine.
288 Degraded,
289}
290
291/// The outcome of comparing two peers' supported versions.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum Negotiation {
294 /// Agreed. `version` is the highest both sides can speak.
295 Agreed { version: u32, mode: SessionMode },
296 /// The peer is too old to talk to at all — its highest version is below
297 /// [`MINIMUM_SPEAKABLE_VERSION`], or it offered none.
298 Rejected { reason: RejectReason },
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "camelCase")]
303pub enum RejectReason {
304 /// The peer advertised no versions at all.
305 NoVersionsOffered,
306 /// Every version the peer offered is below the floor.
307 BelowMinimumVersion,
308}
309
310/// Decide what a session between `ours` and `theirs` may do.
311///
312/// The rule is: take the highest version both sides list. If there is none, and
313/// the peer is at or above the floor, run degraded at the peer's own highest —
314/// that is the version whose frames it will actually understand, so it is the
315/// one to speak while telling it to upgrade.
316pub fn negotiate(ours: &[u32], theirs: &[u32]) -> Negotiation {
317 let Some(their_best) = theirs.iter().copied().max() else {
318 return Negotiation::Rejected {
319 reason: RejectReason::NoVersionsOffered,
320 };
321 };
322 if their_best < MINIMUM_SPEAKABLE_VERSION {
323 return Negotiation::Rejected {
324 reason: RejectReason::BelowMinimumVersion,
325 };
326 }
327 let shared = ours
328 .iter()
329 .copied()
330 .filter(|version| theirs.contains(version))
331 .max();
332 match shared {
333 Some(version) => Negotiation::Agreed {
334 version,
335 mode: SessionMode::Full,
336 },
337 None => Negotiation::Agreed {
338 version: their_best,
339 mode: SessionMode::Degraded,
340 },
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 /// `current()` means "what this build offers", so it has to track the
349 /// newest capability list rather than whichever one it was written against.
350 /// It said `V1` for a while after v2 shipped, which made a current daemon
351 /// look like it could not mirror a terminal it could.
352 #[test]
353 fn current_capabilities_are_the_newest_ones() {
354 let current = CapabilitySet::current();
355 for name in capabilities::V2 {
356 assert!(current.contains(name), "{name} is missing from current()");
357 }
358 }
359
360 #[test]
361 fn identical_peers_agree_on_full() {
362 assert_eq!(
363 negotiate(SUPPORTED_VERSIONS, SUPPORTED_VERSIONS),
364 Negotiation::Agreed {
365 version: PROTOCOL_VERSION,
366 mode: SessionMode::Full,
367 }
368 );
369 }
370
371 /// A newer platform meeting an older daemon takes the highest they share,
372 /// which is the whole point of listing versions rather than sending one.
373 #[test]
374 fn overlapping_peers_take_the_highest_shared_version() {
375 assert_eq!(
376 negotiate(&[1, 2, 3], &[1, 2]),
377 Negotiation::Agreed {
378 version: 2,
379 mode: SessionMode::Full,
380 }
381 );
382 }
383
384 /// The case the revision asked to design for: no overlap, but the daemon is
385 /// still above the floor. The session opens read-only rather than dying, so
386 /// the phone can say why.
387 #[test]
388 fn a_major_gap_degrades_rather_than_refusing() {
389 assert_eq!(
390 negotiate(&[4, 5], &[1]),
391 Negotiation::Agreed {
392 version: 1,
393 mode: SessionMode::Degraded,
394 }
395 );
396 }
397
398 #[test]
399 fn a_peer_below_the_floor_is_refused_outright() {
400 assert_eq!(
401 negotiate(&[1], &[0]),
402 Negotiation::Rejected {
403 reason: RejectReason::BelowMinimumVersion,
404 }
405 );
406 }
407
408 #[test]
409 fn a_peer_offering_nothing_is_refused() {
410 assert_eq!(
411 negotiate(&[1], &[]),
412 Negotiation::Rejected {
413 reason: RejectReason::NoVersionsOffered,
414 }
415 );
416 }
417
418 #[test]
419 fn the_current_capability_set_covers_every_v1_area() {
420 let current = CapabilitySet::current();
421 for name in capabilities::V1 {
422 assert!(current.contains(name), "missing {name}");
423 }
424 }
425
426 /// A capability nobody has heard of must read cleanly. It is a feature this
427 /// peer will not use, not a frame it cannot parse.
428 #[test]
429 fn an_unknown_capability_parses_and_is_simply_absent() {
430 let set: CapabilitySet =
431 serde_json::from_str(r#"["service.list","something.invented.later"]"#).expect("parse");
432 assert!(set.contains("service.list"));
433 assert!(set.contains("something.invented.later"));
434 assert!(!set.contains(capabilities::AGENT_TURNS));
435 }
436
437 #[test]
438 fn the_version_we_speak_is_one_we_support() {
439 assert!(SUPPORTED_VERSIONS.contains(&PROTOCOL_VERSION));
440 }
441}