oxedyne/daimond/src/wasm/pty.rs
27.9 KiB, 9 runs
created by r2519314175:993, 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 | //! The terminal's edge — thin bindings to the JS relay `window.DaimondPty`. |
| 2 | //! |
| 3 | //! The sibling of [`crate::wasm::hand`], and for the same reason: a web page |
| 4 | //! cannot create a process, so the capability lives in a program outside the |
| 5 | //! page and this file is the narrow part of that road. What differs is the |
| 6 | //! shape of the conversation. `hand::run` sends a command and waits for one |
| 7 | //! result; a terminal is a session -- bytes both ways for as long as the |
| 8 | //! program lives, a size the kernel has to be told about, and an ending that |
| 9 | //! arrives when the program decides rather than when the caller does. |
| 10 | //! |
| 11 | //! **The live bytes do not come through here.** `window.DaimondPty` delivers |
| 12 | //! `output` to a subscriber in the page as raw bytes, because the thing drawing |
| 13 | //! a terminal is the page and a screen redrawn through wasm on every chunk |
| 14 | //! would be a boundary crossing per keystroke of output. What crosses here is |
| 15 | //! the control surface: open one, type into it, tell it the window changed, |
| 16 | //! ask it to stop, and ask what is attached. |
| 17 | //! |
| 18 | //! **The fence is composed here, and nowhere else.** A terminal session runs a |
| 19 | //! real program on the user's machine, so it goes through the same fence and |
| 20 | //! the same grant as `Tool::Run`: [`pty_request`] walks the identical path -- |
| 21 | //! ask the hand what machine it is on, refuse a hand that cannot fence, |
| 22 | //! [`diamond_bounds`] then [`fence_spec`] -- and hands back the wire's own |
| 23 | //! `open` request with the compartment already in it. The page asks for that |
| 24 | //! request and passes it through; it composes nothing, because a page that |
| 25 | //! composed a fence would be a second opinion about what a program may touch, |
| 26 | //! free to drift from the one the hand enforces. [`open`] then takes the |
| 27 | //! request unchanged, and the relay refuses one arriving without a fence rather |
| 28 | //! than inventing a weaker path to the same machine. |
| 29 | //! |
| 30 | //! A rejection carries a plain-English `Error` a person or a model is meant to |
| 31 | //! read and act on, so its `message` is passed through **verbatim**, exactly as |
| 32 | //! the hand and Web panel edges do. Wrapping one in an fe2o3 chain wraps ANSI |
| 33 | //! colour and a `src/*.rs:line` frame around the one sentence that matters, and |
| 34 | //! destroys the only instruction the reader gets. |
| 35 | |
| 36 | use crate::llm::{ |
| 37 | extract_json_bool, |
| 38 | extract_json_number, |
| 39 | extract_json_string, |
| 40 | extract_json_string_array, |
| 41 | json_escape, |
| 42 | }; |
| 43 | use crate::tools::{ |
| 44 | diamond_bounds, |
| 45 | fence_enforced, |
| 46 | fence_spec_surfaced, |
| 47 | normalise, |
| 48 | refusal_line, |
| 49 | Bound, |
| 50 | Kit, |
| 51 | Machine, |
| 52 | Surface, |
| 53 | }; |
| 54 | use crate::wasm::js_str; |
| 55 | |
| 56 | use oxedyne_fe2o3_core::prelude::*; |
| 57 | |
| 58 | use wasm_bindgen::prelude::wasm_bindgen; |
| 59 | use wasm_bindgen::{JsCast, JsValue}; |
| 60 | use wasm_bindgen_futures::JsFuture; |
| 61 | |
| 62 | |
| 63 | #[wasm_bindgen] |
| 64 | extern "C" { |
| 65 | |
| 66 | /// The relay object the page installs at `window.DaimondPty`. |
| 67 | #[wasm_bindgen(js_name = DaimondPty)] |
| 68 | type Relay; |
| 69 | |
| 70 | /// Whether a hand is paired, whether this page can carry terminal |
| 71 | /// messages, and how many sessions are open. |
| 72 | #[wasm_bindgen(method)] |
| 73 | fn status(this: &Relay) -> js_sys::Promise; |
| 74 | |
| 75 | /// Open a terminal, resolving with `{id, pid}` when one exists. |
| 76 | #[wasm_bindgen(method)] |
| 77 | fn open(this: &Relay, spec_json: &str) -> js_sys::Promise; |
| 78 | |
| 79 | /// Send keystrokes, as base64 of the raw bytes. |
| 80 | #[wasm_bindgen(method)] |
| 81 | fn input(this: &Relay, id: &str, data_b64: &str) -> js_sys::Promise; |
| 82 | |
| 83 | /// Tell the kernel the window changed size. |
| 84 | #[wasm_bindgen(method)] |
| 85 | fn resize(this: &Relay, id: &str, cols: u16, rows: u16) -> js_sys::Promise; |
| 86 | |
| 87 | /// Ask a terminal's program to stop. |
| 88 | #[wasm_bindgen(method)] |
| 89 | fn close(this: &Relay, id: &str, sig: &str) -> js_sys::Promise; |
| 90 | } |
| 91 | |
| 92 | |
| 93 | /// Reach the relay object on `window`, or refuse in the reader's language. |
| 94 | /// |
| 95 | /// The refusal names the terminal specifically and says what still works: a |
| 96 | /// page without it can still run commands, and a user told only that something |
| 97 | /// failed will go looking for the wrong fault. |
| 98 | fn relay() -> Outcome<Relay> { |
| 99 | let win = res!(web_sys::window() |
| 100 | .ok_or_else(|| err!("A terminal needs a browser window."; System, Missing))); |
| 101 | let obj = res!(js_sys::Reflect::get(&win, &JsValue::from_str("DaimondPty")) |
| 102 | .map_err(|e| err!("Reading window.DaimondPty failed: {}.", js_str(&e); System, Missing))); |
| 103 | if obj.is_undefined() || obj.is_null() { |
| 104 | return Err(err!( |
| 105 | "There is no terminal relay in this page, so no terminal can be \ |
| 106 | opened. Daimond runs in the browser and a browser cannot allocate a \ |
| 107 | terminal; the machine hand is a small companion that can. Commands \ |
| 108 | can still be run if a hand is paired -- only the interactive \ |
| 109 | terminal is unavailable."; |
| 110 | System, Missing)); |
| 111 | } |
| 112 | Ok(obj.unchecked_into::<Relay>()) |
| 113 | } |
| 114 | |
| 115 | /// The `message` of a rejected JS `Error`, verbatim, falling back to the |
| 116 | /// value's own rendering when it is not an `Error`. |
| 117 | fn refusal(e: &JsValue) -> String { |
| 118 | match js_sys::Reflect::get(e, &JsValue::from_str("message")) { |
| 119 | Ok(m) => m.as_string().unwrap_or_else(|| js_str(e)), |
| 120 | Err(_) => js_str(e), |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | /// Render a resolved JS value as the JSON string a caller carries. |
| 125 | fn stringify(v: &JsValue) -> Outcome<String> { |
| 126 | if v.is_undefined() || v.is_null() { |
| 127 | return Ok("{}".to_string()); |
| 128 | } |
| 129 | if let Some(s) = v.as_string() { |
| 130 | return Ok(s); // the relay resolved with JSON already |
| 131 | } |
| 132 | match js_sys::JSON::stringify(v) { |
| 133 | Ok(s) => Ok(String::from(s)), |
| 134 | Err(e) => Err(err!( |
| 135 | "The terminal relay returned a result that cannot be read: {}.", refusal(&e); |
| 136 | Invalid, Data)), |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// Await a relay promise, passing a refusal through untouched. |
| 141 | async fn settle(promise: js_sys::Promise) -> Outcome<String> { |
| 142 | match JsFuture::from(promise).await { |
| 143 | Ok(v) => stringify(&v), |
| 144 | Err(e) => Err(err!("{}", refusal(&e); IO, Invalid)), |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | /// Await a relay promise, turning a refusal into the refusal JSON a caller |
| 149 | /// renders rather than into an error. |
| 150 | /// |
| 151 | /// The same choice `hand::run` makes, and for the same reason: every rejection |
| 152 | /// from the relay is a whole sentence written to be acted on -- the hand is not |
| 153 | /// installed, the user declined, it stopped part-way, the request had no |
| 154 | /// fence -- and an `Err` reaches the reader wrapped in an fe2o3 chain around |
| 155 | /// the one sentence that matters. A terminal that will not open has been |
| 156 | /// refused, so it is handed on as a refusal and rendered as one. |
| 157 | async fn settle_or_refuse(promise: js_sys::Promise) -> Outcome<String> { |
| 158 | match JsFuture::from(promise).await { |
| 159 | Ok(v) => stringify(&v), |
| 160 | Err(e) => Ok(fmt!(r#"{{"refused":"{}"}}"#, crate::llm::json_escape(&refusal(&e)))), |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | /// Whether a hand is paired, whether this page carries terminal messages, and |
| 165 | /// how many sessions are open. |
| 166 | /// |
| 167 | /// It never refuses: a caller asking what is attached is owed an answer, and |
| 168 | /// the JSON carries a `reason` sentence when the answer is "nothing". |
| 169 | pub async fn status() -> Outcome<String> { |
| 170 | let r = res!(relay()); |
| 171 | settle(r.status()).await |
| 172 | } |
| 173 | |
| 174 | /// Open a terminal and attach a program to it. |
| 175 | /// |
| 176 | /// # Arguments |
| 177 | /// * `spec_json` - The `open` request, already rendered as the wire's JSON and |
| 178 | /// already carrying the fence [`crate::tools::fence_spec`] computed. It is |
| 179 | /// passed through unchanged, so there is one place a request is composed and |
| 180 | /// it is the one that holds the compartment. |
| 181 | /// |
| 182 | /// # Returns |
| 183 | /// `{"id":…,"pid":…}` once the terminal exists, or `{"refused":…}` carrying the |
| 184 | /// sentence saying why there is none. |
| 185 | pub async fn open(spec_json: &str) -> Outcome<String> { |
| 186 | let r = res!(relay()); |
| 187 | settle_or_refuse(r.open(spec_json)).await |
| 188 | } |
| 189 | |
| 190 | /// Send keystrokes to a terminal. |
| 191 | /// |
| 192 | /// Base64 in, because a terminal is a byte stream: `Ctrl-C`, an arrow key and a |
| 193 | /// bracketed paste are all just bytes the program is entitled to see exactly as |
| 194 | /// they were typed, and a lossy text conversion corrupts precisely the case a |
| 195 | /// terminal exists to handle. |
| 196 | /// |
| 197 | /// # Arguments |
| 198 | /// * `id` - The session, as `open` returned it. |
| 199 | /// * `data_b64` - Base64 of the bytes typed. |
| 200 | pub async fn input(id: &str, data_b64: &str) -> Outcome<String> { |
| 201 | let r = res!(relay()); |
| 202 | settle_or_refuse(r.input(id, data_b64)).await |
| 203 | } |
| 204 | |
| 205 | /// Tell the kernel the window changed size, which tells the program. |
| 206 | /// |
| 207 | /// # Arguments |
| 208 | /// * `id` - The session. |
| 209 | /// * `cols` - Columns. |
| 210 | /// * `rows` - Rows. |
| 211 | pub async fn resize(id: &str, cols: u16, rows: u16) -> Outcome<String> { |
| 212 | let r = res!(relay()); |
| 213 | settle_or_refuse(r.resize(id, cols, rows)).await |
| 214 | } |
| 215 | |
| 216 | /// Ask a terminal's program to stop. |
| 217 | /// |
| 218 | /// # Arguments |
| 219 | /// * `id` - The session. |
| 220 | /// * `sig` - `"term"` to ask, `"kill"` to insist, `"int"` to interrupt as |
| 221 | /// `Ctrl-C` would. Asking is the default the relay applies to anything else: |
| 222 | /// a shell given `SIGTERM` writes out its history, and one given `SIGKILL` |
| 223 | /// does not. |
| 224 | pub async fn close(id: &str, sig: &str) -> Outcome<String> { |
| 225 | let r = res!(relay()); |
| 226 | settle_or_refuse(r.close(id, sig)).await |
| 227 | } |
| 228 | |
| 229 | |
| 230 | // ┌───────────────────────────────────────────────────────────────┐ |
| 231 | // │ Composing the request │ |
| 232 | // └───────────────────────────────────────────────────────────────┘ |
| 233 | // |
| 234 | // Everything below this line is the terminal's half of what `Tool::Run` does in |
| 235 | // `src/tools.rs`, and it is deliberately the same walk in the same order: ask |
| 236 | // the hand what machine it is standing on, refuse a hand that cannot fence, |
| 237 | // turn the Diamond's workspace into bounds, turn the bounds into a compartment, |
| 238 | // and say what may run inside it. Only the last two lines differ, because a |
| 239 | // terminal is a session rather than a result. |
| 240 | // |
| 241 | // **The verb split does not reach here.** A scope leaves READING free at the |
| 242 | // file tools (`tools::Bound::OnlyWriteUnder`), and `fence_spec` deliberately |
| 243 | // does not follow it: a program is opaque, so a terminal reaches the folders |
| 244 | // the user marked in and nothing else, exactly as it did before. A session is |
| 245 | // a person at a keyboard running programs of their own choosing, which is the |
| 246 | // case for keeping that fence rather than relaxing it. |
| 247 | |
| 248 | /// The terminal a person is given when nobody named a program and the hand did not say. |
| 249 | /// |
| 250 | /// `/bin/sh` is the one program POSIX promises is there, so it is what a terminal falls back |
| 251 | /// to -- but it is a FALLBACK now and no longer the answer. On this machine `/bin/sh` is |
| 252 | /// `dash`: no prompt worth the name, no history, no completion, and none of the user's own |
| 253 | /// aliases, so a person opening a terminal in Daimond met a shell belonging to nobody. |
| 254 | /// |
| 255 | /// The page still cannot read an environment. The HAND can, and now says so: `shell:<path>` |
| 256 | /// rides in its `hello` beside `home:` and `host:`, taken from the `SHELL` the login session |
| 257 | /// set, which is what every other terminal on that machine opens. Where it said nothing -- |
| 258 | /// an older hand, a `SHELL` that is unset or relative -- this is what is left, because a |
| 259 | /// guessed shell is a terminal that opens on a refusal. |
| 260 | const SHELL: &str = "/bin/sh"; |
| 261 | |
| 262 | /// The largest terminal this edge will ask for, in cells each way. |
| 263 | /// |
| 264 | /// The extension refuses anything past it and the wire carries a `u16`, so a |
| 265 | /// bigger number is not a bigger terminal but a frame the hand cannot read. |
| 266 | const CELLS_MAX: u64 = 2000; |
| 267 | |
| 268 | /// Columns assumed when the page did not say. |
| 269 | const COLS_DEFAULT: u64 = 80; |
| 270 | |
| 271 | /// Rows assumed when the page did not say. |
| 272 | const ROWS_DEFAULT: u64 = 24; |
| 273 | |
| 274 | /// A refusal as the caller renders it: one sentence, in JSON. |
| 275 | fn refused(reason: &str) -> String { |
| 276 | fmt!(r#"{{"refused":"{}"}}"#, json_escape(&refusal_line(reason))) |
| 277 | } |
| 278 | |
| 279 | /// A size the wire can carry, from whatever the page offered. |
| 280 | /// |
| 281 | /// # Arguments |
| 282 | /// * `ask` - The page's request JSON. |
| 283 | /// * `key` - `"cols"` or `"rows"`. |
| 284 | /// * `dflt` - What to assume when the page did not say. |
| 285 | fn cells(ask: &str, key: &str, dflt: u64) -> u64 { |
| 286 | match extract_json_number(ask, key) { |
| 287 | Some(n) if n >= 1 => n.min(CELLS_MAX), |
| 288 | _ => dflt, |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | /// Whether the absolute `path` sits at or beneath one of `roots`, comparing |
| 293 | /// whole segments so `/w/notes` is not "inside" `/w/note`. |
| 294 | /// |
| 295 | /// A near-copy of `under` in [`crate::tools`], which is private there and works |
| 296 | /// on workspace-relative paths; this one compares the ABSOLUTE paths the fence |
| 297 | /// is written in, which is what the extension and the hand both compare. |
| 298 | /// |
| 299 | /// # Arguments |
| 300 | /// * `path` - An absolute path. |
| 301 | /// * `roots` - Absolute roots. |
| 302 | fn inside(path: &str, roots: &[String]) -> bool { |
| 303 | roots.iter().any(|r| { |
| 304 | let root = r.trim_end_matches('/'); |
| 305 | path == root || path.starts_with(&fmt!("{}/", root)) |
| 306 | }) |
| 307 | } |
| 308 | |
| 309 | /// The wire's `open` request for a terminal in a Diamond, fence and all. |
| 310 | /// |
| 311 | /// This is the whole of the fence question for a terminal, and it is the same |
| 312 | /// walk `Tool::Run` makes. The page hands over what only it knows -- which |
| 313 | /// Diamond, what the user attached, how big the panel is -- and gets back |
| 314 | /// either the request the wire carries or the sentence saying why there is |
| 315 | /// none. It never rejects: a refusal is written to be read, and an `Err` |
| 316 | /// reaches the reader wrapped in an fe2o3 chain around the one sentence that |
| 317 | /// matters. |
| 318 | /// |
| 319 | /// Three things are settled and none of them is a caller's to change. A |
| 320 | /// toolkit is never inferred from what was asked to run -- it is granted by the |
| 321 | /// user, arrives as a name, and resolves through [`Kit::resolve`] exactly as it |
| 322 | /// does for a command. The fence is never widened by anything a model says: |
| 323 | /// `argv` reaches neither the compartment nor the environment. And `TERM` is |
| 324 | /// not sent, because the hand sets it and refuses a caller who names it -- a |
| 325 | /// page that could name it could promise a program capabilities nothing on the |
| 326 | /// other end can draw. |
| 327 | /// |
| 328 | /// # Arguments |
| 329 | /// * `ask_json` - What the page knows, as JSON: |
| 330 | /// `{own_dir, attached, read_only, cwd, cols, rows}`, plus the optional |
| 331 | /// `argv` (defaulting to [`SHELL`]), `toolkits` (the names the user granted |
| 332 | /// this Diamond) and `tainted` (a session belonging to a turn that has read a |
| 333 | /// stranger's words, which loses the network). |
| 334 | /// |
| 335 | /// # Returns |
| 336 | /// The `open` request as the wire spells it, or `{"refused":"…"}`. |
| 337 | #[wasm_bindgen] |
| 338 | pub async fn pty_request(ask_json: String) -> String { |
| 339 | let ask = ask_json.as_str(); |
| 340 | // Where the hand's grant reaches on this machine. The page cannot know it: |
| 341 | // a real folder arrives through the File System Access API as a handle and |
| 342 | // never a path, so the hand is asked and its answer is what the fence is |
| 343 | // expressed against. |
| 344 | let st = match crate::wasm::hand::status().await { |
| 345 | Ok(s) => s, |
| 346 | Err(e) => return refused(&e.msgs().join(" ")), |
| 347 | }; |
| 348 | if extract_json_bool(&st, "paired") != Some(true) { |
| 349 | return refused(&extract_json_string(&st, "reason").unwrap_or_else(|| fmt!( |
| 350 | "There is no machine hand paired with this browser, so there is nothing to open a \ |
| 351 | terminal on. Daimond runs in the browser and a browser cannot allocate a terminal."))); |
| 352 | } |
| 353 | let machine = Machine::from_status(&st); |
| 354 | // An ABSENT root and an EMPTY one are the same answer and are refused |
| 355 | // alike: the hand compares paths with `starts_with`, for which every path |
| 356 | // on the machine is under "". |
| 357 | if !machine.rooted() { |
| 358 | return refused( |
| 359 | "The machine hand did not say which folder it was granted, so there is no way to say \ |
| 360 | what a terminal may touch. It is not safe to guess, so none was opened."); |
| 361 | } |
| 362 | // Release gate 1, applied to a session: a program that cannot be fenced is |
| 363 | // REFUSED, never run unfenced and mentioned afterwards. The test is |
| 364 | // affirmative -- silence is not a fence. |
| 365 | if !fence_enforced(&machine.caps) { |
| 366 | return refused(&fmt!( |
| 367 | "the machine hand on this computer {}, so nothing would stop a program in a terminal \ |
| 368 | reaching the rest of the machine. Daimond will not open a terminal it cannot contain. \ |
| 369 | Tell the user; the file tools work regardless.", |
| 370 | if machine.caps.iter().any(|c| c == "fence:none") { |
| 371 | "says it cannot fence a program" |
| 372 | } else { |
| 373 | "did not say it can fence a program" |
| 374 | })); |
| 375 | } |
| 376 | // A terminal is a pseudo-terminal device, which is a POSIX thing. Refused |
| 377 | // here rather than left to fail two layers down with a system error nobody |
| 378 | // could act on. |
| 379 | if machine.os == "windows" { |
| 380 | return refused( |
| 381 | "This machine's hand runs on Windows, where the terminal Daimond opens -- a POSIX \ |
| 382 | pseudo-terminal -- does not exist. Commands can still be run."); |
| 383 | } |
| 384 | |
| 385 | let own_dir = extract_json_string(ask, "own_dir").unwrap_or_default(); |
| 386 | let attached = extract_json_string_array(ask, "attached").unwrap_or_default(); |
| 387 | let read_only = extract_json_string_array(ask, "read_only").unwrap_or_default(); |
| 388 | // A TERMINAL IS THE USER AT A KEYBOARD, NOT A DAIMON, so its fence is the machine grant |
| 389 | // and not this Diamond's attachments. The owner asked for it in those words on 2026-08-26: |
| 390 | // *"I don't really want the terminal to be daimon and chat workspace bound, I want it to be |
| 391 | // able to roam over the browser and machine workspaces of the user"*. |
| 392 | // |
| 393 | // Nothing is handed to a model by it. No `Tool` opens a terminal or types into one, so a |
| 394 | // daimon cannot reach this surface at all -- which is the whole reason the widening is safe |
| 395 | // here and would not be in `Tool::Run`. The boundary that remains is the one the user |
| 396 | // actually chose: the folder they granted the hand. |
| 397 | // |
| 398 | // ONLY THE ALLOW-LIST GOES. A folder the user marked read-only keeps its `Bound::NoWrite`, |
| 399 | // and every standing credential denial `fence_spec_surfaced` applies -- the hand's journal, the |
| 400 | // daimon's own key, `.gnupg`, `.aws`, `.config/oxedyne` and the rest -- is untouched. The one |
| 401 | // exception is `~/.ssh`: this surface passes `Surface::Terminal`, which lifts THAT deny alone so |
| 402 | // a user can `ssh` from the terminal as themselves (see `tools::Surface`). With no allow-list |
| 403 | // left, `fence_spec_surfaced` reads the turn as unscoped and grants the granted root, which is |
| 404 | // exactly the ask. |
| 405 | // |
| 406 | // `Bound::Nowhere` goes with them: it means "no Diamond at all", and a terminal that |
| 407 | // belongs to no Diamond is now an ordinary terminal rather than a refusal. |
| 408 | let mut bounds: Vec<Bound> = diamond_bounds(&own_dir, &attached, &read_only) |
| 409 | .into_iter() |
| 410 | .filter(|b| !matches!(b, |
| 411 | Bound::OnlyUnder(_) | Bound::OnlyWriteUnder(_) | Bound::Nowhere)) |
| 412 | .collect(); |
| 413 | // The toolchains the user granted this Diamond, and nothing else. The names |
| 414 | // come from what they decided, never from `argv`: a fence that widened |
| 415 | // itself to fit the program asked for would be a fence the caller chooses, |
| 416 | // and the whole arrangement rests on its not being one. |
| 417 | // `terminal_toolkit_bounds` and not `toolkit_bounds`, and the difference is the whole of |
| 418 | // the trust boundary: the Remote toolkit lends an ssh key, and an ssh reaches a shell on |
| 419 | // another machine that nothing here fences. A person opened this surface and is typing into |
| 420 | // it; a daimon's `run` never can, because `toolkit_bounds` -- which is what every other |
| 421 | // caller uses -- drops the rule. See `Toolkit::terminal_only`. |
| 422 | // |
| 423 | // The second argument is the MACHINE's answer and not this Diamond's. `ssh` is not a thing a |
| 424 | // Diamond holds -- a terminal is not tied to one either -- so the posture is the user's act |
| 425 | // on this computer, `install.sh --remote`, reported in `hello` as `remote:ready` and read |
| 426 | // here off `Machine`. No Diamond grants it, and one that never had it granted opens a |
| 427 | // terminal that can ssh exactly like every other. |
| 428 | bounds.extend(crate::tools::terminal_toolkit_bounds( |
| 429 | &extract_json_string_array(ask, "toolkits").unwrap_or_default(), |
| 430 | machine.remote)); |
| 431 | |
| 432 | // A tainted session loses the network, the same rule `egress_check` applies |
| 433 | // to a URL -- and now the same rule the user's PERMISSION MODE governs. The |
| 434 | // rung is read from `tools::mode()` and never taken from the ask: a page |
| 435 | // that could name its own rung could grant itself one, which is the same |
| 436 | // objection that keeps a toolkit out of `argv`. Only the page can say |
| 437 | // whether the session is tainted, and it can only ever say it in the |
| 438 | // narrowing direction: absent, and the answer is "not tainted", which is |
| 439 | // what a terminal the user opened by hand is. |
| 440 | // |
| 441 | // The rung reaches a terminal through THIS question and no other, which is |
| 442 | // a decision rather than an omission. `Mode::Ask` puts a command to the user |
| 443 | // before it runs; the person typing into a terminal has already asked, and |
| 444 | // a dialog over their own keystrokes would be the app asking permission to |
| 445 | // do what they are in the middle of doing. |
| 446 | let tainted = extract_json_bool(ask, "tainted") == Some(true); |
| 447 | // THE TERMINAL'S OWN ROOT: the ceiling the installer named where there is one, and the |
| 448 | // granted root where there is not. A terminal is the user at a keyboard and a command is a |
| 449 | // daimon, and the owner asked for the two to be different sizes on 2026-08-26. |
| 450 | // |
| 451 | // The ceiling lives on the machine, written by `hand/install/install.sh` and never by a page, |
| 452 | // so a fence expressed against it is still one the hand's own grant could have produced -- |
| 453 | // which is what `vet_roots` checks and the reason this is not a field the page fills in. |
| 454 | // The order is the whole of the policy. A PIN is a decision the user made at a shell and the |
| 455 | // app follows it. Otherwise the app may choose among the folders the MACHINE offered -- and |
| 456 | // an `ask` naming anything else is ignored rather than refused, because a page proposing a |
| 457 | // folder the machine never offered is a page proposing nothing at all. |
| 458 | let asked_root = extract_json_string(ask, "terminal_root").unwrap_or_default(); |
| 459 | let root = machine.terminal_root.clone() |
| 460 | .or_else(|| machine.terminal_ceilings.iter() |
| 461 | .find(|c| c.trim_end_matches('/') == asked_root.trim_end_matches('/')) |
| 462 | .cloned()) |
| 463 | .unwrap_or_else(|| machine.root.clone()) |
| 464 | .trim_end_matches('/').to_string(); |
| 465 | // `fence_spec` resolves every workspace-relative path against `Machine::root`, so handing it |
| 466 | // the machine unchanged would express the fence against the GRANTED root while the session ran |
| 467 | // under the ceiling -- two different folders, and the hand would refuse the difference. The |
| 468 | // home is untouched: the standing credential denies are home-relative and must not move. |
| 469 | let mut tm = machine.clone(); |
| 470 | tm.root = root.clone(); |
| 471 | // `Surface::Terminal` is the ONE thing this call does that a `Tool::Run` fence does not, and |
| 472 | // the whole of the difference: it omits the `.ssh` deny so a user can `ssh` from here as |
| 473 | // themselves. It is a literal, never read from `ask`, `bounds` or the machine, and this is the |
| 474 | // only call site in the app that names it -- see `tools::Surface`. Every other credential deny |
| 475 | // still applies here unchanged. |
| 476 | let fence = fence_spec_surfaced( |
| 477 | &bounds, |
| 478 | &tm, |
| 479 | crate::tools::mode().withholds_net(tainted), |
| 480 | Surface::Terminal, |
| 481 | ); |
| 482 | if fence.rw.is_empty() && fence.ro.is_empty() { |
| 483 | // Two states arrive here and a person can only act on one of them, so they are told |
| 484 | // apart. A Diamond that named itself and nothing else is the ORDINARY case -- a fresh |
| 485 | // Diamond, with a Terminal panel and no folder yet -- and the sentence it gets has to |
| 486 | // say what to do about it, or the user meets a wall with no door in it. A scope naming |
| 487 | // nowhere at all is the panel having no Diamond to ask for, which is a different |
| 488 | // situation and no amount of attaching would fix. |
| 489 | // Since a terminal no longer declares an allow-list, this is no longer a Diamond with |
| 490 | // nothing attached -- that case now opens an ordinary terminal at the granted root. What |
| 491 | // is left is a machine whose grant cannot be expressed at all, and no amount of attaching |
| 492 | // would change it. |
| 493 | return refused( |
| 494 | "the folder this computer's hand was granted cannot be expressed as a fence, so there \ |
| 495 | is nothing to say what a terminal may touch. It is not safe to guess, so none was \ |
| 496 | opened. Re-run 'hand/install/install.sh --check', which lists what has to be true."); |
| 497 | } |
| 498 | |
| 499 | |
| 500 | // Where the session starts, by the same rule a command starts by: `tools::start_dir`, which |
| 501 | // both edges now share. The panel sends the Diamond's own directory as the `cwd`, because |
| 502 | // that is what a Diamond IS to it -- and that directory is in the browser's storage and not |
| 503 | // on this machine, so a session that took it literally asked for a terminal outside its own |
| 504 | // fence and was refused. Every Diamond terminal, including one with a folder attached and |
| 505 | // nothing whatever wrong with it. |
| 506 | let asked = normalise(&extract_json_string(ask, "cwd").unwrap_or_default()); |
| 507 | let cwd_rel = if asked.is_empty() || crate::tools::is_store_path(&asked) { |
| 508 | crate::tools::start_dir(&bounds) |
| 509 | } else { |
| 510 | asked |
| 511 | }; |
| 512 | let cwd = if cwd_rel.is_empty() { root.clone() } else { fmt!("{}/{}", root, cwd_rel) }; |
| 513 | // The hand refuses a working directory outside the fence, and so does the |
| 514 | // extension. Refused here as well, where the sentence can still name the |
| 515 | // folder the caller asked for rather than the absolute path two layers down. |
| 516 | if !inside(&cwd, &fence.rw) && !inside(&cwd, &fence.ro) { |
| 517 | return refused(&fmt!( |
| 518 | "a terminal was asked for in \"{}\", which is outside what this Diamond may touch. A \ |
| 519 | session starts inside the folders the user attached to it.", cwd)); |
| 520 | } |
| 521 | |
| 522 | let argv = match extract_json_string_array(ask, "argv") { |
| 523 | Some(v) if !v.is_empty() && !v[0].trim().is_empty() => v, |
| 524 | // The user's own login shell where the hand named one, and [`SHELL`] where it did |
| 525 | // not. Taken from the machine and never from the ask, exactly as the fence is: a page |
| 526 | // that could name the program could name one outside the fence and be refused two |
| 527 | // layers down in a sentence about a path nobody chose. |
| 528 | _ => vec![machine.shell.clone().unwrap_or_else(|| SHELL.to_string())], |
| 529 | }; |
| 530 | let argv_json: Vec<String> = argv.iter().map(|a| fmt!("\"{}\"", json_escape(a))).collect(); |
| 531 | |
| 532 | // The environment is not a caller's to set, exactly as it is not for a |
| 533 | // command: what goes here is the granted toolkit's own two or three names, |
| 534 | // from the table in `src/tools.rs`, or nothing at all. `TERM` is pointedly |
| 535 | // absent -- the hand sets it and REFUSES a session that names it. |
| 536 | let env_json = match Kit::resolve(&bounds, &machine) { |
| 537 | Some(kit) => kit.env_json(), |
| 538 | None => fmt!("[]"), |
| 539 | }; |
| 540 | |
| 541 | // No `id`: the relay mints one, because it is the end that knows which |
| 542 | // sessions this page already has open and an id has to be unique among |
| 543 | // those. Everything else is composed here, once. |
| 544 | // The granted toolkit names travel with the fence, as they do for a command: the hand clamps |
| 545 | // an arriving fence against the toolchains it was told were granted, and a toolchain folder is |
| 546 | // not under the granted root, so it cannot check the one without the other. Read back out of |
| 547 | // the bounds rather than echoed from the ask, so an unknown name is dropped here and never |
| 548 | // reaches the wire. |
| 549 | fmt!( |
| 550 | r#"{{"t":"open","argv":[{}],"cwd":"{}","env":{},"size":{{"cols":{},"rows":{}}},"fence":{},"toolkits":{}}}"#, |
| 551 | argv_json.join(","), |
| 552 | json_escape(&cwd), |
| 553 | env_json, |
| 554 | cells(ask, "cols", COLS_DEFAULT), |
| 555 | cells(ask, "rows", ROWS_DEFAULT), |
| 556 | fence.to_json(), |
| 557 | crate::tools::toolkit_names_json(&bounds), |
| 558 | ) |
| 559 | } |