Oregami
Repositories/oxedyne/daimond

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
17use crate::PROTO;
18
19use 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.
31pub 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.
37pub 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.
55pub 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)]
63pub 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)]
80pub 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)]
100pub 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)]
120pub 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)]
135pub 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
144impl 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)]
210pub 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
215impl 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)]
227pub 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.
240pub 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`.
247pub 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.
281pub 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.
295pub 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.
309pub 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.
317pub 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)]
326pub 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
394impl 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)]
441pub 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)]
661pub 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)]
670pub 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.
836pub 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.
852pub 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}