nomoreide_remote_protocol/limits.rs
1//! The frozen limits. Every number a v1 peer is allowed to assume about the
2//! other side lives here, and nowhere else.
3//!
4//! They are constants rather than configuration on purpose. A limit either side
5//! can tune is not a limit — it is a negotiation, and the frame that arrives
6//! while the two ends disagree is the one that gets through. Changing any of
7//! these is a protocol change: it needs a new major version, because a phone
8//! built against the old number will send a frame the daemon now refuses.
9//!
10//! The byte counts are all measured over **UTF-8 bytes**, not characters. A
11//! limit counted in `char`s is a limit an attacker picks the units of.
12
13use std::time::Duration;
14
15/// The largest frame either side will read off the socket.
16///
17/// Read *before* parsing: a 4 MiB frame that would have failed validation still
18/// costs the memory to parse it, so the size check is the first thing that
19/// happens and the socket closes rather than answering.
20pub const MAX_FRAME_BYTES: usize = 256 * 1024;
21
22/// The largest prompt one agent turn may carry.
23///
24/// Small relative to the frame budget because a prompt is the one field a
25/// remote caller fully controls, and the daemon forwards it to a process.
26pub const MAX_AGENT_PROMPT_BYTES: usize = 16 * 1024;
27
28/// Where a single log line is cut. A service that writes a megabyte without a
29/// newline must not be able to fill the frame budget by itself.
30pub const MAX_LOG_LINE_BYTES: usize = 8 * 1024;
31
32/// The most log lines one response may carry, whatever was asked for.
33pub const MAX_LOG_LINES: usize = 200;
34
35/// The ceiling on a whole log response, applied after per-line truncation —
36/// 200 lines of 8 KiB would otherwise be 1.6 MiB.
37pub const MAX_LOG_RESPONSE_BYTES: usize = 256 * 1024;
38
39/// How many requests one device may have in flight.
40///
41/// The bound is per device rather than per user: it is the daemon's memory that
42/// a flood consumes, and a user with two machines should not be able to starve
43/// one by hammering the other.
44pub const MAX_PENDING_COMMANDS: usize = 32;
45
46/// How long a service action may take before the relay stops waiting.
47///
48/// A timeout is *not* a failure of the action — the daemon may well finish it.
49/// That is why a timed-out mutation is never retried automatically; see
50/// [`super::idempotency`].
51pub const SERVICE_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
52
53/// How often a connected daemon announces it is still there.
54pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
55
56/// How long the platform waits before calling a device offline.
57///
58/// Three heartbeat intervals, so a single dropped frame on a slow phone network
59/// does not flap the device between states. Reaching it suspends command
60/// routing rather than queueing — presence fails closed.
61pub const PRESENCE_TIMEOUT: Duration = Duration::from_secs(75);
62
63/// The ceiling on the daemon's reconnect backoff. Jittered by the connector, so
64/// a platform restart does not bring every daemon back in the same second.
65pub const RECONNECT_BACKOFF_CAP: Duration = Duration::from_secs(30);
66
67/// How far in the past a frame's `sentAt` may be before it is refused.
68///
69/// The point is not clock accuracy, it is intent: a command that spent two
70/// minutes in a queue is one whose sender has already given up and whose user
71/// has moved on. Executing it then is a surprise, and for a mutation it is a
72/// dangerous one.
73pub const MAX_REQUEST_AGE: Duration = Duration::from_secs(60);
74
75/// How far *ahead* of the receiver a frame's `sentAt` may be.
76///
77/// Phones have wrong clocks. Refusing everything from a device three minutes
78/// fast would make it permanently unusable, so the window is generous in this
79/// direction and the staleness rule above is what carries the security weight.
80pub const MAX_CLOCK_SKEW_AHEAD: Duration = Duration::from_secs(300);
81
82/// How long a request id is remembered for duplicate detection.
83///
84/// Longer than [`MAX_REQUEST_AGE`] so that the two rules cannot disagree: a
85/// frame young enough to execute is always young enough to have been seen.
86pub const REQUEST_ID_DEDUP_WINDOW: Duration = Duration::from_secs(600);
87
88/// How long a remote approval waits for a human before it denies itself.
89pub const APPROVAL_EXPIRY: Duration = Duration::from_secs(120);
90
91/// How far back a reconnecting client may resume an agent run's events.
92pub const AGENT_EVENT_REPLAY_WINDOW: Duration = Duration::from_secs(300);
93
94/// A prompt that could fill a frame on its own would leave no room for the
95/// envelope around it. Checked at compile time rather than in a test, because
96/// it is a relationship between two constants and nothing should be able to
97/// build with it broken.
98const _: () = assert!(MAX_AGENT_PROMPT_BYTES * 2 < MAX_FRAME_BYTES);
99
100/// The most events one run keeps for replay. A run that emits faster than the
101/// window can hold drops the oldest; a client that asks for a sequence older
102/// than the buffer is told to restart from the snapshot rather than handed a
103/// gap it cannot see.
104pub const AGENT_EVENT_REPLAY_EVENTS: usize = 2048;
105
106/// The most raw PTY output carried in one frame, before base64.
107///
108/// Well under [`MAX_FRAME_BYTES`] with the encoding's third added on top, so a
109/// coalesced burst can never be the thing that overflows a frame.
110pub const MAX_TERMINAL_CHUNK_BYTES: usize = 32 * 1024;
111
112/// How long the daemon gathers PTY output before sending it.
113///
114/// A TUI repaints far faster than anyone can read, and a frame per `read()`
115/// would spend a phone's battery redrawing frames it never displays. Roughly
116/// one screen's worth of latency, which is imperceptible while typing and
117/// collapses a spinner into a handful of frames a second.
118pub const TERMINAL_COALESCE_INTERVAL: Duration = Duration::from_millis(16);
119
120/// The most keystroke data one input frame may carry.
121///
122/// Small on purpose: this is for typing, and a phone with a long block of text
123/// to deliver should use the agent prompt path, which is bounded separately and
124/// goes in as one paste rather than as a stream of keys.
125pub const MAX_TERMINAL_INPUT_BYTES: usize = 4 * 1024;
126
127/// How many terminals one device may have mirrored at once.
128pub const MAX_TERMINAL_STREAMS: usize = 4;
129
130/// The widest and tallest a mirrored terminal may claim to be. A resize is
131/// attacker-supplied arithmetic that reaches `ioctl`, so it is bounded before
132/// it gets there rather than trusted.
133pub const MAX_TERMINAL_DIMENSION: u16 = 1_000;
134
135// --- The inspection surface --------------------------------------------------
136//
137// Every list below is bounded twice: by the count, and by the prose budget the
138// count multiplies against. A GitHub payload and a service's own error text are
139// both written by somebody else, so neither the number of rows nor the length
140// of a row is something this side gets to assume.
141
142/// The most workflow runs one response may carry, whatever was asked for.
143///
144/// One page of GitHub's own listing. A phone scrolling further is a request for
145/// a second page, which this protocol does not have — and deliberately: an
146/// endless CI history is a website's job, and the phone's job is "is it green".
147pub const MAX_WORKFLOW_RUNS: usize = 30;
148
149/// The most jobs one workflow run's response may carry.
150///
151/// Larger than the run bound because a matrix build is genuinely wide, and a
152/// truncated job list is the one case where the missing row is the failing one.
153pub const MAX_WORKFLOW_JOBS: usize = 100;
154
155/// The most pull requests one response may carry.
156pub const MAX_PULL_REQUESTS: usize = 30;
157
158/// The most incidents one response may carry.
159pub const MAX_INCIDENTS: usize = 50;
160
161/// The most timeline entries one response may carry.
162pub const MAX_TIMELINE_ENTRIES: usize = 100;
163
164/// Where any single piece of prose on the inspection surface is cut — a run
165/// title, a pull request title, an incident title, a timeline detail.
166///
167/// These strings come from GitHub and from the user's own build output, so they
168/// are unbounded at the source. Cutting each one keeps a single pathological
169/// value from consuming the whole response, and the count bounds above then cap
170/// the total.
171pub const MAX_SUMMARY_BYTES: usize = 512;
172
173/// The ceiling on a whole inspection response, applied after per-field
174/// truncation, for the same reason [`MAX_LOG_RESPONSE_BYTES`] exists: the
175/// per-item bounds multiplied out are much larger than anything worth sending.
176pub const MAX_INSPECTION_RESPONSE_BYTES: usize = 128 * 1024;
177
178/// The per-item cuts multiplied out must not overflow the response budget on
179/// their own — otherwise the byte ceiling would be the only thing doing the
180/// work, and it truncates a list without being asked. Checked at compile time,
181/// like the prompt/frame relationship above, because nothing should build with
182/// it broken.
183const _: () = {
184 assert!(MAX_WORKFLOW_JOBS * MAX_SUMMARY_BYTES <= MAX_INSPECTION_RESPONSE_BYTES);
185 assert!(MAX_TIMELINE_ENTRIES * MAX_SUMMARY_BYTES <= MAX_INSPECTION_RESPONSE_BYTES);
186 assert!(MAX_INSPECTION_RESPONSE_BYTES < MAX_FRAME_BYTES);
187};
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 /// The numbers are frozen. This test exists to make changing one a
194 /// deliberate act with a protocol-version conversation attached, rather
195 /// than a one-character diff nobody reviews.
196 #[test]
197 fn v1_limits_are_frozen() {
198 assert_eq!(MAX_FRAME_BYTES, 262_144);
199 assert_eq!(MAX_AGENT_PROMPT_BYTES, 16_384);
200 assert_eq!(MAX_LOG_LINE_BYTES, 8_192);
201 assert_eq!(MAX_LOG_LINES, 200);
202 assert_eq!(MAX_LOG_RESPONSE_BYTES, 262_144);
203 assert_eq!(MAX_PENDING_COMMANDS, 32);
204 assert_eq!(SERVICE_COMMAND_TIMEOUT.as_secs(), 30);
205 assert_eq!(HEARTBEAT_INTERVAL.as_secs(), 25);
206 assert_eq!(PRESENCE_TIMEOUT.as_secs(), 75);
207 assert_eq!(RECONNECT_BACKOFF_CAP.as_secs(), 30);
208 assert_eq!(APPROVAL_EXPIRY.as_secs(), 120);
209 assert_eq!(MAX_TERMINAL_CHUNK_BYTES, 32_768);
210 assert_eq!(MAX_TERMINAL_INPUT_BYTES, 4_096);
211 assert_eq!(MAX_TERMINAL_STREAMS, 4);
212 assert_eq!(MAX_TERMINAL_DIMENSION, 1_000);
213 assert_eq!(TERMINAL_COALESCE_INTERVAL.as_millis(), 16);
214 }
215
216 /// The inspection bounds are frozen too, for the same reason: a phone that
217 /// sizes a list against one of these numbers is relying on it.
218 #[test]
219 fn inspection_limits_are_frozen() {
220 assert_eq!(MAX_WORKFLOW_RUNS, 30);
221 assert_eq!(MAX_WORKFLOW_JOBS, 100);
222 assert_eq!(MAX_PULL_REQUESTS, 30);
223 assert_eq!(MAX_INCIDENTS, 50);
224 assert_eq!(MAX_TIMELINE_ENTRIES, 100);
225 assert_eq!(MAX_SUMMARY_BYTES, 512);
226 assert_eq!(MAX_INSPECTION_RESPONSE_BYTES, 131_072);
227 }
228
229 /// A coalesced chunk must still fit a frame once base64 has added its
230 /// third, with room for the envelope around it. Base64 is 4 bytes out per
231 /// 3 in, so the check is the encoded size rather than the raw one.
232 #[test]
233 fn a_full_terminal_chunk_fits_a_frame_once_encoded() {
234 let encoded = MAX_TERMINAL_CHUNK_BYTES.div_ceil(3) * 4;
235 assert!(
236 encoded * 2 < MAX_FRAME_BYTES,
237 "a coalesced chunk must leave room for the envelope, not merely fit"
238 );
239 }
240
241 /// A dedup window shorter than the staleness window would let a frame be
242 /// young enough to execute but old enough to have been forgotten — which is
243 /// exactly the replay the request id is there to stop.
244 #[test]
245 fn dedup_window_outlasts_request_staleness() {
246 assert!(REQUEST_ID_DEDUP_WINDOW > MAX_REQUEST_AGE);
247 }
248
249 /// Offline has to mean "missed several", not "missed one".
250 #[test]
251 fn presence_timeout_tolerates_lost_heartbeats() {
252 assert!(PRESENCE_TIMEOUT >= HEARTBEAT_INTERVAL * 3);
253 }
254}