Oregami
Repositories/oxedyne/daimond

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
36use crate::llm::{
37 extract_json_bool,
38 extract_json_number,
39 extract_json_string,
40 extract_json_string_array,
41 json_escape,
42};
43use 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};
54use crate::wasm::js_str;
55
56use oxedyne_fe2o3_core::prelude::*;
57
58use wasm_bindgen::prelude::wasm_bindgen;
59use wasm_bindgen::{JsCast, JsValue};
60use wasm_bindgen_futures::JsFuture;
61
62
63#[wasm_bindgen]
64extern "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.
98fn 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`.
117fn 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.
125fn 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.
141async 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.
157async 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".
169pub 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.
185pub 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.
200pub 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.
211pub 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.
224pub 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.
260const 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.
266const CELLS_MAX: u64 = 2000;
267
268/// Columns assumed when the page did not say.
269const COLS_DEFAULT: u64 = 80;
270
271/// Rows assumed when the page did not say.
272const ROWS_DEFAULT: u64 = 24;
273
274/// A refusal as the caller renders it: one sentence, in JSON.
275fn 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.
285fn 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.
302fn 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]
338pub 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}