Skip to main content

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}