diff --git a/crates/minimal/src/lib.rs b/crates/minimal/src/lib.rs index b9d8986a7..24c8cf5ee 100644 --- a/crates/minimal/src/lib.rs +++ b/crates/minimal/src/lib.rs @@ -1562,10 +1562,15 @@ pub async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(), let compose_options = loadouts::compose_options_from_config(&cfg); let selection = loadouts::LoadoutSelection::from_flags(&args.loadout, args.no_loadouts); let active = loadouts::resolve_active_loadouts(selection, &cfg, global)?; - if !active.is_empty() { - let names: Vec<&str> = active.iter().map(|l| l.name().as_ref()).collect(); + if !active.loadouts.is_empty() { + let names: Vec<&str> = active.loadouts.iter().map(|l| l.name().as_ref()).collect(); eprintln!("Applying loadouts: {}", names.join(", ")); } + // The contribution carries the banner's loadout display list as a + // first-class orientation field (the daemon seeds MINIMAL_LOADOUTS + // from it in the launcher baseline). The banner's other dynamic + // clause — blueprint presence — is a session-filesystem fact, + // tested by the templates in-shell when they print. let (contribution, user_policy) = loadouts::compose_user_contribution(active, user_policy, compose_options)?; diff --git a/crates/minimal/src/loadouts.rs b/crates/minimal/src/loadouts.rs index 2f753ee4e..3f828c36b 100644 --- a/crates/minimal/src/loadouts.rs +++ b/crates/minimal/src/loadouts.rs @@ -53,6 +53,23 @@ const BUILTIN_DEFAULT_NAME: &str = "default"; /// payload once and then unsets both vars, so it prints exactly once /// and never for non-interactive commands; the `[ -t 1 ]` guard keeps /// redirected output clean and the plain text renders under `NO_COLOR`. +/// +/// The MOTD is a STATIC template: after the mark it prints the same +/// orientation lines the launcher-baseline banner prints — the session +/// name, the active loadout list, the detach chord, and a `min init` +/// pointer when the session workspace has no blueprint — by +/// interpolating `$MINIMAL_SESSION_NAME` (seeded by the daemon's +/// launcher baseline) and `$MINIMAL_LOADOUTS` (contributed by the +/// client alongside this loadout) in-shell at print time; both carry a +/// `${VAR:-fallback}` so a missing var still renders sanely. The +/// blueprint clause is a SESSION-filesystem fact, so it is tested +/// in-shell against the workspace root when the banner prints — both +/// mfile layouts, `minimal.toml` and `.minimal/minimal.toml` — which +/// stays correct across skipped uploads, an in-session `min init`, and +/// attaches from unrelated host directories. `/workbench` mirrors +/// `sandbox2::SESSION_DEFAULT_WD`, the attach shell's initial cwd (a +/// literal here because this client crate doesn't depend on the sandbox +/// crate; the daemon-side template derives it from the constant). const BUILTIN_DEFAULT_TOML: &str = r#" name = "default" description = "orientation banner and shaped prompt" @@ -61,7 +78,7 @@ description = "orientation banner and shaped prompt" PROMPT_COMMAND = 'eval "$MINIMAL_MOTD"; unset PROMPT_COMMAND MINIMAL_MOTD' PS1 = 'minimal:\w\$ ' MINIMAL_MOTD = ''' -[ -t 1 ] && printf '\n ████ ████▄\n ▄▄▄ ▀███▄ ▀███▄\n ▀███ ▀███ ▀███\n\n minimal · default loadout\n\n Add tools to this box: min add --session \n Search the registry: min search \n\n' +[ -t 1 ] && { printf '\n ████ ████▄\n ▄▄▄ ▀███▄ ▀███▄\n ▀███ ▀███ ▀███\n\n'; printf ' minimal · session %s · loadout %s\n detach: ctrl-w' "${MINIMAL_SESSION_NAME:-unnamed}" "${MINIMAL_LOADOUTS:-default (built-in)}"; [ -f /workbench/minimal.toml ] || [ -f /workbench/.minimal/minimal.toml ] || printf ' · no minimal.toml here — min init to add one'; printf '\n\n Add tools to this box: min add --session \n Search the registry: min search \n\n'; } ''' "#; @@ -74,6 +91,19 @@ fn builtin_default_loadout() -> sessions::core::loadout::Loadout { toml::from_str(BUILTIN_DEFAULT_TOML).expect("built-in default loadout TOML must parse") } +/// The loadouts resolved for a session activation, plus how the +/// zero-config fallback resolved — the display list interpolated into +/// the orientation banner tags the built-in `default` distinctly. +#[derive(Debug)] +pub(crate) struct ActiveLoadouts { + /// The loadouts to compose, in application order. + pub(crate) loadouts: Vec, + /// True when the zero-config fallback used the built-in `default` + /// loadout (as opposed to user files, including a shadowing user + /// `default.toml`). + pub(crate) builtin_default: bool, +} + /// Resolve the loadout names to apply for a session activation /// and load each from disk. /// @@ -89,10 +119,15 @@ pub(crate) fn resolve_active_loadouts( selection: LoadoutSelection, cfg: &sessions::client::config::Config, global: &GlobalArgs, -) -> Result, anyhow::Error> { +) -> Result { let loadouts_dir = resolve_minimal_config_dir(global).join("loadouts"); let (names, source): (Vec, &str) = match selection { - LoadoutSelection::None => return Ok(Vec::new()), + LoadoutSelection::None => { + return Ok(ActiveLoadouts { + loadouts: Vec::new(), + builtin_default: false, + }); + } LoadoutSelection::Cli(names) => (names, "--loadout"), LoadoutSelection::Defaults => { let configured = cfg.loadouts.default_loadouts.clone(); @@ -102,7 +137,10 @@ pub(crate) fn resolve_active_loadouts( // the user shadows it with their own `default.toml`, // in which case that file is loaded instead. if !loadouts_dir.join("default.toml").exists() { - return Ok(vec![builtin_default_loadout()]); + return Ok(ActiveLoadouts { + loadouts: vec![builtin_default_loadout()], + builtin_default: true, + }); } (vec![BUILTIN_DEFAULT_NAME.to_string()], "default_loadouts") } else { @@ -110,14 +148,37 @@ pub(crate) fn resolve_active_loadouts( } } }; - names + let loadouts = names .iter() .map(|name| { let path = loadouts_dir.join(format!("{name}.toml")); sessions::client::disk::read_loadout_file(&path) .with_context(|| format!("{source} `{name}`")) }) - .collect() + .collect::, _>>()?; + Ok(ActiveLoadouts { + loadouts, + builtin_default: false, + }) +} + +/// Human-readable list of the active loadouts, as interpolated into the +/// orientation banner via `$MINIMAL_LOADOUTS`: comma-joined names, +/// `default (built-in)` for the zero-config fallback, `none` when no +/// loadout applies. +pub(crate) fn loadout_display_list(active: &ActiveLoadouts) -> String { + if active.builtin_default { + return format!("{BUILTIN_DEFAULT_NAME} (built-in)"); + } + if active.loadouts.is_empty() { + return "none".to_string(); + } + active + .loadouts + .iter() + .map(|l| l.name().as_ref()) + .collect::>() + .join(", ") } /// Build the [`ComposeOptions`] the client passes to @@ -131,20 +192,25 @@ pub(crate) fn compose_options_from_config( .with_follow_symlinks(cfg.loadouts.follow_symlinks) } -/// Compose the given loadouts into a +/// Compose the resolved [`ActiveLoadouts`] into a /// [`sessions::wire::request::WireContribution`] under the user's /// [`UserPolicy`] loaded from `user_policy.toml`. User-origin items /// auto-pass the allow step but the policy's `deny` / `ignore` rules /// still apply, so a loadout patch matching a deny rule fails the /// composition here rather than at the daemon. /// +/// The contribution also carries the first-prompt orientation as a +/// first-class field (never a var): the loadout display list computed +/// via [`loadout_display_list`], which the daemon seeds into the banner +/// env (`MINIMAL_LOADOUTS`) in the launcher baseline. +/// /// Returns the possibly-mutated policy alongside the wire /// contribution — a hook (interactive prompt) may have appended /// allow/ignore/deny rules and the caller wants to persist them. /// /// [`UserPolicy`]: sessions::core::policy::UserPolicy pub(crate) fn compose_user_contribution( - loadouts: Vec, + active: ActiveLoadouts, policy: sessions::core::policy::UserPolicy, options: sessions::core::compose::ComposeOptions, ) -> Result< @@ -154,17 +220,20 @@ pub(crate) fn compose_user_contribution( ), anyhow::Error, > { + let loadouts_display = loadout_display_list(&active); // Session transition scripts declared in a loadout are not available // in this release, so they are silently excluded here — before the // loadouts reach the composer — rather than after composition. This // keeps a declared hook from participating in composition at all. // Drop the `without_lifecycle_hooks` map when the feature ships. - let loadouts: Vec<_> = loadouts + let loadouts: Vec<_> = active + .loadouts .into_iter() .map(sessions::core::loadout::Loadout::without_lifecycle_hooks) .collect(); - let mut composer = sessions::client::composer::UserComposer::new(); + let mut composer = sessions::client::composer::UserComposer::new() + .with_orientation(sessions::core::compose::Orientation { loadouts_display }); composer .add_all(loadouts) .map_err(|e| anyhow::anyhow!("composing loadouts: {e}"))?; @@ -394,7 +463,9 @@ mod tests { }; let out = resolve_active_loadouts(LoadoutSelection::None, &cfg, &global) .expect("None → Ok(empty), no I/O"); - assert!(out.is_empty()); + assert!(out.loadouts.is_empty()); + assert!(!out.builtin_default); + assert_eq!(loadout_display_list(&out), "none"); } /// `resolve_active_loadouts` errors when a `--loadout NAME` @@ -441,7 +512,10 @@ on_activate = { type = "inline", value = "echo activated" } assert_eq!(loadout.lifecycle_hooks().len(), 1); let (wire, _policy) = compose_user_contribution( - vec![loadout], + ActiveLoadouts { + loadouts: vec![loadout], + builtin_default: false, + }, sessions::core::policy::UserPolicy::empty(), sessions::core::compose::ComposeOptions::default(), ) @@ -455,6 +529,8 @@ on_activate = { type = "inline", value = "echo activated" } // ...while the loadout's other items compose normally. assert_eq!(wire.requested_packages.len(), 1); assert_eq!(wire.vars.len(), 1); + // The orientation rides as a first-class field, never a var. + assert_eq!(wire.orientation.loadouts_display, "dev"); } /// The built-in `default` loadout parses (guarding the `expect` in @@ -477,6 +553,71 @@ on_activate = { type = "inline", value = "echo activated" } assert!(vars.iter().any(|n| n == "PROMPT_COMMAND")); assert!(vars.iter().any(|n| n == "MINIMAL_MOTD")); assert!(vars.iter().any(|n| n == "PS1")); + + // The MOTD carries the orientation lines as a static template: + // the dynamic parts are `${MINIMAL_*:-fallback}` interpolations + // the shell resolves at print time (the mark and the `min add` + // pointers stay verbatim). + let motd = l + .all_vars() + .find(|(n, _)| n.as_str() == "MINIMAL_MOTD") + .map(|(_, v)| match v { + sessions::core::primitives::VarValue::Specified { value } => value.clone(), + other => panic!("MINIMAL_MOTD must be a literal value, got {other:?}"), + }) + .expect("MINIMAL_MOTD present"); + assert!(motd.contains("████"), "the mark stays"); + assert!(motd.contains("min add --session"), "the pointers stay"); + assert!(motd.contains("${MINIMAL_SESSION_NAME:-")); + assert!(motd.contains("${MINIMAL_LOADOUTS:-")); + // The blueprint clause tests the session workspace itself at + // print time — both mfile layouts — never a client-probed var. + assert!(motd.contains("[ -f /workbench/minimal.toml ]")); + assert!(motd.contains("[ -f /workbench/.minimal/minimal.toml ]")); + assert!( + !motd.contains("MINIMAL_BLUEPRINT"), + "blueprint is a session-filesystem fact, not an env var" + ); + assert!(motd.contains("detach: ctrl-w")); + assert!(motd.contains("min init")); + } + + /// The display list interpolated into the banner: comma-joined names + /// for user loadouts, `none` for an empty set (the built-in and + /// shadow cases are asserted in the resolution tests above). + #[test] + fn loadout_display_list_joins_names() { + let mk = |name: &str| -> sessions::core::loadout::Loadout { + toml::from_str(&format!("name = \"{name}\"\n")).expect("loadout parses") + }; + let active = ActiveLoadouts { + loadouts: vec![mk("helix"), mk("fish")], + builtin_default: false, + }; + assert_eq!(loadout_display_list(&active), "helix, fish"); + } + + /// The composed contribution carries the loadout display list as a + /// first-class orientation field — never a var, so user vars and + /// user policy cannot collide with it. (No blueprint field either: + /// that is a session-filesystem fact the banner templates test + /// in-shell at print time.) + #[test] + fn compose_carries_orientation_as_field_not_var() { + let (wire, _policy) = compose_user_contribution( + ActiveLoadouts { + loadouts: Vec::new(), + builtin_default: true, + }, + sessions::core::policy::UserPolicy::empty(), + sessions::core::compose::ComposeOptions::default(), + ) + .expect("empty composition succeeds"); + assert_eq!(wire.orientation.loadouts_display, "default (built-in)"); + assert!( + wire.vars.iter().all(|v| v.var.name != "MINIMAL_LOADOUTS"), + "orientation must not ride the var lane" + ); } /// Zero-config resolution — no flags, empty `default_loadouts`, no @@ -496,9 +637,11 @@ on_activate = { type = "inline", value = "echo activated" } }; let out = resolve_active_loadouts(LoadoutSelection::Defaults, &cfg, &global) .expect("built-in fallback resolves"); - assert_eq!(out.len(), 1); - assert_eq!(out[0].name().as_ref(), BUILTIN_DEFAULT_NAME); - assert!(out[0].packages().is_empty()); + assert_eq!(out.loadouts.len(), 1); + assert_eq!(out.loadouts[0].name().as_ref(), BUILTIN_DEFAULT_NAME); + assert!(out.loadouts[0].packages().is_empty()); + assert!(out.builtin_default); + assert_eq!(loadout_display_list(&out), "default (built-in)"); } /// A user `default.toml` on disk shadows the built-in: zero-config @@ -523,8 +666,12 @@ on_activate = { type = "inline", value = "echo activated" } }; let out = resolve_active_loadouts(LoadoutSelection::Defaults, &cfg, &global) .expect("user default resolves"); - assert_eq!(out.len(), 1); - assert_eq!(out[0].description(), Some("user override")); + assert_eq!(out.loadouts.len(), 1); + assert_eq!(out.loadouts[0].description(), Some("user override")); + // A user shadow is NOT the built-in — the banner's loadout list + // must not tag it `(built-in)`. + assert!(!out.builtin_default); + assert_eq!(loadout_display_list(&out), "default"); } /// The built-in listing row carries the `(built-in)` tag and a diff --git a/crates/minimal/src/task.rs b/crates/minimal/src/task.rs index 8c9262693..db978d38d 100644 --- a/crates/minimal/src/task.rs +++ b/crates/minimal/src/task.rs @@ -320,10 +320,13 @@ pub async fn cmd_task_run(global: &GlobalArgs, args: TaskRunArgs) -> Result<(), let compose_options = crate::loadouts::compose_options_from_config(&cfg); let selection = crate::loadouts::LoadoutSelection::from_flags(&[], false); let active = crate::loadouts::resolve_active_loadouts(selection, &cfg, global)?; - if !active.is_empty() { - let names: Vec<&str> = active.iter().map(|l| l.name().as_ref()).collect(); + if !active.loadouts.is_empty() { + let names: Vec<&str> = active.loadouts.iter().map(|l| l.name().as_ref()).collect(); eprintln!("Applying loadouts: {}", names.join(", ")); } + // Same first-class orientation field as an activate: a `--keep` + // task session is attachable later, and its banner should orient + // too. let (contribution, user_policy) = crate::loadouts::compose_user_contribution(active, user_policy, compose_options)?; diff --git a/crates/minimald/src/session_host.rs b/crates/minimald/src/session_host.rs index b248c1b5f..47eda35a3 100644 --- a/crates/minimald/src/session_host.rs +++ b/crates/minimald/src/session_host.rs @@ -1070,17 +1070,95 @@ pub(crate) struct AttachEnv { pub(crate) connection: Vec<(String, String)>, } +/// Trigger half of the launcher-baseline orientation banner: evaluates the +/// [`BASELINE_MOTD`] payload at the first interactive prompt, then unsets +/// both vars so the banner prints exactly once and never for +/// non-interactive commands. Identical to the MOTD recipe the built-in +/// `default` loadout ships and `docs/reference/loadouts.md` ("Vars in the +/// attach shell") documents. +const BASELINE_PROMPT_COMMAND: &str = r#"eval "$MINIMAL_MOTD"; unset PROMPT_COMMAND MINIMAL_MOTD"#; + +/// Absolute workspace root inside a session sandbox, `/workbench` by +/// convention. Derived from the same [`sandbox2::SESSION_DEFAULT_WD`] the +/// sandbox uses as the shell's initial cwd (sessions never set a +/// `working_name_override`), so [`BASELINE_MOTD`]'s blueprint test cannot +/// drift from where the workspace actually lives. +const SESSION_WORKSPACE_ROOT: &str = constcat::concat!("/", sandbox2::SESSION_DEFAULT_WD); + +/// Payload half of the launcher-baseline orientation banner: a STATIC +/// template. The dynamic parts resolve in-shell at print time: the +/// template interpolates `$MINIMAL_SESSION_NAME` and `$MINIMAL_LOADOUTS` +/// (both seeded by [`session_baseline_env`] — the loadout list arrives +/// from the client as the composition's first-class orientation field, +/// never as a user var); each carries a `${VAR:-fallback}` so a missing +/// var still renders sanely. Whether the workspace holds a blueprint is a +/// SESSION-filesystem fact, so it is not interpolated from anywhere — the +/// template tests [`SESSION_WORKSPACE_ROOT`] directly (both mfile +/// layouts, `minimal.toml` and `.minimal/minimal.toml`) when it prints, +/// which stays correct across skipped uploads, an in-session `min init`, +/// and attaches from unrelated host directories. TTY-gated, plain text — +/// `NO_COLOR`-safe, no box drawing. +const BASELINE_MOTD: &str = constcat::concat!( + r#"[ -t 1 ] && { printf 'minimal · session %s · loadout %s\ndetach: ctrl-w' "${MINIMAL_SESSION_NAME:-unnamed}" "${MINIMAL_LOADOUTS:-none}"; [ -f "#, + SESSION_WORKSPACE_ROOT, + r#"/minimal.toml ] || [ -f "#, + SESSION_WORKSPACE_ROOT, + r#"/.minimal/minimal.toml ] || printf ' · no minimal.toml here — min init to add one'; printf '\n'; }"#, +); + +/// The launcher-baseline environment seeded beneath every other layer of +/// [`layer_session_env`]: the session's identity (`MINIMAL_SESSION_NAME`, +/// plus `MINIMAL_LOADOUTS` when the composition's first-class orientation +/// field carries a display list) and the once-only orientation banner +/// pair. ALL orientation env is seeded here, daemon-side, from typed +/// data — none of it rides the user var lane, so user vars and policy +/// can never collide with it. Sitting on the lowest layer means any +/// composed `PROMPT_COMMAND` — a user loadout's, or the built-in +/// default's — overrides the baseline banner cleanly, while the identity +/// vars stay available for that override to interpolate. +/// +/// `loadouts_display` is `None` when the composition carries no display +/// list (a client that predates the orientation field, or no +/// composition at all): the var is then left unset so each template's +/// own `${MINIMAL_LOADOUTS:-…}` fallback renders — the baseline banner +/// falls back to `none`, the built-in default loadout's MOTD to +/// `default (built-in)`, each correct for the context it prints in. +fn session_baseline_env( + session_name: &str, + loadouts_display: Option<&str>, +) -> Vec<(String, String)> { + let mut env = vec![ + ("MINIMAL_SESSION_NAME".to_string(), session_name.to_string()), + ( + "PROMPT_COMMAND".to_string(), + BASELINE_PROMPT_COMMAND.to_string(), + ), + ("MINIMAL_MOTD".to_string(), BASELINE_MOTD.to_string()), + ]; + if let Some(display) = loadouts_display { + env.push(("MINIMAL_LOADOUTS".to_string(), display.to_string())); + } + env +} + /// Layers a session shell's environment by precedence, lowest first: the +/// launcher `baseline` (session identity + orientation banner), the /// client-forwarded `inherited` locale/timezone, then the `composition` vars -/// (which may override the inherited defaults), then the `connection` facts +/// (which may override both lower layers), then the `connection` facts /// (which override everything — sshd-style). Later inserts win on a shared key. fn layer_session_env( + baseline: Vec<(String, String)>, inherited: Vec<(String, String)>, composition: Vec<(String, String)>, connection: Vec<(String, String)>, ) -> std::collections::HashMap { let mut env = std::collections::HashMap::new(); - for (k, v) in inherited.into_iter().chain(composition).chain(connection) { + for (k, v) in baseline + .into_iter() + .chain(inherited) + .chain(composition) + .chain(connection) + { env.insert(k, v); } env @@ -1246,11 +1324,13 @@ impl SessionLauncher for SandboxLauncher { // contribution the composer collected. Packages: baseline set // (required for a usable interactive shell) unioned with // everything the composition asks for, dedup-preserving-order - // so the base packages install first. Env vars: purely the - // composition's, since the launcher no longer forces any - // baseline var — sandbox2 sets the session defaults (`PS1`, - // `PATH`, `HOME`, `LANG`, …) which these composed vars then - // override on a shared key. + // so the base packages install first. Env vars: the + // composition's over a small launcher baseline — the session's + // identity (`MINIMAL_SESSION_NAME`) and the once-only + // orientation banner pair (see [`session_baseline_env`]) — + // while sandbox2 sets the session defaults (`PS1`, `PATH`, + // `HOME`, `LANG`, …) which these vars then override on a + // shared key. // // Baseline is intentionally minimal: `base` for the shell, // `coreutils` for `ls`/`cat`/etc, and `socat` for the @@ -1287,8 +1367,9 @@ impl SessionLauncher for SandboxLauncher { } } // Env vars, layered by precedence (see [`layer_session_env`]): the - // client-forwarded locale/timezone sit below the composition, and the - // per-connection facts (`TERM`) sit above it. + // launcher baseline and the client-forwarded locale/timezone sit + // below the composition, and the per-connection facts (`TERM`) sit + // above it. let composition_vars: Vec<(String, String)> = composition .as_ref() .map(|c| { @@ -1298,7 +1379,16 @@ impl SessionLauncher for SandboxLauncher { .collect() }) .unwrap_or_default(); + // The banner's loadout list arrives as the composition's + // first-class orientation field; empty means "unknown" (an old + // client) and seeds nothing — the template's `${…:-}` fallback + // renders instead. + let loadouts_display = composition + .as_ref() + .map(|c| c.orientation().loadouts_display.as_str()) + .filter(|d| !d.is_empty()); let env_vars = layer_session_env( + session_baseline_env(&name, loadouts_display), attach_env.inherited, composition_vars, attach_env.connection, @@ -2003,13 +2093,16 @@ mod tests { ypixel: 0, }; - /// Precedence: client-forwarded `inherited` sits below the composition, - /// which sits below the per-connection `connection` facts. Later layers win - /// on a shared key; non-colliding keys from every layer survive. + /// Precedence: the launcher `baseline` sits below the client-forwarded + /// `inherited`, which sits below the composition, which sits below the + /// per-connection `connection` facts. Later layers win on a shared key; + /// non-colliding keys from every layer survive. #[test] fn layer_session_env_precedence() { let sv = |k: &str, v: &str| (k.to_string(), v.to_string()); let env = layer_session_env( + // baseline: its LANG is overridden by inherited; NAME survives. + vec![sv("LANG", "C"), sv("NAME", "box-1")], // inherited: LANG is overridden by composition; TZ survives. vec![sv("LANG", "de_DE.UTF-8"), sv("TZ", "Europe/Berlin")], // composition: beats inherited LANG; its TERM is overridden by the @@ -2023,11 +2116,102 @@ mod tests { vec![sv("TERM", "xterm-256color")], ); - assert_eq!(env.get("LANG").map(String::as_str), Some("fr_FR.UTF-8")); // composition > inherited + assert_eq!(env.get("LANG").map(String::as_str), Some("fr_FR.UTF-8")); // composition > inherited > baseline + assert_eq!(env.get("NAME").map(String::as_str), Some("box-1")); // baseline-only survives assert_eq!(env.get("TZ").map(String::as_str), Some("Europe/Berlin")); // inherited-only survives assert_eq!(env.get("TERM").map(String::as_str), Some("xterm-256color")); // connection > composition assert_eq!(env.get("EDITOR").map(String::as_str), Some("hx")); // composition-only survives - assert_eq!(env.len(), 4); + assert_eq!(env.len(), 5); + } + + /// The launcher baseline seeds the session's identity plus the + /// once-only orientation banner pair: the session name verbatim in + /// `MINIMAL_SESSION_NAME`, the self-unsetting `PROMPT_COMMAND` + /// trigger, and a STATIC `MINIMAL_MOTD` template that defers the + /// dynamic parts to print time — env interpolation for the name and + /// loadout list (with `${VAR:-fallback}` unset-safety), a direct + /// in-shell filesystem test of the session workspace for the + /// blueprint clause (never a var: the client can't know the + /// workspace's state). + #[test] + fn layer_session_env_seeds_baseline_banner() { + let env = layer_session_env( + session_baseline_env("api-server-4f2a", Some("default (built-in)")), + vec![], + vec![], + vec![], + ); + + assert_eq!( + env.get("MINIMAL_SESSION_NAME").map(String::as_str), + Some("api-server-4f2a") + ); + // The loadout list is seeded daemon-side from the composition's + // first-class orientation field — never from a user var. + assert_eq!( + env.get("MINIMAL_LOADOUTS").map(String::as_str), + Some("default (built-in)") + ); + let pc = env.get("PROMPT_COMMAND").expect("baseline PROMPT_COMMAND"); + assert!(pc.contains(r#"eval "$MINIMAL_MOTD""#)); + assert!(pc.contains("unset PROMPT_COMMAND MINIMAL_MOTD")); + + let motd = env.get("MINIMAL_MOTD").expect("baseline MINIMAL_MOTD"); + assert!(motd.starts_with("[ -t 1 ]"), "banner must be TTY-gated"); + // Static template, dynamic vars: interpolated in-shell, unset-safe. + assert!(motd.contains("${MINIMAL_SESSION_NAME:-")); + assert!(motd.contains("${MINIMAL_LOADOUTS:-")); + // The blueprint clause tests the session workspace itself at + // print time — both mfile layouts — pinned to the same constant + // that is the shell's initial cwd. + assert!(motd.contains("[ -f /workbench/minimal.toml ]")); + assert!(motd.contains("[ -f /workbench/.minimal/minimal.toml ]")); + assert!( + !motd.contains("MINIMAL_BLUEPRINT"), + "blueprint is a session-filesystem fact, not an env var" + ); + assert!(motd.contains("detach: ctrl-w")); + assert!(motd.contains("min init")); + } + + /// A missing loadout display (old client / no composition) leaves + /// `MINIMAL_LOADOUTS` unset so the templates' own `${…:-}` fallbacks + /// render, each correct for its surface. + #[test] + fn baseline_env_omits_loadouts_var_when_display_unknown() { + let env = layer_session_env(session_baseline_env("box-1", None), vec![], vec![], vec![]); + assert!(!env.contains_key("MINIMAL_LOADOUTS")); + assert!(env.contains_key("MINIMAL_SESSION_NAME")); + } + + /// A composed `PROMPT_COMMAND` — a user loadout's, or the built-in + /// default's — overrides the baseline banner trigger cleanly, while + /// the baseline identity vars survive for that override to + /// interpolate. + #[test] + fn composed_prompt_command_overrides_baseline_banner() { + let env = layer_session_env( + session_baseline_env("box-1", Some("helix, fish")), + vec![], + vec![( + "PROMPT_COMMAND".to_string(), + r#"eval "$MY_MOTD""#.to_string(), + )], + vec![], + ); + + assert_eq!( + env.get("PROMPT_COMMAND").map(String::as_str), + Some(r#"eval "$MY_MOTD""#) + ); + assert_eq!( + env.get("MINIMAL_SESSION_NAME").map(String::as_str), + Some("box-1") + ); + assert_eq!( + env.get("MINIMAL_LOADOUTS").map(String::as_str), + Some("helix, fish") + ); } #[test] diff --git a/crates/sessions/src/client/composer.rs b/crates/sessions/src/client/composer.rs index da3e05ce8..6e840b627 100644 --- a/crates/sessions/src/client/composer.rs +++ b/crates/sessions/src/client/composer.rs @@ -30,6 +30,11 @@ pub struct UserComposer { /// /// [`Source::UserLoadout`]: crate::core::source::Source::UserLoadout seen_names: std::collections::HashSet, + /// First-prompt orientation facts, set via + /// [`Self::with_orientation`] and emitted on the composed + /// [`WireContribution`] as a first-class field — control plane, + /// never a user var, so it bypasses the policy gate by design. + orientation: crate::core::compose::Orientation, env: StoredEnv, } @@ -38,6 +43,7 @@ impl core::fmt::Debug for UserComposer { f.debug_struct("UserComposer") .field("contribution", &self.contribution) .field("seen_names", &self.seen_names) + .field("orientation", &self.orientation) .field("env", &"") .finish() } @@ -62,6 +68,7 @@ impl UserComposer { Self { contribution: Contribution::new(), seen_names: std::collections::HashSet::new(), + orientation: crate::core::compose::Orientation::default(), env: default_env(), } } @@ -74,6 +81,17 @@ impl UserComposer { self } + /// Set the first-prompt orientation facts to emit on the composed + /// contribution. The composer cannot derive these itself — the + /// display list depends on HOW the loadouts were selected (the + /// zero-config built-in fallback vs user files vs `--no-loadouts`), + /// which only the caller knows. + #[must_use] + pub fn with_orientation(mut self, orientation: crate::core::compose::Orientation) -> Self { + self.orientation = orientation; + self + } + /// Add a [`Loadout`] to this composer. /// /// # Errors @@ -146,17 +164,24 @@ impl UserComposer { options, home_fallback.as_deref(), )?; - Ok((composition_to_wire(composition), final_policy)) + Ok(( + composition_to_wire(composition, self.orientation), + final_policy, + )) } } -fn composition_to_wire(composition: Composition) -> WireContribution { +fn composition_to_wire( + composition: Composition, + orientation: crate::core::compose::Orientation, +) -> WireContribution { let (vars, patches, packages, lifecycle_hooks) = composition.into_parts(); WireContribution { vars: vars.into_iter().map(Into::into).collect(), patches: patches.into_iter().map(Into::into).collect(), lifecycle_hooks: lifecycle_hooks.into_iter().map(Into::into).collect(), requested_packages: packages.into_iter().map(Into::into).collect(), + orientation: orientation.into(), } } @@ -196,6 +221,44 @@ mod tests { )); } + /// Orientation set on the composer is emitted on the wire form as + /// a first-class field — never as a var, so it bypasses the policy + /// gate and can't collide with user vars. An unset orientation + /// emits the default (empty display list = "unknown"). + #[test] + fn orientation_rides_the_wire_as_a_field_not_a_var() { + let composer = UserComposer::new().with_orientation(crate::core::compose::Orientation { + loadouts_display: "default (built-in)".into(), + }); + let (wire, _) = composer + .compose(UserPolicy::empty(), ComposeOptions::default()) + .unwrap(); + assert_eq!(wire.orientation.loadouts_display, "default (built-in)"); + assert!(wire.vars.is_empty(), "orientation must not be a var"); + + let (wire, _) = UserComposer::new() + .compose(UserPolicy::empty(), ComposeOptions::default()) + .unwrap(); + assert!(wire.orientation.loadouts_display.is_empty()); + } + + /// The orientation field survives the daemon-side merge into the + /// final [`Composition`] — the launcher reads it from there. + /// + /// [`Composition`]: crate::core::compose::Composition + #[test] + fn orientation_survives_extend_from_wire() { + let composer = UserComposer::new().with_orientation(crate::core::compose::Orientation { + loadouts_display: "helix, fish".into(), + }); + let (wire, _) = composer + .compose(UserPolicy::empty(), ComposeOptions::default()) + .unwrap(); + let mut composition = crate::core::compose::Composition::default(); + composition.extend_from_wire(wire).unwrap(); + assert_eq!(composition.orientation().loadouts_display, "helix, fish"); + } + /// User policy's `ignore` rule still applies on the client-side /// path — matching vars are dropped before they hit the wire. /// Uses `Inherit` for both vars so they carry user data and diff --git a/crates/sessions/src/core/compose.rs b/crates/sessions/src/core/compose.rs index 61af38f57..fd4f4de88 100644 --- a/crates/sessions/src/core/compose.rs +++ b/crates/sessions/src/core/compose.rs @@ -1079,6 +1079,23 @@ impl PendingPatchFile { } } +/// Orientation facts for the attached shell's first-prompt banner, +/// carried on the composition as first-class control-plane data — never +/// through the user var lane, so user vars and user policy cannot +/// collide with it. Collected by the client's +/// [`UserComposer`](crate::client::composer::UserComposer) (the only +/// party that knows which loadouts were selected) and read by the +/// session launcher, which seeds the banner env (`MINIMAL_LOADOUTS`) +/// from it in the baseline layer. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct Orientation { + /// Human-readable display list of the active loadouts (comma-joined + /// names, `default (built-in)` for the zero-config fallback, `none` + /// with `--no-loadouts`). Empty means "unknown" — a peer that + /// predates the field — and seeds nothing. + pub loadouts_display: String, +} + /// Everything that survived the policy gate. /// /// Vars and patches are policy-gated. Packages and lifecycle hooks @@ -1091,9 +1108,19 @@ pub struct Composition { patches: Vec, packages: Vec, lifecycle_hooks: Vec, + /// First-prompt orientation facts; see [`Orientation`]. Client-set: + /// the daemon-side passthrough never populates it, and + /// [`Self::extend_from_wire`] installs the client's value. + orientation: Orientation, } impl Composition { + /// The first-prompt orientation facts the client contributed. + #[must_use] + pub fn orientation(&self) -> &Orientation { + &self.orientation + } + /// The vars that survived the policy gate, each paired with its /// source. #[must_use] @@ -1190,12 +1217,15 @@ impl Composition { // `ComposeError::Conflict` via the `#[from]` impl. self.check_incoming_conflicts(&incoming_vars, &incoming_patches)?; - // Checks passed — commit. + // Checks passed — commit. The client is the sole source of + // orientation (the daemon passthrough never populates it), so + // its value is installed rather than merged. self.vars.extend(incoming_vars); self.patches.extend(incoming_patches); self.packages.extend(incoming_packages); dedupe_by_name(&mut self.packages, ProvenancedPackage::package); self.lifecycle_hooks.extend(incoming_hooks); + self.orientation = wire.orientation.into(); Ok(()) } @@ -1212,6 +1242,7 @@ impl Composition { patches: Vec::new(), packages, lifecycle_hooks, + orientation: Orientation::default(), } } @@ -1288,6 +1319,7 @@ impl TryFrom for Composition { patches: wire.patches.into_iter().map(Into::into).collect(), packages: wire.packages.into_iter().map(Into::into).collect(), lifecycle_hooks: hooks, + orientation: wire.orientation.into(), }) } } @@ -1715,6 +1747,10 @@ pub(crate) fn compose_contribution( patches: gated_patches, packages, lifecycle_hooks, + // Orientation never passes through the gate: it is control-plane + // data the caller attaches outside the composition pipeline (see + // `UserComposer::with_orientation`). + orientation: Orientation::default(), }; Ok((composition, final_policy)) } @@ -3656,8 +3692,8 @@ mod tests { mod extend_from_wire { use super::*; use crate::wire::primitives::{ - WireLifecycleHook, WirePackageRef, WireProvenancedHook, WireResolvedPatch, - WireResolvedVar, WireSessionPatch, WireSessionVar, WireSource, + WireLifecycleHook, WireOrientation, WirePackageRef, WireProvenancedHook, + WireResolvedPatch, WireResolvedVar, WireSessionPatch, WireSessionVar, WireSource, }; use crate::wire::request::WireContribution; @@ -3714,6 +3750,7 @@ mod tests { patches, packages: Vec::new(), lifecycle_hooks: Vec::new(), + orientation: Orientation::default(), } } @@ -3726,6 +3763,7 @@ mod tests { patches, requested_packages: vec![], lifecycle_hooks: vec![], + orientation: WireOrientation::default(), } } @@ -3756,6 +3794,7 @@ mod tests { hook: WireLifecycleHook::default(), source: WireSource::UserLoadout { name: "dev".into() }, }], + orientation: WireOrientation::default(), }; let before = Composition::default(); @@ -3862,6 +3901,7 @@ mod tests { source: dev_loadout(), }], lifecycle_hooks: vec![], + orientation: WireOrientation::default(), }; let err = composition.extend_from_wire(wire).unwrap_err(); assert!( @@ -3933,6 +3973,7 @@ mod tests { }, )], lifecycle_hooks: vec![], + orientation: Orientation::default(), }; let wire = WireContribution { vars: vec![], @@ -3948,6 +3989,7 @@ mod tests { }, ], lifecycle_hooks: vec![], + orientation: WireOrientation::default(), }; composition.extend_from_wire(wire).unwrap(); let names: Vec<&str> = composition diff --git a/crates/sessions/src/wire/primitives.rs b/crates/sessions/src/wire/primitives.rs index dbc7b5f08..872916458 100644 --- a/crates/sessions/src/wire/primitives.rs +++ b/crates/sessions/src/wire/primitives.rs @@ -26,6 +26,41 @@ pub enum WireSource { }, } +/// Orientation facts for the attached shell's first-prompt banner. +/// Mirrors [`crate::core::compose::Orientation`]. +/// +/// Control-plane data, deliberately NOT a session var: it rides the +/// composition as a first-class field so user vars and user policy can +/// never collide with it. The daemon seeds the banner env +/// (`MINIMAL_LOADOUTS`) from it in the launcher baseline. Serde-defaulted +/// end to end so peers that predate the field interop cleanly. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct WireOrientation { + /// Human-readable display list of the active loadouts (comma-joined + /// names, `default (built-in)` for the zero-config fallback, `none` + /// with `--no-loadouts`). Empty means "unknown" — a peer that + /// predates the field — and seeds nothing, leaving the banner + /// template's own `${MINIMAL_LOADOUTS:-…}` fallback to render. + #[serde(default)] + pub loadouts_display: String, +} + +impl From for WireOrientation { + fn from(o: crate::core::compose::Orientation) -> Self { + Self { + loadouts_display: o.loadouts_display, + } + } +} + +impl From for crate::core::compose::Orientation { + fn from(o: WireOrientation) -> Self { + Self { + loadouts_display: o.loadouts_display, + } + } +} + /// A fully-resolved variable: name and value as plain strings. /// /// Mirrors [`crate::core::primitives::ResolvedVar`] in a form diff --git a/crates/sessions/src/wire/request.rs b/crates/sessions/src/wire/request.rs index 4e7bd84ff..f21de0000 100644 --- a/crates/sessions/src/wire/request.rs +++ b/crates/sessions/src/wire/request.rs @@ -12,8 +12,8 @@ use super::errors::WireError; use super::policy::{WirePatchVerdict, WireVarVerdict}; use super::primitives::{ - WirePackageRef, WirePendingPatch, WirePendingVar, WireProvenancedHook, WireSessionPatch, - WireSessionVar, + WireOrientation, WirePackageRef, WirePendingPatch, WirePendingVar, WireProvenancedHook, + WireSessionPatch, WireSessionVar, }; use crate::SessionId; @@ -34,6 +34,11 @@ pub struct WireContribution { pub lifecycle_hooks: Vec, /// Packages the client requested be brought in. pub requested_packages: Vec, + /// First-prompt orientation facts (no policy applies — control + /// plane, not user vars). Serde-defaulted so payloads from clients + /// that predate the field deserialize cleanly. + #[serde(default)] + pub orientation: WireOrientation, } /// Persisted composition snapshot, written by the daemon as a sidecar @@ -63,6 +68,11 @@ pub struct WireComposition { /// Lifecycle hooks contributed to the session (pass-through; no /// gate). pub lifecycle_hooks: Vec, + /// First-prompt orientation facts (pass-through; no gate). + /// Serde-defaulted so sidecars written before the field existed + /// deserialize cleanly. + #[serde(default)] + pub orientation: WireOrientation, } fn default_composition_version() -> u32 { @@ -82,6 +92,7 @@ impl From<&crate::core::compose::Composition> for WireComposition { .cloned() .map(Into::into) .collect(), + orientation: c.orientation().clone().into(), } } } @@ -250,6 +261,9 @@ mod tests { source: WireSource::UserLoadout { name: "dev".into() }, }], lifecycle_hooks: vec![], + orientation: WireOrientation { + loadouts_display: "dev".into(), + }, }; assert_eq!(round_trip(&c), c); } @@ -263,4 +277,35 @@ mod tests { let c: WireComposition = serde_json::from_str(json).unwrap(); assert_eq!(c.version, COMPOSITION_SNAPSHOT_VERSION); } + + /// Payloads written before the `orientation` field existed + /// deserialize cleanly to the default (empty display list, meaning + /// "unknown") — the interop contract for old clients (contribution) + /// and old sidecars (composition snapshot) alike. + #[test] + fn orientation_field_defaults_when_absent() { + let json = r#"{"vars":[],"patches":[],"lifecycle_hooks":[],"requested_packages":[]}"#; + let c: WireContribution = serde_json::from_str(json).unwrap(); + assert_eq!(c.orientation, WireOrientation::default()); + assert!(c.orientation.loadouts_display.is_empty()); + + let json = r#"{"vars":[],"patches":[],"packages":[],"lifecycle_hooks":[]}"#; + let c: WireComposition = serde_json::from_str(json).unwrap(); + assert_eq!(c.orientation, WireOrientation::default()); + } + + /// The contribution round-trips its orientation field. + #[test] + fn contribution_orientation_round_trips() { + let c = WireContribution { + vars: vec![], + patches: vec![], + lifecycle_hooks: vec![], + requested_packages: vec![], + orientation: WireOrientation { + loadouts_display: "default (built-in)".into(), + }, + }; + assert_eq!(round_trip(&c), c); + } } diff --git a/docs/reference/loadouts.md b/docs/reference/loadouts.md index bc335b831..a94f88f3e 100644 --- a/docs/reference/loadouts.md +++ b/docs/reference/loadouts.md @@ -267,7 +267,8 @@ When a session is activated with no loadout flags and an empty `default_loadouts`, a built-in `default` loadout applies so a fresh box comes up oriented rather than in a bare shell. It contributes **no packages** — only a shaped `PS1` and a once-only banner (the minimal -mark plus a pointer to `min add`), shipped through the +mark, the [orientation lines](#orientation-banner) naming the session +and its loadouts, plus a pointer to `min add`), shipped through the [MOTD recipe](#vars-in-the-attach-shell). The banner is TTY-gated, prints exactly once per session, and renders without color. @@ -358,9 +359,10 @@ patches cannot influence it. Interactive setup travels through the environment instead, i.e. through `[vars]`: - **Prompt**: the session launcher seeds a baseline environment - (currently a stock `PS1`) before merging in the composed vars, and a - composed var overwrites a baseline entry with the same name. Setting - `PS1` in `[vars]` therefore replaces the stock prompt. This baseline is + (a stock `PS1`, plus the [orientation banner](#orientation-banner) + vars below) before merging in the composed vars, and a composed var + overwrites a baseline entry with the same name. Setting `PS1` in + `[vars]` therefore replaces the stock prompt. This baseline is a layer *beneath* composition, not a contributor: the no-override conflict rule above arbitrates between contributors and does not apply to the launcher's defaults. @@ -380,3 +382,41 @@ environment instead, i.e. through `[vars]`: and never runs for non-interactive commands; the `[ -t 1 ]` guard keeps redirected output clean. Multi-line literal values survive composition intact. + +### Orientation banner {#orientation-banner} + +Unless a loadout overrides it, the first interactive prompt of an +attached session prints a two-line orientation banner: + +``` +minimal · session api-server-4f2a · loadout default (built-in) +detach: ctrl-w · no minimal.toml here — min init to add one +``` + +The second line drops the `min init` pointer when the session workspace +carries a `minimal.toml` (either layout, `minimal.toml` or +`.minimal/minimal.toml`) — the template tests the workspace root +(`/workbench`) in-shell at the moment it prints, so the clause reflects +the session's actual filesystem: it stays correct when an activation +skipped the file upload, and disappears after an in-session `min init` +once a fresh shell launches. The banner is TTY-gated, prints exactly +once, and is plain text (`NO_COLOR`-safe). + +It ships as a *static template* in the launcher baseline (the MOTD +recipe above), interpolated by the shell at print time from two env +vars every session carries: + +| Var | Value | +|-----|-------| +| `MINIMAL_SESSION_NAME` | The session's name | +| `MINIMAL_LOADOUTS` | Display list of the active loadouts: comma-joined names, `default (built-in)` for the zero-config fallback, `none` with `--no-loadouts` | + +Both are seeded daemon-side in the launcher baseline; the loadout list +travels from the client as a first-class field on the composition +(control-plane data, never a session var), so user vars and user policy +cannot collide with either. + +Because the trigger lives in the baseline layer, a loadout that sets its +own `PROMPT_COMMAND` replaces the banner cleanly — and can interpolate +the same `$MINIMAL_*` vars in its own MOTD, as the built-in `default` +loadout does for its orientation lines. diff --git a/scripts/session-e2e.sh b/scripts/session-e2e.sh index 7033fb12a..2c9a4867f 100755 --- a/scripts/session-e2e.sh +++ b/scripts/session-e2e.sh @@ -126,6 +126,13 @@ if [ -n "$SEED_DIR" ] || { [ ! -e "$PROJECT_DIR/minimal.toml" ] && [ ! -e "$PROJ # A dir we own is cleaned wholesale; otherwise track the lone file we dropped. [ -n "$SEED_DIR" ] || SEEDED_MFILE="$PROJECT_DIR/minimal.toml" fi +# A dir we own also becomes a VCS root (a bare `.git` marker, exactly like +# the task seed below): the headless upload gate then ships the seed into +# the session workspace. The sandbox proof's banner assertion depends on +# it — the orientation banner tests /workbench/minimal.toml in-shell at +# print time, so the blueprint must actually be IN the workspace for the +# `min init` pointer to stay suppressed. +[ -z "$SEED_DIR" ] || mkdir "$SEED_DIR/.git" # Fresh state dir — a clean (no-daemon) cold-start on persistent runners: # post-#690, all daemon state (minvmd.toml, locks, the bridge socket) lives @@ -156,6 +163,12 @@ export XDG_RUNTIME_DIR="$WORK/runtime" mkdir -p "$XDG_RUNTIME_DIR" "$XDG_STATE_HOME" chmod 700 "$XDG_RUNTIME_DIR" +# Hermetic user config: the CLI resolves loadouts and config.toml under +# XDG_CONFIG_HOME, and the sandbox proof below asserts the zero-config +# orientation banner (built-in `default` loadout). An operator's own +# `default_loadouts`/`default.toml` must not leak into the canonical proof. +export XDG_CONFIG_HOME="$WORK/config" + # The CLI's tracing layer writes to STDOUT (ot::StdoutWriter, minimal/src/ # main.rs), so at the default level the autospawn INFO lines interleave with # the session id `activate` prints for piping. Quiet the logs; the last-line @@ -263,9 +276,14 @@ fi # new session id on stdout. The id is the LAST stdout line (any log lines # that slip through the RUST_LOG filter precede it), validated as a UUID. echo "::group::cold activate (auto-spawns the daemon)" +# Explicit name: the sandbox proof asserts the orientation banner +# interpolates the ACTUAL session name at the first prompt; an autogen +# name would make that assertion a moving target. The state dir is fresh +# per run, so a fixed name cannot collide. +SESSION_NAME="e2e-banner" t0=$(now_ms) # shellcheck disable=SC2086 -activate_out="$(cd "$PROJECT_DIR" && mnl session activate . ${E2E_ACTIVATE_ARGS:-} 2>"$WORK/activate.err")" \ +activate_out="$(cd "$PROJECT_DIR" && mnl session activate . --name "$SESSION_NAME" ${E2E_ACTIVATE_ARGS:-} 2>"$WORK/activate.err")" \ || { echo "::error::cold 'min session activate' failed to auto-spawn the daemon / create a session"; fail; } t1=$(now_ms) sid="$(printf '%s\n' "$activate_out" | tail -n1 | tr -d '\r')" @@ -291,10 +309,10 @@ echo "warm 'min ls': $((t1 - t0))ms" # `min task run` proof: a declared task runs in an ephemeral session — output # streamed through, the task's exit code relayed, the session destroyed # afterwards (or kept with --keep). Runs against its own tiny seeded project: -# the shared PROJECT_DIR seed deliberately declares no tasks (and, not being a -# VCS root, a headless activate skips its upload), so this seed carries the -# same pinned [upstream] + shell stack PLUS the tasks and a `.git` marker so -# the headless upload gate ships the config into the session. Skipped when the +# the shared PROJECT_DIR seed deliberately declares no tasks, so this seed +# carries the same pinned [upstream] + shell stack PLUS the tasks (and the +# same `.git` marker, so the headless upload gate ships the config into the +# session). Skipped when the # caller supplied a project we didn't seed — its minimal.toml declares none of # these tasks. Short mktemp template on purpose (mirrors the PROJECT_DIR # seed): the basename lands in the state root's task-dir paths, inside the @@ -449,7 +467,32 @@ if [[ "$attach_out" != *"$ADD_TOOL_MARKER"* ]]; then echo "--- driver stderr ---"; cat "$WORK/exec.err" 2>/dev/null || true fail fi -echo "sandbox proof: in-sandbox 'min add $ADD_TOOL' + run OK ($((t1 - t0))ms)" +# Orientation banner: the first interactive prompt must have printed the +# two orientation lines, with the ACTUAL session name and loadout list +# interpolated in-shell from the $MINIMAL_* vars (daemon baseline + +# client-composed). XDG_CONFIG_HOME is hermetic (see the export above), +# so the composed loadout is deterministically the built-in `default`. +if [[ "$attach_out" != *"minimal · session $SESSION_NAME · loadout default (built-in)"* ]]; then + echo "::error::attach output lacks the orientation banner line (session name + loadout list)" + echo "--- attach output ---"; printf '%s\n' "$attach_out" + fail +fi +if [[ "$attach_out" != *"detach: ctrl-w"* ]]; then + echo "::error::attach output lacks the orientation banner's detach line" + echo "--- attach output ---"; printf '%s\n' "$attach_out" + fail +fi +# The banner tests /workbench/minimal.toml IN-SHELL at print time, so this +# asserts the workspace's real state: our owned seed is a VCS root whose +# upload shipped the blueprint, so the `min init` pointer must not have +# printed. Only asserted for a seed we own — a caller-provided +# E2E_PROJECT_DIR controls its own upload-gate outcome. +if [ -n "$SEED_DIR" ] && [[ "$attach_out" == *"no minimal.toml here"* ]]; then + echo "::error::banner shows the 'min init' pointer despite the uploaded minimal.toml" + echo "--- attach output ---"; printf '%s\n' "$attach_out" + fail +fi +echo "sandbox proof: in-sandbox 'min add $ADD_TOOL' + run OK, orientation banner rendered ($((t1 - t0))ms)" echo "::endgroup::" # We answered the exit prompt with "Delete", so the session was destroyed and