oxedyne/daimond/hand/src/wire.rs
40.2 KiB, 1 run
created by r2519314175:929, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! What the page and the hand say to each other, and how it is framed. |
| 2 | //! |
| 3 | //! **Designed for remote from the first line.** Loopback is the degenerate |
| 4 | //! case of remote, and a protocol built for localhost only is a rewrite waiting |
| 5 | //! to happen. So the *messages* here are transport-neutral JSON, and the |
| 6 | //! framing is a separate, swappable concern: |
| 7 | //! |
| 8 | //! * **Native messaging** (the `Machine` tier): a 4-byte native-endian length |
| 9 | //! prefix followed by UTF-8 JSON, which is Chrome's format and not |
| 10 | //! negotiable. Chrome caps a host→extension message at 1 MB. |
| 11 | //! * **WebSocket** (the `Cloud` tier): the identical JSON as one text frame, |
| 12 | //! so a phone can drive a desktop with the same hand binary. |
| 13 | //! |
| 14 | //! The types below are the contract. Everything else in the crate is written |
| 15 | //! against them. |
| 16 | |
| 17 | use crate::PROTO; |
| 18 | |
| 19 | use oxedyne_fe2o3_core::prelude::*; |
| 20 | |
| 21 | // ┌───────────────────────────────────────────────────────────────┐ |
| 22 | // │ Limits │ |
| 23 | // └───────────────────────────────────────────────────────────────┘ |
| 24 | |
| 25 | /// The largest encoded frame the hand will ever emit. |
| 26 | /// |
| 27 | /// Chrome caps a host→extension message at 1 MB and drops the connection |
| 28 | /// without ceremony when one exceeds it. JSON escaping can inflate a payload |
| 29 | /// severalfold in the worst case (a control byte costs six), so the budget for |
| 30 | /// the *data* inside a chunk is set well below the cap rather than at it. |
| 31 | pub const FRAME_MAX: usize = 1_000_000; |
| 32 | |
| 33 | /// The largest run of output bytes carried in a single [`Resp::Chunk`]. |
| 34 | /// |
| 35 | /// Chosen so that even output escaping at the worst rate JSON can manage still |
| 36 | /// lands inside [`FRAME_MAX`] with room for the envelope. |
| 37 | pub const CHUNK_MAX: usize = 128 * 1024; |
| 38 | |
| 39 | /// The largest whole number the wire carries, in either direction. |
| 40 | /// |
| 41 | /// `Number.MAX_SAFE_INTEGER`. One end of this conversation is JavaScript, where |
| 42 | /// a number is a `f64`: past 2^53 the integers stop being consecutive, so |
| 43 | /// `JSON.parse` reads `18446744073709551615` back as `18446744073709552000` and |
| 44 | /// reads 2^53 and 2^53+1 as the same value. A `u64` field is therefore only a |
| 45 | /// `u64` up to here, and the contract says so rather than leaving each message |
| 46 | /// to find out. |
| 47 | /// |
| 48 | /// It binds all five numbers that could reach it: [`Req::Exec`]'s `timeout_ms`, |
| 49 | /// [`Resp::Chunk`]'s and [`Resp::Output`]'s `seq`, and [`Resp::Ended`]'s |
| 50 | /// `out_bytes` and `err_bytes`. The rest of the wire's numbers are `u32`, `i32` |
| 51 | /// or `u16`, all of which cross unharmed. |
| 52 | /// |
| 53 | /// **Exceeding it is refused at both ends, never clamped** -- see [`crate::codec`] |
| 54 | /// for why a clamp is the worse failure. |
| 55 | pub const SAFE_INT_MAX: u64 = (1u64 << 53) - 1; |
| 56 | |
| 57 | // ┌───────────────────────────────────────────────────────────────┐ |
| 58 | // │ Requests: the page asks │ |
| 59 | // └───────────────────────────────────────────────────────────────┘ |
| 60 | |
| 61 | /// Which of a command's streams the caller wants back. |
| 62 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 63 | pub enum Capture { |
| 64 | /// Both stdout and stderr, tagged by [`Stream`]. |
| 65 | Both, |
| 66 | /// Standard output only; standard error is discarded. |
| 67 | Out, |
| 68 | /// Standard error only; standard output is discarded. |
| 69 | Err, |
| 70 | /// Neither: the exit code is the whole of the answer. |
| 71 | None, |
| 72 | } |
| 73 | |
| 74 | /// A signal the page may send to a running command. |
| 75 | /// |
| 76 | /// Three, not the whole POSIX set: an agent needs to ask a process to stop, to |
| 77 | /// insist, or to interrupt it as a terminal would. The rest are a way to reach |
| 78 | /// behaviour nobody asked for. |
| 79 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 80 | pub enum Sig { |
| 81 | /// Ask it to stop (`SIGTERM`). |
| 82 | Term, |
| 83 | /// Insist (`SIGKILL`). |
| 84 | Kill, |
| 85 | /// Interrupt, as `Ctrl-C` would (`SIGINT`). |
| 86 | Int, |
| 87 | } |
| 88 | |
| 89 | /// What a command may touch, in the shape `diamond_bounds` already produces. |
| 90 | /// |
| 91 | /// The app's `Bound::OnlyUnder` list, its `NoWrite` prefixes and its deny of |
| 92 | /// `.daimond/` map onto these three fields exactly. That is not a coincidence |
| 93 | /// and it is the point: the compartment is **not a new concept**, it is the |
| 94 | /// same rule enforced one layer down, so the guarantee the guide already |
| 95 | /// describes survives the move from a structural boundary to a kernel one. |
| 96 | /// |
| 97 | /// Paths are absolute and already resolved by the caller; the hand does not |
| 98 | /// interpret workspace-relative spellings. |
| 99 | #[derive(Clone, Debug, Eq, PartialEq, Default)] |
| 100 | pub struct FenceSpec { |
| 101 | /// Roots the command may read and write. |
| 102 | pub rw: Vec<String>, |
| 103 | /// Roots the command may read and not write. |
| 104 | pub ro: Vec<String>, |
| 105 | /// Subtrees denied outright, even where they sit inside `rw` or `ro`. |
| 106 | pub deny: Vec<String>, |
| 107 | /// Whether the command may reach the network at all. |
| 108 | /// |
| 109 | /// False is the interesting case: it is what makes a fenced build unable to |
| 110 | /// post the source tree somewhere, whatever it was told to do. |
| 111 | pub net: bool, |
| 112 | } |
| 113 | |
| 114 | /// How big the terminal is, in character cells. |
| 115 | /// |
| 116 | /// A program asks the kernel this, not the page, so it has to be told at the pty |
| 117 | /// and told again whenever the window changes -- a `less` that thinks it has 24 |
| 118 | /// rows on an 80-row screen is the visible symptom of forgetting the second half. |
| 119 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 120 | pub struct PtySize { |
| 121 | /// Columns. |
| 122 | pub cols: u16, |
| 123 | /// Rows. |
| 124 | pub rows: u16, |
| 125 | } |
| 126 | |
| 127 | /// Which of a verifier's declared breaks a [`Req::Verify`] should run. |
| 128 | /// |
| 129 | /// **Not a free string, and that is the point.** A break is a mode the verifier |
| 130 | /// itself implements and names in its own source; a caller who could invent one |
| 131 | /// would run the file unchanged and be handed a pass that measured nothing. So |
| 132 | /// the only shapes here are "every break the file declares", "this one, which |
| 133 | /// the file must declare", and "none, and say so". |
| 134 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 135 | pub enum Breaks { |
| 136 | /// Every break the verifier declares, one run each. |
| 137 | All, |
| 138 | /// One named break, which the verifier must declare or the request is refused. |
| 139 | One(String), |
| 140 | /// None. The clean run alone, whose result is UNPROVEN and says so in words. |
| 141 | None, |
| 142 | } |
| 143 | |
| 144 | impl Breaks { |
| 145 | /// The word this travels under. |
| 146 | pub fn word(&self) -> &'static str { |
| 147 | match self { |
| 148 | Self::All => "all", |
| 149 | Self::One(_) => "one", |
| 150 | Self::None => "none", |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // ── Why a pty is a separate pair of messages ──────────────────────── |
| 156 | // |
| 157 | // [`Req::Exec`] is non-interactive by design: stdin is a string decided before the |
| 158 | // command starts, output is captured, and nothing can answer a question. That covers |
| 159 | // nearly everything an agent does, and it is deliberately the simpler shape. |
| 160 | // |
| 161 | // A pty is the other thing. `sudo` wants a password, `ssh` wants a passphrase, `git` |
| 162 | // wants an editor, `vim` wants the whole screen, and a REPL wants a conversation. |
| 163 | // None of them work down a pipe, because they ask the kernel whether they are talking |
| 164 | // to a terminal and behave differently when they are not. So the hand allocates a real |
| 165 | // terminal, gives the command the far end of it as its controlling terminal, and the |
| 166 | // bytes flow both ways for as long as the program lives. |
| 167 | // |
| 168 | // It is kept as its own messages rather than a flag on `Exec` because almost nothing |
| 169 | // is shared: there are no separate out and err streams (a terminal merges them by |
| 170 | // construction), input arrives repeatedly rather than once, the size matters and can |
| 171 | // change, and the interesting end is a session rather than a result. |
| 172 | // |
| 173 | // **Data is base64 in both directions**, unlike `Chunk`, which carries text. A pty |
| 174 | // carries arbitrary bytes -- a `cat` of a binary file, a half-written UTF-8 character |
| 175 | // at the edge of a read, a control sequence -- and a terminal that mangles one byte |
| 176 | // draws the rest of the screen wrong. Text with a lossy conversion would be smaller |
| 177 | // and would silently corrupt exactly the case a terminal exists to handle. |
| 178 | |
| 179 | // ── What a run leaves behind, and how it is reached afterwards ────── |
| 180 | // |
| 181 | // A command may outlive itself. `bash dev/world.sh 3 --up` starts a dev server |
| 182 | // and a mock provider in the background and exits; the direct child is reaped, |
| 183 | // its process GROUP is not empty, and the two servers go on holding their ports. |
| 184 | // That is a legitimate thing to want -- a browser verifier needs a server to |
| 185 | // drive -- so the answer is not to refuse it. |
| 186 | // |
| 187 | // The answer that was there was worse than none. Nothing recorded the group, so |
| 188 | // nothing could reach it; the fence scopes signals to the Landlock domain that |
| 189 | // sent them, so a LATER command cannot signal an earlier one's leftovers and gets |
| 190 | // "Operation not permitted"; `/proc` is outside the fence, so the pid cannot even |
| 191 | // be found; and `dev/world.sh --down` swallowed the failed kill with `2>/dev/null`, |
| 192 | // reported success and deleted its own pid files. Two servers were left on 8780 |
| 193 | // and 9102 with no route to them and a person had to clear them from outside. |
| 194 | // |
| 195 | // So the hand keeps what it started. [`Req::Runs`] asks what is still going and |
| 196 | // [`Req::Signal`] stops one of them BY THE IDENTIFIER THE RUN WAS GIVEN -- never |
| 197 | // a pid, never a name, never a pattern, because a hand that took any of those |
| 198 | // would be `pkill` with extra steps. Only a group this hand's own launcher |
| 199 | // created can be named at all, which is the whole of the guard. |
| 200 | // |
| 201 | // There is deliberately no "stopped" answer. A signal that could not be |
| 202 | // delivered comes back as [`Resp::Error`]; a signal that could is confirmed by |
| 203 | // asking again, because the listing is a measurement and an acknowledgement is a |
| 204 | // claim. Reporting success on a kill that failed is the defect this exists to |
| 205 | // close, and the cheapest way not to write it again is to have nowhere to write |
| 206 | // it. |
| 207 | |
| 208 | /// Where a run this hand started has got to. |
| 209 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 210 | pub enum RunState { |
| 211 | Running, // the command itself has not finished |
| 212 | Standing, // the command has finished and its process group has not emptied |
| 213 | } |
| 214 | |
| 215 | impl RunState { |
| 216 | /// The word this travels under. |
| 217 | pub fn word(&self) -> &'static str { |
| 218 | match self { |
| 219 | Self::Running => "running", |
| 220 | Self::Standing => "standing", |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// One command this hand started, as [`Resp::Runs`] reports it. |
| 226 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 227 | pub struct Run { |
| 228 | pub id: String, // the identifier the run was given, and the way to signal it |
| 229 | pub pid: u32, // the process, which is also its group |
| 230 | pub what: String, // the command line, cut to [`RUN_WHAT_MAX`] |
| 231 | pub state: RunState, |
| 232 | pub secs: u32, // how long it has been in that state |
| 233 | } |
| 234 | |
| 235 | /// The longest command line a listing carries for one run. |
| 236 | /// |
| 237 | /// Enough to recognise a command by and not enough for a listing of many runs to |
| 238 | /// approach [`FRAME_MAX`]. A cut is marked, because a command line a reader |
| 239 | /// takes for whole is one they will try to run again. |
| 240 | pub const RUN_WHAT_MAX: usize = 160; |
| 241 | |
| 242 | /// The most runs one [`Resp::Runs`] carries. |
| 243 | /// |
| 244 | /// A listing is bounded so that a page asking what is running cannot be answered |
| 245 | /// with a frame it must then refuse. What did not fit is COUNTED rather than |
| 246 | /// dropped -- see [`Resp::Runs`]'s `more`. |
| 247 | pub const RUNS_MAX: usize = 200; |
| 248 | |
| 249 | // ── Why a file edit is a message and not a command ────────────────── |
| 250 | // |
| 251 | // Everything below this line is the answer to one measured failure. A daimon asked to |
| 252 | // restore one localisation key in eight files had no way to change a file on the machine |
| 253 | // except by running a program that changes files, so it ran `sed -i`; call 20 put a French |
| 254 | // apostrophe into a single-quoted JavaScript string, and 71 of the 91 remaining calls went |
| 255 | // on repairing that one line, every attempt another `sed` whose own quoting had to survive |
| 256 | // the argument vector and the JavaScript string at once. |
| 257 | // |
| 258 | // The missing thing was never a permission. `Req::Exec` already carries the fence, the |
| 259 | // working directory and the whole compartment; what it does not carry is a VERB that says |
| 260 | // "replace this text with that text", so the intent had to be spelled as a program, and a |
| 261 | // program that edits text is a small language with its own escaping. |
| 262 | // |
| 263 | // So this is a request rather than a convention on top of `Exec`, for the same reason |
| 264 | // `Req::Verify` is: there is no element of it a page can turn into a program. The op names |
| 265 | // what to do, the paths are absolute and vetted, and the strings are DATA at both ends -- |
| 266 | // nothing here is ever parsed as syntax by anything. |
| 267 | // |
| 268 | // **It is fenced exactly as a command is, by the same kernel, from the same plan.** The |
| 269 | // hand does not touch the file: it spawns the same launcher a command is spawned through, |
| 270 | // which applies the same Landlock ruleset and the same system-call filter and only then |
| 271 | // opens anything. A path the fence does not reach fails with the kernel's own refusal, not |
| 272 | // with a check written here -- which is the whole reason the work happens in a child at all |
| 273 | // rather than in the hand, whose own process is deliberately unfenced. |
| 274 | |
| 275 | /// The largest text one [`Resp::Filed`] carries back. |
| 276 | /// |
| 277 | /// A read is paged by the caller, so this is the backstop rather than the budget: a read stops |
| 278 | /// on the last whole line that fits and SAYS how many lines went, and a single line longer than |
| 279 | /// this is cut with the count of what was left on it, because a frame that will not fit is |
| 280 | /// dropped and silence is the one answer that lies. |
| 281 | pub const FILE_TEXT_MAX: usize = 512 * 1024; |
| 282 | |
| 283 | /// The prefix a walk's glob is written against, and why one is needed. |
| 284 | /// |
| 285 | /// **A glob is written by a MODEL, in the paths a model sees.** `www/i18n/en.js` is the |
| 286 | /// spelling in the workspace; on this machine the same file is |
| 287 | /// `/home/…/granted/repo/www/i18n/en.js`, and a glob matched against the second excludes every |
| 288 | /// file there is. Measured on the door's first live run, 2026-08-25: three searches in a row |
| 289 | /// answered *"No matches"* with *"804 file(s) the glob excluded"* beside them, which is the |
| 290 | /// note doing its job and the filter doing the opposite of its job. |
| 291 | /// |
| 292 | /// So the page sends the prefix it would strip off a result, and the hand matches the glob |
| 293 | /// against what is left. A path that is somehow not under it is matched whole, because a file |
| 294 | /// silently excluded is the failure this exists to end. |
| 295 | pub const GLOB_BASE_DOC: () = (); |
| 296 | |
| 297 | /// The largest answer a [`FileOp::Search`] builds before it stops adding files. |
| 298 | /// |
| 299 | /// A search answers with the LINES its pattern matched on, so its size is set by how much |
| 300 | /// matched rather than by how big the files are. Below [`FILE_TEXT_MAX`] on purpose: an answer |
| 301 | /// at the frame's own ceiling leaves nothing for the envelope. What did not fit is COUNTED and |
| 302 | /// named, never dropped in silence -- a search that stopped early and did not say so is a search |
| 303 | /// that has established nothing. |
| 304 | /// |
| 305 | /// **It answered with whole file texts until 2026-08-25**, which made this a ceiling on the SIZE |
| 306 | /// OF A FILE rather than on the size of an answer: `src/tools.rs`, 1,211,990 bytes, was passed |
| 307 | /// over with the answer still empty, and no `glob` or `path` a caller could write made one file |
| 308 | /// smaller. That is `dev/BLOCKERS.md` B17. |
| 309 | pub const SEARCH_ANSWER_MAX: usize = 384 * 1024; |
| 310 | |
| 311 | /// How many neighbours of a matching line travel with it. |
| 312 | /// |
| 313 | /// The page's own ceiling on `before` and `after` (`SEARCH_CONTEXT_MAX` in `src/tools.rs`), so |
| 314 | /// every line the page could be asked to print is in the answer and none of the ones it could |
| 315 | /// not are. The two numbers are the same number and a search that sent fewer would silently |
| 316 | /// print less context than it was asked for. |
| 317 | pub const SEARCH_CONTEXT_LINES: usize = 20; |
| 318 | |
| 319 | /// What a [`Req::File`] is asking to be done to one file. |
| 320 | /// |
| 321 | /// The shapes are the file tools' own, deliberately: the app already offers a page a read |
| 322 | /// by line range, an overwrite, an exact-substring replacement, a rename and a listing, and |
| 323 | /// a second editing model reaching the machine would be worse than one that only half |
| 324 | /// works. Nothing is added here that browser storage does not already do. |
| 325 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 326 | pub enum FileOp { |
| 327 | /// Text from `path`, from the 1-based line `offset`, at most `limit` lines. |
| 328 | Read { |
| 329 | path: String, |
| 330 | offset: u32, |
| 331 | limit: u32, |
| 332 | }, |
| 333 | /// Replace the whole of `path` with `content`, creating it and its parents. |
| 334 | Write { |
| 335 | path: String, |
| 336 | content: String, |
| 337 | }, |
| 338 | /// Replace `old` with `new` in `path`, exactly once. |
| 339 | /// |
| 340 | /// The count is the answer, not a detail: an `old` occurring twice or not at all is |
| 341 | /// REFUSED with the number it found, so a caller is never left guessing which of six |
| 342 | /// edits landed. |
| 343 | Edit { |
| 344 | path: String, |
| 345 | old: String, |
| 346 | new: String, |
| 347 | }, |
| 348 | /// Rename `path` to `to`, which must not already exist. |
| 349 | Move { |
| 350 | path: String, |
| 351 | to: String, |
| 352 | }, |
| 353 | /// What is in the directory `path`. |
| 354 | List { |
| 355 | path: String, |
| 356 | }, |
| 357 | /// Create the directory `path` and any parent it needs. |
| 358 | MkDir { |
| 359 | path: String, |
| 360 | }, |
| 361 | /// Walk `paths` and return the text of every file the pattern matches somewhere in. |
| 362 | /// |
| 363 | /// **A verb of its own, and not a `List` the page then walks.** A search is the one file |
| 364 | /// operation whose cost is in the WALK rather than in the file, and a page that listed a |
| 365 | /// directory, then listed its children, then read each candidate would pay a round trip per |
| 366 | /// entry for a question whose answer is usually "no". Measured 2026-08-25: with the editing |
| 367 | /// door open, 33 of a daimon's 45 calls were `run grep -n` for a line number. |
| 368 | /// |
| 369 | /// **The hand's match is a FILTER and the page's is the answer.** Both ends compile the |
| 370 | /// same `fe2o3_text` regex from the same source, so what comes back is every file the page |
| 371 | /// would have found something in -- and the page then runs its own scan over those files |
| 372 | /// unchanged, which is what keeps the context lines, the paging and the report one |
| 373 | /// implementation rather than two that agree until they do not. |
| 374 | Search { |
| 375 | paths: Vec<String>, // absolute start directories, walked in this order |
| 376 | query: String, // the regex source, already quoted where the caller asked for a literal |
| 377 | ci: bool, // fold case |
| 378 | glob: String, // only consider paths matching this; empty for all |
| 379 | base: String, // the prefix the glob is written against; see below |
| 380 | skip: Vec<String>, // directory NAMES to pass over, decided by the page |
| 381 | budget: u32, // entries the walk may look at before it stops and says where |
| 382 | cap: u32, // the largest file, in bytes, worth opening |
| 383 | }, |
| 384 | /// Walk `paths` and return every path matching `pattern`, reading none of them. |
| 385 | Glob { |
| 386 | paths: Vec<String>, |
| 387 | pattern: String, |
| 388 | base: String, |
| 389 | skip: Vec<String>, |
| 390 | budget: u32, |
| 391 | }, |
| 392 | } |
| 393 | |
| 394 | impl FileOp { |
| 395 | /// The word this travels under. |
| 396 | pub fn word(&self) -> &'static str { |
| 397 | match self { |
| 398 | Self::Read { .. } => "read", |
| 399 | Self::Write { .. } => "write", |
| 400 | Self::Edit { .. } => "edit", |
| 401 | Self::Move { .. } => "move", |
| 402 | Self::List { .. } => "list", |
| 403 | Self::MkDir { .. } => "mkdir", |
| 404 | Self::Search { .. } => "search", |
| 405 | Self::Glob { .. } => "glob", |
| 406 | } |
| 407 | } |
| 408 | |
| 409 | /// The path the op is about, which is the one a refusal must name. |
| 410 | /// |
| 411 | /// A walk names several, and answers the FIRST -- the place the caller asked about, which is |
| 412 | /// where a refusal is most useful and what the journal should record it under. |
| 413 | pub fn path(&self) -> &str { |
| 414 | match self { |
| 415 | Self::Read { path, .. } | Self::Write { path, .. } | Self::Edit { path, .. } |
| 416 | | Self::Move { path, .. } | Self::List { path } | Self::MkDir { path } => path, |
| 417 | Self::Search { paths, .. } | Self::Glob { paths, .. } => |
| 418 | paths.first().map(|s| s.as_str()).unwrap_or(""), |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | /// Every path the op names, which is what a caller vetting them has to see. |
| 423 | pub fn paths(&self) -> Vec<&str> { |
| 424 | match self { |
| 425 | Self::Move { path, to } => vec![path.as_str(), to.as_str()], |
| 426 | Self::Search { paths, .. } | Self::Glob { paths, .. } => |
| 427 | paths.iter().map(|s| s.as_str()).collect(), |
| 428 | other => vec![other.path()], |
| 429 | } |
| 430 | } |
| 431 | |
| 432 | /// Does the op change anything on disk? |
| 433 | pub fn writes(&self) -> bool { |
| 434 | !matches!(self, Self::Read { .. } | Self::List { .. } |
| 435 | | Self::Search { .. } | Self::Glob { .. }) |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | /// A message from the page to the hand. |
| 440 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 441 | pub enum Req { |
| 442 | /// The opening exchange, which settles whether the two ends agree. |
| 443 | Hello { |
| 444 | /// The protocol version the page speaks. |
| 445 | proto: u32, |
| 446 | /// Which build of the app is asking, for the journal. |
| 447 | client: String, |
| 448 | }, |
| 449 | /// Run a command. |
| 450 | /// |
| 451 | /// **`argv`, never a shell string.** Handing a string to `sh -c` means |
| 452 | /// defending against the shell itself -- `;`, `$(…)`, backticks, `|`, |
| 453 | /// `eval`, `base64 -d | sh`, `find -exec`, `tar --to-command` -- and a |
| 454 | /// fence made of string matching is not a fence. Passing the argument |
| 455 | /// vector removes the entire injection surface rather than guarding it. |
| 456 | Exec { |
| 457 | /// Caller-chosen identifier, echoed on every response about this run. |
| 458 | id: String, |
| 459 | /// The program and its arguments. Never a shell string. |
| 460 | argv: Vec<String>, |
| 461 | /// Absolute working directory. Must lie inside the fence. |
| 462 | cwd: String, |
| 463 | /// The environment the command runs with, as explicit pairs. |
| 464 | /// |
| 465 | /// An allow-list rather than an inheritance: the hand's own environment |
| 466 | /// holds whatever launched the browser, and a command that inherits it |
| 467 | /// inherits credentials nobody meant to lend it. |
| 468 | env: Vec<(String, String)>, |
| 469 | /// Text written to the command's standard input, then closed. |
| 470 | /// |
| 471 | /// This is what replaces a pipeline: redirection becomes a field rather |
| 472 | /// than a character the shell would have interpreted. |
| 473 | stdin: Option<String>, |
| 474 | /// Hard wall-clock limit. On expiry the child is killed. |
| 475 | /// |
| 476 | /// At most [`SAFE_INT_MAX`], which is 285,000 years; a larger one is |
| 477 | /// refused rather than trimmed, because a limit the two ends disagree |
| 478 | /// about is worse than no limit. |
| 479 | timeout_ms: u64, |
| 480 | /// Which streams to send back. |
| 481 | capture: Capture, |
| 482 | /// What the command may touch. |
| 483 | fence: FenceSpec, |
| 484 | /// The toolchains the USER granted this turn, by name (`rust`, `node`, |
| 485 | /// `python`, `go`). |
| 486 | /// |
| 487 | /// The hand needs these to clamp the fence, and it needs them stated |
| 488 | /// rather than inferred. A toolchain does not live in the workspace, so |
| 489 | /// a fence naming `~/.cargo/registry` cannot be checked against the |
| 490 | /// granted root -- and the obvious repair, allowing every toolchain |
| 491 | /// folder unconditionally, is what let a fence name `~/.local/bin` |
| 492 | /// writable when no toolkit had been granted at all. `~/.local/bin` is |
| 493 | /// first on `PATH`; a shim written there is unfenced execution as the |
| 494 | /// user on the next shell command. |
| 495 | /// |
| 496 | /// **Never derived from `argv`.** The whole arrangement rests on the |
| 497 | /// fence not being one the model can widen by asking for a program, and |
| 498 | /// a hand that read the toolchain out of the command would hand that |
| 499 | /// back. A name this build does not know grants nothing, and an absent |
| 500 | /// field is no grant -- both fail closed. |
| 501 | toolkits: Vec<String>, |
| 502 | }, |
| 503 | /// Run one named verifier from the tracked tree, clean and under its breaks. |
| 504 | /// |
| 505 | /// **A NAME, not a path and not a command line.** `name` is looked up in the |
| 506 | /// granted root's `dev/` directory and must match a `verify_<name>.mjs` that |
| 507 | /// is actually there; what reaches the argument vector is the directory |
| 508 | /// entry's own file name, never the caller's string. `breaks` is checked |
| 509 | /// against the declarations in that file's own source. So there is no |
| 510 | /// element of this request that a page can turn into a program, an argument |
| 511 | /// or a path -- which is why it is a request of its own rather than an |
| 512 | /// [`Req::Exec`] with a convention attached to it. |
| 513 | /// |
| 514 | /// **It runs OUTSIDE the command fence, deliberately.** A fenced command |
| 515 | /// cannot open the display server's socket or listen on a port, so every |
| 516 | /// verifier that drives a real browser dies under it -- and those are the |
| 517 | /// ones a release actually rests on. The justification is provenance and |
| 518 | /// not confinement: a verifier is tracked repository code in the same trust |
| 519 | /// class as `cargo test`, and the model supplies a selector rather than a |
| 520 | /// command. The journal records each run with `fence:none` in its |
| 521 | /// mechanisms, so the claim is checkable rather than merely written down. |
| 522 | Verify { |
| 523 | /// Caller-chosen identifier, echoed on every response about this run. |
| 524 | id: String, |
| 525 | /// The verifier's short name: `graph` for `dev/verify_graph.mjs`. |
| 526 | name: String, |
| 527 | /// Which of its declared breaks to run beside the clean pass. |
| 528 | breaks: Breaks, |
| 529 | /// The WHOLE sequence's wall-clock budget, not one run's. |
| 530 | /// |
| 531 | /// The page arms one timer from this, so it has to cover every run the |
| 532 | /// sequence makes. A break the budget does not reach is reported as |
| 533 | /// never having run, which is a worse result than a slow one and is |
| 534 | /// meant to be. |
| 535 | timeout_ms: u64, |
| 536 | }, |
| 537 | /// Change one file on the machine, behind the same fence a command runs behind. |
| 538 | /// |
| 539 | /// **No program, no argument vector, no shell, and nothing to escape.** That is the |
| 540 | /// whole of why it exists; the section above this enum has the measurement. |
| 541 | File { |
| 542 | /// Caller-chosen identifier, echoed on the response. |
| 543 | id: String, |
| 544 | /// What to do, and to what. |
| 545 | op: FileOp, |
| 546 | /// Absolute working directory, which must lie inside the fence. |
| 547 | /// |
| 548 | /// Carried for the same reason [`Req::Exec`] carries one -- it is the place the op |
| 549 | /// happens in, and the hand vets it against the fence before anything is opened -- |
| 550 | /// though every path in an op is absolute, so nothing is resolved against it. |
| 551 | cwd: String, |
| 552 | /// What the op may touch. The same field, the same shape and the same clamp as |
| 553 | /// [`Req::Exec`]'s. |
| 554 | fence: FenceSpec, |
| 555 | /// The toolchains the user granted this turn, carried for the same reason |
| 556 | /// [`Req::Exec`] carries them: the hand clamps the fence against them and will not |
| 557 | /// take a root on the page's word alone. |
| 558 | toolkits: Vec<String>, |
| 559 | }, |
| 560 | /// Send a signal to a running command. |
| 561 | Signal { |
| 562 | /// The identifier given at [`Req::Exec`]. |
| 563 | id: String, |
| 564 | /// Which signal. |
| 565 | sig: Sig, |
| 566 | }, |
| 567 | /// Open a terminal and run a command attached to it. |
| 568 | /// |
| 569 | /// Everything `Exec` says about `argv`, `env` and the fence holds here too; only |
| 570 | /// the shape of the conversation differs. |
| 571 | Open { |
| 572 | /// Caller-chosen identifier, echoed on every response about this session. |
| 573 | id: String, |
| 574 | /// The program and its arguments. Never a shell string -- though a shell is a |
| 575 | /// perfectly ordinary thing to put in `argv[0]` here, and usually the point. |
| 576 | argv: Vec<String>, |
| 577 | /// Absolute working directory. Must lie inside the fence. |
| 578 | cwd: String, |
| 579 | /// The environment, as explicit pairs. `TERM` is the hand's to set: a program |
| 580 | /// asks it what the terminal can do, and a caller who could name it could |
| 581 | /// promise capabilities the page cannot draw. |
| 582 | env: Vec<(String, String)>, |
| 583 | /// How big the terminal is when it opens. |
| 584 | size: PtySize, |
| 585 | /// What the session may touch. |
| 586 | fence: FenceSpec, |
| 587 | /// The toolchains the user granted, exactly as [`Req::Exec`] carries them |
| 588 | /// and for exactly the same reason: a terminal is a command with a |
| 589 | /// screen, and it is clamped by the same rule. |
| 590 | toolkits: Vec<String>, |
| 591 | }, |
| 592 | /// Keystrokes for a terminal, base64 of the raw bytes. |
| 593 | /// |
| 594 | /// Raw, and not a line: a terminal is a byte stream, and `Ctrl-C`, an arrow key and |
| 595 | /// a bracketed paste are all just bytes the program is entitled to see as they were |
| 596 | /// typed. |
| 597 | Input { |
| 598 | /// The session's identifier. |
| 599 | id: String, |
| 600 | /// Base64 of the bytes typed. |
| 601 | data: String, |
| 602 | }, |
| 603 | /// The window changed size; tell the kernel, which tells the program. |
| 604 | Resize { |
| 605 | /// The session's identifier. |
| 606 | id: String, |
| 607 | /// The new size. |
| 608 | size: PtySize, |
| 609 | }, |
| 610 | /// What is this hand still running, including what a finished run left |
| 611 | /// standing. |
| 612 | /// |
| 613 | /// Takes nothing, on purpose. A field would be a filter and a filter is a |
| 614 | /// selector, and the one selector this message must not grow is one that |
| 615 | /// names a process the hand did not start. |
| 616 | Runs, |
| 617 | /// The directories inside `path`, so a person can CHOOSE a folder and get its real path. |
| 618 | /// |
| 619 | /// The browser's own `showDirectoryPicker` cannot serve this: it hands the page a handle |
| 620 | /// with a name and no path, which is why the granted root is written on the machine at |
| 621 | /// install time in the first place. A fence needs `/home/u/work` and a handle cannot say |
| 622 | /// it, so the only end that can offer a folder browser is this one. |
| 623 | /// |
| 624 | /// **Names of directories, and nothing else.** No files, no contents, no sizes. It is |
| 625 | /// bounded by what this hand would be willing to fence a terminal to -- the user's own |
| 626 | /// home, and the granted root -- and refused anywhere else, so it is not a way to |
| 627 | /// enumerate the machine. |
| 628 | /// |
| 629 | /// **No daimon can send it.** There is no `Tool` that reaches this surface, exactly as |
| 630 | /// there is none that opens a terminal, which is why a person browsing their own home |
| 631 | /// directory is not the model browsing it. |
| 632 | Dirs { |
| 633 | /// Absolute path to list. Empty asks for the places this hand will start from. |
| 634 | path: String, |
| 635 | }, |
| 636 | /// Set the folder this hand may work in, writing it where the installer writes it. |
| 637 | /// |
| 638 | /// **The grant is a decision and this does not make it one.** It writes what the user |
| 639 | /// chose, having walked the machine's own folders through [`Req::Dirs`]; the choosing is |
| 640 | /// theirs and the walk is bounded. It exists because the alternative is what a new user |
| 641 | /// meets today: pick a folder at a shell before knowing what the app does with one, and |
| 642 | /// then edit `root.txt` by hand on discovering it was wrong. |
| 643 | /// |
| 644 | /// **It takes effect when the hand next starts.** A running hand's root is read once, and |
| 645 | /// re-reading it mid-conversation would move a fence under commands already running. |
| 646 | Grant { |
| 647 | /// Absolute path to the folder. Refused if it is not a directory, if it is `/`, or if |
| 648 | /// it would contain this hand's own journal. |
| 649 | path: String, |
| 650 | }, |
| 651 | /// The page is going away; stop everything and exit. |
| 652 | Bye, |
| 653 | } |
| 654 | |
| 655 | // ┌───────────────────────────────────────────────────────────────┐ |
| 656 | // │ Responses: the hand answers │ |
| 657 | // └───────────────────────────────────────────────────────────────┘ |
| 658 | |
| 659 | /// Which of a command's output streams a chunk came from. |
| 660 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 661 | pub enum Stream { |
| 662 | /// Standard output. |
| 663 | Out, |
| 664 | /// Standard error. |
| 665 | Err, |
| 666 | } |
| 667 | |
| 668 | /// A message from the hand to the page. |
| 669 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 670 | pub enum Resp { |
| 671 | /// The answering half of the opening exchange. |
| 672 | Hello { |
| 673 | /// The protocol version this hand speaks. |
| 674 | proto: u32, |
| 675 | /// The host's name, for the device roster. |
| 676 | host: String, |
| 677 | /// The build. |
| 678 | version: String, |
| 679 | /// Which operating system, in the wire's own vocabulary. |
| 680 | os: String, |
| 681 | /// What this build can actually do. |
| 682 | /// |
| 683 | /// A list rather than a version number, because the fence lands on one |
| 684 | /// platform before another and the page must be able to say *which* |
| 685 | /// guarantee it is offering the user on this machine. |
| 686 | caps: Vec<String>, |
| 687 | }, |
| 688 | /// The command started. |
| 689 | Started { |
| 690 | /// The caller's identifier. |
| 691 | id: String, |
| 692 | /// The child's process id, so the journal names something real. |
| 693 | pid: u32, |
| 694 | }, |
| 695 | /// A run of output. |
| 696 | Chunk { |
| 697 | /// The caller's identifier. |
| 698 | id: String, |
| 699 | /// Which stream it came from. |
| 700 | stream: Stream, |
| 701 | /// Monotonic per-stream sequence, so a dropped frame is detectable. |
| 702 | /// |
| 703 | /// At most [`SAFE_INT_MAX`]: the page detects a gap by comparing this |
| 704 | /// for equality, and two sequence numbers that read back as one number |
| 705 | /// break exactly the check they are here for. |
| 706 | seq: u64, |
| 707 | /// The bytes, as text. Invalid UTF-8 is replaced, not rejected. |
| 708 | data: String, |
| 709 | }, |
| 710 | /// The command finished, one way or another. |
| 711 | Ended { |
| 712 | /// The caller's identifier. |
| 713 | id: String, |
| 714 | /// The exit status, or -1 where there was none. |
| 715 | exit: i32, |
| 716 | /// Whether the hard timeout killed it. |
| 717 | timed_out: bool, |
| 718 | /// Whether a [`Req::Signal`] killed it. |
| 719 | killed: bool, |
| 720 | /// How many bytes of standard output were produced. |
| 721 | /// |
| 722 | /// The *true* total, not the total forwarded, so a reader comparing it |
| 723 | /// against what arrived can tell that a tail was lost. At most |
| 724 | /// [`SAFE_INT_MAX`], since a count the page rounds down is a count that |
| 725 | /// says nothing went missing. |
| 726 | out_bytes: u64, |
| 727 | /// How many bytes of standard error were produced, on the same terms. |
| 728 | err_bytes: u64, |
| 729 | }, |
| 730 | /// The hand declined, and this is the sentence the model reads. |
| 731 | /// |
| 732 | /// A refusal is not an error. It says what was refused and why, in the |
| 733 | /// same voice the file tools already use, so a model can recover instead of |
| 734 | /// retrying the same call. |
| 735 | Refused { |
| 736 | /// The caller's identifier. |
| 737 | id: String, |
| 738 | /// The whole sentence. |
| 739 | reason: String, |
| 740 | }, |
| 741 | /// One [`Req::File`] finished, and this is what to tell the model. |
| 742 | /// |
| 743 | /// One blob and not a stream, because a file op has one answer. `ok` false is a |
| 744 | /// refusal in the same voice as [`Resp::Refused`] -- the string was not found, it was |
| 745 | /// found twice, the kernel would not open the path -- and it is carried here rather |
| 746 | /// than as a `Refused` so that the caller can tell "the hand declined the request" from |
| 747 | /// "the request was carried out and this is what happened". |
| 748 | Filed { |
| 749 | /// The caller's identifier. |
| 750 | id: String, |
| 751 | /// Whether anything was done. |
| 752 | ok: bool, |
| 753 | /// The answer: the file's text, the listing, or the sentence explaining the refusal. |
| 754 | text: String, |
| 755 | }, |
| 756 | /// A terminal is open and the command is attached to it. |
| 757 | Opened { |
| 758 | /// The caller's identifier. |
| 759 | id: String, |
| 760 | /// The child's process id. |
| 761 | pid: u32, |
| 762 | }, |
| 763 | /// Bytes from the terminal, base64 of exactly what it produced. |
| 764 | /// |
| 765 | /// One stream, not two: a terminal merges them by construction, and a program |
| 766 | /// writing a prompt to stderr expects it to land in the same place as the rest. |
| 767 | Output { |
| 768 | /// The session's identifier. |
| 769 | id: String, |
| 770 | /// Monotonic sequence, so a dropped frame is detectable. At most |
| 771 | /// [`SAFE_INT_MAX`], for the reason [`Resp::Chunk`] gives. |
| 772 | seq: u64, |
| 773 | /// Base64 of the bytes. |
| 774 | data: String, |
| 775 | }, |
| 776 | /// The terminal closed and the command is gone. |
| 777 | Closed { |
| 778 | /// The session's identifier. |
| 779 | id: String, |
| 780 | /// The exit status, or -1 where there was none. |
| 781 | exit: i32, |
| 782 | /// Whether a signal ended it rather than the program itself. |
| 783 | killed: bool, |
| 784 | }, |
| 785 | /// Everything this hand started that has not finished going. |
| 786 | /// |
| 787 | /// The honest picture and not a receipt: a run listed here was measured a |
| 788 | /// moment ago, and a run absent from it is one the hand can no longer reach. |
| 789 | /// After a [`Req::Signal`] this is what says whether the signal took. |
| 790 | Runs { |
| 791 | runs: Vec<Run>, |
| 792 | more: u32, // how many did not fit, past [`RUNS_MAX`] |
| 793 | }, |
| 794 | /// The answer to [`Req::Grant`]: what was written, and that a restart is what applies it. |
| 795 | Granted { |
| 796 | path: String, // the folder now named in `root.txt`, canonical |
| 797 | note: String, // what the user still has to do, in a sentence |
| 798 | }, |
| 799 | /// The answer to [`Req::Dirs`]: where it looked, what is above it, and the directories in it. |
| 800 | Dirs { |
| 801 | path: String, // the directory listed, canonical and absolute |
| 802 | up: String, // its parent, or empty where the bound stops here |
| 803 | dirs: Vec<String>, // the directory NAMES inside it, sorted, dotted ones last |
| 804 | roots: Vec<String>, // the places this hand will start from, when `path` was empty |
| 805 | }, |
| 806 | /// Something went wrong that is nobody's fault in particular. |
| 807 | Error { |
| 808 | /// The run it concerns, where there is one. |
| 809 | id: Option<String>, |
| 810 | /// What happened. |
| 811 | message: String, |
| 812 | }, |
| 813 | /// The hand will not start, and this is the sentence saying why. |
| 814 | /// |
| 815 | /// The one response sent before the opening exchange, and the only one that names no |
| 816 | /// run, because there is no conversation yet to name anything in. A hand that cannot |
| 817 | /// configure itself -- no granted root, a journal it cannot open, a second hand already |
| 818 | /// holding the record -- has always had a whole sentence for the reader and has always |
| 819 | /// written it to standard error, where a browser discards it. What reached the page was |
| 820 | /// the browser's own "Native host has exited", from which nothing can be worked out and |
| 821 | /// nothing can be done. |
| 822 | /// |
| 823 | /// So the sentence goes down the pipe first and the process exits after it. It is |
| 824 | /// written for a PERSON: whoever has to fix a hand that will not start is at the keyboard, |
| 825 | /// not in a turn. |
| 826 | Fault { |
| 827 | /// The whole sentence: what happened, and the one thing that fixes it. |
| 828 | reason: String, |
| 829 | }, |
| 830 | } |
| 831 | |
| 832 | /// Whether a page speaking `proto` can talk to this hand. |
| 833 | /// |
| 834 | /// # Arguments |
| 835 | /// * `proto` - The version the page announced. |
| 836 | pub fn proto_ok(proto: u32) -> bool { |
| 837 | proto == PROTO |
| 838 | } |
| 839 | |
| 840 | /// The sentence a version mismatch produces, which names both ends and the |
| 841 | /// executable that is actually running. |
| 842 | /// |
| 843 | /// The path is there because a mismatch is nearly always a mismatch of BUILDS, |
| 844 | /// and the manifest a browser reads can name something other than the checkout |
| 845 | /// a reader is looking at. Without it the sentence states a contradiction -- |
| 846 | /// the source says 2, the hand says 1 -- and leaves the reader to guess which |
| 847 | /// of a dozen target directories answered. On 2026-08-26 that guess cost an |
| 848 | /// afternoon. |
| 849 | /// |
| 850 | /// # Arguments |
| 851 | /// * `proto` - The version the page announced. |
| 852 | pub fn proto_refusal(proto: u32) -> String { |
| 853 | let exe = match std::env::current_exe() { |
| 854 | Ok(p) => p.display().to_string(), |
| 855 | Err(e) => fmt!("<a path this hand cannot name: {}>", e), |
| 856 | }; |
| 857 | fmt!( |
| 858 | "This hand, at '{}', speaks protocol {} and the page speaks {}. Update whichever \ |
| 859 | is older; they cannot agree on what a command is until you do.", |
| 860 | exe, PROTO, proto) |
| 861 | } |