diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 8a77dccd7..ae251332b 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -196,15 +196,25 @@ jobs: # already encodes it — but nothing ran the linter, so two PRs merged in one # afternoon each added a file loose at `docs/` root. # - # ⚠ This is DELIBERATELY three rules, not the whole linter. The tree carries a + # ⚠ This is DELIBERATELY four rules, not the whole linter. The tree carries a # real backlog (156 findings across ten rules, measured 2026-09-22), and a gate - # that fails on arrival gets disabled rather than obeyed. These three are clean + # that fails on arrival gets disabled rather than obeyed. These four are clean # on main today and can be held that way; the step prints how many findings it # is NOT reporting, so a filtered pass never reads as "the docs are clean". # Widen the list as a rule's backlog reaches zero — never past it. + # tree/research-cites-repo joined on 2026-09-28, when Crew's own design and records left + # docs/research/ (which holds studies of systems outside this repo) at zero findings. - name: Documents live where the rule says (placement only) if: ${{ !cancelled() }} - run: python3 scripts/docs-lint.py --only tree/loose-at-root,tree/no-index,status/folder-disagreement + run: python3 scripts/docs-lint.py --only tree/loose-at-root,tree/no-index,status/folder-disagreement,tree/research-cites-repo + + # The Crew manual and its landing page quote dialog titles, name what a + # `biorouter serve` browser can do and say who may add people. Each rule + # reads the code it describes, and the suite first shows every rule + # failing on the wording a 2026-09-27 audit found, then runs it on the tree. + - name: The Crew manual agrees with the code it describes + if: ${{ !cancelled() }} + run: node --test scripts/check-crew-manual.test.mjs # The published registry, the desktop fallback snapshot and the # compiled-in private set are one catalog in three forms. Being written by diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 806d7a20b..2e457f3a6 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -608,12 +608,20 @@ jobs: # The token is exchanged for a cookie and leaves the address bar. It is # not consumed (SD-9 in docs/deployment/serve-decisions.md): the - # readiness loop above has already redeemed it. - curl -s -o /dev/null -D "$RUNNER_TEMP/h" "http://127.0.0.1:18777/?t=citoken" - grep -qi '^HTTP/1.1 303' "$RUNNER_TEMP/h" || fail 'no redirect after the token exchange' + # readiness loop above has already redeemed it. The answer is a page of + # the daemon's own origin that moves on to /, not a 303: a redirect + # drops a SameSite=Strict cookie on a navigation that `serve --open`'s + # file: page started (routes::web_ui::exchange_bounce). + curl -s -o "$RUNNER_TEMP/bounce.html" -D "$RUNNER_TEMP/h" "http://127.0.0.1:18777/?t=citoken" + grep -qi '^HTTP/1.1 200' "$RUNNER_TEMP/h" || fail 'the token exchange did not answer its page' grep -qi 'set-cookie: biorouter_session=' "$RUNNER_TEMP/h" || fail 'no session cookie' grep -qi 'HttpOnly' "$RUNNER_TEMP/h" || fail 'the session cookie is readable from script' grep -qi 'SameSite=Strict' "$RUNNER_TEMP/h" || fail 'the session cookie is sent cross-site' + grep -qi 'cache-control: no-store' "$RUNNER_TEMP/h" || fail 'the exchange page is cacheable' + grep -qi 'referrer-policy: no-referrer' "$RUNNER_TEMP/h" || fail 'the exchange page leaks its address' + grep -q 'http-equiv="refresh" content="0;url=/"' "$RUNNER_TEMP/bounce.html" \ + || fail 'the exchange page does not move on to the interface' + ! grep -q citoken "$RUNNER_TEMP/bounce.html" || fail 'the exchange page carries the token' # With the cookie, the shell and its runtime configuration. curl -fsS -b 'biorouter_session=citoken' http://127.0.0.1:18777/ > "$RUNNER_TEMP/shell.html" diff --git a/README.md b/README.md index 4deabc4d1..9d18f9159 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,16 @@ Work with papers, genes, diseases, drugs, cohorts, and experimental data in the | **Analyze and visualize** | Write and run R or Python, fit machine-learning models, and create figures and interactive dashboards. | | **Work with desktop software** | Biorouter Copilot can inspect and operate applications on the backend computer, with your approval for each request. | | **Build and reuse tools** | Create small research apps with Agent Drafter, run parallel chats, and save or schedule recurring tasks as workflows. | +| **Work with your lab** | In Crew, chat in channels, share files and give AI agents tasks the whole channel can see, in a workspace one lab member hosts on a shared Linux server. | Copilot keeps observations and approvals separate for each chat. The applications themselves share a desktop, so choose the model before opening sensitive records or research data. See [Copilot setup and limits](docs/extensions/built-in/computer-controller.md). +Crew runs in the desktop app and in the `biorouter crew` commands, not in a `biorouter serve` browser. Each member connects with their own server account over SSH. Crew's Private and Public settings decide which AI models may read the workspace, and the stricter of your connection's and the workspace's setting applies. See the [Crew user manual](docs/crew/README.md). + ## Get started 1. **[Download Biorouter](https://biorouter.ucsf.edu/download)** for macOS, Windows, or Linux. -2. **Choose a model.** UCSF users can select **Versa API Azure** or **Versa API Bedrock** under Institutional Models. For local inference, choose **Llama Server** and download a model. You can also connect a commercial provider. +2. **Choose a model.** UCSF users can select **Versa API Azure** or **Versa API Bedrock** on the **Institutional** tab. For local inference, choose **Llama Server** and download a model. You can also connect a commercial provider. 3. **Start a chat.** Add a paper or dataset, or install an agent from BAAM. For example, attach a few papers and ask: @@ -96,6 +99,7 @@ Biorouter uses a shared Rust agent core, an Electron and React desktop app, and - [Documentation](docs/README.md): guides and references by topic. - [Architecture](docs/architecture/system-overview.md): how the components fit together. - [Knowledge bases and BioOKF](docs/knowledge-base/README.md), [workflows](docs/workflows/README.md), and [Apps SDK](docs/apps-sdk/sdk-reference.md). +- [Crew user manual](docs/crew/README.md): joining or hosting a lab workspace, channels, files, agent tasks and server setup. - [Contributing](CONTRIBUTING.md): build from source, test, and submit changes. - [Report an issue](https://github.com/BaranziniLab/biorouter/issues). Report security concerns privately using [SECURITY.md](SECURITY.md). diff --git a/crates/biorouter-cli/src/cli.rs b/crates/biorouter-cli/src/cli.rs index bc94e0681..53ee0674e 100644 --- a/crates/biorouter-cli/src/cli.rs +++ b/crates/biorouter-cli/src/cli.rs @@ -43,8 +43,12 @@ use std::io::Read; use std::path::PathBuf; use tracing::warn; +/// `biorouter --version` prints `biorouter 1.91.2`, as `biorouterd` and `biorouter-crew` print +/// theirs (SF-F8). It printed ` 1.91.2`, with a leading space and no name, while an empty +/// `display_name` stood in for the name. A desktop reader pulls the number out with a pattern, +/// so the name costs it nothing. #[derive(Parser)] -#[command(author, version, display_name = "", about, long_about = None)] +#[command(name = "biorouter", author, version, about, long_about = None)] struct Cli { #[command(subcommand)] command: Option, @@ -298,7 +302,7 @@ pub struct ModelOptions { long = "provider", value_name = "PROVIDER", help = "Specify the LLM provider to use (e.g., 'openai', 'anthropic')", - long_help = "Override the BIOROUTER_PROVIDER environment variable for this run. Available providers include openai, anthropic, google, ollama, llamacpp, databricks, and others." + long_help = "Override the BIOROUTER_PROVIDER environment variable for this run. Available providers include openai, anthropic, google, ollama, llamacpp, databricks, and others. Without --model, a provider other than the configured one runs its own default model." )] pub provider: Option, @@ -371,16 +375,37 @@ pub struct RunBehavior { /// /// `Config::get_param` consults the environment before the file, so /// `BIOROUTER_PROVIDER=… biorouter` resolves through this same call. -fn resolution_for_a_new_row( +async fn resolution_for_a_new_row( provider: Option<&str>, model: Option<&str>, ) -> (Option, Option) { let config = Config::global(); + let default_provider = config.get_biorouter_provider().ok(); + let default_model = config.get_biorouter_model().ok(); + // PROV-F10: the chosen provider's own default, asked for only when no model is named for + // it, as `build_session` resolves it. + let chosen = provider + .map(str::to_owned) + .or_else(|| default_provider.clone()); + let own_default = match chosen.as_deref() { + Some(chosen) + if crate::session::owned_model( + Some(chosen), + model.map(str::to_owned), + &[(default_provider.as_deref(), default_model.clone())], + ) + .is_none() => + { + crate::session::model_for_run(Some(chosen), None, &[]).await + } + _ => None, + }; resolution_with_global_default( provider, model, - config.get_biorouter_provider().ok(), - config.get_biorouter_model().ok(), + default_provider, + default_model, + own_default, ) } @@ -389,16 +414,27 @@ fn resolution_for_a_new_row( /// Separated from [`resolution_for_a_new_row`] only so the tests can drive /// every combination of "typed now / configured / neither" without a config /// file and without racing the process-global `Config`. +/// +/// `own_default` is the chosen provider's own default model. The configured model belongs to +/// the configured provider, so it is used only for that provider (PROV-F10); another provider +/// runs its own default, and one the registry does not know falls back to the configured model, +/// so the guard never claims "no model" for a run `build_session` would give one. fn resolution_with_global_default( provider: Option<&str>, model: Option<&str>, default_provider: Option, default_model: Option, + own_default: Option, ) -> (Option, Option) { - ( - provider.map(str::to_string).or(default_provider), - model.map(str::to_string).or(default_model), + let provider = provider.map(str::to_string).or(default_provider.clone()); + let model = crate::session::owned_model( + provider.as_deref(), + model.map(str::to_string), + &[(default_provider.as_deref(), default_model.clone())], ) + .or(own_default) + .or(default_model); + (provider, model) } /// The refusal text for a run that cannot name a provider or a model, or `None` @@ -406,8 +442,8 @@ fn resolution_with_global_default( /// /// The exit is in the caller so that this half — which is all of the logic — /// can be unit-tested rather than only observed by killing a process. -fn refusal_for_a_new_row(provider: Option<&str>, model: Option<&str>) -> Option { - let (provider, model) = resolution_for_a_new_row(provider, model); +async fn refusal_for_a_new_row(provider: Option<&str>, model: Option<&str>) -> Option { + let (provider, model) = resolution_for_a_new_row(provider, model).await; crate::session::unconfigured_precondition(provider.as_deref(), model.as_deref()) } @@ -424,11 +460,11 @@ fn refusal_for_a_new_row(provider: Option<&str>, model: Option<&str>) -> Option< /// created, so that slot is empty by construction. The paths here that return /// an EXISTING id do not call this, because such a row can legitimately carry /// the only provider a resumed chat has. -pub(crate) fn refuse_unconfigured_before_creating_a_row( +pub(crate) async fn refuse_unconfigured_before_creating_a_row( provider: Option<&str>, model: Option<&str>, ) -> Result<()> { - if let Some(text) = refusal_for_a_new_row(provider, model) { + if let Some(text) = refusal_for_a_new_row(provider, model).await { crate::session::output::render_error(&text); std::process::exit(1); } @@ -455,7 +491,7 @@ async fn get_or_create_session_id( Ok(Some(latest.id.clone())) } else { eprintln!("No previous chat to resume; starting a new chat."); - refuse_unconfigured_before_creating_a_row(provider, model)?; + refuse_unconfigured_before_creating_a_row(provider, model).await?; let session = session_manager .create_session( std::env::current_dir()?, @@ -466,7 +502,7 @@ async fn get_or_create_session_id( Ok(Some(session.id)) } } else { - refuse_unconfigured_before_creating_a_row(provider, model)?; + refuse_unconfigured_before_creating_a_row(provider, model).await?; let session = session_manager .create_session( std::env::current_dir()?, @@ -499,7 +535,7 @@ async fn get_or_create_session_id( ); } - refuse_unconfigured_before_creating_a_row(provider, model)?; + refuse_unconfigured_before_creating_a_row(provider, model).await?; let session = session_manager .create_session(std::env::current_dir()?, name.clone(), SessionType::User) .await?; @@ -519,7 +555,7 @@ async fn get_or_create_session_id( .ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))?; Ok(Some(session_id)) } else { - refuse_unconfigured_before_creating_a_row(provider, model)?; + refuse_unconfigured_before_creating_a_row(provider, model).await?; let session = session_manager .create_session( std::env::current_dir()?, @@ -1511,14 +1547,17 @@ enum Command { }, /// Start or resume interactive chat sessions - /// - /// ⚠ `sessions` is an alias, and it is not cosmetic. `session_watch.rs`'s - /// own error message told users to run `biorouter sessions watch `, the - /// BR-71 plan wrote `biorouter sessions …` in roughly forty places, and - /// `docs/cli/command-reference.md` printed it — while the plural was never a - /// registered command, so every one of those instructions ended in - /// `unrecognized subcommand 'sessions'`. Registering it makes the - /// instructions true rather than making forty documents wrong. + // + // ⚠ `sessions` is an alias, and it is not cosmetic. `session_watch.rs`'s + // own error message told users to run `biorouter sessions watch `, the + // BR-71 plan wrote `biorouter sessions …` in roughly forty places, and + // `docs/cli/command-reference.md` printed it — while the plural was never a + // registered command, so every one of those instructions ended in + // `unrecognized subcommand 'sessions'`. Registering it makes the + // instructions true rather than making forty documents wrong. + // + // A `//` comment, not `///`: clap turns a doc comment into the command's long + // help, and this note printed on every `biorouter session --help` (AG-F8). #[command( about = "Start or resume interactive chat sessions", visible_aliases = ["s", "sessions"] @@ -2945,8 +2984,19 @@ fn needs_tool_bridge(command: &Option) -> bool { ) } -pub async fn cli() -> anyhow::Result<()> { - let cli = Cli::parse(); +/// The command line, read and checked. +pub struct CommandLine(Cli); + +/// Read the command line. `--help`, `--version` and a usage mistake are answered here and the +/// process exits, so call this before anything writes to disk: a version probe is the first +/// thing an install script runs, and it used to create `config.yaml`, stamp the privacy +/// master-switch record and open a log directory in a home nobody had set up (SF-F8). +pub fn read_command_line() -> CommandLine { + CommandLine(Cli::parse()) +} + +pub async fn cli(command_line: CommandLine) -> anyhow::Result<()> { + let CommandLine(cli) = command_line; if !is_shared_conversation(&cli.command) { if let Err(e) = crate::project_tracker::update_project_tracker(None, None) { @@ -3079,6 +3129,37 @@ async fn dispatch(command: Option) -> anyhow::Result<()> { mod cli_tests { use super::*; + /// AG-F8: `--help` is for the people who run the command. A doc comment on a command + /// becomes its long help unless `long_about` is set, so a maintainer's note there (`⚠`, a + /// source file, a plan ID, a repository path) reached every `biorouter session --help`. + /// This walks every command's long help, so a note that leaks again anywhere is caught. + #[test] + fn no_command_help_prints_a_maintainers_note() { + fn walk(command: &mut clap::Command, path: &str, leaks: &mut Vec) { + let internal = + regex::Regex::new(r"⚠|\b[\w-]+\.rs\b|\b(?:BR|SD|DR|QA)-\d|\bdocs/|\bcrates/") + .expect("the marker pattern compiles"); + let help = command.render_long_help().to_string(); + if let Some(found) = internal.find(&help) { + leaks.push(format!("{path}: {}", found.as_str())); + } + let names: Vec = command + .get_subcommands() + .map(|sub| sub.get_name().to_owned()) + .collect(); + for name in names { + if let Some(sub) = command.find_subcommand_mut(&name) { + walk(sub, &format!("{path} {name}"), leaks); + } + } + } + let mut leaks = Vec::new(); + let mut root = command_tree(); + root.build(); + walk(&mut root, "biorouter", &mut leaks); + assert!(leaks.is_empty(), "internal notes in --help: {leaks:#?}"); + } + #[test] fn shared_daemon_options_parse_on_session_and_run_without_starting_tool_bridge() { let session = Cli::try_parse_from([ @@ -3464,7 +3545,7 @@ mod cli_tests { .split_once("fn resolution_for_a_new_row(") .expect("the guard no longer resolves anything"); let (resolution, _) = resolution - .split_once("\nfn refusal_for_a_new_row(") + .split_once("\nasync fn refusal_for_a_new_row(") .expect("could not find the end of resolution_for_a_new_row"); for accessor in ["get_biorouter_provider", "get_biorouter_model"] { assert!( @@ -3483,15 +3564,15 @@ mod cli_tests { /// precedence alone would have passed on the broken build too. /// `Config::get_param` reads the environment before the file, so this is /// deterministic wherever it runs. - #[test] - fn a_configured_default_satisfies_the_precondition_for_a_new_row() { + #[tokio::test] + async fn a_configured_default_satisfies_the_precondition_for_a_new_row() { let _env = env_lock::lock_env([ ("BIOROUTER_PROVIDER", Some("claude_code".to_string())), ("BIOROUTER_MODEL", Some("claude-opus-5".to_string())), ]); assert_eq!( - resolution_for_a_new_row(None, None), + resolution_for_a_new_row(None, None).await, ( Some("claude_code".to_string()), Some("claude-opus-5".to_string()) @@ -3499,21 +3580,21 @@ mod cli_tests { "a bare `biorouter` names no provider, so the configured one is the answer" ); assert_eq!( - refusal_for_a_new_row(None, None), + refusal_for_a_new_row(None, None).await, None, "a configured install must get past the guard with no flags at all" ); } /// What the user typed now still wins over what they configured earlier. - #[test] - fn an_explicit_flag_still_outranks_the_configured_default() { + #[tokio::test] + async fn an_explicit_flag_still_outranks_the_configured_default() { let _env = env_lock::lock_env([ ("BIOROUTER_PROVIDER", Some("claude_code".to_string())), ("BIOROUTER_MODEL", Some("claude-opus-5".to_string())), ]); assert_eq!( - resolution_for_a_new_row(Some("anthropic"), Some("opus")), + resolution_for_a_new_row(Some("anthropic"), Some("opus")).await, (Some("anthropic".to_string()), Some("opus".to_string())) ); } @@ -3521,6 +3602,10 @@ mod cli_tests { /// The model has the same four slots as the provider, and shipped with the /// same gap: `biorouter run --provider claude_code` got past the provider /// half and was refused with "No model is configured". + /// + /// PROV-F10: naming another provider must not cost a model, and must not + /// borrow the configured provider's either: it runs its own default. Only a + /// provider the registry cannot describe falls back to the configured model. #[test] fn the_model_slot_falls_back_too() { assert_eq!( @@ -3528,13 +3613,53 @@ mod cli_tests { Some("claude_code"), None, Some("versa_azure".to_string()), - Some("claude-opus-5".to_string()), + Some("gpt-5.5".to_string()), + Some("claude-sonnet-5".to_string()), ), ( Some("claude_code".to_string()), - Some("claude-opus-5".to_string()) + Some("claude-sonnet-5".to_string()) + ), + "another provider runs its own default model, not the configured provider's" + ); + assert_eq!( + resolution_with_global_default( + Some("claude_code"), + None, + Some("versa_azure".to_string()), + Some("gpt-5.5".to_string()), + None, + ), + (Some("claude_code".to_string()), Some("gpt-5.5".to_string())), + "a provider with no known default still gets past the guard" + ); + assert_eq!( + resolution_with_global_default( + Some("versa_azure"), + None, + Some("versa_azure".to_string()), + Some("gpt-5.5".to_string()), + Some("gpt-6-sol".to_string()), ), - "naming a provider must not cost the configured model" + (Some("versa_azure".to_string()), Some("gpt-5.5".to_string())), + "naming the configured provider keeps the configured model" + ); + } + + /// PROV-F10, through the real `Config` and registry: `--provider codex` with a + /// Versa model configured resolves Codex's own default model. + #[tokio::test] + async fn naming_another_provider_resolves_its_own_default_model() { + let _env = env_lock::lock_env([ + ("BIOROUTER_PROVIDER", Some("versa_azure".to_string())), + ("BIOROUTER_MODEL", Some("gpt-5.5-2026-04-24".to_string())), + ]); + let (provider, model) = resolution_for_a_new_row(Some("codex"), None).await; + assert_eq!(provider.as_deref(), Some("codex")); + let model = model.expect("codex has a default model"); + assert_ne!( + model, "gpt-5.5-2026-04-24", + "the configured Versa model was sent to Codex" ); } @@ -3544,7 +3669,7 @@ mod cli_tests { #[test] fn an_install_with_nothing_configured_is_still_refused() { assert_eq!( - resolution_with_global_default(None, None, None, None), + resolution_with_global_default(None, None, None, None, None), (None, None) ); let text = crate::session::unconfigured_precondition(None, None) @@ -3554,7 +3679,13 @@ mod cli_tests { // A workflow pin alone is enough — that is the whole reason `run` // resolves one before asking. assert_eq!( - resolution_with_global_default(Some("claude_code"), Some("claude-opus-5"), None, None), + resolution_with_global_default( + Some("claude_code"), + Some("claude-opus-5"), + None, + None, + None + ), ( Some("claude_code".to_string()), Some("claude-opus-5".to_string()) diff --git a/crates/biorouter-cli/src/commands/apps.rs b/crates/biorouter-cli/src/commands/apps.rs index e9ae24891..74a86a370 100644 --- a/crates/biorouter-cli/src/commands/apps.rs +++ b/crates/biorouter-cli/src/commands/apps.rs @@ -10,7 +10,8 @@ //! coupling to the store's internal `Manifest` shape) and print a table, or //! `--json` for machine output. //! * `open` — ensure a daemon is up and open `http://:/apps//` -//! in the default browser. +//! in the default browser, through a one-time link that never goes on a +//! command line (see [`write_launch_page`]). //! * `serve` — ensure a daemon is up, print the URL, and stay in the foreground //! until it is stopped (when it started the daemon) or return immediately with //! a note (when it reused a running one). @@ -39,8 +40,25 @@ //! `GET /status`, reuse a daemon if one answers, and otherwise best-effort spawn //! the sibling `biorouterd agent`. If no `biorouterd` binary can be located the //! command fails with the exact command to run — an honest v1 over a fragile -//! spawn. The `/apps//` GET routes are auth-exempt, so the browser loads the -//! app without needing the daemon's secret key. +//! spawn. +//! +//! **Opening an app takes the daemon's secret (W2-HRD-1).** A daemon serves an +//! app's page only to a browser holding that app's access cookie, set by a +//! one-time link the daemon hands out on `POST /apps//launch`, which needs +//! the secret. The `/apps//` routes used to be auth-exempt, and any local +//! account could then read an app's socket token off its page. So a daemon these +//! commands start gets a secret they generate, and a daemon they did not start is +//! used only when `BIOROUTER_SERVER__SECRET_KEY` names its secret. +//! +//! Both commands remember the secret of a daemon they start, in a record only +//! this account can read, for as long as that daemon runs. A launch link works +//! once and for five minutes, so the next link has to come from somewhere: for a +//! daemon `open` left running it is the next `open`, and for one `serve` holds in +//! the foreground it is an `open` (or a second `serve`) in another terminal. +//! `serve` used to keep its daemon's secret to itself, so the advice it printed, +//! to run it again, reached a daemon nothing could use: a second `serve` or +//! `open` refused it as unknown, and the only way on was to stop the first. The +//! record of a `serve` daemon is removed when `serve` stops it. //! //! In-terminal rendering of an app is explicitly OUT of scope (design §7); the //! app always opens in a real browser. @@ -288,7 +306,318 @@ enum Daemon { /// A daemon was already listening; we did not start it. Reused, /// We spawned `biorouterd agent`; hold the child so `serve` can supervise it. - Started(tokio::process::Child), + Started { + child: tokio::process::Child, + /// Whether its secret was recorded, so that a later `apps open` or + /// `apps serve` can mint another launch link on it while it runs. + remembered: bool, + }, +} + +/// One request to the daemon on `port`, carrying its secret. The status and the +/// body, or `None` when nothing answered in time. A raw socket like +/// [`daemon_ok`]; the body of a refusal or a launch link is small, so it is read +/// whole (bounded) until the daemon closes the connection. +/// +/// ⚠ HTTP/1.0 on purpose. The daemon's compression layer makes every body's +/// length unknown up front, so an HTTP/1.1 answer comes chunked, and this reader +/// would hand the chunk sizes to the JSON parser (measured: the launch link was +/// refused while the daemon answered 200). A 1.0 answer is the plain body, ended +/// by the close. +async fn with_secret(port: u16, method: &str, path: &str, secret: &str) -> Option<(u16, String)> { + let addr = format!("{DAEMON_HOST}:{port}"); + let connect = biorouter::net::connect_non_inheritable(&addr); + let mut stream = tokio::time::timeout(Duration::from_secs(2), connect) + .await + .ok()? + .ok()?; + let request = format!( + "{method} {path} HTTP/1.0\r\nHost: {addr}\r\nX-Secret-Key: {secret}\r\n\ + Content-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream.write_all(request.as_bytes()).await.ok()?; + let mut response = Vec::new(); + tokio::time::timeout( + Duration::from_secs(5), + (&mut stream).take(1 << 20).read_to_end(&mut response), + ) + .await + .ok()? + .ok()?; + let text = String::from_utf8_lossy(&response); + let (head, body) = text.split_once("\r\n\r\n")?; + let status = head.split_whitespace().nth(1)?.parse().ok()?; + Some((status, body.to_string())) +} + +/// Whether `secret` is the secret of the daemon on `port`. +async fn secret_works(port: u16, secret: &str) -> bool { + matches!( + with_secret(port, "GET", "/apps", secret).await, + Some((200, _)) + ) +} + +/// A secret for a daemon this command starts: 32 bytes from the system generator. +fn new_daemon_secret() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Where `apps open` and `apps serve` remember the secret of the daemon they +/// started on `port`: a record readable by this account alone, in Biorouter's +/// state folder. +fn remembered_daemon_path(port: u16) -> PathBuf { + biorouter::config::paths::Paths::state_dir() + .join("apps-daemon") + .join(format!("{port}.json")) +} + +#[derive(serde::Deserialize, Serialize)] +struct RememberedDaemon { + port: u16, + secret: String, +} + +/// Record `secret` as the secret of the daemon this command started on `port`, +/// and answer whether the record was written. Without it a later `apps open` or +/// `apps serve` cannot mint a launch link on this daemon while it runs. +fn remember_daemon(port: u16, secret: &str) -> bool { + let path = remembered_daemon_path(port); + let written = path + .parent() + .ok_or_else(|| anyhow!("no folder for {}", path.display())) + .and_then(biorouter::daemon_runtime::private_directory) + .and_then(|()| { + biorouter::daemon_runtime::write_private( + &path, + &RememberedDaemon { + port, + secret: secret.to_string(), + }, + ) + }); + match written { + Ok(()) => true, + Err(error) => { + eprintln!( + " {} could not record this daemon's key ({error}), so a later \ + `biorouter apps open` cannot use this daemon", + style("!").yellow() + ); + false + } + } +} + +fn remembered_secret(port: u16) -> Option { + let record: RememberedDaemon = + biorouter::daemon_runtime::read_private(&remembered_daemon_path(port)).ok()?; + (record.port == port).then_some(record.secret) +} + +/// Remove the record of the daemon on `port` once `serve` has stopped it, if the +/// record still names `secret`. A record naming another secret describes a +/// daemon started on the port since, and is left alone. +fn forget_daemon(port: u16, secret: &str) { + if remembered_secret(port).is_some_and(|recorded| recorded == secret) { + let _ = std::fs::remove_file(remembered_daemon_path(port)); + } +} + +/// How long the daemon keeps a launch link open, in the words the person reads. +/// The daemon's own figure is `APP_LAUNCH_TTL` in `biorouter-server`'s `auth`, +/// which this crate cannot import; a test pins the two together. +const LAUNCH_LINK_LIFETIME: &str = "five minutes"; + +/// The one-time address a browser opens app `id` at (W2-HRD-1): the daemon's +/// launch link, minted with its secret. Opening it sets the app's access cookie +/// and lands on the app; it works once, for [`LAUNCH_LINK_LIFETIME`]. +async fn launch_url(port: u16, id: &str, secret: &str) -> Result { + // The id goes into a request line, so it must be an app name and nothing else. + biorouter_mcp::agent_drafter::store::validate_artifact_id(id) + .map_err(|_| anyhow!("'{id}' is not an app name"))?; + let answer = with_secret(port, "POST", &format!("/apps/{id}/launch"), secret).await; + let path = match &answer { + Some((200, body)) => serde_json::from_str::(body) + .ok() + .and_then(|value| value.get("path")?.as_str().map(str::to_string)), + _ => None, + }; + match path { + Some(path) if is_launch_path(&path, id) => Ok(format!("http://{DAEMON_HOST}:{port}{path}")), + _ => bail!( + "the daemon on port {port} would not open '{id}'{}", + match answer { + Some((404, _)) => ": it has no such app".to_string(), + Some((status, _)) => format!(" (HTTP {status})"), + None => ": it did not answer".to_string(), + } + ), + } +} + +/// Whether `path` is exactly app `id`'s page with a launch token and nothing else. +fn is_launch_path(path: &str, id: &str) -> bool { + path.strip_prefix(&format!("/apps/{id}/?t=")) + .is_some_and(|token| { + token.len() == 64 + && token + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + }) +} + +/// How long a launch page is kept. Its link expired minutes before, so a page +/// this old opens nothing; it is removed the next time a page is written. +const LAUNCH_PAGE_STALE: Duration = Duration::from_secs(10 * 60); + +/// Where `apps open` and `biorouter serve --open` write the pages that hand a +/// link carrying a credential to the browser. +pub(crate) fn launch_pages_dir() -> PathBuf { + biorouter::config::paths::Paths::state_dir().join("app-launch") +} + +/// Write `launch` into a page only this account can read, which sends the +/// browser on to it, and answer that page's path: the only thing an opener is +/// handed (W2-HRD-1). +/// +/// ⚠ Never hand the link itself to `open`, `xdg-open` or a browser. Their +/// arguments are readable by every account on the machine (`ps` on macOS, +/// `/proc//cmdline` on Linux, for as long as a browser started that way +/// runs). An app's launch link opens the app for whoever redeems it FIRST: a +/// co-tenant polling for `?t=` beats a browser that is still starting, keeps the +/// app's cookie for the daemon's run, and the owner sees only a refusal. The +/// browser token `biorouter serve` prints is worse, because it works until the +/// daemon stops and its cookie is handed the daemon's secret with the +/// interface. Jupyter hands its token over the same way. The page is mode 0600 +/// in a 0700 folder this account owns, and names the link twice: a meta +/// refresh, and a link to click if the refresh does not run. The daemon answers +/// either link with a page of its own that sets the cookie, because a `file:` +/// page starting the navigation would keep a redirect from carrying it +/// (`routes::apps::launch_bounce`, `routes::web_ui::exchange_bounce`). +pub(crate) fn write_launch_page(directory: &Path, launch: &str) -> Result { + use std::io::Write; + + tighten_own_folder(directory); + biorouter::daemon_runtime::private_directory(directory)?; + remove_stale_launch_pages(directory, std::time::SystemTime::now()); + let target = html_attribute(launch); + let mut page = tempfile::Builder::new() + .prefix("launch-") + .suffix(".html") + .rand_bytes(16) + .tempfile_in(directory)?; + writeln!( + page, + "\ + \ + \ + Opening Biorouter\ +

Opening Biorouter. If nothing happens, open it here.

" + )?; + page.as_file().sync_all()?; + Ok(page.into_temp_path().keep()?) +} + +/// Make `directory` 0700 when it is a real folder this account owns and others +/// can enter, so one loosened folder does not keep every later `apps open` from +/// opening a browser. A link, or another account's folder, is left for +/// `private_directory` to refuse. +fn tighten_own_folder(directory: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let Ok(folder) = std::fs::symlink_metadata(directory) else { + return; + }; + // SAFETY: geteuid has no preconditions and cannot fail. + let own = folder.uid() == unsafe { libc::geteuid() }; + if folder.is_dir() && own && folder.permissions().mode() & 0o077 != 0 { + let _ = std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)); + } + } + #[cfg(not(unix))] + let _ = directory; +} + +/// Remove the launch pages in `directory` older than [`LAUNCH_PAGE_STALE`]. +/// Best effort: a page left behind holds an expired link. +fn remove_stale_launch_pages(directory: &Path, now: std::time::SystemTime) { + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !(name.starts_with("launch-") && name.ends_with(".html")) { + continue; + } + let stale = entry + .metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age >= LAUNCH_PAGE_STALE); + if stale { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +/// `value` escaped for a double-quoted HTML attribute. +fn html_attribute(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + other => escaped.push(other), + } + } + escaped +} + +/// Open `launch`, a link that carries a credential, in the default browser +/// through a launch page in `directory`, calling `opener` with the page and +/// never with the link. Answers the page, for a caller that removes it later, or +/// what went wrong, for the caller to print the link instead. +pub(crate) fn open_launch_link( + launch: &str, + directory: &Path, + opener: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Result { + let page = write_launch_page(directory, launch) + .map_err(|error| anyhow!("could not write the page that opens the browser: {error}"))?; + match opener(&page) { + Ok(()) => Ok(page), + Err(error) => { + let _ = std::fs::remove_file(&page); + Err(anyhow!("could not open a browser: {error}")) + } + } +} + +/// Open the launch page `page` in the default browser. Only a launch page is +/// ever handed to this: see [`write_launch_page`] for why the link itself never +/// reaches an opener. +/// +/// A browser, and not whatever opens `.html` files, which on a developer's +/// machine is often an editor: `webbrowser` asks the platform for the handler of +/// `https:` and opens the page in it. The page goes over as its `file:` address. +/// On macOS that is a LaunchServices call, which starts no process whose +/// arguments could be read; elsewhere it is the browser's argument, which names +/// the page and not the link. +pub(crate) fn open_page_in_browser(page: &Path) -> std::io::Result<()> { + let page_url = url::Url::from_file_path(page).map_err(|()| { + std::io::Error::other(format!("{} is not an absolute path", page.display())) + })?; + webbrowser::open(page_url.as_str()) } /// Whether a daemon this command starts is tied to this process's lifetime. @@ -303,12 +632,30 @@ pub(crate) enum Supervision { TiedToThisProcess, } -/// Ensure a daemon is reachable on the configured port, spawning one if needed. -async fn ensure_daemon(port: u16, supervision: Supervision) -> Result { +/// Ensure a daemon whose secret this command knows is reachable on the configured +/// port, spawning one if needed. Answers the daemon and its secret. +async fn ensure_daemon(port: u16, supervision: Supervision) -> Result<(Daemon, String)> { if daemon_ok(DAEMON_HOST, port).await { - return Ok(Daemon::Reused); + let known = std::env::var("BIOROUTER_SERVER__SECRET_KEY") + .ok() + .filter(|secret| !secret.is_empty()) + .into_iter() + .chain(remembered_secret(port)); + for secret in known { + if secret_works(port, &secret).await { + return Ok((Daemon::Reused, secret)); + } + } + bail!( + "a Biorouter daemon is already running on port {port}, and opening an app on it takes \ + that daemon's secret key, which this command does not know. It knows the key of a \ + daemon `biorouter apps open` or `biorouter apps serve` started; for any other, set \ + BIOROUTER_SERVER__SECRET_KEY to its key, or set BIOROUTER_PORT to a free port so \ + this command starts a daemon of its own." + ); } + let secret = new_daemon_secret(); let bin = biorouterd_path(); let mut command = tokio::process::Command::new(&bin); command.arg("agent"); @@ -322,6 +669,9 @@ async fn ensure_daemon(port: u16, supervision: Supervision) -> Result { } let mut child = command .env("BIOROUTER_PORT", port.to_string()) + // The secret rides the environment, which only this account can read, + // and never the command line. + .env("BIOROUTER_SERVER__SECRET_KEY", &secret) // Issue #56 DR-16: `biorouterd agent` now reads one line off stdin at // startup (the launcher's user-action digest). `Command` INHERITS fd 0, // so without this the spawned daemon would consume a line of the CLI's @@ -359,8 +709,12 @@ async fn ensure_daemon(port: u16, supervision: Supervision) -> Result { Is port {port} already in use? Try a different BIOROUTER_PORT." ); } - if daemon_ok(DAEMON_HOST, port).await { - return Ok(Daemon::Started(child)); + if secret_works(port, &secret).await { + // Both supervisions: the next launch link for this daemon comes from + // a later `apps open` or `apps serve`, which can mint it only with + // this secret. `serve` removes the record when it stops the daemon. + let remembered = remember_daemon(port, &secret); + return Ok((Daemon::Started { child, remembered }, secret)); } if tokio::time::Instant::now() >= deadline { let _ = child.start_kill(); @@ -386,6 +740,32 @@ fn app_url(port: u16, id: &str) -> String { format!("http://{DAEMON_HOST}:{port}/apps/{id}/") } +/// The command that mints another launch link for app `id` on the daemon on +/// `port`, spelled so it reaches that port. +fn open_again_command(port: u16, id: &str) -> String { + if port == 3000 { + format!("biorouter apps open {id}") + } else { + format!("BIOROUTER_PORT={port} biorouter apps open {id}") + } +} + +/// What `apps serve` tells the person about the link it printed, and where the +/// next one comes from. Each answer is true of the daemon it describes: one it +/// started and recorded, one it started and could not record, or one it reused +/// with a key it already knew. +fn serve_link_advice(port: u16, id: &str, started: Option) -> String { + let once = format!("the address below opens the app once, within {LAUNCH_LINK_LIFETIME}"); + match started { + Some(true) => format!( + "{once}. For another, run `{}` in another terminal while this one keeps serving.", + open_again_command(port, id) + ), + Some(false) => format!("{once}. For another, stop this with Ctrl-C and run it again."), + None => format!("{once}. Run this again for another."), + } +} + // ────────────────────────────────────────────────────────────────────────────── // open // ────────────────────────────────────────────────────────────────────────────── @@ -393,8 +773,9 @@ fn app_url(port: u16, id: &str) -> String { pub async fn handle_apps_open(id: String) -> Result<()> { require_app(&id)?; let port = configured_port(); - let daemon = ensure_daemon(port, Supervision::Detached).await?; + let (daemon, secret) = ensure_daemon(port, Supervision::Detached).await?; let url = app_url(port, &id); + let launch = launch_url(port, &id, &secret).await?; match daemon { Daemon::Reused => { @@ -403,15 +784,33 @@ pub async fn handle_apps_open(id: String) -> Result<()> { style("·").dim() ); } - Daemon::Started(_child) => { + Daemon::Started { .. } => { // Leave the child running (detached) so the app stays served after // this command returns — the tokio child is not killed on drop. println!(" {} started biorouterd on port {port}", style("✓").green()); } } - if let Err(e) = open::that(&url) { - eprintln!(" {} could not open a browser: {e}", style("!").yellow()); + // The link itself never reaches an opener's command line: see + // `write_launch_page`. The terminal is this account's alone, so the link is + // printed there when no browser could be opened. + // + // It is printed when the page did open, too: a browser that cannot read the + // page (a snap-packaged browser cannot read hidden folders such as the state + // dir) shows an error, and `open` still reports success. + let once = format!("it works once, within {LAUNCH_LINK_LIFETIME}"); + match open_launch_link(&launch, &launch_pages_dir(), open_page_in_browser) { + Ok(_) => println!( + " {} if the app does not appear, open this address instead ({once}): {launch}", + style("·").dim() + ), + Err(e) => { + eprintln!(" {} {e}", style("!").yellow()); + println!( + " {} open this address instead ({once}): {launch}", + style("·").dim() + ); + } } println!(" {} {}", style("→").fg(ACCENT), style(&url).bold()); Ok(()) @@ -430,14 +829,32 @@ pub async fn handle_apps_serve(id: String) -> Result<()> { // and leave the daemon behind — so from here on a signal waits to be read, // including one that lands during the readiness wait inside `ensure_daemon`. let mut stop = StopSignals::install()?; - let daemon = ensure_daemon(port, Supervision::TiedToThisProcess).await?; - let url = app_url(port, &id); + let (daemon, secret) = ensure_daemon(port, Supervision::TiedToThisProcess).await?; + let url = match launch_url(port, &id, &secret).await { + Ok(url) => url, + Err(error) => { + if let Daemon::Started { mut child, .. } = daemon { + stop_daemon(&mut child, &mut stop).await; + forget_daemon(port, &secret); + } + return Err(error); + } + }; + let started = match &daemon { + Daemon::Reused => None, + Daemon::Started { remembered, .. } => Some(*remembered), + }; + println!( + " {} {}", + style("·").dim(), + serve_link_advice(port, &id, started) + ); match daemon { Daemon::Reused => { // Reusing an external daemon: print the URL and a note, then exit 0. // We do not own its lifecycle, so there is nothing to keep in the - // foreground — and nothing of ours to stop. + // foreground — and nothing of ours to stop, or to forget. println!(" {} {}", style("→").fg(ACCENT), style(&url).bold()); println!( " {} reusing the daemon already running on port {port}; it keeps running after this command.", @@ -445,7 +862,7 @@ pub async fn handle_apps_serve(id: String) -> Result<()> { ); Ok(()) } - Daemon::Started(mut child) => { + Daemon::Started { mut child, .. } => { println!(" {} {}", style("→").fg(ACCENT), style(&url).bold()); println!( " {} serving on port {port}. Press Ctrl-C to stop.", @@ -467,6 +884,10 @@ pub async fn handle_apps_serve(id: String) -> Result<()> { // Asks, waits out the grace, then kills and reaps. A daemon that has // already exited is only reaped, so this is safe on both arms. stop_daemon(&mut child, &mut stop).await; + // After the stop, not before: until the daemon is gone an `apps open` + // may still use it. A daemon started on the port since has a record + // of its own, which this leaves alone. + forget_daemon(port, &secret); Ok(()) } } @@ -612,4 +1033,339 @@ mod tests { fn app_url_is_well_formed() { assert_eq!(app_url(3000, "beta"), "http://127.0.0.1:3000/apps/beta/"); } + + /// W2-HRD-1: only exactly the app's page with a launch token is opened. + #[test] + fn only_the_apps_own_launch_link_is_opened() { + let token = "ab".repeat(32); + assert!(is_launch_path(&format!("/apps/beta/?t={token}"), "beta")); + for path in [ + format!("/apps/other/?t={token}"), + format!("/apps/beta/?t={token}&next=/sessions"), + format!("/apps/beta/?t={}", token.to_uppercase()), + format!("/apps/beta/?t={}", "ab".repeat(31)), + format!("/apps/beta/agent?t={token}"), + format!("//evil.test/apps/beta/?t={token}"), + "/apps/beta/".to_string(), + ] { + assert!(!is_launch_path(&path, "beta"), "{path}"); + } + } + + fn a_launch_link() -> String { + format!("http://127.0.0.1:3000/apps/beta/?t={}", "ab".repeat(32)) + } + + /// W2-HRD-1: the opener is handed a page only this account can read, and + /// never the link. The link opens the app for whoever redeems it first, and + /// an opener's arguments are readable by every account on the machine. + #[test] + fn the_opener_is_handed_a_private_page_and_never_the_link() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("app-launch"); + let launch = a_launch_link(); + let mut handed = Vec::new(); + let written = open_launch_link(&launch, &dir, |page| { + handed.push(page.to_path_buf()); + Ok(()) + }) + .unwrap(); + + assert_eq!(handed.len(), 1); + let page = &handed[0]; + assert_eq!( + &written, page, + "the caller is told which page to remove later" + ); + let argument = page.to_string_lossy(); + assert!(!argument.contains("?t="), "{argument}"); + assert!(!argument.contains(&"ab".repeat(32)), "{argument}"); + assert_eq!(page.parent(), Some(dir.as_path())); + let html = fs::read_to_string(page).unwrap(); + assert!( + html.contains(&format!("content=\"0;url={launch}\"")), + "{html}" + ); + assert!(html.contains(&format!("href=\"{launch}\"")), "{html}"); + assert!(html.contains("")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(page), 0o600, "the page is this account's alone"); + assert_eq!(mode(&dir), 0o700, "and so is its folder"); + } + } + + /// With no browser to open, the caller learns it and prints the link to the + /// terminal instead; the page is still this account's alone. + #[test] + fn a_failed_opener_is_reported_so_the_link_is_printed_instead() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("app-launch"); + let error = open_launch_link(&a_launch_link(), &dir, |_| { + Err(std::io::Error::other("no display")) + }) + .unwrap_err(); + assert!(error.to_string().contains("no display"), "{error}"); + assert_eq!( + fs::read_dir(&dir).unwrap().count(), + 0, + "a page no browser will read is not left holding the link" + ); + } + + /// A folder of ours that others can enter is closed to them before a page + /// goes in it, rather than left to stop every later `apps open`. + #[cfg(unix)] + #[test] + fn a_loose_folder_of_ours_is_tightened_before_a_page_goes_in() { + use std::os::unix::fs::PermissionsExt; + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("app-launch"); + fs::create_dir(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + let page = write_launch_page(&dir, &a_launch_link()).unwrap(); + assert_eq!( + fs::metadata(&dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + fs::metadata(&page).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + /// A folder that is a link is not used for a page, and nothing is opened: + /// the caller prints the link instead. + #[cfg(unix)] + #[test] + fn a_folder_that_is_a_link_is_refused() { + let tmp = TempDir::new().unwrap(); + let elsewhere = tmp.path().join("elsewhere"); + fs::create_dir(&elsewhere).unwrap(); + let dir = tmp.path().join("app-launch"); + std::os::unix::fs::symlink(&elsewhere, &dir).unwrap(); + let mut opened = false; + let result = open_launch_link(&a_launch_link(), &dir, |_| { + opened = true; + Ok(()) + }); + assert!(result.is_err()); + assert!(!opened); + assert_eq!( + fs::read_dir(&elsewhere).unwrap().count(), + 0, + "no page was written" + ); + } + + /// Pages whose link has long expired are removed when the next is written; + /// other files in the folder, and recent pages, are left alone. + #[test] + fn expired_launch_pages_are_swept() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("app-launch"); + let first = write_launch_page(&dir, &a_launch_link()).unwrap(); + let recent = write_launch_page(&dir, &a_launch_link()).unwrap(); + let other = dir.join("notes.txt"); + fs::write(&other, "keep").unwrap(); + let long_ago = std::time::SystemTime::now() - LAUNCH_PAGE_STALE - Duration::from_secs(1); + fs::File::options() + .write(true) + .open(&first) + .unwrap() + .set_modified(long_ago) + .unwrap(); + let next = write_launch_page(&dir, &a_launch_link()).unwrap(); + assert!(!first.exists(), "an expired page is removed"); + assert!(recent.exists() && next.exists() && other.exists()); + } + + #[test] + fn a_launch_page_escapes_what_it_quotes() { + assert_eq!( + html_attribute(r#"a&b"c'df"#), + "a&b"c'd<e>f" + ); + } + + /// A source guard for the property above: this file opens a browser in one + /// place, `open_page_in_browser`, which is handed the page and never the + /// link, and no other opener appears at all. + #[test] + fn nothing_but_a_launch_page_reaches_an_opener() { + let source = include_str!("apps.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .unwrap(); + let calls: Vec<&str> = source.split("webbrowser::").skip(1).collect(); + assert_eq!(calls.len(), 1, "one browser opener, and only one"); + assert!( + calls[0].starts_with("open(page_url.as_str())"), + "webbrowser::{:.40}", + calls[0] + ); + for opener in [ + "open::that", + "open::with", + "open::commands", + "Command::new(\"open\")", + "Command::new(\"xdg-open\")", + ] { + assert!(!source.contains(opener), "{opener}"); + } + assert!(source.contains("fn open_page_in_browser(page: &Path)")); + } + + /// A page goes to the browser as a `file:` address, which needs an absolute + /// path; a relative one is refused before any browser is asked. + #[test] + fn a_relative_page_is_not_opened() { + let error = open_page_in_browser(Path::new("launch-relative.html")).unwrap_err(); + assert!( + error.to_string().contains("not an absolute path"), + "{error}" + ); + } + + #[test] + fn a_daemon_secret_is_64_hex_and_fresh() { + let one = new_daemon_secret(); + assert_eq!(one.len(), 64); + assert!(one.bytes().all(|b| b.is_ascii_hexdigit())); + assert_ne!(one, new_daemon_secret()); + } + + /// A stand-in for `biorouterd` on a free loopback port. `/status` answers + /// anyone; `/apps` and `POST /apps/{app}/launch` answer only `secret`, and + /// the launch route mints a fresh link each time, as the daemon's does. + async fn a_daemon_with(secret: &'static str, app: &'static str) -> u16 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(n) => request.extend_from_slice(&chunk[..n]), + } + } + let text = String::from_utf8_lossy(&request); + let mut line = text.lines().next().unwrap_or_default().split_whitespace(); + let keyed = text + .lines() + .any(|header| header == format!("X-Secret-Key: {secret}")); + let (status, body) = match (line.next(), line.next()) { + (Some("GET"), Some("/status")) => ("200 OK", "ok".to_string()), + (Some("GET"), Some("/apps")) if keyed => ("200 OK", "[]".to_string()), + (Some("POST"), Some(path)) + if keyed && path == format!("/apps/{app}/launch") => + { + ( + "200 OK", + serde_json::json!({ + "path": format!("/apps/{app}/?t={}", new_daemon_secret()) + }) + .to_string(), + ) + } + _ => ("401 Unauthorized", String::new()), + }; + let answer = format!( + "HTTP/1.0 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(answer.as_bytes()).await; + let _ = stream.shutdown().await; + }); + } + }); + port + } + + /// W2-HRD-1, the dead end `apps serve` left: its daemon's key was kept to + /// itself, so once the one link it printed was spent or five minutes old, a + /// second `apps serve` or `apps open` on the port refused the daemon as + /// unknown and the only way on was to stop the first. Every started daemon + /// is now recorded, and a second command mints another link on it. + /// + /// The first `ensure_daemon` below is the shipped behaviour for a `serve` + /// daemon (nothing recorded); `tests/apps_serve_lifecycle.rs` covers the + /// same with the real binaries, which is where the record is written. + #[tokio::test] + async fn a_daemon_apps_serve_started_gives_a_second_command_another_link() { + const SECRET: &str = "5ec2e75ec2e75ec2e75ec2e75ec2e75ec2e75ec2e75ec2e75ec2e75ec2e75ec2"; + let port = a_daemon_with(SECRET, "beta").await; + + let unknown = match ensure_daemon(port, Supervision::TiedToThisProcess).await { + Ok(_) => panic!("a daemon whose key was never recorded must be refused"), + Err(refused) => refused.to_string(), + }; + assert!(unknown.contains("does not know"), "{unknown}"); + + assert!(remember_daemon(port, SECRET)); + let (daemon, secret) = ensure_daemon(port, Supervision::TiedToThisProcess) + .await + .expect("a recorded daemon is reused"); + assert!(matches!(daemon, Daemon::Reused)); + assert_eq!(secret, SECRET); + + let first = launch_url(port, "beta", &secret).await.unwrap(); + let second = launch_url(port, "beta", &secret).await.unwrap(); + for link in [&first, &second] { + let path = link + .strip_prefix(&format!("http://127.0.0.1:{port}")) + .unwrap(); + assert!(is_launch_path(path, "beta"), "{link}"); + } + assert_ne!(first, second, "each command gets a link of its own"); + + // A stop forgets only the record that names the daemon it stopped. + forget_daemon(port, "a-daemon-started-on-the-port-since"); + assert_eq!(remembered_secret(port).as_deref(), Some(SECRET)); + forget_daemon(port, SECRET); + assert_eq!(remembered_secret(port), None); + assert!(!remembered_daemon_path(port).exists()); + } + + /// The line `apps serve` prints under its link is true of the daemon it + /// describes. It said "run this again for another" of a daemon a second run + /// could not use. + #[test] + fn the_advice_under_a_served_link_names_a_way_that_works() { + let recorded = serve_link_advice(3000, "beta", Some(true)); + assert!( + recorded.contains("`biorouter apps open beta`"), + "{recorded}" + ); + assert!( + recorded.contains("while this one keeps serving"), + "{recorded}" + ); + assert!(!recorded.contains("run this again"), "{recorded}"); + assert!(serve_link_advice(4100, "beta", Some(true)) + .contains("`BIOROUTER_PORT=4100 biorouter apps open beta`")); + let unrecorded = serve_link_advice(3000, "beta", Some(false)); + assert!(unrecorded.contains("stop this with Ctrl-C"), "{unrecorded}"); + assert!(!unrecorded.contains("apps open"), "{unrecorded}"); + for advice in [recorded, unrecorded, serve_link_advice(3000, "beta", None)] { + assert!(advice.contains("once, within five minutes"), "{advice}"); + } + } + + /// What the person is told about a link's lifetime is the daemon's figure, + /// which lives in a crate this one cannot import. + #[test] + fn the_lifetime_the_person_is_told_is_the_daemons() { + let auth = include_str!("../../../biorouter-server/src/auth.rs"); + assert!( + auth.contains("const APP_LAUNCH_TTL: Duration = Duration::from_secs(5 * 60);"), + "the daemon's launch-link lifetime changed; update LAUNCH_LINK_LIFETIME" + ); + assert_eq!(LAUNCH_LINK_LIFETIME, "five minutes"); + } } diff --git a/crates/biorouter-cli/src/commands/configure.rs b/crates/biorouter-cli/src/commands/configure.rs index 76021ef93..f4ed79cbb 100644 --- a/crates/biorouter-cli/src/commands/configure.rs +++ b/crates/biorouter-cli/src/commands/configure.rs @@ -322,8 +322,8 @@ async fn handle_existing_config() -> anyhow::Result<()> { .item("remove", "Remove Extension", "Remove an extension") .item( "settings", - "biorouter settings", - "Set the biorouter mode, Tool Output, Tool Permissions, Experiment, biorouter workflow github repo and more", + "Biorouter settings", + "Set the mode, tool output, tool permissions, experiments, the workflow GitHub repository and more", ) .interact()?; @@ -546,18 +546,225 @@ fn prompt_unlisted_model( Ok(model.trim().to_string()) } +/// W2-PRV-2 — a provider's settings as they were before `configure` changed +/// them. `configure` stores each key as it is typed and only then asks the +/// provider (a model listing, then one tool call), so a key the provider +/// rejected had already replaced the working one. Captured before the first +/// write, and put back when the provider refuses what was typed. +struct PreviousProviderSettings { + config: &'static Config, + /// `(name, secret, stored value)`. A setting whose store could not be read + /// at capture is left out: restoring from a failed read would delete a key + /// that was there all along. + keys: Vec<(String, bool, Option)>, + /// Put the settings back if the dialog is left without [`Self::keep`]: + /// an error or an interrupt part-way through leaves nothing half-replaced. + armed: bool, +} + +impl PreviousProviderSettings { + /// The stored value of every setting in `keys`, from the config file and + /// the secret store only: an environment variable is not a saved value. + fn capture(config: &'static Config, keys: &[biorouter::providers::base::ConfigKey]) -> Self { + let values = config.all_values().ok(); + let secrets = if keys.iter().any(|key| key.secret) { + config.all_secrets().ok() + } else { + Some(HashMap::new()) + }; + Self { + config, + keys: keys + .iter() + .filter_map(|key| { + let store = if key.secret { + secrets.as_ref() + } else { + values.as_ref() + }?; + Some((key.name.clone(), key.secret, store.get(&key.name).cloned())) + }) + .collect(), + armed: true, + } + } + + /// The new settings stand: nothing will be put back. + fn keep(&mut self) { + self.armed = false; + } + + /// Put back every setting that changed since [`Self::capture`], removing + /// one that did not exist then. Returns whether anything was put back. + fn restore(&mut self) -> anyhow::Result { + self.armed = false; + let config = self.config; + let values = config.all_values()?; + let secrets = if self.keys.iter().any(|(_, secret, _)| *secret) { + config.all_secrets()? + } else { + HashMap::new() + }; + let mut restored = false; + for (name, secret, previous) in &self.keys { + let now = if *secret { + secrets.get(name) + } else { + values.get(name) + }; + if now == previous.as_ref() { + continue; + } + let result = match previous { + Some(value) => config.set(name, value, *secret), + None if *secret => config.delete_secret(name), + None => config.delete(name), + }; + match result { + Ok(()) | Err(ConfigError::FallbackToFileStorage) => restored = true, + Err(error) => return Err(error.into()), + } + } + Ok(restored) + } + + /// [`Self::restore`], said to the person, after the provider refused. + /// `typed_something` says whether values were typed, which since T3-SH-5 + /// are never written before the check and so need no putting back. + fn restore_after_refusal( + &mut self, + display_name: &str, + typed_something: bool, + ) -> anyhow::Result<()> { + if self.restore()? || typed_something { + let _ = cliclack::log::info(format!( + "Your previous {display_name} settings were kept; nothing you typed was saved." + )); + } + Ok(()) + } +} + +impl Drop for PreviousProviderSettings { + fn drop(&mut self) { + if self.armed { + if let Err(error) = self.restore() { + tracing::warn!("could not put the previous provider settings back: {error}"); + } + } + } +} + fn try_store_secret(config: &Config, key_name: &str, value: String) -> anyhow::Result { + Ok(store_secret_reporting(config, key_name, value)?.is_some()) +} + +/// Where a secret [`try_store_secret`] stored ended up: the system keychain, or +/// the private secrets file when there is no usable keychain (a headless Linux +/// host, `BIOROUTER_DISABLE_KEYRING`, or a keychain that refused the write). +/// +/// T3-SH-6. `configure` asked "save this value to your keyring?" and then said +/// "Saved KEY to " for a secret, on a host where it had gone to +/// `secrets.yaml` and `config.yaml` held nothing of it. +fn secret_home(file_before_write: bool, fell_back: bool) -> String { + if file_before_write || fell_back { + format!( + "the private file {}", + Paths::config_dir().join("secrets.yaml").display() + ) + } else { + "the system keychain".to_string() + } +} + +/// [`try_store_secret`], and where the secret went, for the confirmation line. +/// `None` when it could not be stored (the reason has been said). +fn store_secret_reporting( + config: &Config, + key_name: &str, + value: String, +) -> anyhow::Result> { + let file_before_write = std::env::var("BIOROUTER_DISABLE_KEYRING").is_ok(); match config.set_secret(key_name, &value) { - Ok(_) => Ok(true), - Err(ConfigError::FallbackToFileStorage) => Ok(true), + Ok(()) => Ok(Some(secret_home(file_before_write, false))), + Err(ConfigError::FallbackToFileStorage) => Ok(Some(secret_home(file_before_write, true))), Err(e) => { cliclack::outro(style(format!( "Failed to store {} securely: {}. Please ensure your system's secure storage is accessible. Alternatively you can run with BIOROUTER_DISABLE_KEYRING=true or set the key in your environment variables", key_name, e )).on_red().white())?; - Ok(false) + Ok(None) + } + } +} + +/// T3-SH-5 — a value typed for a provider in `configure`, held in memory until +/// the provider has accepted it. +/// +/// `configure` used to store each value as it was typed and check the provider +/// afterwards. A refusal, Esc or Ctrl-C put the old values back, but a SIGTERM +/// or SIGHUP (closing the terminal) at "Enter a model from that provider" left +/// the wrong key saved and the working one gone. Nothing typed is written now +/// until the provider has answered the check with it. +#[derive(Debug, Clone, PartialEq)] +struct TypedSetting { + name: String, + secret: bool, + value: String, +} + +/// The typed values as the task-local overrides the provider is built and +/// checked under, the form `/config/check_provider` uses: keys upper-cased, a +/// secret as a JSON string literal so an all-digit key stays a string. +fn typed_overrides(typed: &[TypedSetting]) -> HashMap { + typed + .iter() + .map(|setting| { + let value = if setting.secret { + serde_json::to_string(&setting.value).unwrap_or_else(|_| setting.value.clone()) + } else { + setting.value.clone() + }; + (setting.name.to_uppercase(), value) + }) + .collect() +} + +/// Write the typed values once the provider has accepted them: settings +/// first, credentials last (a failed setting stops before any key is written), +/// saying where each one went. `Ok(false)` when a secret could not be stored. +fn save_typed_settings(config: &Config, typed: &[TypedSetting]) -> anyhow::Result { + for setting in typed.iter().filter(|setting| !setting.secret) { + config.set_param(&setting.name, &setting.value)?; + let _ = cliclack::log::info(format!("Saved {} to {}", setting.name, config.path())); + } + for setting in typed.iter().filter(|setting| setting.secret) { + match store_secret_reporting(config, &setting.name, setting.value.clone())? { + Some(home) => { + let _ = cliclack::log::info(format!("Saved {} in {home}", setting.name)); + } + None => return Ok(false), } } + Ok(true) +} + +/// Run `check` with the typed values in force but not written, and write them +/// only if it passes. Returns the check's own result; on `Err` nothing typed +/// has been written. +async fn check_then_save( + config: &Config, + typed: &[TypedSetting], + check: F, +) -> anyhow::Result> +where + F: std::future::Future>, +{ + let checked = biorouter::config::with_config_overrides(typed_overrides(typed), check).await?; + if !save_typed_settings(config, typed)? { + return Ok(None); + } + Ok(Some(checked)) } #[allow(clippy::too_many_lines)] @@ -609,6 +816,13 @@ pub async fn configure_provider_dialog() -> anyhow::Result { // the master privacy switch, which turns off enforcement and not the truth. print_non_private_model_disclosure(provider_meta)?; + // W2-PRV-2: before the first write, so a key the provider refuses below + // never replaces the one that worked. Typed values are no longer written + // before the check (T3-SH-5, `TypedSetting`); what this still guards is an + // OAuth sign-in, which stores its own tokens as it completes. + let mut previous = PreviousProviderSettings::capture(config, &provider_meta.config_keys); + let mut typed: Vec = Vec::new(); + // Configure required provider keys for key in &provider_meta.config_keys { if !key.required { @@ -622,18 +836,18 @@ pub async fn configure_provider_dialog() -> anyhow::Result { Some(env_value) => { let _ = cliclack::log::info(format!("{} is set via environment variable", key.name)); - if cliclack::confirm("Would you like to save this value to your keyring?") + // T3-SH-6: where a value is kept depends on the host (the + // system keychain, or a private file where there is none), and + // the confirmation after the check names the place it went. + if cliclack::confirm("Save this value in Biorouter's settings?") .initial_value(true) .interact()? { - if key.secret { - if !try_store_secret(config, &key.name, env_value)? { - return Ok(false); - } - } else { - config.set_param(&key.name, &env_value)?; - } - let _ = cliclack::log::info(format!("Saved {} to {}", key.name, config.path())); + typed.push(TypedSetting { + name: key.name.clone(), + secret: key.secret, + value: env_value, + }); } } None => { @@ -667,13 +881,11 @@ pub async fn configure_provider_dialog() -> anyhow::Result { input.interact()? }; - if key.secret { - if !try_store_secret(config, &key.name, value)? { - return Ok(false); - } - } else { - config.set_param(&key.name, &value)?; - } + typed.push(TypedSetting { + name: key.name.clone(), + secret: key.secret, + value, + }); } } } @@ -700,13 +912,11 @@ pub async fn configure_provider_dialog() -> anyhow::Result { input.interact()? }; - if key.secret { - if !try_store_secret(config, &key.name, value)? { - return Ok(false); - } - } else { - config.set_param(&key.name, &value)?; - } + typed.push(TypedSetting { + name: key.name.clone(), + secret: key.secret, + value, + }); } } } @@ -765,25 +975,64 @@ pub async fn configure_provider_dialog() -> anyhow::Result { } input.interact()? }; - if key.secret { - if !try_store_secret(config, &key.name, value)? { - return Ok(false); - } - } else { - config.set_param(&key.name, &value)?; - } + typed.push(TypedSetting { + name: key.name.clone(), + secret: key.secret, + value, + }); } } + // Everything below runs with the typed values in force and none of them + // written: the provider is built, listed and tried with them, and they are + // saved only once that passed (`check_then_save`). + let overrides = typed_overrides(&typed); + let nothing_typed_was_saved = !typed.is_empty(); let spin = spinner(); spin.start("Attempting to fetch supported models..."); - let models_res = { - let temp_model_config = ModelConfig::new(&provider_meta.default_model)?; - let temp_provider = create(provider_name, temp_model_config).await?; + let temp_model_config = ModelConfig::new(&provider_meta.default_model)?; + let temp_provider = match biorouter::config::with_config_overrides( + overrides.clone(), + create(provider_name, temp_model_config), + ) + .await + { + Ok(provider) => provider, + Err(error) => { + spin.stop(style("The provider could not be set up").red()); + previous.restore_after_refusal(&provider_meta.display_name, nothing_typed_was_saved)?; + return Err(error); + } + }; + let models_res = biorouter::config::with_config_overrides( + overrides.clone(), retry_operation(&RetryConfig::default(), || async { temp_provider.fetch_recommended_models().await - }) + }), + ) + .await; + // A provider with no model listing (the Versa gateways) sent nothing above, + // so ask it the way it can be asked before the model prompt: a key it + // refuses is said now, not after a model has been chosen (T3-SH-3). + let models_res = match models_res { + // Bounded, as the daemon's check is: a gateway that does not answer + // says nothing about the key, and the model prompt follows. + Ok(None) => match tokio::time::timeout( + std::time::Duration::from_secs(10), + biorouter::config::with_config_overrides(overrides, temp_provider.check_credentials()), + ) .await + { + Ok(Err(biorouter::providers::errors::ProviderError::Authentication(reason))) => Err( + biorouter::providers::errors::ProviderError::Authentication(format!( + "{} rejected these credentials: {}", + provider_meta.display_name, + reason.trim() + )), + ), + _ => Ok(None), + }, + other => other, }; spin.stop(style("Model fetch complete").green()); @@ -791,6 +1040,7 @@ pub async fn configure_provider_dialog() -> anyhow::Result { let model: String = match models_res { Err(e) => { // Provider hook error + previous.restore_after_refusal(&provider_meta.display_name, nothing_typed_was_saved)?; cliclack::outro(style(e.to_string()).on_red().white())?; return Ok(false); } @@ -813,15 +1063,25 @@ pub async fn configure_provider_dialog() -> anyhow::Result { .unwrap_or(false); let toolshim_model = std::env::var("BIOROUTER_TOOLSHIM_OLLAMA_MODEL").ok(); - match test_provider_configuration(provider_name, &model, toolshim_enabled, toolshim_model).await + match check_then_save( + config, + &typed, + test_provider_configuration(provider_name, &model, toolshim_enabled, toolshim_model), + ) + .await { - Ok(()) => { + Ok(Some(())) => { + previous.keep(); config.set_biorouter_provider_and_model(provider_name, &model)?; print_config_file_saved()?; Ok(true) } + // A setting was saved but a key could not be; the guard puts the + // settings back, and the reason has been said. + Ok(None) => Ok(false), Err(e) => { spin.stop(style(e.to_string()).red()); + previous.restore_after_refusal(&provider_meta.display_name, nothing_typed_was_saved)?; cliclack::outro(style("Failed to configure provider: init chat completion request with tool did not succeed.").on_red().white())?; Ok(false) } @@ -994,7 +1254,7 @@ fn configure_builtin_extension() -> anyhow::Result<()> { ( "autovisualiser", "Auto Visualiser", - "Interactive charts, diagrams, networks, maps and scientific plots, rendered inline.", + "Interactive charts, diagrams, networks, maps and scientific plots, shown in the side panel.", ), ( "computercontroller", @@ -1231,8 +1491,8 @@ pub async fn configure_settings_dialog() -> anyhow::Result<()> { let setting_type = cliclack::select("What setting would you like to configure?") .item( "biorouter_mode", - "biorouter mode", - "Configure biorouter mode", + "Biorouter mode", + "Choose how much Biorouter does without asking", ) .item( "tool_permission", @@ -1266,8 +1526,8 @@ pub async fn configure_settings_dialog() -> anyhow::Result<()> { ) .item( "workflow", - "biorouter workflow github repo", - "biorouter will pull workflows from this repo if not found locally.", + "Workflow GitHub repository", + "Where Biorouter looks for a workflow it does not find on this computer", ) .interact()?; @@ -1461,42 +1721,52 @@ pub fn configure_biorouter_mode_dialog() -> anyhow::Result<()> { let _ = cliclack::log::info("Notice: BIOROUTER_MODE environment variable is set and will override the configuration here."); } - let mode = cliclack::select("Which biorouter mode would you like to configure?") - .item( - BioRouterMode::Auto, - "Auto Mode", - "Full file modification, extension usage, edit, create and delete files freely" - ) - .item( - BioRouterMode::Approve, - "Approve Mode", - "All tools, extensions and file modifications will require human approval" - ) - .item( - BioRouterMode::SmartApprove, - "Smart Approve Mode", - "Editing, creating, deleting files and using extensions will require human approval" - ) - .item( - BioRouterMode::Chat, - "Chat Mode", - "Engage with the selected provider without using tools, extensions, or file modification" - ) - .interact()?; + let mut select = cliclack::select("Which mode should Biorouter use?"); + for mode in MODES { + let (label, description) = mode_choice(mode); + select = select.item(mode, label, description); + } + let mode = select.interact()?; config.set_biorouter_mode(mode)?; - let msg = match mode { - BioRouterMode::Auto => "Set to Auto Mode - full file modification enabled", - BioRouterMode::Approve => { - "Set to Approve Mode - all tools and modifications require approval" - } - BioRouterMode::SmartApprove => "Set to Smart Approve Mode - modifications require approval", - BioRouterMode::Chat => "Set to Chat Mode - no tools or modifications enabled", - }; - cliclack::outro(msg)?; + cliclack::outro(format!("Mode set to {}.", mode_choice(mode).0))?; Ok(()) } +/// The modes `configure` offers, in the desktop picker's order. +const MODES: [BioRouterMode; 4] = [ + BioRouterMode::Auto, + BioRouterMode::Approve, + BioRouterMode::SmartApprove, + BioRouterMode::Chat, +]; + +/// A mode's name and description, exactly as the desktop's mode picker +/// (`ui/desktop/src/components/settings/mode/ModeSelectionItem.tsx`) says them. +/// `configure` used to say "Auto/Approve/Smart Approve/Chat Mode" while the app +/// said "Autonomous/Manual/Smart/Chat only", so a person meeting a mode in one +/// surface could not find it under the same name in the other. +fn mode_choice(mode: BioRouterMode) -> (&'static str, &'static str) { + match mode { + BioRouterMode::Auto => ( + "Autonomous", + "Use tools and edit, create, or delete files without asking first.", + ), + BioRouterMode::Approve => ( + "Manual", + "Ask before using tools, extensions, or making file changes.", + ), + BioRouterMode::SmartApprove => ( + "Smart", + "Ask only when an action\u{2019}s risk level requires your approval.", + ), + BioRouterMode::Chat => ( + "Chat only", + "Chat with the selected model without tools or extensions.", + ), + } +} + pub fn configure_tool_output_dialog() -> anyhow::Result<()> { let config = Config::global(); @@ -1832,29 +2102,36 @@ pub fn configure_max_turns_dialog() -> anyhow::Result<()> { cliclack::input("Set maximum number of agent turns without user input:") .placeholder(¤t_max_turns.to_string()) .default_input(¤t_max_turns.to_string()) - .validate(|input: &String| match input.parse::() { - Ok(value) => { - if value < 1 { - Err("Value must be at least 1") - } else { - Ok(()) - } - } - Err(_) => Err("Please enter a valid number"), - }) + .validate(|input: &String| parse_max_turns(input).map(|_| ())) .interact()?; - let max_turns: u32 = max_turns_input.parse()?; + let max_turns = parse_max_turns(&max_turns_input).map_err(anyhow::Error::msg)?; config.set_param("BIOROUTER_MAX_TURNS", max_turns)?; cliclack::outro(format!( - "Set maximum turns to {} - biorouter will ask for input after {} consecutive actions", - max_turns, max_turns + "Max turns set to {max_turns}: Biorouter asks for your input after {max_turns} actions in a row." ))?; Ok(()) } +/// A max-turns entry as a limit, or the sentence saying why it is not one. +/// The same rule as Settings and `/config/upsert`: only a whole number of at +/// least 1, because a stored 0 stops every new chat before its first reply. +/// +/// T3-SH-8: one sentence for every entry that is not a limit, the one Settings +/// shows. 0 used to get "Max turns must be at least 1" and -5 "Enter a whole +/// number of at least 1", two answers to one mistake. +fn parse_max_turns(input: &str) -> Result { + match input.trim().parse::() { + Ok(value) if value >= 1 => Ok(value), + _ => Err(MAX_TURNS_REFUSAL), + } +} + +/// Settings' own sentence (`ConversationLimitsDropdown.tsx`), asserted there too. +const MAX_TURNS_REFUSAL: &str = "Enter a whole number of at least 1."; + /// Handle OpenRouter authentication pub async fn handle_openrouter_auth() -> anyhow::Result<()> { use biorouter::config::{configure_openrouter, signup_openrouter::OpenRouterAuth}; @@ -2357,3 +2634,289 @@ mod needs_terminal_tests { ); } } + +#[cfg(test)] +mod mode_name_tests { + use super::{mode_choice, MODES}; + use std::path::Path; + + /// `configure` names and describes every mode exactly as the desktop's mode + /// picker does, read from its source so a rename on either side fails here. + #[test] + fn configure_uses_the_desktop_mode_names() { + let picker = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../ui/desktop/src/components/settings/mode/ModeSelectionItem.tsx"), + ) + .expect("the desktop mode picker source"); + for mode in MODES { + let (label, description) = mode_choice(mode); + assert!( + picker.contains(&format!("label: '{label}'")), + "the desktop has no mode named {label:?}" + ); + assert!( + picker.contains(&format!("description: '{description}'")), + "the desktop describes {label} differently from {description:?}" + ); + } + for retired in [ + "Auto Mode", + "Approve Mode", + "Smart Approve Mode", + "Chat Mode", + ] { + assert!( + MODES.iter().all(|mode| mode_choice(*mode).0 != retired), + "{retired}" + ); + } + } +} + +#[cfg(test)] +mod max_turns_tests { + use super::parse_max_turns; + + /// W2-PRV-10: `configure` refuses what Settings and `/config/upsert` refuse, + /// and (T3-SH-8) in the one sentence Settings uses, whatever was typed. + #[test] + fn configure_accepts_only_a_whole_number_of_at_least_one() { + let settings = + std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../../ui/desktop/src/components/settings/mode/ConversationLimitsDropdown.tsx", + )) + .expect("the Settings field's source"); + for refused in ["", "0", "-5", "2.5", "many", "4294967296"] { + let refusal = parse_max_turns(refused).expect_err(refused); + assert_eq!( + refusal, "Enter a whole number of at least 1.", + "{refused:?}" + ); + assert!( + settings.contains(&format!("'{refusal}'")), + "Settings says it differently" + ); + } + assert_eq!(parse_max_turns("1"), Ok(1)); + assert_eq!(parse_max_turns(" 250 "), Ok(250)); + } +} + +#[cfg(test)] +mod previous_provider_settings_tests { + use super::PreviousProviderSettings; + use biorouter::config::Config; + use biorouter::providers::base::ConfigKey; + + /// W2-PRV-2: a key the provider refused had already replaced the working + /// one. Whatever `configure` wrote is put back to what was saved before, + /// and a setting that did not exist before is removed again. + #[test] + fn a_refused_setup_leaves_the_saved_settings_as_they_were() { + let dir = tempfile::TempDir::new().unwrap(); + let config: &'static Config = Box::leak(Box::new( + Config::new_with_file_secrets( + dir.path().join("config.yaml"), + dir.path().join("secrets.yaml"), + ) + .unwrap(), + )); + config + .set_secret("W2PRV2_PROBE_API_KEY", &"working-key") + .unwrap(); + config + .set_param("W2PRV2_PROBE_HOST", "https://api.example.test") + .unwrap(); + let keys = vec![ + ConfigKey::new("W2PRV2_PROBE_API_KEY", true, true, None), + ConfigKey::new("W2PRV2_PROBE_HOST", true, false, None), + ConfigKey::new("W2PRV2_PROBE_ORG", false, false, None), + ]; + + let mut previous = PreviousProviderSettings::capture(config, &keys); + config + .set_secret("W2PRV2_PROBE_API_KEY", &"typo-key") + .unwrap(); + config + .set_param("W2PRV2_PROBE_HOST", "https://elsewhere.test") + .unwrap(); + config.set_param("W2PRV2_PROBE_ORG", "new-org").unwrap(); + + assert!(previous.restore().unwrap()); + assert_eq!( + config.get_secret::("W2PRV2_PROBE_API_KEY").unwrap(), + "working-key" + ); + assert_eq!( + config.get_param::("W2PRV2_PROBE_HOST").unwrap(), + "https://api.example.test" + ); + assert!(config.get_param::("W2PRV2_PROBE_ORG").is_err()); + + // Nothing changed since: nothing to put back. + assert!(!previous.restore().unwrap()); + } + + /// An interrupt or error part-way through the dialog drops the capture + /// without `keep`, and that puts the settings back too. + #[test] + fn leaving_the_dialog_early_puts_the_settings_back() { + let dir = tempfile::TempDir::new().unwrap(); + let config: &'static Config = Box::leak(Box::new( + Config::new_with_file_secrets( + dir.path().join("config.yaml"), + dir.path().join("secrets.yaml"), + ) + .unwrap(), + )); + config + .set_secret("W2PRV2_DROP_API_KEY", &"working-key") + .unwrap(); + let keys = vec![ConfigKey::new("W2PRV2_DROP_API_KEY", true, true, None)]; + { + let _previous = PreviousProviderSettings::capture(config, &keys); + config + .set_secret("W2PRV2_DROP_API_KEY", &"half-typed") + .unwrap(); + } + assert_eq!( + config.get_secret::("W2PRV2_DROP_API_KEY").unwrap(), + "working-key" + ); + { + let mut previous = PreviousProviderSettings::capture(config, &keys); + config + .set_secret("W2PRV2_DROP_API_KEY", &"accepted") + .unwrap(); + previous.keep(); + } + assert_eq!( + config.get_secret::("W2PRV2_DROP_API_KEY").unwrap(), + "accepted" + ); + } +} + +#[cfg(test)] +mod typed_settings_tests { + use super::{check_then_save, secret_home, typed_overrides, TypedSetting, MAX_TURNS_REFUSAL}; + use biorouter::config::Config; + + fn leaked_config(dir: &tempfile::TempDir) -> &'static Config { + Box::leak(Box::new( + Config::new_with_file_secrets( + dir.path().join("config.yaml"), + dir.path().join("secrets.yaml"), + ) + .unwrap(), + )) + } + + fn typed() -> Vec { + vec![ + TypedSetting { + name: "T3SH5_PROBE_API_KEY".to_string(), + secret: true, + value: "1234567890".to_string(), + }, + TypedSetting { + name: "T3SH5_PROBE_HOST".to_string(), + secret: false, + value: "https://typed.example.test".to_string(), + }, + ] + } + + #[test] + fn typed_values_become_overrides_the_way_the_daemon_passes_them() { + let overrides = typed_overrides(&typed()); + // A secret is a JSON string literal, so an all-digit key stays a string. + assert_eq!(overrides["T3SH5_PROBE_API_KEY"], "\"1234567890\""); + assert_eq!(overrides["T3SH5_PROBE_HOST"], "https://typed.example.test"); + } + + /// T3-SH-5: a key was written as it was typed, so a session killed before + /// the check finished left it saved over the working one. + #[tokio::test] + async fn nothing_typed_is_written_unless_the_check_passes() { + let dir = tempfile::TempDir::new().unwrap(); + let config = leaked_config(&dir); + config + .set_secret("T3SH5_PROBE_API_KEY", &"working-key") + .unwrap(); + + // The check sees what was typed… + let refused = check_then_save::<(), _>(config, &typed(), async { + let host: String = Config::global().get_param("T3SH5_PROBE_HOST")?; + let key: String = Config::global().get_secret("T3SH5_PROBE_API_KEY")?; + assert_eq!(host, "https://typed.example.test"); + assert_eq!(key, "1234567890"); + anyhow::bail!("401 Unauthorized: Invalid client id or secret") + }) + .await; + // …and a refusal writes none of it. + assert!(refused.is_err()); + assert_eq!( + config.get_secret::("T3SH5_PROBE_API_KEY").unwrap(), + "working-key" + ); + assert!(config.get_param::("T3SH5_PROBE_HOST").is_err()); + + let accepted = check_then_save::<(), _>(config, &typed(), async { Ok(()) }).await; + assert!(matches!(accepted, Ok(Some(()))), "{accepted:?}"); + assert_eq!( + config.get_secret::("T3SH5_PROBE_API_KEY").unwrap(), + "1234567890" + ); + assert_eq!( + config.get_param::("T3SH5_PROBE_HOST").unwrap(), + "https://typed.example.test" + ); + } + + /// The dialog itself: no value is stored before `check_then_save`. Read + /// at the source, because the dialog is a terminal conversation. + #[test] + fn the_provider_dialog_stores_nothing_before_the_check() { + let source = include_str!("configure.rs"); + let (_, body) = source + .split_once("pub async fn configure_provider_dialog()") + .expect("the dialog"); + let (body, _) = body.split_once("\n}\n").expect("its end"); + let body: String = body + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + let checked_at = body + .find("check_then_save(") + .expect("the dialog checks first"); + for write in [ + "try_store_secret(", + "store_secret_reporting(", + "set_param(", + "set_secret(", + "save_typed_settings(", + ] { + if let Some(at) = body.find(write) { + assert!( + at > checked_at, + "the dialog writes with {write} before its check" + ); + } + } + } + + /// T3-SH-6: the confirmation names where a secret really went. + #[test] + fn a_secret_is_said_to_be_where_it_went() { + assert_eq!(secret_home(false, false), "the system keychain"); + for (file_before, fell_back) in [(true, false), (false, true), (true, true)] { + let home = secret_home(file_before, fell_back); + assert!(home.starts_with("the private file "), "{home}"); + assert!(home.ends_with("secrets.yaml"), "{home}"); + } + assert!(MAX_TURNS_REFUSAL.ends_with('.')); + } +} diff --git a/crates/biorouter-cli/src/commands/crew/args.rs b/crates/biorouter-cli/src/commands/crew/args.rs index 5defa2348..5426065f9 100644 --- a/crates/biorouter-cli/src/commands/crew/args.rs +++ b/crates/biorouter-cli/src/commands/crew/args.rs @@ -1,7 +1,7 @@ //! Arguments for `biorouter crew`. //! //! Every argument that names a person, team or channel takes a *selector* -//! ("Selectors and the resolver" in `docs/research/biorouter-crew/naming-design.md`): +//! ("Selectors and the resolver" in `docs/crew/design/naming-design.md`): //! //! - a person is `@bob` (the username on the server; a display name never selects anyone); //! - a team is its name or handle, `analysis-lab` or `"Analysis Lab"`; @@ -12,7 +12,7 @@ //! The shared daemon resolves names against the person's own workspace snapshot. UUID-shaped //! text is always an ID and is sent as it is, so scripts that pass IDs keep working. -use clap::{Args, Subcommand, ValueEnum}; +use clap::{ArgGroup, Args, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Clone, Copy, Debug, Default, ValueEnum)] @@ -74,6 +74,7 @@ pub struct CrewOptions { /// Read the human approval key from stdin's first line instead of a hidden prompt. #[arg(long, global = true)] pub approval_key_stdin: bool, + /// Print text for people (the default), indented JSON, or one JSON value per line. #[arg(long, global = true, value_enum, default_value = "text")] pub output_format: OutputFormat, /// Show the machine IDs of people, teams, channels, tasks and files in text output. @@ -89,12 +90,15 @@ pub struct CrewOptions { #[derive(Subcommand)] pub enum CrewCommand { + /// Start, check or stop this profile's shared Biorouter daemon. #[command(subcommand)] Daemon(DaemonCommand), + /// Set up, unlock or lock the passphrase vault that can hold device keys. #[command(subcommand)] Credentials(CredentialCommand), /// Show saved connections and their daemon-reported state. Status, + /// List, save, change or remove this computer's saved connections. #[command(subcommand)] Connections(ConnectionCommand), /// Authenticate SSH through the shared daemon's owned authentication session. @@ -106,23 +110,31 @@ pub enum CrewCommand { /// Join the workspace a saved connection points to: show this computer's code for the /// host, then wait until they let you in. Join(JoinArgs), + /// Show the workspace, set it up once as its host, or rename it. #[command(subcommand)] Workspace(WorkspaceCommand), + /// Invite people to the workspace, let them in with their code, or remove them (host). #[command(subcommand)] Enroll(EnrollmentCommand), /// List the people in the selected workspace, or add one to a team or channel. Members(MembersArgs), + /// List, create or rename teams. #[command(subcommand)] Teams(TeamCommand), + /// List, create, rename, archive or mark channels read. #[command(subcommand)] Channels(ChannelCommand), + /// Invite a member to a team or channel, or accept an invitation. #[command(subcommand)] Invites(InvitationCommand), + /// Show or set your display name and avatar. #[command(subcommand)] Profile(ProfileCommand), + /// Hand a channel you own to another member, or accept one offered to you. #[command(subcommand)] Ownership(OwnershipCommand), - /// Remove a member from a channel you own. + /// Remove a member from a channel you own. Asks first; add --yes where there is no + /// terminal to ask in. RemoveMember { /// The channel: methods, '#methods' or analysis-lab/methods. channel: String, @@ -131,6 +143,9 @@ pub enum CrewCommand { /// Remove someone who has already left the workspace. #[arg(long)] former: bool, + /// Remove without asking. Needed when there is no terminal to ask in. + #[arg(long)] + yes: bool, }, /// Read a page of authorized channel messages. History(HistoryArgs), @@ -138,60 +153,97 @@ pub enum CrewCommand { Search { /// The channel: methods, '#methods' or analysis-lab/methods. channel: String, + /// The words to find, in any letter case. query: String, + /// How many matches to return, 1 to 200. #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=200))] limit: u16, + /// Continue after this message cursor. #[arg(long)] after: Option, }, - /// Follow channel messages. Ctrl-C detaches without cancelling tasks. + /// Show a channel's newest messages, then print new ones as they arrive. Ctrl-C detaches + /// without cancelling tasks. Watch(WatchArgs), /// Post as your own authenticated workspace identity. Send(SendArgs), - /// Show the channel/context scope of an existing grant. - Context { session: String }, + /// Show a chat's Crew access: the channel it posts in, the channels it also reads, then + /// their recent messages, oldest first. + Context { + /// The chat's session ID. + session: String, + }, + /// Upload, download, follow or share files and server paths. #[command(subcommand)] Files(FileCommand), + /// Start, follow or stop your own agent tasks. #[command(subcommand)] Tasks(TaskCommand), + /// Give a chat access to Crew, list access, or revoke it. #[command(subcommand)] Grants(GrantCommand), + /// Show or change how this computer and the workspace treat privacy. #[command(subcommand)] Privacy(PrivacyCommand), } #[derive(Subcommand)] pub enum DaemonCommand { + /// Start the shared daemon and choose its approval secret. Start, + /// Show whether the shared daemon is running, without asking for the approval secret. Status, + /// Stop the shared daemon, for the desktop app too. Stop, } #[derive(Subcommand)] pub enum CredentialCommand { + /// Show whether device keys are in the system keyring or the vault, and whether it is locked. Status, + /// Set up a passphrase vault for device keys; asks for the new passphrase twice. Init, + /// Open the vault with its passphrase. Unlock, + /// Close the vault. Lock, } #[derive(Subcommand)] pub enum ConnectionCommand { + /// List saved connections with their state and last error. List, + /// Show one saved connection in detail. Show, /// Prepare this device's enrollment public key in the daemon. Prepare, /// Save a verified connection descriptor from JSON. Use - for stdin. Save { + /// The JSON descriptor file, or - for stdin. input: PathBuf, }, /// Replace the selected connection descriptor from JSON. Use - for stdin. Update { + /// The JSON descriptor file, or - for stdin. input: PathBuf, }, - Remove, + /// Remove the selected connection from this computer and delete its device key for the + /// workspace. Asks you to type the connection's name first. A host's only computer is + /// refused unless --give-up-host-controls is added, because nothing restores the host + /// controls afterwards. A host with other enrolled computers is shown them first: the host + /// controls continue only if one of them still has the workspace saved. + Remove { + /// Confirm by typing the connection's name again. Required when there is no terminal + /// to ask in. + #[arg(long, value_name = "NAME")] + confirm: Option, + /// Remove the workspace even from the only computer that can act as its host, which + /// ends the host controls for good. + #[arg(long)] + give_up_host_controls: bool, + }, /// Save a connection from the invitation your host sent. Use - to paste it on stdin. - JoinInvitation(JoinInvitationArgs), + JoinInvitation(Box), /// Print the invitation message to send someone you invited (host). Invitation { /// The invited person, as @username; the message then names them. @@ -229,8 +281,11 @@ pub struct JoinInvitationArgs { /// username@server. The invitation's port and jump host are then not applied. #[arg(long)] pub ssh_target: Option, + /// The server's SSH port. #[arg(long)] pub port: Option, + /// The SSH private key file to sign in with. A relative path or a leading ~/ is made + /// absolute before it is saved. #[arg(long)] pub identity_file: Option, /// A jump route. An empty value means none, even when the invitation suggests one. @@ -246,6 +301,10 @@ pub struct JoinInvitationArgs { /// `biorouter-crew start` printed. #[arg(long)] pub preparation_id: Option, + /// Save in place of a connection this computer already has for the same workspace that + /// signs in as another account and has never connected, removing it and its key. + #[arg(long)] + pub replace: bool, } #[derive(Args)] @@ -257,11 +316,13 @@ pub struct JoinArgs { #[derive(Subcommand)] pub enum WorkspaceCommand { + /// Show the workspace's privacy, people, teams, channels, invitations and agent grants. Show, /// Initialize the workspace using this device's prepared public identity. Bootstrap, /// Rename the workspace (host only): lowercase letters, numbers and dashes. Rename { + /// The new name. name: String, }, } @@ -278,6 +339,7 @@ pub struct SecretInput { #[derive(Subcommand)] pub enum EnrollmentCommand { + /// Prepare this computer's device key (the same as connections prepare). Prepare, /// Invite someone to join the workspace by their username on the server (host). Invite(EnrollInviteArgs), @@ -288,6 +350,7 @@ pub enum EnrollmentCommand { /// The person, as @username. person: String, /// The 16-character code they sent you, like 7QK2-M9XA-3JTP-WZ4D. + #[arg(value_parser = device_code)] code: String, /// Replace a code you already approved for them. #[arg(long)] @@ -353,7 +416,8 @@ pub enum MembersCommand { /// own), or to channels you own. They already joined the workspace, so there is nothing /// for them to accept. #[command( - after_help = "Examples:\n biorouter crew members add @bob --team \"Analysis Lab\" --channel '#methods'\n biorouter crew members add @bob --channel analysis-lab/methods" + after_help = "Examples:\n biorouter crew members add @bob --team \"Analysis Lab\" --channel '#methods'\n biorouter crew members add @bob --channel analysis-lab/methods", + group(ArgGroup::new("place").required(true).multiple(true).args(["team", "channels"])) )] Add { /// The person, as @username. They must already be in the workspace. @@ -371,8 +435,11 @@ pub enum MembersCommand { #[derive(Subcommand)] pub enum TeamCommand { + /// List your teams. List, + /// Create a team, with its #general channel. Create { + /// The team's name. name: String, }, /// Rename a team you created. @@ -386,22 +453,31 @@ pub enum TeamCommand { #[derive(Subcommand)] pub enum ChannelCommand { + /// List your channels, with their classification, owner and unread count. List { /// Only this team's channels. #[arg(long)] team: Option, }, + /// Create a channel in a team. Create { + /// The channel's name; it is saved lowercase with dashes. name: String, /// The team: its name or handle. #[arg(long)] team: String, + /// Restricted channels are read only by private models. #[arg(long, value_enum, default_value = "restricted")] classification: Classification, }, + /// Archive a channel you own, for everyone. Nobody can post in it again, and it cannot be + /// undone. Asks first; add --yes where there is no terminal to ask in. Archive { /// The channel: methods, '#methods' or analysis-lab/methods. channel: String, + /// Archive without asking. Needed when there is no terminal to ask in. + #[arg(long)] + yes: bool, }, /// Mark a channel read, up to its newest message or to an opaque message cursor. MarkRead { @@ -421,6 +497,7 @@ pub enum ChannelCommand { #[derive(Subcommand)] pub enum InvitationCommand { + /// List the invitations you sent or received. List, /// Invite a member to a team you created or a channel you own. Create { @@ -443,8 +520,11 @@ pub enum InvitationCommand { #[derive(Subcommand)] pub enum ProfileCommand { + /// Show your name and enrolled computers. Show, + /// Set your display name, and optionally an avatar. Set { + /// Your display name. nickname: String, /// Emoji or initials, up to the workspace's supported length. #[arg(long)] @@ -472,10 +552,13 @@ pub enum OwnershipCommand { pub struct HistoryArgs { /// The channel: methods, '#methods' or analysis-lab/methods. pub channel: String, + /// Read the page before this message cursor. #[arg(long, conflicts_with = "after")] pub before: Option, + /// Read the page after this message cursor. #[arg(long, conflicts_with = "before")] pub after: Option, + /// How many messages to read, 1 to 200. #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=200))] pub limit: u16, /// Read the newest matching page. @@ -487,12 +570,20 @@ pub struct HistoryArgs { pub struct WatchArgs { /// The channel: methods, '#methods' or analysis-lab/methods. pub channel: String, - #[arg(long)] + /// Start after this message cursor. + #[arg(long, conflicts_with_all = ["from_start", "new_only"])] pub after: Option, + /// Replay the channel from its oldest message first. + #[arg(long, conflicts_with = "new_only")] + pub from_start: bool, + /// Print only messages posted from now on. + #[arg(long)] + pub new_only: bool, } #[derive(Args)] pub struct TextInput { + /// The text itself. #[arg(long, conflicts_with = "input", required_unless_present = "input")] pub text: Option, /// Read UTF-8 content from a file, or - for stdin. @@ -501,55 +592,81 @@ pub struct TextInput { } #[derive(Args)] +#[command(group( + ArgGroup::new("content") + .required(true) + .multiple(true) + .args(["text", "input", "attachments", "references"]) +))] pub struct SendArgs { /// The channel: methods, '#methods' or analysis-lab/methods. pub channel: String, + /// The message text. #[arg(long, conflicts_with = "input")] pub text: Option, /// Read UTF-8 content from a file, or - for stdin. #[arg(long, conflicts_with = "text")] pub input: Option, - #[arg(long = "attachment")] + /// An uploaded file's attachment ID. Repeat for more. + #[arg(long = "attachment", value_name = "ATTACHMENT_ID")] pub attachments: Vec, - #[arg(long = "reference")] + /// A remote reference's ID. Repeat for more. + #[arg(long = "reference", value_name = "REFERENCE_ID")] pub references: Vec, } #[derive(Subcommand)] pub enum FileCommand { + /// Upload a file to a channel. It is posted only when you send it with --attachment. Upload { /// The channel: methods, '#methods' or analysis-lab/methods. channel: String, + /// The file to upload. file: PathBuf, }, + /// Resume a paused or interrupted transfer with its original file. Resume { + /// The transfer's ID. transfer: String, + /// The original file to upload, or the original download destination. file: PathBuf, + /// Allow replacing the download destination. #[arg(long)] overwrite: bool, }, + /// Show one transfer. Status { + /// The transfer's ID. transfer: String, }, /// Follow daemon transfer progress; Ctrl-C leaves the transfer running. Watch { + /// The transfer's ID. transfer: String, }, + /// List this connection's transfers. Pending, + /// Pause a running transfer. Pause { + /// The transfer's ID. transfer: String, }, /// Remove a transfer receipt, cleaning up its owned partial download when needed. Forget { + /// The transfer's ID. transfer: String, /// Original destination for an incomplete download. Published files are retained. #[arg(long)] file: Option, }, + /// Download a shared file. Download { + /// The file's attachment ID, which history --show-ids prints beside its name. blob: String, + /// Where to save it. #[arg(long)] output: PathBuf, + /// Allow replacing an existing file there. #[arg(long)] overwrite: bool, }, @@ -557,49 +674,72 @@ pub enum FileCommand { Reference { /// The channel: methods, '#methods' or analysis-lab/methods. channel: String, + /// The path on the server. path: String, + /// The name people see for it. #[arg(long)] label: String, }, + /// Show one remote reference. ShowReference { + /// The reference's ID. reference: String, }, + /// Show a shared file by its attachment ID: its name, size, type and channel. + Show { + /// The file's attachment ID, which history --show-ids prints beside its name. + attachment: String, + }, } #[derive(Subcommand)] pub enum TaskCommand { + /// Start your own agent on a prompt; it reads the channel and posts its result there. + #[command(group(ArgGroup::new("posting").required(true).args(["allow_posting"])))] Start { /// The channel the task posts to: methods, '#methods' or analysis-lab/methods. channel: String, #[command(flatten)] prompt: TextInput, + /// The provider's name in your Biorouter configuration. #[arg(long)] provider: String, + /// The model to run. #[arg(long)] model: String, /// Another channel the task may read. Repeat for more. #[arg(long = "context-channel")] context_channels: Vec, - /// Allow this owned task to publish results to its destination channel. + /// Allow this owned task to publish results to its destination channel. Required. #[arg(long)] allow_posting: bool, }, + /// List your tasks on this connection. List, + /// Show one task. Show { + /// The task's ID. run: String, }, + /// Follow a task until it ends; Ctrl-C stops watching, not the task. Watch { + /// The task's ID. run: String, }, + /// Stop a task and revoke its access. Cancel { + /// The task's ID. run: String, }, } #[derive(Subcommand)] pub enum GrantCommand { + /// List every chat and task with Crew access, its state and time left. List, + /// Give an existing chat access to a channel. Grant { + /// The chat's session ID (biorouter session list shows it). session: String, /// The channel the chat may read and post in: methods, '#methods' or /// analysis-lab/methods. @@ -610,29 +750,60 @@ pub enum GrantCommand { }, /// Stop a chat or task using Crew. Exits non-zero unless the workspace confirmed it. Revoke { + /// The chat's session ID. session: String, }, } #[derive(Subcommand)] pub enum PrivacyCommand { + /// Show the privacy, institution and policy epoch of your connection and the workspace. Show, + /// Choose how this computer treats the workspace. Making it public asks you to type the + /// workspace's name first. SetPersonal { + /// private or public. #[arg(value_enum)] mode: PrivacyMode, /// Canonical institution ID for this SSH cluster, required for a new Private label. #[arg(long = "institution")] institution_id: Option, + /// Confirm public by typing the workspace's name again. Required when there is no + /// terminal to ask in. + #[arg(long, value_name = "WORKSPACE")] + confirm: Option, }, + /// Change the workspace's privacy for everyone (host). It ends every agent grant. Allowing + /// Public asks you to type the workspace's name first. SetWorkspace { + /// private or public. #[arg(value_enum)] mode: PrivacyMode, /// Confirm the immutable workspace institution as its authorized host. #[arg(long = "institution")] institution_id: Option, + /// Confirm public by typing the workspace's name again. Required when there is no + /// terminal to ask in. + #[arg(long, value_name = "WORKSPACE")] + confirm: Option, }, } +/// A device code as the joiner sent it: 16 letters and digits, whatever separates them. Checked +/// while the command line is read, so a mistyped code is refused before anything is asked or +/// sent. The broker applies the full Crockford normalization and refuses what it cannot read. +pub(super) fn device_code(code: &str) -> Result { + let bare: Vec = code.chars().filter(|c| c.is_alphanumeric()).collect(); + if bare.len() == 16 && bare.iter().all(char::is_ascii_alphanumeric) { + Ok(code.to_owned()) + } else { + Err( + "A code has 16 letters and digits, like 7QK2-M9XA-3JTP-WZ4D. Copy it exactly as they sent it." + .to_owned(), + ) + } +} + #[cfg(test)] mod tests { use super::*; @@ -764,7 +935,7 @@ mod tests { }; assert_eq!(args.channel, channel); - let CrewCommand::Channels(ChannelCommand::Archive { channel: got }) = + let CrewCommand::Channels(ChannelCommand::Archive { channel: got, .. }) = parse(&["channels", "archive", channel]).command else { panic!("archive") @@ -857,6 +1028,29 @@ mod tests { } } + #[test] + fn watch_takes_one_starting_point() { + let CrewCommand::Watch(args) = parse(&["watch", "methods", "--new-only"]).command else { + panic!("watch") + }; + assert!(args.new_only && !args.from_start && args.after.is_none()); + assert!(refused(&["watch", "methods", "--new-only", "--from-start"])); + assert!(refused(&[ + "watch", + "methods", + "--after", + "m-1", + "--from-start" + ])); + assert!(refused(&[ + "watch", + "methods", + "--after", + "m-1", + "--new-only" + ])); + } + #[test] fn mark_read_takes_an_optional_cursor() { let CrewCommand::Channels(ChannelCommand::MarkRead { channel, cursor }) = @@ -891,6 +1085,7 @@ mod tests { channel, member, former, + .. } = parse(&["remove-member", "#methods", person]).command else { panic!("remove-member") @@ -1140,6 +1335,97 @@ mod tests { assert!(refused(&["enroll", "invite", "@bob", "--public-key", "k"])); } + /// CLI-17: `--help` is where the manual sends people, so every command and group says what + /// it does, and every positional argument what it takes. + #[test] + fn every_command_and_positional_argument_has_help_text() { + fn check(command: &clap::Command, path: &str, missing: &mut Vec) { + for sub in command.get_subcommands().filter(|sub| !sub.is_hide_set()) { + let name = format!("{path} {}", sub.get_name()); + if sub.get_about().is_none() && sub.get_long_about().is_none() { + missing.push(name.clone()); + } + for arg in sub.get_positionals().filter(|arg| !arg.is_hide_set()) { + if arg.get_help().is_none() && arg.get_long_help().is_none() { + missing.push(format!("{name} <{}>", arg.get_id())); + } + } + check(sub, &name, missing); + } + } + let mut missing = Vec::new(); + check(&TestCli::command(), "crew", &mut missing); + assert!(missing.is_empty(), "no help text: {missing:#?}"); + + let mut command = TestCli::command(); + let remove = command + .find_subcommand_mut("connections") + .and_then(|connections| connections.find_subcommand_mut("remove")) + .expect("connections remove"); + let help = remove.render_long_help().to_string(); + assert!(help.contains("device key"), "{help}"); + } + + /// CLI-14: a wrong command line is refused while it is read, with clap's usage status 2, + /// before any approval secret is asked for or a daemon is started. + #[test] + fn usage_mistakes_are_refused_before_anything_is_asked() { + let usage = |args: &[&str]| { + let mut argv = vec!["crew"]; + argv.extend_from_slice(args); + match TestCli::try_parse_from(argv) { + Ok(_) => panic!("{args:?} should be refused"), + Err(error) => error, + } + }; + for args in [ + &[ + "tasks", + "start", + "methods", + "--text", + "hi", + "--provider", + "p", + "--model", + "m", + ][..], + &["members", "add", "@bob"], + &["send", "methods"], + &["enroll", "approve", "@bob", "7QK2-M9XA"], + &["enroll", "approve", "@bob", "7QK2-M9XA-3JTP-WZ4\u{0414}"], + ] { + let error = usage(args); + assert_eq!(error.exit_code(), 2, "{args:?}: {error}"); + } + let code = usage(&["enroll", "approve", "@bob", "7QK2-M9XA"]).to_string(); + assert!(code.contains("A code has 16 letters and digits"), "{code}"); + assert!(usage(&[ + "tasks", + "start", + "methods", + "--text", + "hi", + "--provider", + "p", + "--model", + "m" + ]) + .to_string() + .contains("--allow-posting")); + + for args in [ + &["send", "methods", "--attachment", ID][..], + &["send", "methods", "--reference", ID], + &["send", "methods", "--input", "-"], + &["members", "add", "@bob", "--channel", "methods"], + &["members", "add", "@bob", "--team", "lab"], + &["enroll", "approve", "@bob", "7qk2 m9xa 3jtp wz4d"], + ] { + parse(args); + } + } + #[test] fn the_deprecated_forms_are_hidden_from_help() { let mut command = TestCli::command(); diff --git a/crates/biorouter-cli/src/commands/crew/files.rs b/crates/biorouter-cli/src/commands/crew/files.rs index ab5f10913..013024c4f 100644 --- a/crates/biorouter-cli/src/commands/crew/files.rs +++ b/crates/biorouter-cli/src/commands/crew/files.rs @@ -1,6 +1,6 @@ use super::{ args::FileCommand, - output::{component, emit, stream_format}, + output::{component, emit_with, stream_format, HumanOptions}, Api, }; use anyhow::{ensure, Context, Result}; @@ -59,6 +59,15 @@ pub(super) async fn handle(api: &Api, command: FileCommand) -> Result { api.broker("reference.get", json!({"reference_id":reference}), false) .await } + // DW-17: which file an attachment ID is, before downloading it. + FileCommand::Show { attachment } => { + api.broker( + "blob.status", + json!({"blob_id": component(&attachment)?}), + false, + ) + .await + } } } @@ -81,6 +90,7 @@ async fn upload(api: &Api, channel: &str, file: &Path) -> Result { } async fn download(api: &Api, blob: &str, output: &Path, overwrite: bool) -> Result { + not_a_folder(output)?; let connection = api.connection_id().await?; let record = if let Some(previous) = existing_request(api).await? { verify_request_scope(&previous, &connection, None, "download", Some(blob))?; @@ -183,7 +193,8 @@ async fn receipt(api: &Api, id: &str) -> Result { let receipt = api .client .request("GET", &format!("/crew/transfers/{}", component(id)?), None) - .await?; + .await + .map_err(unknown_transfer)?; if let Some(selected) = &api.selected { ensure!( receipt["connection_id"].as_str() == Some(selected.as_str()), @@ -193,6 +204,33 @@ async fn receipt(api: &Api, id: &str) -> Result { Ok(receipt) } +/// What `files status`, `resume`, `pause`, `forget` and `watch` say for an ID this computer has +/// no receipt for (CLIDOCS-F6): the daemon's bare "Unknown transfer" says neither whose list +/// nor what to check. +pub(super) const UNKNOWN_TRANSFER: &str = + "No transfer with that ID on this computer. Run biorouter crew files pending to see them."; + +/// The daemon's refusal of a transfer ID it has no receipt for, said as [`UNKNOWN_TRANSFER`]; +/// the refusal stays its source, so JSON keeps the daemon's code. Anything else is left as it is. +fn unknown_transfer(error: anyhow::Error) -> anyhow::Error { + let unknown = error.chain().any(|cause| { + super::refusal_message(cause).is_some_and(|message| { + message + .trim() + .trim_end_matches('.') + .eq_ignore_ascii_case("unknown transfer") + }) + }); + if !unknown { + return error; + } + super::Worded { + sentence: UNKNOWN_TRANSFER.to_owned(), + source: error, + } + .into() +} + async fn forget(api: &Api, id: &str, file: Option<&Path>) -> Result { let receipt = receipt(api, id).await?; let direction = receipt["direction"] @@ -249,6 +287,26 @@ async fn forget(api: &Api, id: &str, file: Option<&Path>) -> Result { .await } +/// `files download --output` names the file to save, never a folder (FILES-F9). A folder, or +/// a path ending in a separator, is refused before anything is asked of the daemon, which could +/// only answer that the destination exists, and suggest `--overwrite`, which cannot help. +fn not_a_folder(output: &Path) -> Result<()> { + let text = output.to_string_lossy(); + let folder = output.is_dir() || text.ends_with(std::path::is_separator); + if folder { + let example = output.join("counts.csv"); + return Err(super::restated( + format!( + "--output {} is a folder. Name the file to save, like --output {}.", + super::output::safe_text(&text), + super::output::safe_text(&example.to_string_lossy()) + ), + Some("crew_output_is_a_folder"), + )); + } + Ok(()) +} + fn absolute_path(path: &Path) -> Result { let path = if path.is_absolute() { path.to_owned() @@ -290,7 +348,14 @@ async fn register( .to_string()) } +/// How `files watch` prints each receipt: named from the workspace snapshot, read once, and +/// with IDs when `--show-ids` asks, as `files status` prints the same receipt (DW-07). +pub(super) async fn watch_options(api: &Api) -> HumanOptions { + api.human(api.names().await) +} + async fn watch(api: &Api, id: &str) -> Result { + let options = watch_options(api).await; let mut previous = Value::Null; loop { let current = tokio::select! { @@ -298,13 +363,15 @@ async fn watch(api: &Api, id: &str) -> Result { signal = tokio::signal::ctrl_c() => { signal?; return Ok(json!({"detached":true,"transfer_id":id})); } }; if current != previous { - emit(¤t, stream_format(api.format))?; + emit_with(¤t, stream_format(api.format), &options)?; } if !matches!( current["state"].as_str(), Some("starting" | "uploading" | "downloading" | "publishing" | "pause_requested") ) { - return Ok(json!({"transfer_id":id,"state":current["state"]})); + return Ok( + json!({"transfer_id":id,"state":current["state"],"direction":current["direction"]}), + ); } previous = current; tokio::select! { @@ -313,3 +380,29 @@ async fn watch(api: &Api, id: &str) -> Result { } } } + +#[cfg(test)] +mod tests { + use super::not_a_folder; + use crate::daemon_client::Restated; + + /// FILES-F9: a folder is refused before the daemon is asked, in words that say what to do. + #[test] + fn a_download_to_a_folder_is_refused_before_the_daemon_is_asked() { + let folder = tempfile::tempdir().expect("a folder"); + let with_separator = format!("{}{}", folder.path().display(), std::path::MAIN_SEPARATOR); + for output in [folder.path().to_path_buf(), with_separator.into()] { + let error = not_a_folder(&output).expect_err("a folder is refused"); + let refused = error.downcast_ref::().expect("said for a person"); + assert_eq!(refused.code, Some("crew_output_is_a_folder")); + let text = error.to_string(); + assert!( + text.contains("is a folder. Name the file to save"), + "{text}" + ); + assert!(!text.contains("--overwrite"), "{text}"); + } + assert!(not_a_folder(&folder.path().join("counts.csv")).is_ok()); + assert!(not_a_folder(std::path::Path::new("./counts.csv")).is_ok()); + } +} diff --git a/crates/biorouter-cli/src/commands/crew/mod.rs b/crates/biorouter-cli/src/commands/crew/mod.rs index 78675122a..63c2dd78e 100644 --- a/crates/biorouter-cli/src/commands/crew/mod.rs +++ b/crates/biorouter-cli/src/commands/crew/mod.rs @@ -1,7 +1,7 @@ //! `biorouter crew`: the terminal's view of Crew, through the profile's shared daemon. //! //! People, teams, channels and saved connections are named, not numbered ("Selectors and the -//! resolver" in `docs/research/biorouter-crew/naming-design.md`). Every name is resolved by the +//! resolver" in `docs/crew/design/naming-design.md`). Every name is resolved by the //! daemon's `POST /crew/resolve` against the person's own workspace snapshot, so the CLI and the //! desktop share one resolver and a candidate can never be something the person could not see. //! UUID-shaped text is always an ID and never goes to the resolver, so scripts that pass IDs work @@ -13,7 +13,8 @@ mod args; mod files; mod output; -use crate::daemon_client::{CrewClient, DaemonRefusal}; +use crate::commands::needs_terminal::{self, NeedsTerminal}; +use crate::daemon_client::{CrewClient, DaemonRefusal, NotSent, Restated}; use anyhow::{anyhow, bail, ensure, Context, Result}; pub use args::CrewOptions; use args::*; @@ -39,6 +40,8 @@ const RESTART_FOR_JOINING: &str = const STOPPED_ON_THIS_DEVICE: &str = "Stopped on this device. The workspace hasn't confirmed the revocation yet; Biorouter confirms it with the workspace by itself when the connection is back. biorouter crew grants list shows when it has."; /// How often `crew join` asks where joining stands, as the desktop's join screen does. const JOIN_POLL: Duration = Duration::from_secs(5); +/// How long a listing waits for a workspace to answer before it says what it could not check. +const LISTING_WAIT: Duration = Duration::from_secs(10); pub async fn handle(mut options: CrewOptions) -> Result<()> { let format = options.output_format; @@ -47,18 +50,40 @@ pub async fn handle(mut options: CrewOptions) -> Result<()> { .clone() .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); options.request_id = Some(request_id.clone()); + // A streamed command's failure is its last line, so it is one JSON value like the rest. + let format = if streams(&options.command) { + output::stream_format(format) + } else { + format + }; let sent = Arc::new(AtomicBool::new(false)); execute(options, Arc::clone(&sent)) .await .map_err(|error| failure(&error, format, &request_id, sent.load(Ordering::SeqCst))) } +/// The commands that print as they go, one JSON value per line in both JSON formats (the +/// contract `docs/crew/command-line.md` states): their last value and their failure are one +/// line too. +fn streams(command: &CrewCommand) -> bool { + matches!( + command, + CrewCommand::Watch(_) + | CrewCommand::Join(_) + | CrewCommand::Tasks(TaskCommand::Watch { .. }) + | CrewCommand::Files(FileCommand::Watch { .. }) + ) +} + /// The error a failed command exits with. /// /// Each line is made terminal-safe on its own, so a list of candidates stays a list. The request /// ID is a machine ID, so it appears only where it is useful: after a mutation that carried it /// was sent and its outcome is unknown, as the one retry that is safe. JSON output always /// carries it, and the daemon's refusal code when there is one. +/// +/// A command that needed a person at a terminal and had none keeps that type +/// ([`NeedsTerminal`]), so it exits with the usage status 2 like every other such refusal. fn failure( error: &anyhow::Error, format: OutputFormat, @@ -66,34 +91,241 @@ fn failure( sent: bool, ) -> anyhow::Error { let message = safe_lines(&error_text(error)); - if matches!(format, OutputFormat::Json | OutputFormat::StreamJson) { - let mut body = json!({"error": message, "request_id": request_id}); - if let Some(code) = error_code(error) { - body["code"] = json!(code); - } - if let Some((broker_code, _)) = error.chain().find_map(broker_refusal) { - body["broker_code"] = json!(broker_code); - } - let _ = emit(&body, format); + if let Some(line) = failure_json(error, &message, request_id, format) { + // A closed standard output (`| head`) must not turn a failure into a panic, so the + // write's own error is ignored, as it always was here. + let mut stdout = std::io::stdout().lock(); + let _ = writeln!(stdout, "{line}"); + let _ = stdout.flush(); } - if sent && outcome_uncertain(error) { + if error + .chain() + .any(|cause| cause.is::() || cause.is::()) + { + needs_a_terminal(message) + } else if sent && outcome_uncertain(error) { anyhow!("{message}\n{}", output::retry_hint(request_id)) } else { anyhow!("{message}") } } +/// What a failure prints to standard output in a JSON format: [`failure_body`], indented for +/// `json` and on one line for `stream-json` (the format a streamed command's failure uses). +fn failure_json( + error: &anyhow::Error, + message: &str, + request_id: &str, + format: OutputFormat, +) -> Option { + if matches!(format, OutputFormat::Text) { + return None; + } + output::formatted( + &failure_body(error, message, request_id), + format, + &HumanOptions::default(), + ) + .ok() +} + +/// A failure in JSON: the message, the request ID, and the codes a script can match on. A +/// watch the daemon ended is also the observer's error frame (`type`, its `code` and `clear`), +/// so a script reading lines gets one value for it, not a frame and then a second error. +fn failure_body(error: &anyhow::Error, message: &str, request_id: &str) -> Value { + let mut body = json!({"error": message, "request_id": request_id}); + if let Some(code) = error_code(error) { + body["code"] = json!(code); + } else if error.chain().any(|cause| cause.is::()) { + body["code"] = json!(NEEDS_TERMINAL_CODE); + } + if let Some((broker_code, _)) = error.chain().find_map(broker_refusal) { + body["broker_code"] = json!(broker_code); + } + // OpenSSH's words beside a refused connect, or any other refusal's diagnostic `detail` + // (the unreadable-registry refusal keeps its parse error there, AGT2-N6): for a script or + // support, never in text. + let detail = connect_detail(error).or_else(|| { + error + .chain() + .find_map(|cause| refusal_code_and_detail(cause).and_then(|(_, detail)| detail)) + .map(str::to_owned) + }); + if let Some(detail) = detail { + body["detail"] = json!(detail); + } + // AGT2-N5: the daemon's other machine-readable fields (`actual_mode`, `expected_mode`, + // `institution_refusal`, `reason`, `connection_id`, `host`, `candidates`, …), so a script + // reads which mode or institution failed without parsing the sentence. What the CLI wrote + // above is never replaced. + if let Some(fields) = error.chain().find_map(refusal_fields) { + for (key, value) in fields { + if body.get(key).is_none() { + body[key.as_str()] = value.clone(); + } + } + } + if let Some(stopped) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + body["type"] = json!("error"); + body["code"] = json!(stopped.code); + body["clear"] = json!(stopped.clear); + } + body +} + +/// A failure already said in words for a person, naming what the command knew about it +/// ([`Api::worded`]). The failure it words is its source, so JSON output keeps the daemon's and +/// the broker's codes; text output prints only the words. +#[derive(Debug)] +struct Worded { + sentence: String, + source: anyhow::Error, +} + +impl std::fmt::Display for Worded { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.sentence) + } +} + +impl std::error::Error for Worded { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.source.as_ref()) + } +} + +/// A watch the daemon's observer ended: the sentence for a person, and the observer's code +/// and `clear` for JSON output. +#[derive(Debug)] +struct WatchStopped { + message: String, + code: String, + clear: bool, +} + +impl std::fmt::Display for WatchStopped { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for WatchStopped {} + +/// The JSON code of a command that needed a person at a terminal and had none. +const NEEDS_TERMINAL_CODE: &str = "crew_needs_terminal"; + +/// A command line refused before anything was sent, with its own code for JSON: several saved +/// connections and no `--connection`, or a message too long to post. It exits with the usage +/// status 2, as the manual promises for a wrong command line (SF2-N6, MSG2-N7). +#[derive(Debug)] +struct Usage { + sentence: String, + code: &'static str, +} + +impl std::fmt::Display for Usage { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.sentence) + } +} + +impl std::error::Error for Usage {} + +fn usage(sentence: impl Into, code: &'static str) -> anyhow::Error { + Usage { + sentence: sentence.into(), + code, + } + .into() +} + +/// The daemon's code for a request that needs a saved connection chosen. +const CONNECTION_REQUIRED: &str = "crew_connection_required"; + +/// The refusal of a command that needs a person at a terminal and was run without one. It +/// exits with the usage status 2: nothing was attempted. +fn needs_a_terminal(sentence: impl Into) -> anyhow::Error { + let sentence = sentence.into(); + match needs_terminal::require(false, &sentence) { + Err(refusal) => refusal.into(), + Ok(()) => anyhow!(sentence), + } +} + /// `{error:#}`, except that a broker refusal the daemon forwarded is said in words for a person /// ([`output::broker_refusal_text`]) instead of as `Daemon returned 400: code: …`. +/// +/// A failed connect with one of the daemon's typed codes (`crew_ssh_auth_required`, …) is said +/// the same way, with what to run, then the code the manual's table is keyed on and OpenSSH's +/// own words (CLI-7), instead of the transport's internal text. Any other daemon refusal with a +/// code is the daemon's own sentence ([`daemon_sentence`]); only one without a code keeps the +/// `Daemon returned NNN:` it is displayed with. fn error_text(error: &anyhow::Error) -> String { + let mut parts = Vec::new(); + for cause in error.chain() { + // Already worded with everything the command knew: what it words is not said again. + if let Some(worded) = cause.downcast_ref::() { + parts.push(worded.sentence.clone()); + break; + } + parts.push(if let Some((code, message)) = broker_refusal(cause) { + output::broker_refusal_text(code, message) + } else if let Some((code, sentence, detail)) = connect_failure(cause) { + connect_failure_lines(code, sentence, detail) + } else if let Some(sentence) = daemon_sentence(cause) { + sentence + } else { + cause.to_string() + }); + } + parts.join(": ") +} + +/// A refused connect the daemon typed: its code, the sentence for it, and OpenSSH's words. +fn connect_failure<'a>( + cause: &'a (dyn std::error::Error + 'static), +) -> Option<(&'a str, &'static str, Option<&'a str>)> { + let (code, detail) = refusal_code_and_detail(cause)?; + Some((code, output::connect_failure_text(code)?, detail)) +} + +/// A daemon refusal's code and `detail`. +fn refusal_code_and_detail<'a>( + cause: &'a (dyn std::error::Error + 'static), +) -> Option<(&'a str, Option<&'a str>)> { + if let Some(refused) = cause.downcast_ref::() { + return Some((refused.kind.as_deref()?, refused.detail())); + } + #[cfg(test)] + if let Some(refused) = cause.downcast_ref::() { + return Some((refused.code.as_deref()?, refused.detail.as_deref())); + } + None +} + +fn connect_failure_lines(code: &str, sentence: &str, detail: Option<&str>) -> String { + let mut lines = vec![sentence.to_owned(), format!(" Code: {code}")]; + if let Some(detail) = detail { + for (index, line) in detail.lines().enumerate() { + lines.push(if index == 0 { + format!(" Details: {line}") + } else { + format!(" {line}") + }); + } + } + lines.join("\n") +} + +/// OpenSSH's own words beside a failed connect, for JSON output. +fn connect_detail(error: &anyhow::Error) -> Option { error .chain() - .map(|cause| match broker_refusal(cause) { - Some((code, message)) => output::broker_refusal_text(code, message), - None => cause.to_string(), - }) - .collect::>() - .join(": ") + .find_map(|cause| connect_failure(cause).and_then(|(_, _, detail)| detail)) + .map(str::to_owned) } /// A broker refusal the daemon forwarded: the broker's code and its own `code: sentence`. @@ -114,14 +346,19 @@ fn broker_refusal<'a>(cause: &'a (dyn std::error::Error + 'static)) -> Option<(& None } +/// Each line made terminal-safe on its own. The CLI's own isolates around a name survive +/// ([`output::safe_text_keeping_isolates`]): the names in these sentences were made safe when +/// they were written, so a second escape would print the isolates as `\u{2068}` text. fn safe_lines(text: &str) -> String { text.split('\n') - .map(|line| safe_text(line.strip_suffix('\r').unwrap_or(line))) + .map(|line| output::safe_text_keeping_isolates(line.strip_suffix('\r').unwrap_or(line))) .collect::>() .join("\n") } async fn execute(options: CrewOptions, sent: Arc) -> Result<()> { + // Before any prompt: nothing here can work without the shared daemon's Unix socket. + crate::daemon_client::require_supported_platform(cfg!(unix))?; let CrewOptions { connection, expected_mode, @@ -182,8 +419,13 @@ async fn execute(options: CrewOptions, sent: Arc) -> Result<()> { interactive: std::io::stdin().is_terminal() && std::io::stderr().is_terminal(), poll: JOIN_POLL, connection_id: tokio::sync::OnceCell::new(), + channel_labels: std::sync::Mutex::default(), }; - run(&api, command).await?.print(api.format) + let reply = match run(&api, command).await { + Ok(reply) => reply, + Err(error) => return Err(api.worded_failure(error).await), + }; + reply.print(api.format) } /// The shared daemon, or a scripted stand-in in tests. Every request goes through @@ -259,7 +501,8 @@ fn refusal(error: &anyhow::Error) -> Option { }) } -/// The code a script can match on: the daemon's, or the one a restated refusal kept. +/// The code a script can match on: the daemon's, the one a restated refusal kept, or +/// [`NotSent::CODE`] for a request the connection never took. fn error_code(error: &anyhow::Error) -> Option { error .chain() @@ -268,41 +511,279 @@ fn error_code(error: &anyhow::Error) -> Option { .downcast_ref::() .and_then(|restated| restated.code.map(str::to_owned)) }) + .or_else(|| { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .map(|usage| usage.code.to_owned()) + }) + .or_else(|| { + error + .chain() + .any(|cause| cause.is::()) + .then(|| NotSent::CODE.to_owned()) + }) .or_else(|| refusal(error).and_then(|refused| refused.code)) } -/// A refusal is a definite answer: nothing changed. A daemon failure or a lost answer is not. +/// The daemon's code for a request whose outcome it could not confirm (W2-DMN-7): the bridge +/// was lost after the request was written, so it may have landed. +const OUTCOME_UNKNOWN: &str = "crew_outcome_unknown"; +/// The daemon's code for a request it never wrote to the workspace. +const NOT_SENT: &str = "crew_not_sent"; +/// The daemon's code for a request it did not send because it is dialling a broken bridge +/// again (W2-DMN-6): nothing was sent, and the same command works in a moment. +const RECONNECTING: &str = "crew_reconnecting"; + +/// Whether the change may have landed although the command failed (R-3), so the one safe retry, +/// the same request ID, is offered. A refusal is a definite answer: nothing changed. So is a +/// request that was never sent, by the daemon ([`NOT_SENT`]) or by the connection here +/// ([`NotSent`]). An outcome the daemon could not confirm ([`OUTCOME_UNKNOWN`]), a daemon +/// failure (5xx) and a lost answer are not. fn outcome_uncertain(error: &anyhow::Error) -> bool { if error .chain() - .any(|cause| cause.downcast_ref::().is_some()) + .any(|cause| cause.is::() || cause.is::() || cause.is::()) { return false; } - refusal(error).is_none_or(|refused| refused.status >= 500) + // The broker says it could not tell whether a change was saved (W2-BRK-3). + if error + .chain() + .find_map(broker_refusal) + .is_some_and(|(code, message)| output::storage_outcome_unknown(code, message)) + { + return true; + } + match refusal(error) { + None => true, + Some(refused) => match refused.code.as_deref() { + Some(OUTCOME_UNKNOWN) => true, + Some(NOT_SENT | RECONNECTING) => false, + _ => refused.status >= 500, + }, + } } -/// A refusal said again for a person, keeping the daemon's code for JSON output. -#[derive(Debug)] -struct Restated { - message: String, - code: Option<&'static str>, +/// A daemon refusal said for a person without the `Daemon returned N:` prefix: an outcome the +/// daemon could not confirm (the retry line follows it), a request it never sent, and the +/// model's institution and privacy-mode refusals (W2-DMN-9), said from their details, naming +/// both sides. Any other institution refusal is the daemon's own sentence ([`is_model_refusal`]). +/// +/// A request the daemon never sent ([`NOT_SENT`]) is its own sentence too (W2-DMN-7). Each one +/// says why nothing was sent, and whether trying again can help: a server that could not be +/// reached may answer later, but an SSH key the server refused is refused every time, so a +/// fixed "run it again" would give advice that cannot work and drop the cause. +/// +/// Every other refusal with a code is the daemon's own sentence too (FILES2-N4, CLIDOCS-F6): +/// `CrewError.error` is written for a person, and a code says the daemon typed it. Where that +/// sentence names something only the desktop has, the terminal's words stand in: the +/// `--overwrite` flag for an existing file, `connect` for a dropped connection, `grants grant` +/// for a chat's ended access. A refusal with no code, or no sentence, is left to its display, +/// `Daemon returned NNN: …`. +fn daemon_sentence(cause: &(dyn std::error::Error + 'static)) -> Option { + if is_disconnected_refusal(cause) { + return Some(NOT_CONNECTED_SENTENCE.to_owned()); + } + let (code, message) = refusal_code_and_message(cause)?; + let own = || { + Some(message.trim()) + .filter(|message| !message.is_empty()) + .map(str::to_owned) + }; + match code { + NOT_SENT => Some(own().unwrap_or_else(|| "Nothing was sent; run it again.".to_owned())), + OUTCOME_UNKNOWN => Some(own().unwrap_or_else(|| { + "Crew couldn't confirm whether this reached the workspace. Check the channel before you retry." + .to_owned() + })), + INSTITUTION_MISMATCH => { + let details = refusal_institution_details(cause); + match details.and_then(|details| details["model"].as_str()) { + Some(model) if is_model_refusal(message, refusal_connection_institution(cause)) => { + Some(output::institution_refusal_text(model, details)) + } + _ => own(), + } + } + MODE_MISMATCH => match refusal_modes(cause) { + Some((actual, expected)) => Some(output::mode_mismatch_text(actual, expected)), + None => own(), + }, + DESTINATION_EXISTS => own().map(|sentence| overwrite_sentence(&sentence)), + GRANT_ENDED => Some(grant_ended_sentence( + refusal_field(cause, "reason") == Some("settings_changed"), + None, + )), + _ => own(), + } } -impl std::fmt::Display for Restated { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(&self.message) +/// The daemon's code for a destination file that exists and was not approved for replacing. +const DESTINATION_EXISTS: &str = "crew_destination_exists"; +/// The daemon's code for a chat whose Crew access ended (T3-BE-7), with `reason` +/// `settings_changed` or `ended`. +const GRANT_ENDED: &str = "crew_grant_ended"; +/// The daemon's code for a connection that is not connected. +const NOT_CONNECTED: &str = "crew_not_connected"; +/// A disconnected connection, when the command could not name it ([`Api::worded_failure`] +/// names it when it can). +const NOT_CONNECTED_SENTENCE: &str = + "This connection is disconnected. Run biorouter crew connect, then try again."; +/// The daemon's sentence for a disconnected connection, which a daemon before T3-BE-3 sent +/// untyped (`crew_request_refused`, 400) and a current one sends as [`NOT_CONNECTED`]. It is +/// written for the desktop ("connect in Crew"), so the terminal says its own. +const LEGACY_DISCONNECTED: &str = + "Crew connection is disconnected; authenticate and connect in Crew"; + +/// Whether `cause` is the daemon's refusal of a request on a disconnected connection: its +/// code, or an older daemon's untyped sentence for it. +fn is_disconnected_refusal(cause: &(dyn std::error::Error + 'static)) -> bool { + match refusal_code_and_message(cause) { + Some((NOT_CONNECTED, _)) => true, + Some((_, message)) => message.trim().trim_end_matches('.') == LEGACY_DISCONNECTED, + None => refusal_message(cause) + .is_some_and(|message| message.trim().trim_end_matches('.') == LEGACY_DISCONNECTED), } } -impl std::error::Error for Restated {} +/// Whether `error` is a refusal because its connection is disconnected. +fn disconnected(error: &anyhow::Error) -> bool { + error.chain().any(is_disconnected_refusal) +} -fn restated(message: impl Into, code: Option<&'static str>) -> anyhow::Error { - Restated { - message: message.into(), - code, +/// An existing file's refusal with the flag that replaces it (FILES2-N4): the desktop's +/// "Replace it" is a button this terminal does not have. +fn overwrite_sentence(sentence: &str) -> String { + const DESKTOP: &str = "Replace it, or choose another name."; + const TERMINAL: &str = "Add --overwrite to replace it, or save it under another name."; + match sentence.strip_suffix(DESKTOP) { + Some(lead) => format!("{lead}{TERMINAL}"), + None => format!("{sentence} Add --overwrite to replace it."), } - .into() +} + +/// A chat whose Crew access ended (SF2-N4): why, when the daemon said, and the command that +/// grants it again, naming the chat and channel when the command knows them. +fn grant_ended_sentence(settings_changed: bool, grant: Option<(&str, &str)>) -> String { + let why = if settings_changed { + "This chat's Crew access ended because Crew settings changed." + } else { + "This chat's Crew access ended." + }; + let (session, channel) = grant.unwrap_or(("SESSION", "CHANNEL")); + format!("{why} Run biorouter crew grants grant {session} {channel} to grant it again.") +} + +/// The daemon's code for an institution refusal on any route (W2-DMN-9 a). +const INSTITUTION_MISMATCH: &str = "crew_institution_mismatch"; +/// The daemon's code for a request whose `--expected-mode` is not the connection's (W2-DMN-9 e). +const MODE_MISMATCH: &str = "crew_mode_mismatch"; + +/// An institution refusal's details: the model, who approved it, the workspace and its +/// institution. +fn refusal_institution_details<'a>( + cause: &'a (dyn std::error::Error + 'static), +) -> Option<&'a Value> { + if let Some(refused) = cause.downcast_ref::() { + return refused.institution_refusal.as_ref(); + } + #[cfg(test)] + if let Some(refused) = cause.downcast_ref::() { + return refused.institution_refusal.as_ref(); + } + None +} + +/// The connection's institution beside an institution refusal about the connection, not the +/// model. +fn refusal_connection_institution<'a>( + cause: &'a (dyn std::error::Error + 'static), +) -> Option<&'a str> { + if let Some(refused) = cause.downcast_ref::() { + return refused.connection_institution.as_deref(); + } + #[cfg(test)] + if let Some(refused) = cause.downcast_ref::() { + return refused.connection_institution.as_deref(); + } + None +} + +/// Whether an institution refusal is the model refusal (`check_provider`'s "…the model's +/// resolved affiliation…"), which is said from its details, naming the model and whom it is +/// approved for. Admission's refusal of a connection set to another institution ("This +/// connection is for stanford, but lab belongs to ucsf.") carries the same details, but +/// `connection_institution` beside them marks it: it refuses before any model is judged, so a +/// sentence about the model would contradict itself and advise a local model that is refused +/// too. That one is the daemon's own sentence, as the desktop shows it. +fn is_model_refusal(message: &str, connection_institution: Option<&str>) -> bool { + message.contains(AFFILIATION_REFUSAL) || connection_institution.is_none() +} + +/// A privacy-mode refusal's connection mode and the mode the request required. +fn refusal_modes<'a>(cause: &'a (dyn std::error::Error + 'static)) -> Option<(&'a str, &'a str)> { + if let Some(refused) = cause.downcast_ref::() { + return refused.modes(); + } + #[cfg(test)] + if let Some(refused) = cause.downcast_ref::() { + return refused + .modes + .as_ref() + .map(|(actual, expected)| (actual.as_str(), expected.as_str())); + } + None +} + +/// A daemon refusal's code and its own text. +fn refusal_code_and_message<'a>( + cause: &'a (dyn std::error::Error + 'static), +) -> Option<(&'a str, &'a str)> { + if let Some(refused) = cause.downcast_ref::() { + return Some((refused.kind.as_deref()?, refused.message())); + } + #[cfg(test)] + if let Some(refused) = cause.downcast_ref::() { + return Some((refused.code.as_deref()?, refused.message.as_str())); + } + None +} + +/// A daemon refusal's own text, with a code or without one. +fn refusal_message<'a>(cause: &'a (dyn std::error::Error + 'static)) -> Option<&'a str> { + if let Some(refused) = cause.downcast_ref::() { + return Some(refused.message()); + } + #[cfg(test)] + if let Some(refused) = cause.downcast_ref::() { + return Some(refused.message.as_str()); + } + None +} + +/// The daemon refusal's other fields ([`crate::daemon_client::REFUSAL_FIELDS`]). +fn refusal_fields<'a>( + cause: &'a (dyn std::error::Error + 'static), +) -> Option<&'a serde_json::Map> { + if let Some(refused) = cause.downcast_ref::() { + return Some(refused.fields()); + } + #[cfg(test)] + if let Some(refused) = cause.downcast_ref::() { + return Some(&refused.fields); + } + None +} + +/// One of the daemon refusal's other fields as text, such as `reason`. +fn refusal_field<'a>(cause: &'a (dyn std::error::Error + 'static), key: &str) -> Option<&'a str> { + refusal_fields(cause)?.get(key)?.as_str() +} + +/// A refusal said again for a person, keeping the daemon's code for JSON output. +fn restated(message: impl Into, code: Option<&'static str>) -> anyhow::Error { + Restated::new(message, code).into() } /// A route this CLI needs that the running daemon does not have: a bare 404 (or a 405 where the @@ -418,19 +899,50 @@ struct Target { username: Option, } -/// One resolution from `POST /crew/resolve`, or the sentence explaining why it did not resolve. -fn target_from(kind: Kind, text: &str, result: &Value) -> std::result::Result { +/// Why a selector did not resolve: the code a script matches on (the resolver's own), and the +/// sentence for a person. +type Unresolved = (&'static str, String); + +/// The code of a selector the resolver answered with something this command cannot use. +const LOOKUP_FAILED: &str = "crew_lookup_failed"; + +/// The status the resolver appends to an archived channel's label (`with_archived` in +/// `routes/crew/names.rs`), which a list shows beside the name. +const ARCHIVED_LABEL_SUFFIX: &str = " · archived"; + +/// A team's or channel's label as a sentence names it (MSG2-N5): `#archive-me`, not +/// `#archive-me · archived`. A sentence that needs the status says it in words ("… is +/// archived"), and the separator is not ASCII, so kept it would also isolate the whole label. +fn bare_label(label: &str) -> &str { + let label = label.trim(); + label.strip_suffix(ARCHIVED_LABEL_SUFFIX).unwrap_or(label) +} + +/// One resolution from `POST /crew/resolve`, or why it did not resolve. +/// +/// A name no channel or team of yours has is said neutrally (M12): "No channel you're in is +/// called #x." is as true of a typo as of a channel renamed or left, where "You're not in a +/// channel called #x" read as a membership problem. +fn target_from(kind: Kind, text: &str, result: &Value) -> std::result::Result { let shown = kind.shown(text); match result["status"].as_str() { Some("resolved") if result["kind"].as_str() == Some(kind.wire()) => { let id = result["id"] .as_str() .and_then(|id| component(id).ok()) - .ok_or_else(|| format!("Biorouter couldn't look up {shown}."))?; + .ok_or_else(|| { + ( + LOOKUP_FAILED, + format!("Biorouter couldn't look up {shown}."), + ) + })?; let is_person = matches!(kind, Kind::Person | Kind::FormerPerson); Ok(Target { id: id.to_owned(), - label: result["label"].as_str().map(str::to_owned), + label: result["label"].as_str().map(|label| match kind { + Kind::Channel | Kind::Team => bare_label(label).to_owned(), + Kind::Person | Kind::FormerPerson => label.to_owned(), + }), username: result["username"] .as_str() .filter(|_| is_person) @@ -441,15 +953,15 @@ fn target_from(kind: Kind, text: &str, result: &Value) -> std::result::Result format!("There's no member {shown} in this workspace."), Kind::FormerPerson => format!("There's no former member {shown} here."), - Kind::Team => format!("You're not in a team called {shown}."), - Kind::Channel => format!("You're not in a channel called {shown}."), + Kind::Team => format!("No team you're in is called {shown}."), + Kind::Channel => format!("No channel you're in is called {shown}."), }; if let Some(suggestion) = result["did_you_mean"].as_str() { message.push_str(&format!( " Did you mean {suggestion}? Usernames must match exactly." )); } - Err(message) + Err(("unknown_name", message)) } Some("ambiguous_name") => { let mut lines = vec![format!("{shown} matches more than one {}:", kind.noun())]; @@ -474,9 +986,12 @@ fn target_from(kind: Kind, text: &str, result: &Value) -> std::result::Result Err(format!("Biorouter couldn't look up {shown}.")), + _ => Err(( + LOOKUP_FAILED, + format!("Biorouter couldn't look up {shown}."), + )), } } @@ -489,12 +1004,18 @@ enum Reply { Say(Value, Vec), /// Printed as it happened (watchers, joining). Streamed, + /// The last value of a streamed command, printed like the lines before it: one JSON value + /// per line in both JSON formats. + Last(Value, Box), } impl Reply { fn print(self, format: OutputFormat) -> Result<()> { match self { Self::Show(value, options) => emit_with(&value, format, &options), + Self::Last(value, options) => { + emit_with(&value, output::stream_format(format), &options) + } Self::Say(value, lines) => match format { OutputFormat::Text => print_lines(&lines), OutputFormat::Json | OutputFormat::StreamJson => emit(&value, format), @@ -518,20 +1039,24 @@ async fn run(api: &Api, command: CrewCommand) -> Result { CrewCommand::Daemon(_) | CrewCommand::Credentials(_) => { bail!("Daemon and credential commands run before connecting") } - CrewCommand::Status => api.show(api.connections().await?), + CrewCommand::Status => api.show(api.connections_with_privacy().await?), CrewCommand::Connections(command) => connections(api, command).await?, CrewCommand::Auth => { let id = api.connection_id().await?; api.show(api.client.shared()?.authenticate_ssh(&id).await?) } - CrewCommand::Connect => api.show(api.connection_action("connect", json!({})).await?), + CrewCommand::Connect => api.show(api.connect().await?), CrewCommand::Disconnect => api.show(api.connection_action("disconnect", json!({})).await?), CrewCommand::Join(args) => join(api, args).await?, CrewCommand::Workspace(command) => workspace(api, command).await?, CrewCommand::Enroll(command) => enrollment(api, command).await?, CrewCommand::Members(MembersArgs { command: None }) => { let snapshot = api.snapshot().await?; - let people = snapshot_field(&snapshot, "principals")?; + let mut people = with_roles(snapshot_field(&snapshot, "principals")?, &snapshot); + if let Some(list) = people.as_array_mut() { + let host = snapshot_host_id(&snapshot); + output::people_in_order(list, host.as_deref(), snapshot["actor"]["id"].as_str()); + } api.show_with(people, Directory::from_snapshot(&snapshot)) } CrewCommand::Members(MembersArgs { @@ -563,16 +1088,18 @@ async fn run(api: &Api, command: CrewCommand) -> Result { channel, member, former, - } => remove_member(api, &channel, &member, former).await?, + yes, + } => remove_member(api, &channel, &member, former, yes).await?, CrewCommand::History(args) => { - let channel = api.target(Kind::Channel, &args.channel).await?; - let page = api + let channel = api.your_channel(&args.channel).await?; + let mut page = api .broker( "messages.history", history_params(&channel.id, &args), false, ) .await?; + AttachmentNames::default().name_page(api, &mut page).await; api.show_with(page, api.names().await) } CrewCommand::Search { @@ -581,17 +1108,18 @@ async fn run(api: &Api, command: CrewCommand) -> Result { limit, after, } => { - let channel = api.target(Kind::Channel, &channel).await?; + let channel = api.your_channel(&channel).await?; let mut params = json!({"channel_id":channel.id,"query":query,"limit":limit}); if let Some(after) = after { params["after"] = json!(after); } - let page = api.broker("messages.search", params, false).await?; + let mut page = api.broker("messages.search", params, false).await?; + AttachmentNames::default().name_page(api, &mut page).await; api.show_with(page, api.names().await) } CrewCommand::Watch(args) => watch(api, args).await?, CrewCommand::Send(args) => send_message(api, args).await?, - CrewCommand::Context { session } => api.show(api.session_get(&session, "context").await?), + CrewCommand::Context { session } => context(api, &session).await?, CrewCommand::Files(command) => file_command(api, command).await?, CrewCommand::Tasks(command) => tasks(api, command).await?, CrewCommand::Grants(command) => grants(api, command).await?, @@ -613,6 +1141,9 @@ struct Api { interactive: bool, poll: Duration, connection_id: tokio::sync::OnceCell, + /// Each channel a selector named, by ID, as the resolver labelled it (`#methods`): what a + /// refusal about that channel names. + channel_labels: std::sync::Mutex>, } impl Api { @@ -684,6 +1215,98 @@ impl Api { self.client.request("GET", "/crew/connections", None).await } + /// The saved connections, each with its effective privacy ([`Self::with_effective_privacy`]). + async fn connections_with_privacy(&self) -> Result { + let mut listed = self.connections().await?; + if let Some(connections) = listed["connections"].as_array_mut() { + for connection in connections.iter_mut() { + *connection = self.with_effective_privacy(connection.take()).await; + } + } + Ok(listed) + } + + /// `connection` with its effective privacy (SF-F1): `effective_mode` (null when it cannot + /// be read), and the workspace's `workspace_mode` and `workspace_name` when read. Privacy is + /// Public only when the connection is Public and the workspace allows it, so the workspace + /// is asked only about a Public connection, and only while it is connected. + /// + /// Two more facts are read for a connected connection, as the desktop's status row reads + /// them. Whether this computer has joined (`joined`, SC2-N5): a connection the host has not + /// let in yet is connected but not a member, and reads "Not joined yet", never "Connected". + /// And, when the daemon says the workspace server has stopped saving (`server_storage`, + /// RES2-N2), whether the person hosts it (`you_host`), since only the host can fix it. + async fn with_effective_privacy(&self, connection: Value) -> Value { + let connected = connection["status"].as_str() == Some("connected"); + let Some(id) = connection["id"] + .as_str() + .and_then(|id| component(id).ok()) + .map(str::to_owned) + .filter(|_| connected) + else { + return with_privacy_of(connection, None); + }; + let mut connection = connection; + let joined = self.joined(&id).await; + if let Some(joined) = joined { + connection["joined"] = json!(joined); + } + let stopped_saving = connection["server_storage"].is_object(); + let public = connection["mode"].as_str() == Some("public"); + // A person who has not joined cannot read the workspace, and nothing else needs it. + if joined == Some(false) || !(public || stopped_saving) { + return with_privacy_of(connection, None); + } + // A listing must not wait on a workspace that is slow to answer: after a few seconds + // its privacy reads as not checked, as it does offline. + let snapshot = tokio::time::timeout( + LISTING_WAIT, + self.client.request( + "POST", + &format!("/crew/connections/{id}/request"), + Some(json!({"method": "workspace.snapshot", "params": {}, "request_id": null})), + ), + ) + .await + .ok() + .and_then(Result::ok); + if stopped_saving { + let standing = snapshot.as_ref().map_or(HostStanding::Unknown, |snapshot| { + host_standing(snapshot, None) + }); + match standing { + HostStanding::NotHost => connection["you_host"] = json!(false), + HostStanding::Unknown => {} + _ => connection["you_host"] = json!(true), + } + } + if public { + with_privacy_of(connection, snapshot.as_ref()) + } else { + with_privacy_of(connection, None) + } + } + + /// Whether this computer has joined the connected workspace `id`, from `GET …/join` (the + /// desktop's "Not joined yet" rule): `false` while the host has not let it in, `true` once + /// it is a member, and `None` when the daemon could not say in time, or the workspace's + /// server cannot tell (`unsupported`). + async fn joined(&self, id: &str) -> Option { + let status = tokio::time::timeout( + LISTING_WAIT, + self.client + .request("GET", &format!("/crew/connections/{id}/join"), None), + ) + .await + .ok()? + .ok()?; + match status["status"].as_str()? { + "joined" => Some(true), + "invited" | "approved" | "code_mismatch" | "not_invited" | "expired" => Some(false), + _ => None, + } + } + async fn connection(&self) -> Result { let response = self.connections().await?; let connections = response["connections"] @@ -699,7 +1322,11 @@ impl Api { match connections.as_slice() { [only] => Ok(only.clone()), [] => bail!("No Crew connection is saved on this computer. Save the invitation your host sent with biorouter crew connections join-invitation -"), - _ => bail!("Several Crew connections are saved; choose one with --connection NAME. Run biorouter crew connections list to see them."), + // SF2-N6: a wrong command line, exit 2, with the daemon's own code for the case. + _ => Err(usage( + "Several Crew connections are saved; choose one with --connection NAME. Run biorouter crew connections list to see them.", + CONNECTION_REQUIRED, + )), } } @@ -733,13 +1360,168 @@ impl Api { .await } + /// `POST …/connect`. A refused connect to a login on this machine (`crew_iris@localhost`) + /// is said in the same-host words ([`output::same_host_connect_failure_text`], SETUPHPC2-F-A), + /// with the code and OpenSSH's words as for any other connect; JSON keeps both. + async fn connect(&self) -> Result { + let error = match self.connection_action("connect", json!({})).await { + Ok(connected) => return Ok(connected), + Err(error) => error, + }; + let Some((code, detail)) = error.chain().find_map(|cause| { + connect_failure(cause) + .map(|(code, _, detail)| (code.to_owned(), detail.map(str::to_owned))) + }) else { + return Err(error); + }; + let Ok(connection) = self.connection().await else { + return Err(error); + }; + let Some(sentence) = connection["ssh_target"] + .as_str() + .and_then(|target| output::same_host_connect_failure_text(&code, target)) + else { + return Err(error); + }; + Err(Worded { + sentence: connect_failure_lines(&code, &sentence, detail.as_deref()), + source: error, + } + .into()) + } + async fn broker(&self, method: &str, mut params: Value, mutation: bool) -> Result { add_personal_mode(method, &mut params, self.expected_mode); if mutation { params["idempotency_key"] = json!(self.request_id); } let body = json!({"method":method,"params":params,"request_id":if mutation {Some(&self.request_id)} else {None}}); - self.connection_action("request", body).await + let answer = self.connection_action("request", body).await; + self.worded(answer, ¶ms).await + } + + /// A broker read that is only a lookup for a refusal's words: never worded itself. + async fn lookup(&self, method: &str, params: Value) -> Option { + let body = json!({"method":method,"params":params,"request_id":null}); + self.connection_action("request", body).await.ok() + } + + /// A refusal that can name more than its own text (DW-11, M20, R-2, FILES-F9), said with + /// what this command knows: the channel it acted on, where a shared file came from, and for + /// a server that can no longer save, whether the person hosts it. The refusal stays behind + /// the words, so JSON output keeps its codes. Any other answer is returned as it is. + async fn worded(&self, answer: Result, params: &Value) -> Result { + let error = match answer { + Ok(value) => return Ok(value), + Err(error) => error, + }; + let Some((code, message)) = error + .chain() + .find_map(broker_refusal) + .map(|(code, message)| (code.to_owned(), message.to_owned())) + else { + return Err(error); + }; + let Some(subject) = output::refusal_subject(&code, &message) else { + return Err(error); + }; + let place = self.refusal_place(subject, params).await; + if place == output::RefusalPlace::default() { + return Err(error); + } + Err(Worded { + sentence: output::broker_refusal_text_in(&code, &message, &place), + source: error, + } + .into()) + } + + /// What `subject` needs named, as far as this command can read it. + async fn refusal_place( + &self, + subject: output::RefusalSubject, + params: &Value, + ) -> output::RefusalPlace { + let mut place = output::RefusalPlace::default(); + match subject { + output::RefusalSubject::Channel => { + if let Some(id) = params["channel_id"].as_str() { + place.channel = self.channel_label(id).await; + } + } + output::RefusalSubject::SharedIn(kind) => { + place.shared_in = self.shared_in(kind, params).await; + } + output::RefusalSubject::Storage => { + // Reading still works on a server that can no longer save. + if let Some(snapshot) = self.lookup("workspace.snapshot", json!({})).await { + let directory = Directory::from_snapshot(&snapshot); + place.host = match host_standing(&snapshot, None) { + HostStanding::NotHost => Some(false), + HostStanding::Unknown => None, + _ => Some(true), + }; + place.host_label = output::host_label(&directory); + } + if let Ok(connection) = self.connection().await { + // The server, not the login: `bob@hpc.ucsf.edu` is on `hpc.ucsf.edu`. + place.server = connection["server_label"] + .as_str() + .or_else(|| { + connection["ssh_target"] + .as_str() + .and_then(|target| target.rsplit('@').next()) + }) + .map(str::trim) + .filter(|server| !server.is_empty()) + .map(safe_text); + } + } + } + place + } + + /// `#methods` for a channel ID: the resolver's label, else the person's snapshot. `None` + /// when neither names it. + async fn channel_label(&self, id: &str) -> Option { + let known = self + .channel_labels + .lock() + .ok() + .and_then(|labels| labels.get(id).cloned()); + if let Some(label) = known { + return Some(name_text(&label)); + } + let snapshot = self.lookup("workspace.snapshot", json!({})).await?; + Some(Directory::from_snapshot(&snapshot).channel_label(id)) + .filter(|label| label.starts_with('#')) + } + + /// The channel the first attachment or reference in `params` from another channel was + /// shared in (FILES-F9), named from the person's snapshot. + async fn shared_in(&self, kind: output::SharedKind, params: &Value) -> Option { + let (key, method, field) = match kind { + output::SharedKind::Attachment => ("attachments", "blob.status", "blob_id"), + output::SharedKind::Reference => ("references", "reference.get", "reference_id"), + }; + let destination = params["channel_id"].as_str(); + for id in params[key] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + let Some(shared) = self.lookup(method, json!({ field: id })).await else { + continue; + }; + match shared["channel_id"].as_str() { + Some(channel) if Some(channel) != destination => { + return self.channel_label(channel).await; + } + _ => {} + } + } + None } /// Step `step` of a command that makes several mutations: each gets its own idempotency @@ -753,7 +1535,8 @@ impl Api { let key = format!("{}:{step}", self.request_id); params["idempotency_key"] = json!(key); let body = json!({"method":method,"params":params,"request_id":key}); - self.connection_action("request", body).await + let answer = self.connection_action("request", body).await; + self.worded(answer, ¶ms).await } async fn snapshot(&self) -> Result { @@ -761,15 +1544,53 @@ impl Api { } /// Names for text output: the workspace snapshot, when it can be read. JSON output needs - /// none, and a list is still printed (with "Unknown member") when the snapshot fails. + /// none. When the snapshot cannot be read, a list is still printed, and every ID it cannot + /// name says so, with the workspace and the ID (R-8, AG-F17). async fn names(&self) -> Directory { if !self.text() { return Directory::default(); } - self.snapshot() - .await - .map(|snapshot| Directory::from_snapshot(&snapshot)) - .unwrap_or_default() + match self.snapshot().await { + Ok(snapshot) => Directory::from_snapshot(&snapshot), + Err(error) => { + let workspace = match self.connection().await { + Ok(connection) => connection_name(&connection), + Err(_) => "this workspace".to_owned(), + }; + // W2-CLI-9: an older daemon's untyped refusal is the same fact. + Directory::names_unavailable(&workspace, disconnected(&error)) + } + } + } + + /// A failed command's error, said with what this command knows that the daemon's refusal + /// does not (CLIDOCS-F6): a disconnected connection is named, with the command that + /// connects it, in place of the daemon's "authenticate and connect in Crew", which is + /// written for the desktop. The refusal stays its source, so JSON keeps the daemon's code. + async fn worded_failure(&self, error: anyhow::Error) -> anyhow::Error { + if !disconnected(&error) || error.chain().any(|cause| cause.is::()) { + return error; + } + let Ok(connection) = self.connection().await else { + return error; + }; + let name = connection_name(&connection); + let connect = if self.selected.is_some() { + format!( + "biorouter crew --connection {} connect", + safe_text(&shell_word(&name)) + ) + } else { + "biorouter crew connect".to_owned() + }; + Worded { + sentence: format!( + "{} is disconnected. Run {connect}, then try again.", + name_text(&name) + ), + source: error, + } + .into() } /// `"Bob Lee" (@bob)`, always both, for a decision about a person; from the snapshot, else @@ -845,12 +1666,23 @@ impl Api { for (&index, result) in lookups.iter().zip(results) { let (kind, text) = selectors[index]; match target_from(kind, text, result) { - Ok(target) => targets[index] = Some(target), + Ok(target) => { + if let (Kind::Channel, Some(label), Ok(mut labels)) = + (kind, target.label.as_deref(), self.channel_labels.lock()) + { + labels.insert(target.id.clone(), label.to_owned()); + } + targets[index] = Some(target); + } Err(problem) => problems.push(problem), } } - if !problems.is_empty() { - bail!("{}", problems.join("\n")); + if let Some(&(code, _)) = problems.first() { + // DW-10: a code, like every refusal the daemon types, so a script can tell an + // unknown name from an ambiguous one without matching the words. The first + // problem's code stands for the list. + let sentences: Vec = problems.into_iter().map(|(_, text)| text).collect(); + return Err(restated(sentences.join("\n"), Some(code))); } } targets @@ -863,6 +1695,148 @@ impl Api { let mut targets = self.resolve(&[(kind, text)]).await?; targets.pop().context("A name was left unresolved") } + + /// [`Self::resolve`] for a command only a member of each channel can run (history, search, + /// watch, send, mark-read, files, tasks, grants). A channel given by ID is checked against + /// the person's own channels too (AG-F13), so an ID from another workspace, or of a channel + /// they left, is refused here, as a name would be, rather than reaching the daemon's + /// "refresh" refusal, which no refresh can help. + /// + /// Only a snapshot that lists every channel of theirs can prove an ID absent: one that + /// leaves some out (`totals.channels`) or cannot be read leaves the ID to the broker, which + /// authorizes every request either way. Commands a host may run on a channel they are not + /// in (archive, rename, remove-member, members add) never come here. + async fn resolve_yours(&self, selectors: &[(Kind, &str)]) -> Result> { + let targets = self.resolve(selectors).await?; + let by_id: Vec = selectors + .iter() + .enumerate() + .filter(|(_, (kind, text))| *kind == Kind::Channel && kind.literal_id(text).is_some()) + .map(|(index, _)| index) + .collect(); + if by_id.is_empty() { + return Ok(targets); + } + let Ok(snapshot) = self.snapshot().await else { + return Ok(targets); + }; + let Some(channels) = snapshot["channels"].as_array() else { + return Ok(targets); + }; + let listed: std::collections::HashSet<&str> = channels + .iter() + .filter_map(|channel| channel["id"].as_str()) + .collect(); + let complete = snapshot["totals"]["channels"] + .as_u64() + .is_none_or(|total| usize::try_from(total).is_ok_and(|total| total <= listed.len())); + if !complete { + return Ok(targets); + } + let problems: Vec = by_id + .iter() + .filter(|&&index| !listed.contains(targets[index].id.as_str())) + .map(|&index| { + format!( + "No channel you're in has the ID {}.", + safe_text(&targets[index].id) + ) + }) + .collect(); + if problems.is_empty() { + Ok(targets) + } else { + Err(restated(problems.join("\n"), Some("unknown_name"))) + } + } + + /// [`Self::target`] for a channel only its members can use ([`Self::resolve_yours`]). + async fn your_channel(&self, text: &str) -> Result { + let mut targets = self.resolve_yours(&[(Kind::Channel, text)]).await?; + targets.pop().context("A name was left unresolved") + } +} + +/// `connection` with `effective_mode` from its own mode and `snapshot`'s workspace (SF-F1), and +/// `workspace_mode` and `workspace_name` when the snapshot was read. +fn with_privacy_of(mut connection: Value, snapshot: Option<&Value>) -> Value { + if !connection.is_object() { + return connection; + } + let workspace_mode = snapshot.and_then(|snapshot| snapshot["workspace"]["mode"].as_str()); + connection["effective_mode"] = json!(output::effective_mode( + connection["mode"].as_str(), + workspace_mode + )); + if let Some(mode) = workspace_mode { + connection["workspace_mode"] = json!(mode); + } + if let Some(name) = snapshot.and_then(workspace_name_in) { + connection["workspace_name"] = json!(name); + } + connection +} + +/// The host's principal ID: projected by newer brokers, else the active principal holding the +/// host's UID. +fn snapshot_host_id(snapshot: &Value) -> Option { + snapshot["workspace"]["host_principal_id"] + .as_str() + .map(str::to_owned) + .or_else(|| { + let uid = snapshot["workspace"]["host_uid"].as_u64()?; + snapshot["principals"] + .as_array()? + .iter() + .find(|person| { + person["uid"].as_u64() == Some(uid) && person["active"].as_bool() != Some(false) + }) + .and_then(|person| person["id"].as_str()) + .map(str::to_owned) + }) +} + +/// Each person in `people` with the two facts the text list shows beside them (CLI-15): +/// `is_you` and `is_host`, from the snapshot's actor and host. +fn with_roles(mut people: Value, snapshot: &Value) -> Value { + let actor = snapshot["actor"]["id"].as_str(); + // An older broker names only the host's UID; one active principal holds it. + let host = snapshot_host_id(snapshot); + // Presence (W2-BRK-6), when the broker reports it; an older one reports none, and nothing + // is invented. + let online: Option> = snapshot["online_principal_ids"] + .as_array() + .map(|ids| ids.iter().filter_map(Value::as_str).collect()); + if let Some(people) = people.as_array_mut() { + for person in people.iter_mut().filter(|person| person.is_object()) { + let id = person["id"].as_str().map(str::to_owned); + person["is_you"] = json!(id.is_some() && id.as_deref() == actor); + person["is_host"] = json!(id.is_some() && id == host); + if let Some(online) = &online { + person["is_online"] = json!(id.as_deref().is_some_and(|id| online.contains(&id))); + } + } + } + people +} + +/// Each channel in `channels` with its `unread` count from the snapshot's separate map, which +/// the text list shows (CLI-15). A snapshot without the map (an older broker) adds nothing. +fn with_unread(mut channels: Value, snapshot: &Value) -> Value { + let Some(unread) = snapshot["unread"].as_object() else { + return channels; + }; + if let Some(channels) = channels.as_array_mut() { + for channel in channels.iter_mut().filter(|channel| channel.is_object()) { + let count = channel["id"] + .as_str() + .and_then(|id| unread.get(id)) + .and_then(Value::as_u64) + .unwrap_or(0); + channel["unread"] = json!(count); + } + } + channels } fn snapshot_field(snapshot: &Value, field: &str) -> Result { @@ -986,10 +1960,333 @@ async fn ask(question: String) -> Result { .context("The question could not be asked")? } +/// How a consequential command is confirmed (CLI-1, CLI-10), matching the desktop's friction: +/// a plain question for archiving a channel or removing someone from it, the name typed again +/// for removing a connection or making anything public. +#[derive(Debug, PartialEq, Eq)] +enum Consent { + /// `--yes`, or the name given again with `--confirm`. + Given, + /// Ask this on the terminal. + Ask(String), +} + +/// A yes-or-no decision: `--yes`, else a question on the terminal, else the terminal refusal +/// (exit status 2, nothing sent). +fn yes_or_ask( + yes: bool, + interactive: bool, + question: String, + no_terminal: String, +) -> Result { + if yes { + Ok(Consent::Given) + } else if interactive { + Ok(Consent::Ask(question)) + } else { + Err(needs_a_terminal(no_terminal)) + } +} + +async fn ask_yes(question: String, declined: &str) -> Result<()> { + let answer = ask(format!("{question} [y/N]")).await?; + ensure!( + matches!(answer.to_lowercase().as_str(), "y" | "yes"), + "{declined}" + ); + Ok(()) +} + +/// A decision confirmed by typing `phrase` again, as the desktop's typed confirmations are +/// (letter case aside): `--confirm PHRASE`, else a question on the terminal, else the terminal +/// refusal. +fn typed_or_ask( + phrase: &str, + confirm: Option<&str>, + interactive: bool, + no_terminal: String, +) -> Result { + match confirm { + Some(typed) => { + ensure!( + same_phrase(typed, phrase), + "Not done: --confirm {} doesn't match {}.", + safe_text(typed.trim()), + name_text(phrase) + ); + Ok(Consent::Given) + } + None if interactive => Ok(Consent::Ask(format!( + "Type {} to confirm:", + name_text(phrase) + ))), + None => Err(needs_a_terminal(no_terminal)), + } +} + +fn same_phrase(typed: &str, phrase: &str) -> bool { + typed.trim().to_lowercase() == phrase.trim().to_lowercase() +} + +/// Ask for `phrase` on the terminal after `lines`, when [`typed_or_ask`] says to. +async fn typed_consent( + phrase: &str, + confirm: Option<&str>, + interactive: bool, + lines: &[String], + no_terminal: String, +) -> Result<()> { + if let Consent::Ask(question) = typed_or_ask(phrase, confirm, interactive, no_terminal)? { + let mut stderr = std::io::stderr().lock(); + for line in lines { + writeln!(stderr, "{line}")?; + } + drop(stderr); + let answer = ask(question).await?; + ensure!( + same_phrase(&answer, phrase), + "Not done: that isn't {}.", + name_text(phrase) + ); + } + Ok(()) +} + +/// A public setting, confirmed by typing the workspace's name. +async fn confirm_public( + api: &Api, + workspace: &str, + confirm: Option<&str>, + lines: Vec, + no_terminal: String, +) -> Result<()> { + typed_consent(workspace, confirm, api.interactive, &lines, no_terminal).await +} + +/// The name a typed confirmation asks for: the workspace's own name, else the caller falls +/// back to the saved connection's (`workspacePhraseFor` on the desktop). +fn workspace_name_in(snapshot: &Value) -> Option { + snapshot["workspace"]["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty() && !uuid_shaped(name)) + .map(str::to_owned) +} + +fn connection_name(connection: &Value) -> String { + connection["name"] + .as_str() + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or("this connection") + .to_owned() +} + +/// Whether the person hosts the workspace, and whether another of their computers could still +/// act as its host, read from their snapshot. +#[derive(Debug, PartialEq, Eq)] +enum HostStanding { + NotHost, + /// The host, and the workspace lists more than one computer enrolled as them. `others` are + /// the listed computers other than this one. The list is not proof that any of them can still + /// act as host: a computer stays on it after its connection is removed there, because the + /// broker drops a device only when its whole member is revoked. + HostElsewhereToo { + others: Vec, + }, + /// The host, and no other computer of theirs is known: removing this one ends the host + /// controls for good. + OnlyHostComputer { + username: String, + }, + /// The snapshot could not be read or does not say. + Unknown, +} + +/// The first 16 hex digits of a device ID or a snapshot fingerprint, uppercased: the broker's +/// fingerprint is those digits of the device ID, grouped. +fn fingerprint_digits(text: &str) -> String { + text.chars() + .filter(char::is_ascii_hexdigit) + .take(16) + .map(|digit| digit.to_ascii_uppercase()) + .collect() +} + +/// `own_device_id` is this connection's device ID, left out of `others` when it is known. +fn host_standing(snapshot: &Value, own_device_id: Option<&str>) -> HostStanding { + let actor = &snapshot["actor"]; + let Some(actor_id) = actor["id"].as_str() else { + return HostStanding::Unknown; + }; + let workspace = &snapshot["workspace"]; + let hosts = match workspace["host_principal_id"].as_str() { + Some(host) => host == actor_id, + None => match (workspace["host_uid"].as_u64(), actor["uid"].as_u64()) { + (Some(host), Some(uid)) => host == uid, + _ => return HostStanding::Unknown, + }, + }; + if !hosts { + return HostStanding::NotHost; + } + let own = own_device_id + .map(fingerprint_digits) + .filter(|own| !own.is_empty()); + let others: Vec = match actor["devices"].as_array() { + // One listed computer is this one, or the only one there is. + Some(devices) if devices.len() > 1 => devices + .iter() + .filter(|device| { + own.as_deref().is_none_or(|own| { + device["fingerprint"] + .as_str() + .map(fingerprint_digits) + .as_deref() + != Some(own) + }) + }) + .cloned() + .collect(), + _ => Vec::new(), + }; + if others.is_empty() { + HostStanding::OnlyHostComputer { + username: actor["username"].as_str().unwrap_or_default().to_owned(), + } + } else { + HostStanding::HostElsewhereToo { others } + } +} + +/// The device ID of the computer a saved connection belongs to, when the daemon says. +fn own_device_id(connection: &Value) -> Option<&str> { + connection["device_id"] + .as_str() + .filter(|device| !fingerprint_digits(device).is_empty()) +} + +/// What `connections remove` says about hosting before it asks, or its refusal (CLI-1). +/// `workspace` is already escaped for display, and `own_device_known` says whether `others` +/// has this computer left out. +fn host_removal_notice( + standing: &HostStanding, + workspace: &str, + own_device_known: bool, + give_up_host_controls: bool, + options: &HumanOptions, +) -> Result> { + Ok(match standing { + HostStanding::NotHost => None, + HostStanding::OnlyHostComputer { username } if !give_up_host_controls => { + let invite = if username.is_empty() { + "biorouter crew enroll invite @YOUR_USERNAME --add-device".to_owned() + } else { + format!( + "biorouter crew enroll invite @{} --add-device", + safe_text(username) + ) + }; + return Err(restated( + format!( + "Not removed. You host {workspace}, and no other computer of yours can act as its host. Removing it here ends the host controls for good: nobody could let people in, change its privacy or remove anyone again.\nTo keep them, first add another computer with {invite}.\nTo remove it anyway, add --give-up-host-controls." + ), + Some("crew_host_controls_would_end"), + )); + } + HostStanding::OnlyHostComputer { .. } => Some(format!( + "You host {workspace} and no other computer of yours can act as its host, so its host controls end for good." + )), + HostStanding::HostElsewhereToo { others } => { + let listed = if own_device_known { + format!( + "You host {workspace}. Its host controls continue only if one of your other enrolled computers still has {workspace} saved:" + ) + } else { + format!( + "You host {workspace}. Its host controls continue only if another of your enrolled computers still has {workspace} saved. These computers are enrolled as you, this one among them:" + ) + }; + let mut notice = vec![listed]; + notice.extend( + others + .iter() + .map(|device| format!(" {}", output::device_text(device, options))), + ); + notice.push(format!( + "A computer stays on this list after its connection is removed there. If none of them still has {workspace} saved, removing it here ends the host controls for good." + )); + Some(notice.join("\n")) + } + HostStanding::Unknown => Some(format!( + "Biorouter couldn't check whether you host {workspace}. If you do and this is your only computer in it, removing it ends the host controls for good." + )), + }) +} + +/// `connections remove` (CLI-1): delete the saved connection and this computer's device key. +/// For a host's only computer that ends the host controls for good, so it is refused unless +/// `--give-up-host-controls` says so; every removal is confirmed by typing the name, and what +/// removing it means for the host controls is said first, on stderr when nothing is asked. +async fn remove_connection( + api: &Api, + confirm: Option<&str>, + give_up_host_controls: bool, +) -> Result { + let connection = api.connection().await?; + let name = connection_name(&connection); + let shown = name_text(&name); + let own_device = own_device_id(&connection); + let snapshot = api.snapshot().await.ok(); + let standing = snapshot.as_ref().map_or(HostStanding::Unknown, |snapshot| { + host_standing(snapshot, own_device) + }); + let workspace = snapshot + .as_ref() + .and_then(workspace_name_in) + .map_or_else(|| shown.clone(), |workspace| name_text(&workspace)); + let mut lines = vec![format!( + "Remove {shown} from this computer? It disconnects, ends every chat's access through it and deletes this computer's device key for the workspace. Your messages stay on the server." + )]; + if let Some(notice) = host_removal_notice( + &standing, + &workspace, + own_device.is_some(), + give_up_host_controls, + &HumanOptions::new(api.show_ids), + )? { + if confirm.is_some() || !api.interactive { + eprintln!("{notice}"); + } + lines.push(notice); + } + typed_consent( + &name, + confirm, + api.interactive, + &lines, + format!( + "Removing {shown} deletes this computer's device key for the workspace. There is no terminal to ask in, so confirm with --confirm {}.", + safe_text(&shell_word(&name)) + ), + ) + .await?; + let result = api + .client + .request("DELETE", &api.path("").await?, None) + .await?; + Ok(api.say(result, vec![format!("Removed {shown} from this computer.")])) +} + async fn connections(api: &Api, command: ConnectionCommand) -> Result { Ok(match command { - ConnectionCommand::List => api.show(api.connections().await?), - ConnectionCommand::Show => api.show(api.connection().await?), + ConnectionCommand::List => api.show(api.connections_with_privacy().await?), + ConnectionCommand::Show => { + let connection = api.connection().await?; + api.show(api.with_effective_privacy(connection).await) + } + // SF-F3: the host's first step and a joiner's are this one command, the same as + // `enroll prepare`, so the key is described for both uses in one set of words. ConnectionCommand::Prepare => api.show(prepare(api).await?), ConnectionCommand::Save { input } => { let body: Value = serde_json::from_str(&read_input(&input)?) @@ -1009,12 +2306,11 @@ async fn connections(api: &Api, command: ConnectionCommand) -> Result { .await?, ) } - ConnectionCommand::Remove => api.show( - api.client - .request("DELETE", &api.path("").await?, None) - .await?, - ), - ConnectionCommand::JoinInvitation(args) => join_invitation(api, args).await?, + ConnectionCommand::Remove { + confirm, + give_up_host_controls, + } => remove_connection(api, confirm.as_deref(), give_up_host_controls).await?, + ConnectionCommand::JoinInvitation(args) => join_invitation(api, *args).await?, ConnectionCommand::Invitation { invitee } => { let invitation = fetch_invitation(api, invitee.as_deref()).await?; let lines = invitation_lines(&invitation); @@ -1058,13 +2354,27 @@ async fn join_invitation(api: &Api, args: JoinInvitationArgs) -> Result { !pasted.trim().is_empty(), "Paste the whole invitation your host sent, or the brcrew1: line in it." ); - let request = invitation_request(&pasted, &args); + let mut request = invitation_request(&pasted, &args)?; let answer = from_invitation(api, &request, true).await?; let preview = answer .get("preview") .filter(|preview| preview.is_object()) .context("The daemon did not describe the invitation")?; - let summary = invitation_summary(preview); + // SC2-N1: a connection saved from someone else's invitation, never connected, is offered + // for replacement, and replaced only when the person asks with --replace. + let replaceable = preview["replaceable_connection_id"].as_str(); + if args.replace && replaceable.is_none() { + return Err(usage( + "There's no saved connection for this workspace that this invitation can replace. Run it again without --replace.", + NOTHING_TO_REPLACE, + )); + } + let saved_names = saved_connection_names(api).await; + let mut summary = invitation_summary(preview, args.preparation_id.is_some()); + summary.extend(saved_connection_lines(preview, &saved_names, args.replace)); + if args.replace { + request["replace"] = json!(replaceable); + } if args.preview { return Ok(api.say(answer.clone(), summary)); } @@ -1077,14 +2387,16 @@ async fn join_invitation(api: &Api, args: JoinInvitationArgs) -> Result { } let mut asked = false; if !args.yes { - ensure!( - !from_stdin, - "Add --yes to save this connection: the invitation came from stdin, so there is no terminal to ask in. Run with --preview to check it first." - ); - ensure!( - api.interactive, - "Add --yes to save this connection; there is no terminal to ask in. Run with --preview to check it first." - ); + if from_stdin { + return Err(needs_a_terminal( + "Add --yes to save this connection: the invitation came from stdin, so there is no terminal to ask in. Run with --preview to check it first.", + )); + } + if !api.interactive { + return Err(needs_a_terminal( + "Add --yes to save this connection; there is no terminal to ask in. Run with --preview to check it first.", + )); + } let mut stderr = std::io::stderr().lock(); for line in &summary { writeln!(stderr, "{line}")?; @@ -1097,7 +2409,10 @@ async fn join_invitation(api: &Api, args: JoinInvitationArgs) -> Result { ); asked = true; } - let saved = from_invitation(api, &request, false).await?; + let saved = match from_invitation(api, &request, false).await { + Ok(saved) => saved, + Err(error) => return Err(saved_conflict(error, preview, &saved_names)), + }; let connection = saved .get("connection") .filter(|connection| connection.is_object()) @@ -1113,15 +2428,41 @@ async fn join_invitation(api: &Api, args: JoinInvitationArgs) -> Result { "connection ID", connection["id"].as_str().unwrap_or_default(), )); - lines.push(format!( - "Next: biorouter crew --connection {} join", - safe_text(&shell_word(name)) + lines.extend(next_steps( + name, + args.preparation_id.is_some(), + connection["status"].as_str() == Some("connected"), )); Ok(api.say(saved, lines)) } +/// What to run after saving a connection (CLI-9). A host saving their own workspace +/// (`--preparation-id`) signs in and then sets the workspace up; anyone else signs in and then +/// joins. Signing in is skipped when the connection is already up. +fn next_steps(name: &str, hosting: bool, connected: bool) -> Vec { + let command = |verb: &str| { + format!( + "biorouter crew --connection {} {verb}", + safe_text(&shell_word(name)) + ) + }; + let then = if hosting { + "workspace bootstrap" + } else { + "join" + }; + if connected { + vec![format!("Next: {}", command(then))] + } else { + vec![ + format!("Next: {}", command("auth")), + format!("Then: {}", command(then)), + ] + } +} + /// `POST /crew/connections/from-invitation`'s body: the pasted text and every choice made. -fn invitation_request(pasted: &str, args: &JoinInvitationArgs) -> Value { +fn invitation_request(pasted: &str, args: &JoinInvitationArgs) -> Result { let mut body = json!({"invitation": pasted}); if let Some(username) = &args.username { let username = username.trim(); @@ -1134,9 +2475,18 @@ fn invitation_request(pasted: &str, args: &JoinInvitationArgs) -> Value { body["institution_id"] = json!(institution); } let mut advanced = serde_json::Map::new(); + let identity_file = args + .identity_file + .as_deref() + .map(|path| { + absolute_local_path(path, etcetera::home_dir().ok(), || { + Ok(std::env::current_dir()?) + }) + }) + .transpose()?; for (key, value) in [ ("ssh_target", &args.ssh_target), - ("identity_file", &args.identity_file), + ("identity_file", &identity_file), ("proxy_jump", &args.proxy_jump), ("name", &args.name), ("remote_root", &args.remote_root), @@ -1155,7 +2505,42 @@ fn invitation_request(pasted: &str, args: &JoinInvitationArgs) -> Value { if !advanced.is_empty() { body["advanced"] = Value::Object(advanced); } - body + Ok(body) +} + +/// A local file path as the daemon needs it, absolute (CLI-19): a leading `~/` (or `~\` on +/// Windows) is the home folder, since a shell leaves `--identity-file=~/.ssh/key` as it is, +/// and a relative path is taken from the current folder, as the files commands take theirs. +/// An empty value is left for the daemon to judge. +fn absolute_local_path( + text: &str, + home: Option, + current_dir: impl FnOnce() -> Result, +) -> Result { + if text.is_empty() { + return Ok(String::new()); + } + // What follows `~` and a separator, or "" for a bare `~`. `~bob/key` is not in it. + let under_home = match text.strip_prefix('~') { + Some("") => Some(""), + Some(rest) => rest.strip_prefix(std::path::is_separator), + None => None, + }; + let path = if let Some(rest) = under_home { + let home = home.context("Biorouter couldn't find your home folder for the ~ in --identity-file; give the full path")?; + if rest.is_empty() { + home + } else { + home.join(rest) + } + } else if Path::new(text).is_absolute() { + return Ok(text.to_owned()); + } else { + current_dir()?.join(text) + }; + path.to_str() + .map(str::to_owned) + .context("Crew local paths must be valid Unicode") } async fn from_invitation(api: &Api, request: &Value, preview: bool) -> Result { @@ -1177,21 +2562,41 @@ fn privacy_badge(mode: Option<&str>, institution: Option<&str>) -> String { } } -/// What the invitation says and what saving it would do, as the Join screen shows it. -fn invitation_summary(preview: &Value) -> Vec { +/// What the invitation says and what saving it would do, as the Join screen shows it. A host +/// saving their own workspace (`--preparation-id`, what `biorouter-crew start` printed) is not +/// invited and does not join: it is their workspace, and they host it (SF-F3). +fn invitation_summary(preview: &Value, hosting: bool) -> Vec { let field = |key: &str| preview[key].as_str().filter(|value| !value.is_empty()); let workspace = field("workspace_label") .or_else(|| field("workspace_name")) .map_or_else(|| "a workspace".to_owned(), name_text); - let mut lines = vec![format!("Invitation to {workspace}")]; + let mut lines = vec![if hosting { + format!("Your workspace {workspace}") + } else { + format!("Invitation to {workspace}") + }]; let host = field("host_username").map(|host| person_text(host, field("host_display_name"))); - let server = field("server").or_else(|| field("ssh_host")).map(safe_text); + // SC2-N5: the server by the name the desktop shows (`server_label`: the person's own SSH + // alias for it, else its host), not the address the invitation resolved. + let server = field("server_label") + .or_else(|| field("server")) + .or_else(|| field("ssh_host")) + .map(safe_text); match (&host, &server) { (Some(host), Some(server)) => lines.push(format!(" Hosted by {host} on {server}")), (Some(host), None) => lines.push(format!(" Hosted by {host}")), (None, Some(server)) => lines.push(format!(" On {server}")), (None, None) => {} } + // SETUPHPC2-F-C: a member on the workspace's own server signs in to this machine. + if field("proxy_jump").is_none() { + if let Some((_, here)) = field("ssh_target").and_then(output::loopback_login) { + lines.push(format!( + " Crew will connect to this machine ({}).", + safe_text(here) + )); + } + } let workspace_privacy = privacy_badge(field("workspace_mode"), field("workspace_institution_id")); lines.push(format!(" Workspace privacy: {workspace_privacy}")); @@ -1205,8 +2610,28 @@ fn invitation_summary(preview: &Value) -> Vec { safe_text(username) )); } + // SC2-N1: the invitation names one account, and this computer's SSH settings sign in to the + // server as another: someone else's invitation, which the host must replace. + let mismatch = &preview["login_mismatch"]; + if let (Some(config_user), Some(invitee)) = ( + mismatch["config_user"] + .as_str() + .filter(|user| !user.is_empty()), + mismatch["invitee"].as_str().filter(|user| !user.is_empty()), + ) { + lines.push(format!( + " This invitation is for @{}, but this computer signs in to {} as {}. Ask your host for your own invitation.", + safe_text(invitee), + server.as_deref().unwrap_or("the server"), + safe_text(config_user) + )); + } let choice = privacy_badge(field("mode"), field("institution_id")); - lines.push(format!(" You'll join as {choice}.")); + lines.push(if hosting { + format!(" You'll host it as {choice}.") + } else { + format!(" You'll join as {choice}.") + }); if let (Some(workspace_institution), Some(chosen)) = (field("workspace_institution_id"), field("institution_id")) { @@ -1232,56 +2657,220 @@ fn invitation_summary(preview: &Value) -> Vec { name_text(name) )); } - if field("existing_connection_id").is_some() { - lines.push( - " This computer already has this workspace; saving again keeps that connection." - .into(), - ); - } lines } -/// What saving still needs, with the option that supplies each. -fn missing_choices(preview: &Value) -> Vec<&'static str> { - preview["missing"] +/// The code of `join-invitation --replace` when nothing saved can be replaced. +const NOTHING_TO_REPLACE: &str = "crew_nothing_to_replace"; + +/// Each saved connection's name by ID, for sentences about one this computer already has. +/// Empty when the list cannot be read: those sentences then say "a connection". +async fn saved_connection_names(api: &Api) -> std::collections::HashMap { + let Ok(listed) = api.connections().await else { + return std::collections::HashMap::new(); + }; + listed["connections"] .as_array() .into_iter() .flatten() - .filter_map(Value::as_str) - .map(|missing| match missing { - "username" => "your username on the server (--username)", - "server" => "the server's address (--ssh-target)", - "institution" => "an institution for a Private connection (--institution)", - _ => "a choice this version of the command can't make; use the desktop app", + .filter_map(|connection| { + Some(( + connection["id"].as_str()?.to_owned(), + connection_name(connection), + )) }) .collect() } -/// `crew join`: show this computer's code and wait until the host lets it in. The code comes -/// from the daemon's `GET …/join`, computed there from this computer's own key and the pinned -/// workspace key; nothing the workspace sends can change it. -async fn join(api: &Api, args: JoinArgs) -> Result { - let path = api.path("/join").await?; - let mut shown: Option<(String, Option)> = None; - // Whether the status is being read again right after a claim was refused. - let mut rechecking = false; - loop { - let status = join_status(api, &path).await?; - let state = status["status"].as_str().unwrap_or_default().to_owned(); - let code = status["code"].as_str().map(str::to_owned); - let changed = shown.as_ref() != Some(&(state.clone(), code.clone())); - match state.as_str() { - "joined" => { - api.stream(&status, &joined_lines(&status))?; - return Ok(Reply::Streamed); - } +/// A saved connection's name for a sentence: `chen-lab`, isolated when it could hold +/// right-to-left text, or "a connection" when it could not be read. +fn saved_name(names: &std::collections::HashMap, id: &str) -> String { + names + .get(id) + .map_or_else(|| "a connection".to_owned(), |name| name_text(name)) +} + +/// What the invitation summary says about a connection this computer already has for the same +/// workspace (SC2-N1, CLIDOCS-F6): the one saving keeps, or one saved from someone else's +/// invitation that `--replace` replaces. +fn saved_connection_lines( + preview: &Value, + names: &std::collections::HashMap, + replace: bool, +) -> Vec { + let mut lines = Vec::new(); + if let Some(id) = preview["existing_connection_id"].as_str() { + lines.push(format!( + " This computer already has this workspace as {}; saving again keeps that connection if its settings match.", + saved_name(names, id) + )); + } + if let Some(id) = preview["replaceable_connection_id"].as_str() { + let name = saved_name(names, id); + lines.push(if replace { + format!(" Saving replaces {name}, which signs in as another account and has never connected. Its key is deleted.") + } else { + format!(" This computer already has {name} for this workspace, signing in as another account, and it has never connected. Add --replace to save this invitation in its place.") + }); + } + lines +} + +/// A save refused because this computer already has the workspace (`crew_connection_exists`), +/// said with the command that acts on it (SC2-N1, CLIDOCS-F6): the daemon's "Replace it … or +/// change it in its connection settings" names the desktop's controls. JSON keeps its code. +fn saved_conflict( + error: anyhow::Error, + preview: &Value, + names: &std::collections::HashMap, +) -> anyhow::Error { + let conflict = error.chain().find_map(|cause| { + let (code, _) = refusal_code_and_message(cause)?; + (code == "crew_connection_exists") + .then(|| refusal_field(cause, "connection_id").map(str::to_owned)) + }); + let Some(id) = conflict else { + return error; + }; + let replaceable = preview["replaceable_connection_id"].as_str(); + let id = id.or_else(|| replaceable.map(str::to_owned)); + let raw = id + .as_deref() + .and_then(|id| names.get(id)) + .map_or("NAME", String::as_str); + let name = id + .as_deref() + .map_or_else(|| "a connection".to_owned(), |id| saved_name(names, id)); + let command = |verb: &str| { + format!( + "biorouter crew --connection {} {verb}", + safe_text(&shell_word(raw)) + ) + }; + let sentence = if id.is_some() && id.as_deref() == replaceable { + format!( + "This computer already has {name} for this workspace, signing in as another account, and it has never connected. Run it again with --replace, or remove it with {}.", + command("connections remove") + ) + } else { + // T3-CLI-4: the daemon refuses an existing connection only when a setting it compares + // differs (`same_settings`: the login, port, key file, jump host, mode and + // institution); a matching save succeeds. So the saved one is not what was asked for, + // and nothing was saved: joining with it is only one of the ways on. + [ + format!("This workspace is already saved as {name} with a different server login, port, key file, jump host or privacy, so nothing was saved."), + format!(" To compare them: {}", command("connections show")), + format!( + " To give it this invitation's privacy: {}", + command(&set_personal_words(preview)) + ), + format!( + " To change its server login, port, key file or jump host: {}", + command("connections update FILE") + ), + format!( + " If it has not joined yet, remove it and save this invitation again: {}", + command("connections remove") + ), + format!(" To use it as it is: {}", command("join")), + ] + .join("\n") + }; + Worded { + sentence, + source: error, + } + .into() +} + +/// `privacy set-personal private --institution ucsf`: the privacy this invitation would save, +/// as the command that gives it to a connection already saved. The preview's words are used +/// only when they are a mode this command takes; anything else is left as `MODE`. +fn set_personal_words(preview: &Value) -> String { + match preview["mode"].as_str() { + Some("private") => match preview["institution_id"] + .as_str() + .map(str::trim) + .filter(|institution| !institution.is_empty()) + { + Some(institution) => format!( + "privacy set-personal private --institution {}", + safe_text(&shell_word(institution)) + ), + None => "privacy set-personal private".to_owned(), + }, + Some("public") => "privacy set-personal public".to_owned(), + _ => "privacy set-personal MODE".to_owned(), + } +} + +/// What saving still needs, with the option that supplies each. +fn missing_choices(preview: &Value) -> Vec<&'static str> { + preview["missing"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(|missing| match missing { + "username" => "your username on the server (--username)", + "server" => "the server's address (--ssh-target)", + "institution" => "an institution for a Private connection (--institution)", + _ => "a choice this version of the command can't make; use the desktop app", + }) + .collect() +} + +/// `crew join`: show this computer's code and wait until the host lets it in. The code comes +/// from the daemon's `GET …/join`, computed there from this computer's own key and the pinned +/// workspace key; nothing the workspace sends can change it. +async fn join(api: &Api, args: JoinArgs) -> Result { + join_until(api, args, tokio::signal::ctrl_c()).await +} + +/// [`join`], stopped by `interrupt` (Ctrl-C). One listener serves the whole wait (CLI-18): a +/// fresh `ctrl_c()` per sleep was not listening while the status was re-read or a claim was +/// made (either can wait on a slow SSH connect), and tokio's handler, installed by the first +/// one, then swallowed that press. +async fn join_until( + api: &Api, + args: JoinArgs, + interrupt: impl std::future::Future>, +) -> Result { + tokio::pin!(interrupt); + let stopped = || { + eprintln!("Stopped waiting. Run biorouter crew join again to continue."); + Ok(Reply::Streamed) + }; + let path = api.path("/join").await?; + let mut shown: Option<(String, Option)> = None; + // Whether the status is being read again right after a claim was refused. + let mut rechecking = false; + loop { + let status = tokio::select! { + biased; + signal = &mut interrupt => { signal?; return stopped(); } + status = join_status(api, &path) => status?, + }; + let state = status["status"].as_str().unwrap_or_default().to_owned(); + let code = status["code"].as_str().map(str::to_owned); + let changed = shown.as_ref() != Some(&(state.clone(), code.clone())); + match state.as_str() { + "joined" => { + api.stream(&status, &joined_lines(&status))?; + return Ok(Reply::Streamed); + } "approved" => { // Claim first and speak after (T-13): "approved" only means the host saved a // code, and a claim refused because it isn't this computer's must never have // been announced as "Joining…". The status the refusal leaves (usually // `code_mismatch`) is read once more, straight away, and that is what is said. if !rechecking { - if let Some(joined) = claim(api, &path).await? { + let claimed = tokio::select! { + biased; + signal = &mut interrupt => { signal?; return stopped(); } + claimed = claim(api, &path) => claimed?, + }; + if let Some(joined) = claimed { api.stream(&joined, &joined_lines(&joined))?; return Ok(Reply::Streamed); } @@ -1325,12 +2914,9 @@ async fn join(api: &Api, args: JoinArgs) -> Result { } shown = Some((state, code)); tokio::select! { + biased; + signal = &mut interrupt => { signal?; return stopped(); } () = tokio::time::sleep(api.poll) => {} - signal = tokio::signal::ctrl_c() => { - signal?; - eprintln!("Stopped waiting. Run biorouter crew join again to continue."); - return Ok(Reply::Streamed); - } } } } @@ -1338,13 +2924,16 @@ async fn join(api: &Api, args: JoinArgs) -> Result { /// `GET …/join`, connecting first when the connection is not up. async fn join_status(api: &Api, path: &str) -> Result { match api.client.request("GET", path, None).await { - Err(error) - if refusal(&error) - .is_some_and(|refused| refused.code.as_deref() == Some("crew_not_connected")) => - { - api.connection_action("connect", json!({})) - .await - .context("Connect to the workspace first: biorouter crew auth")?; + Err(error) if disconnected(&error) => { + // A typed connect failure already says what to run; anything else is pointed at + // `auth`, which signs in and connects. + api.connect().await.map_err(|error| { + if error.chain().any(|cause| connect_failure(cause).is_some()) { + error + } else { + error.context("Connect to the workspace first: biorouter crew auth") + } + })?; api.client .request("GET", path, None) .await @@ -1476,7 +3065,7 @@ async fn enrollment(api: &Api, command: EnrollmentCommand) -> Result { let joins = snapshot .get("pending_joins") .cloned() - .unwrap_or_else(|| json!([])); + .ok_or_else(|| no_pending_joins(&snapshot))?; let lines = pending_lines(&joins, now()); api.say(joins, lines) } @@ -1533,15 +3122,11 @@ async fn enrollment(api: &Api, command: EnrollmentCommand) -> Result { }) } -/// A device code as the joiner sent it: 16 letters and digits, whatever separates them. The -/// broker applies the full Crockford normalization and refuses what it cannot read. +/// [`args::device_code`] again, for a caller that did not come through the command line. fn check_device_code(code: &str) -> Result<()> { - let bare: Vec = code.chars().filter(|c| c.is_alphanumeric()).collect(); - ensure!( - bare.len() == 16 && bare.iter().all(char::is_ascii_alphanumeric), - "A code has 16 letters and digits, like 7QK2-M9XA-3JTP-WZ4D. Copy it exactly as they sent it." - ); - Ok(()) + args::device_code(code) + .map(drop) + .map_err(anyhow::Error::msg) } async fn enroll_invite(api: &Api, args: EnrollInviteArgs) -> Result { @@ -1609,6 +3194,23 @@ fn invite_lines(result: &Value, username: &str, invitation: Option<&Value>) -> V lines } +/// Why a snapshot lists no one waiting to join, when it has no `pending_joins` at all (DW-13). +/// The broker lists them only to the host, so a member is refused as every other `enroll` +/// command refuses them; a host's server that leaves them out cannot let people join by name. +/// Either way it is not an empty queue, which is what "No one is waiting to join." said. +fn no_pending_joins(snapshot: &Value) -> anyhow::Error { + match host_standing(snapshot, None) { + HostStanding::HostElsewhereToo { .. } | HostStanding::OnlyHostComputer { .. } => restated( + "This workspace's server doesn't support joining by name.", + Some("crew_join_by_name_unsupported"), + ), + HostStanding::NotHost | HostStanding::Unknown => restated( + "Only the workspace host can see who is waiting to join.", + Some("crew_host_required"), + ), + } +} + fn pending_lines(joins: &Value, now: i64) -> Vec { let rows = joins.as_array().map(Vec::as_slice).unwrap_or_default(); if rows.is_empty() { @@ -1683,9 +3285,9 @@ fn revoke_confirmation( "Revoke {label}? Their membership, devices and agent grants stop working. Type @{} to confirm:", safe_text(username) ))), - None => bail!( + None => Err(needs_a_terminal(format!( "Revoking @{username} removes their membership, devices and agent grants. There is no terminal to ask in, so confirm with --confirm @{username}." - ), + ))), } } @@ -1780,8 +3382,21 @@ async fn teams(api: &Api, command: TeamCommand) -> Result { Ok(match command { TeamCommand::List => { let snapshot = api.snapshot().await?; - let teams = snapshot_field(&snapshot, "teams")?; - api.show_with(teams, Directory::from_snapshot(&snapshot)) + let mut teams = snapshot_field(&snapshot, "teams")?; + let listed = teams.as_array().map_or(0, Vec::len); + if let Some(list) = teams.as_array_mut() { + output::teams_in_order(list); + } + let note = + output::partial_list_note("teams", listed, snapshot["totals"]["teams"].as_u64()); + Reply::Show( + teams, + Box::new( + api.human(Directory::from_snapshot(&snapshot)) + .with_view(output::View::Teams) + .with_notes(note), + ), + ) } // The result names the team as the broker stored it, which `output` prints. TeamCommand::Create { name } => api.show( @@ -1814,22 +3429,45 @@ async fn teams(api: &Api, command: TeamCommand) -> Result { }) } +/// `channels list [--team T]`: the person's channels in a reader's order, with their unread +/// counts, and a note when the snapshot left some out. +async fn list_channels(api: &Api, team: Option<&str>) -> Result { + let snapshot = api.snapshot().await?; + let mut channels = snapshot_field(&snapshot, "channels")?; + if let Some(team) = team { + let team = api.target(Kind::Team, team).await?; + channels = json!(channels + .as_array() + .context("Invalid channel list")? + .iter() + .filter(|item| item["team_id"].as_str() == Some(team.id.as_str())) + .collect::>()); + } + let mut channels = with_unread(channels, &snapshot); + if let Some(list) = channels.as_array_mut() { + output::channels_in_order( + list, + snapshot["teams"].as_array().map_or(&[], Vec::as_slice), + ); + } + // A partial snapshot lists some of the person's channels (wave 1's bounded + // snapshot): say so, rather than let the list read as all of them. + let listed = snapshot["channels"].as_array().map_or(0, Vec::len); + let note = + output::partial_list_note("channels", listed, snapshot["totals"]["channels"].as_u64()); + Ok(Reply::Show( + channels, + Box::new( + api.human(Directory::from_snapshot(&snapshot)) + .with_view(output::View::Channels) + .with_notes(note), + ), + )) +} + async fn channels(api: &Api, command: ChannelCommand) -> Result { Ok(match command { - ChannelCommand::List { team } => { - let snapshot = api.snapshot().await?; - let mut channels = snapshot_field(&snapshot, "channels")?; - if let Some(team) = team { - let team = api.target(Kind::Team, &team).await?; - channels = json!(channels - .as_array() - .context("Invalid channel list")? - .iter() - .filter(|item| item["team_id"].as_str() == Some(team.id.as_str())) - .collect::>()); - } - api.show_with(channels, Directory::from_snapshot(&snapshot)) - } + ChannelCommand::List { team } => list_channels(api, team.as_deref()).await?, ChannelCommand::Create { name, team, @@ -1862,8 +3500,18 @@ async fn channels(api: &Api, command: ChannelCommand) -> Result { } api.say(result, vec![line]) } - ChannelCommand::Archive { channel } => { + ChannelCommand::Archive { channel, yes } => { let channel = api.target(Kind::Channel, &channel).await?; + let label = api.label(&channel, "the channel", "channel ID"); + // The broker has no way back: archiving closes the channel to posts for everyone. + if let Consent::Ask(question) = yes_or_ask( + yes, + api.interactive, + format!("Archive {label} for everyone? Nobody can post in it after this, and it can't be undone."), + format!("Archiving {label} is permanent for everyone. There is no terminal to ask in, so add --yes."), + )? { + ask_yes(question, "Nothing was archived.").await?; + } let result = api .broker("channel.archive", json!({"channel_id":channel.id}), true) .await?; @@ -1898,7 +3546,7 @@ async fn channels(api: &Api, command: ChannelCommand) -> Result { /// `channels mark-read CHANNEL [CURSOR]`: up to `cursor`, or to the newest message. async fn mark_read(api: &Api, channel: &str, cursor: Option) -> Result { - let channel = api.target(Kind::Channel, channel).await?; + let channel = api.your_channel(channel).await?; let label = api.label(&channel, "the channel", "channel ID"); let cursor = match cursor { Some(cursor) => cursor, @@ -1936,7 +3584,14 @@ async fn invitations(api: &Api, command: InvitationCommand) -> Result { InvitationCommand::List => { let snapshot = api.snapshot().await?; let invitations = snapshot_field(&snapshot, "invitations")?; - api.show_with(invitations, Directory::from_snapshot(&snapshot)) + // CLIDOCS-F12: each list says its own empty sentence, never "No items.". + Reply::Show( + invitations, + Box::new( + api.human(Directory::from_snapshot(&snapshot)) + .with_view(output::View::Invitations), + ), + ) } InvitationCommand::Create { person, @@ -2229,6 +3884,19 @@ async fn add_member( .split_first() .context("A name was left unresolved")?; let username = member_username(api, who).await?; + if team.is_none() { + // One channel.add_member per channel cannot be taken back, so what the person's own + // view already shows would be refused stops the command before the first one. + if let Ok(snapshot) = api.snapshot().await { + let problems = channel_add_problems(&snapshot, &who.id, &username, places); + if !problems.is_empty() { + return Err(restated( + format!("{}\nNothing was added.", problems.join("\n")), + Some("crew_request_refused"), + )); + } + } + } let (result, added) = match team { Some(_) => { let (team, channels) = places.split_first().context("A name was left unresolved")?; @@ -2237,7 +3905,8 @@ async fn add_member( None => add_to_channels(api, who, &username, places).await?, }; let lines = if api.text() { - added_lines(api, &username, &added, places).await + let team = team.and_then(|_| places.first()); + added_lines(api, who, &username, team, &added, places).await } else { Vec::new() }; @@ -2331,12 +4000,21 @@ async fn add_to_channels( .map_err(|error| direct_add_unsupported(error, username)); let result = match answer { Ok(result) => result, + // Say what already changed. Whether a retry with --request-id is offered is the + // failure path's to decide: only for an outcome that is uncertain, never for a + // refusal, which the same command would meet again. Err(error) if !added.is_empty() => { + let done: Vec = channels + .iter() + .filter(|target| added.contains(&target.id)) + .map(|target| api.label(target, "a channel", "channel ID")) + .collect(); return Err(error.context(format!( - "@{} was added to some of the channels before this one failed; run the same command again with --request-id {} to finish.", + "Added @{} to {}, then stopped at {}", safe_text(username), - api.request_id - ))) + and_list(&done), + api.label(channel, "the next channel", "channel ID") + ))); } Err(error) => return Err(error), }; @@ -2348,36 +4026,132 @@ async fn add_to_channels( Ok((Value::Array(results), added)) } -/// "Added. @bob can now see #general and #methods.", or that nothing needed adding. +/// What the person's snapshot already shows the broker would refuse for `channel.add_member` +/// (CLI-11): a channel they neither own nor host, an archived one, or one whose team the person +/// being added is not in. A channel the snapshot does not list (a host adding to a channel they +/// are not in) is left to the broker, which authorizes every add either way. +fn channel_add_problems( + snapshot: &Value, + principal_id: &str, + username: &str, + channels: &[Target], +) -> Vec { + let actor = snapshot["actor"]["id"].as_str(); + let host = matches!( + host_standing(snapshot, None), + HostStanding::HostElsewhereToo { .. } | HostStanding::OnlyHostComputer { .. } + ); + let listed = |key: &str, id: &str| { + snapshot[key] + .as_array() + .into_iter() + .flatten() + .find(|item| item["id"].as_str() == Some(id)) + .cloned() + }; + let mut problems = Vec::new(); + for target in channels { + let Some(channel) = listed("channels", &target.id) else { + continue; + }; + let name = channel["name"] + .as_str() + .map_or_else(|| "this channel".to_owned(), channel_text); + if channel["archived"].as_bool() == Some(true) { + problems.push(format!("{name} is archived, so no one can be added to it.")); + } else if !host + && channel["owner_id"].as_str().is_some() + && channel["owner_id"].as_str() != actor + { + problems.push(format!( + "You don't own {name}. Only its owner or the workspace host can add people to it." + )); + } else if let Some(members) = channel["team_id"] + .as_str() + .and_then(|team| listed("teams", team)) + .and_then(|team| team["members"].as_array().cloned()) + { + if !members + .iter() + .any(|member| member.as_str() == Some(principal_id)) + { + problems.push(format!( + "@{} isn't in the team {name} belongs to yet. Add them to the team first.", + safe_text(username) + )); + } + } + } + problems +} + +/// What a direct add changed (M11), naming the person as every decision about a person does, +/// `"Carol Nguyen" (@crew_carol)`, and the places so they cannot be mistaken: +/// +/// - into a team: `Added "Dan Ro" (@dan) to Analysis Lab. They can now see #general.`; +/// - into channels: `Added "Dan Ro" (@dan) to #methods.`, with the team in front of a channel +/// whose name another of the person's teams also has: `Chen Lab / #general`, since every team +/// has a `#general`. +/// +/// Or that nothing needed adding. async fn added_lines( api: &Api, + who: &Target, username: &str, + team: Option<&Target>, added: &[String], places: &[Target], ) -> Vec { - let handle = format!("@{}", safe_text(username)); + let names = api.names().await; + let person = { + let label = output::authority_label(&names, &who.id, api.show_ids); + if label.starts_with("Unknown member") { + api.with_id(format!("@{}", safe_text(username)), "ID", &who.id) + } else { + label + } + }; if added.is_empty() { - return vec![format!("{handle} is already in everything you chose.")]; + return vec![format!("{person} is already in everything you chose.")]; } - let names = api.names().await; - let seen: Vec = added - .iter() - .map(|id| { - let known = names.channel_label(id); - if known.starts_with('#') { - return known; - } + let place = |id: &String, across_teams: bool| { + let known = if across_teams { + output::channel_across_teams(&names, id) + } else { + Some(names.channel_label(id)).filter(|label| label.starts_with('#')) + }; + known.unwrap_or_else(|| { places .iter() .find(|target| target.id == *id) .and_then(|target| target.label.as_deref()) - .map_or(known, name_text) + .map_or_else(|| "a channel".to_owned(), name_text) }) - .collect(); - vec![format!("Added. {handle} can now see {}.", and_list(&seen))] + }; + match team { + // The channels a team add gave are all that team's, so it names them once. + Some(team) => { + let seen: Vec = added.iter().map(|id| place(id, false)).collect(); + vec![format!( + "Added {person} to {}. They can now see {}.", + api.label(team, "the team", "team ID"), + and_list(&seen) + )] + } + None => { + let seen: Vec = added.iter().map(|id| place(id, true)).collect(); + vec![format!("Added {person} to {}.", and_list(&seen))] + } + } } -async fn remove_member(api: &Api, channel: &str, member: &str, former: bool) -> Result { +async fn remove_member( + api: &Api, + channel: &str, + member: &str, + former: bool, + yes: bool, +) -> Result { let kind = if former { Kind::FormerPerson } else { @@ -2387,6 +4161,27 @@ async fn remove_member(api: &Api, channel: &str, member: &str, former: bool) -> .resolve(&[(Kind::Channel, channel), (kind, member)]) .await?; let (channel, who) = (&targets[0], &targets[1]); + let place = api.label(channel, "the channel", "channel ID"); + let handle = who.username.as_deref().map_or_else( + || "this member".to_owned(), + |username| format!("@{}", safe_text(username)), + ); + let question = if !yes && api.interactive { + format!( + "Remove {} from {place}? They lose access to its messages and files.", + api.authority_label(who).await + ) + } else { + String::new() + }; + if let Consent::Ask(question) = yes_or_ask( + yes, + api.interactive, + question, + format!("Removing {handle} from {place} needs a yes. There is no terminal to ask in, so add --yes."), + )? { + ask_yes(question, "Nothing was changed.").await?; + } let mut params = json!({"channel_id":channel.id,"principal_id":who.id}); if let Some(username) = &who.username { params["expected_username"] = json!(username); @@ -2423,6 +4218,21 @@ fn text_input(input: TextInput) -> Result { } } +/// The code of a message refused here for its size, before anything was sent. +const MESSAGE_TOO_LONG_CODE: &str = "crew_message_too_long"; + +/// A message the workspace would refuse for its size is refused here first (MSG2-N7), as a +/// usage error: the broker takes at most 64 KB of text, and at most twice that once +/// JSON-escaped (`message.post` in `broker.rs`). +fn check_message_size(body: &str) -> Result<()> { + const MAX: usize = biorouter::crew::source_line::MAX_POSTED_BYTES; + let escaped = serde_json::to_string(body)?.len().saturating_sub(2); + if body.len() > MAX || escaped > 2 * MAX { + return Err(usage(output::MESSAGE_TOO_LONG, MESSAGE_TOO_LONG_CODE)); + } + Ok(()) +} + async fn send_message(api: &Api, args: SendArgs) -> Result { let body = if args.text.is_none() && args.input.is_none() { ensure!( @@ -2436,7 +4246,8 @@ async fn send_message(api: &Api, args: SendArgs) -> Result { input: args.input, })? }; - let channel = api.target(Kind::Channel, &args.channel).await?; + check_message_size(&body)?; + let channel = api.your_channel(&args.channel).await?; let result = api .broker( "message.post", @@ -2456,70 +4267,181 @@ async fn send_message(api: &Api, args: SendArgs) -> Result { } async fn watch(api: &Api, args: WatchArgs) -> Result { - let channel = api.target(Kind::Channel, &args.channel).await?; + let channel = api.your_channel(&args.channel).await?; + let (mut cursor, initial) = watch_start(api, &channel.id, &args).await?; let path = api.path("/observe").await?; let client = api.client.shared()?; - let mut cursor = args.after; - let mut names = Directory::default(); + let state = std::sync::Mutex::new(WatchState::default()); let watched = api.label(&channel, "the channel", "channel ID"); loop { let request = ObserveRequest { channel_id: Some(channel.id.clone()), after: cursor.clone(), - initial: Initial::All, + initial: match &initial { + Initial::Latest => Initial::Latest, + Initial::All => Initial::All, + }, }; cursor = tokio::select! { - result = client.observe(&path, &request, |event| watch_event(api, &mut names, &watched, event)) => result?, + result = client.observe(&path, &request, |event| watch_event(api, &state, &watched, event)) => result?, signal = tokio::signal::ctrl_c() => { signal?; return Ok(Reply::Streamed); } }; } } -fn watch_event( +/// Where `crew watch` starts (CLI-6): the channel's newest page, as the desktop's channel +/// view does; with `--new-only`, after the newest message, so only new posts print; with +/// `--from-start`, the oldest message; with `--after`, that cursor. It used to replay the whole +/// channel from its oldest message before following it. +async fn watch_start( + api: &Api, + channel_id: &str, + args: &WatchArgs, +) -> Result<(Option, Initial)> { + if let Some(after) = &args.after { + return Ok((Some(after.clone()), Initial::All)); + } + if args.from_start { + return Ok((None, Initial::All)); + } + if !args.new_only { + return Ok((None, Initial::Latest)); + } + let newest = api + .broker( + "messages.history", + json!({"channel_id": channel_id, "latest": true, "limit": 1}), + false, + ) + .await?; + // An empty channel has no cursor: everything posted to it from now on is new. + Ok((newest["cursor"].as_str().map(str::to_owned), Initial::All)) +} + +/// What `crew watch` carries from frame to frame: the names the last state frame gave, and the +/// attachments already looked up. +#[derive(Default)] +struct WatchState { + names: Directory, + attachments: AttachmentNames, +} + +async fn watch_event( api: &Api, - names: &mut Directory, + state: &std::sync::Mutex, watched: &str, event: ObserveEvent, ) -> Result>> { + let locked = || { + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }; match event { // The frame's snapshot names the authors of the messages that follow. ObserveEvent::State { snapshot, .. } => { if api.text() { - *names = Directory::from_snapshot(&snapshot); + locked().names = Directory::from_snapshot(&snapshot); } } - ObserveEvent::Messages { messages, .. } => { - let options = api.human(names.clone()); + ObserveEvent::Messages { mut messages, .. } => { + // The lookups wait on the daemon, so the state is not held across them. + let mut attachments = std::mem::take(&mut locked().attachments); + attachments.name_in(api, &mut messages).await; + let names = { + let mut state = locked(); + state.attachments = attachments; + state.names.clone() + }; + let options = api.human(names); for message in messages { emit_with(&message, output::stream_format(api.format), &options)?; } } ObserveEvent::Reconnect { cursor } => return Ok(std::ops::ControlFlow::Break(cursor)), + // Printed once, by the failure path: a sentence in text, and in JSON one line that is + // both the observer's error frame and the command's error. ObserveEvent::Error { code, error, clear } => { - if !api.text() { - emit( - &json!({"type":"error","code":code,"error":error,"clear":clear}), - output::stream_format(api.format), - )?; + return Err(WatchStopped { + message: watch_stopped(watched, &error), + code: safe_text(&code), + clear, } - return Err(anyhow!(watch_stopped(watched, &error))); + .into()); } } Ok(std::ops::ControlFlow::Continue(())) } +/// Each attachment's name, size and type, looked up with `blob.status` (DW-17), so history and +/// watch can say which file is which: a message carries only its attachments' IDs, and +/// `files download ID` needs the right one. Each ID is asked about once per command, and at most +/// [`Self::MOST`] are asked about in all, so a long page never waits long; an attachment past +/// that, or one the person cannot see, is shown by its ID alone. +#[derive(Debug, Default)] +struct AttachmentNames { + known: std::collections::HashMap>, +} + +impl AttachmentNames { + const MOST: usize = 40; + + /// [`Self::name_in`] for a history or search page's `messages`. + async fn name_page(&mut self, api: &Api, page: &mut Value) { + if let Some(messages) = page["messages"].as_array_mut() { + self.name_in(api, messages).await; + } + } + + /// Add `attachment_details` ({ID: {name, size, media_type}}) beside each message's + /// unchanged `attachments`, for every attachment that could be looked up. + async fn name_in(&mut self, api: &Api, messages: &mut [Value]) { + for message in messages.iter_mut() { + let ids: Vec = message["attachments"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + let mut details = serde_json::Map::new(); + for id in ids { + if let Some(found) = self.look_up(api, &id).await { + details.insert(id, found); + } + } + if !details.is_empty() { + message["attachment_details"] = Value::Object(details); + } + } + } + + async fn look_up(&mut self, api: &Api, id: &str) -> Option { + if let Some(known) = self.known.get(id) { + return known.clone(); + } + if self.known.len() >= Self::MOST || component(id).is_err() { + return None; + } + let found = api + .lookup("blob.status", json!({"blob_id": id})) + .await + .map(|blob| json!({"name": blob["name"], "size": blob["size"], "media_type": blob["media_type"]})) + .filter(|details| details["name"].is_string()); + self.known.insert(id.to_owned(), found.clone()); + found + } +} + /// Why `crew watch` stopped, for a person (Q2-76): the channel, then the observer's own plain /// sentence. The code stays in the JSON error frame; the cursor is an internal, never named. +/// `watched` is the channel's label as [`Api::label`] made it, already safe and isolated. fn watch_stopped(watched: &str, error: &str) -> String { let sentence = error.trim().trim_end_matches(['.', '!', '?']); if sentence.is_empty() { - format!("Stopped watching {}.", safe_text(watched)) + format!("Stopped watching {watched}.") } else { - format!( - "Stopped watching {}: {}.", - safe_text(watched), - safe_text(sentence) - ) + format!("Stopped watching {watched}: {}.", safe_text(sentence)) } } @@ -2527,10 +4449,32 @@ async fn file_command(api: &Api, mut command: FileCommand) -> Result { if let FileCommand::Upload { channel, .. } | FileCommand::Reference { channel, .. } = &mut command { - *channel = api.target(Kind::Channel, channel).await?.id; - } + *channel = api.your_channel(channel).await?.id; + } + let watching = matches!(command, FileCommand::Watch { .. }); + // CLIDOCS-F10: `forget` is said in the terminal's words; the daemon's "Receipt removed + // after authorized partial cleanup…" is its internal account. JSON keeps the daemon's. + let forgetting = match &command { + FileCommand::Forget { file, .. } => Some(file.is_some()), + _ => None, + }; let result = files::handle(api, command).await?; - Ok(api.show_with(result, api.names().await)) + if let Some(partial_deleted) = forgetting { + let sentence = if partial_deleted { + "Removed from your list, and deleted the partial download. Files already shared stay in the channel." + } else { + "Removed from your list. Files already shared stay in the channel." + }; + return Ok(api.say(result, vec![sentence.to_owned()])); + } + let names = api.names().await; + Ok(if watching { + // The summary sentence ("Transfer Saved."), not one more transfer row. + let options = api.human(names).with_view(output::View::Result); + Reply::Last(result, Box::new(options)) + } else { + api.show_with(result, names) + }) } async fn tasks(api: &Api, command: TaskCommand) -> Result { @@ -2554,13 +4498,15 @@ async fn tasks(api: &Api, command: TaskCommand) -> Result { .iter() .map(|channel| (Kind::Channel, channel.as_str())), ); - let targets = api.resolve(&selectors).await?; + let targets = api.resolve_yours(&selectors).await?; let (destination, context) = targets .split_first() .context("The task's channel was left unresolved")?; let context: Vec<&str> = context.iter().map(|target| target.id.as_str()).collect(); let started = api.connection_action("runs", api.with_run_policy(json!({"request_id":api.request_id,"channel_id":destination.id,"prompt":prompt,"provider":provider,"model":model,"context_channels":context,"posting_grant":allow_posting}))).await; - api.show(started.map_err(|error| institution_refusal(error, &model))?) + let started = started.map_err(|error| institution_refusal(error, &model))?; + // The task is named by the channel it posts to, as `tasks list` names it. + api.show_with(started, api.names().await) } TaskCommand::List => { let runs = api @@ -2574,12 +4520,17 @@ async fn tasks(api: &Api, command: TaskCommand) -> Result { api.show_with(run, api.names().await) } TaskCommand::Watch { run } => watch_task(api, &run).await?, + // The route takes no body, so no request ID is sent and no `--request-id` retry is + // offered: running `tasks cancel` again is the retry (CLI-20). TaskCommand::Cancel { run } => api.show( - api.connection_action( - &format!("runs/{}/cancel", component(&run)?), - json!({"request_id":api.request_id}), - ) - .await?, + api.client + .request( + "POST", + &api.path(&format!("/runs/{}/cancel", component(&run)?)) + .await?, + None, + ) + .await?, ), }) } @@ -2600,8 +4551,12 @@ fn institution_refusal(error: anyhow::Error, requested_model: &str) -> anyhow::E } None }); + // A daemon that types the refusal (`crew_institution_mismatch`) is said by the failure path + // from its details, keeping that code; this rewords only an older daemon's sentence. + let typed = + refusal(&error).and_then(|refused| refused.code).as_deref() == Some(INSTITUTION_MISMATCH); match found { - Some((message, details)) if message.contains(AFFILIATION_REFUSAL) => restated( + Some((message, details)) if !typed && message.contains(AFFILIATION_REFUSAL) => restated( output::institution_refusal_text(requested_model, details.as_ref()), Some("crew_request_refused"), ), @@ -2659,13 +4614,88 @@ async fn watch_task(api: &Api, id: &str) -> Result { } } +/// `crew context SESSION` (AG-F7): what the help promises, the grant's scope first, then the +/// messages the chat can read, oldest first. The daemon's manifest names the channels the chat +/// reads but not the one it posts in, which the grants list has. JSON is the manifest as it is. +async fn context(api: &Api, session: &str) -> Result { + let manifest = match api.session_get(session, "context").await { + Ok(manifest) => manifest, + Err(error) => return Err(access_ended(api, error, session).await), + }; + if !api.text() { + return Ok(api.show(manifest)); + } + let destination = grant_channel(api, session).await; + let lines = output::context_lines( + &manifest, + destination.as_deref(), + &api.human(api.names().await), + ); + Ok(api.say(manifest, lines)) +} + +/// The channel `session`'s grant posts in, from the grants list, when it can be read. +async fn grant_channel(api: &Api, session: &str) -> Option { + let path = api.path("/grants").await.ok()?; + let grants = api.client.request("GET", &path, None).await.ok()?; + grants["grants"] + .as_array()? + .iter() + .find(|grant| grant["session_id"].as_str() == Some(session)) + .and_then(|grant| grant["channel_id"].as_str()) + .map(str::to_owned) +} + +/// The daemon's sentence for a grant that ended because Crew's settings changed, which a daemon +/// before T3-BE-7 sent as `crew_profile_refused`, a code it also gives unrelated refusals. +const LEGACY_SETTINGS_CHANGED: &str = "Crew settings changed since access was granted"; + +/// `context` refused because the chat's Crew access ended (SF2-N4): why, and the +/// `grants grant` that grants it again, with the chat and the channel its grant posted in (by +/// name when the workspace can be read, else by ID). The daemon says "Grant access again from +/// Crew", a desktop control. Any other refusal is left as it is; JSON keeps the daemon's code. +async fn access_ended(api: &Api, error: anyhow::Error, session: &str) -> anyhow::Error { + let ended = error.chain().find_map(|cause| { + let (code, message) = refusal_code_and_message(cause)?; + match code { + GRANT_ENDED => Some(refusal_field(cause, "reason") == Some("settings_changed")), + "crew_profile_refused" if message.trim().starts_with(LEGACY_SETTINGS_CHANGED) => { + Some(true) + } + _ => None, + } + }); + let Some(settings_changed) = ended else { + return error; + }; + let channel = match grant_channel(api, session).await { + Some(id) => { + let label = api.names().await.channel_label(&id); + match label.strip_prefix('#') { + Some(name) => shell_word(name), + None => id, + } + } + None => "CHANNEL".to_owned(), + }; + Worded { + sentence: grant_ended_sentence( + settings_changed, + Some((&shell_word(session), &safe_text(&channel))), + ), + source: error, + } + .into() +} + async fn grants(api: &Api, command: GrantCommand) -> Result { Ok(match command { GrantCommand::List => { - let grants = api + let mut grants = api .client .request("GET", &api.path("/grants").await?, None) .await?; + with_task_statuses(api, &mut grants).await; api.show_with(grants, api.names().await) } GrantCommand::Grant { @@ -2680,7 +4710,7 @@ async fn grants(api: &Api, command: GrantCommand) -> Result { .iter() .map(|channel| (Kind::Channel, channel.as_str())), ); - let targets = api.resolve(&selectors).await?; + let targets = api.resolve_yours(&selectors).await?; let (destination, context) = targets .split_first() .context("The grant's channel was left unresolved")?; @@ -2699,13 +4729,55 @@ async fn grants(api: &Api, command: GrantCommand) -> Result { }) } -/// `grants revoke`. Success is only the workspace's confirmation (`200` with `revoked: true` -/// and the revocation confirmed); a grant stopped here but not confirmed there (`503 -/// crew_revocation_unconfirmed`, or a `2xx` saying so) is printed as that and exits non-zero, -/// as `tasks cancel` does. -async fn revoke_grant(api: &Api, session: &str) -> Result { - let path = api - .path(&format!("/sessions/{}/revoke", component(session)?)) +/// Each task's grant in `grants` with its task's status from the task list (`task_status`, +/// AGT2-N3), so a task the person stopped reads Stopped and one that finished reads Ended, as +/// the channel's card says it: the grant itself cannot tell them apart, since a finished task's +/// grant is revoked as a stopped one's is. Nothing is added when the task list cannot be read. +async fn with_task_statuses(api: &Api, grants: &mut Value) { + let is_task = |grant: &Value| grant["kind"].as_str() == Some("task"); + let has_task = ["grants", "replaced_grants"] + .iter() + .filter_map(|key| grants[*key].as_array()) + .flatten() + .any(is_task); + if !has_task { + return; + } + let Ok(path) = api.path("/runs").await else { + return; + }; + let Ok(runs) = api.client.request("GET", &path, None).await else { + return; + }; + let statuses: std::collections::HashMap<&str, &str> = runs["runs"] + .as_array() + .into_iter() + .flatten() + .filter_map(|run| Some((run["run_id"].as_str()?, run["status"].as_str()?))) + .collect(); + for key in ["grants", "replaced_grants"] { + let Some(list) = grants[key].as_array_mut() else { + continue; + }; + for grant in list.iter_mut().filter(|grant| is_task(grant)) { + let status = grant["run_id"] + .as_str() + .and_then(|run| statuses.get(run)) + .map(|status| (*status).to_owned()); + if let (Some(status), None) = (status, grant.get("task_status")) { + grant["task_status"] = json!(status); + } + } + } +} + +/// `grants revoke`. Success is only the workspace's confirmation (`200` with `revoked: true` +/// and the revocation confirmed); a grant stopped here but not confirmed there (`503 +/// crew_revocation_unconfirmed`, or a `2xx` saying so) is printed as that and exits non-zero, +/// as `tasks cancel` does. +async fn revoke_grant(api: &Api, session: &str) -> Result { + let path = api + .path(&format!("/sessions/{}/revoke", component(session)?)) .await?; let answer = api .client @@ -2747,60 +4819,170 @@ fn revoked_lines(session: &str, answer: &Value) -> Result> { Some("cancelled") => lines.push("Its task was stopped.".into()), Some(status) => lines.push(format!( "Its task's status: {}.", - safe_text(&status.replace('_', " ")) + output::run_status_word(status) )), None => {} } Ok(lines) } +/// `privacy set-personal MODE`: how this computer treats the workspace. Going public is +/// confirmed by typing the workspace's name (CLI-10), and in a workspace that is Private for +/// everyone it changes nothing until the host allows Public, which is said (SF-F1). +async fn set_personal( + api: &Api, + mode: PrivacyMode, + institution_id: Option, + confirm: Option<&str>, +) -> Result { + let connection = api.connection().await?; + // SF-F1: in a workspace that is Private for everyone, a public connection changes + // nothing until the host allows Public, and the person is told so. Going Private + // waits on nothing it does not need. + let snapshot = match mode { + PrivacyMode::Public => api.snapshot().await.ok(), + PrivacyMode::Private => None, + }; + let workspace_private = snapshot + .as_ref() + .is_some_and(|snapshot| snapshot["workspace"]["mode"] == "private"); + let workspace = snapshot + .as_ref() + .and_then(workspace_name_in) + .unwrap_or_else(|| connection_name(&connection)); + let shown = name_text(&workspace); + if matches!(mode, PrivacyMode::Public) && connection["mode"].as_str() != Some("public") { + let effect = if workspace_private { + format!("Make your {shown} connection public? Nothing changes while {shown} is Private for everyone; if the host allows Public, public models will be able to read public-safe work you can see there. Restricted content stays private.") + } else { + format!("Make your {shown} connection public? Public models will be able to read public-safe work you can see here. Restricted content stays private.") + }; + confirm_public( + api, + &workspace, + confirm, + vec![effect], + format!("Making your {shown} connection public needs its name typed. There is no terminal to ask in, so confirm with --confirm {}.", safe_text(&shell_word(&workspace))), + ) + .await?; + } + let mut input = serde_json::Map::new(); + for key in [ + "name", + "ssh_target", + "port", + "identity_file", + "proxy_jump", + "socket_path", + "owner_uid", + "workspace_id", + "workspace_public_key", + "remote_root", + "remote_execution", + "cluster_connection_id", + "institution_id", + ] { + if let Some(value) = connection.get(key) { + input.insert(key.into(), value.clone()); + } + } + input.insert("mode".into(), json!(mode.as_str())); + if let Some(institution_id) = institution_id { + input.insert("institution_id".into(), json!(institution_id)); + } + let saved = api + .client + .request("PATCH", &api.path("").await?, Some(Value::Object(input))) + .await?; + let saved = with_privacy_of(saved, snapshot.as_ref()); + let note = (matches!(mode, PrivacyMode::Public) && workspace_private) + .then(|| format!("Nothing changes while {shown} is Private for everyone.")); + Ok(Reply::Show( + saved, + Box::new(api.human(Directory::default()).with_notes(note)), + )) +} + +/// `privacy show` (SF2-N3, CLIDOCS-F5): the privacy in force (`effective_mode`, the rule +/// `status` uses), the connection's own setting, the workspace's, and each channel's +/// classification in the channels-list order. While disconnected the connection's own setting +/// is still printed, and the workspace's is said to be unreadable (`workspace` is `null`, and +/// `effective_mode` too unless the connection is Private, which is Private whatever the +/// workspace says). +async fn privacy_show(api: &Api) -> Result { + let connection = api.connection().await?; + let snapshot = match api.snapshot().await { + Ok(snapshot) => Some(snapshot), + Err(error) if disconnected(&error) => None, + Err(error) => return Err(error), + }; + let workspace = snapshot + .as_ref() + .map_or(Value::Null, |snapshot| snapshot["workspace"].clone()); + let mut channels = snapshot + .as_ref() + .map_or(Value::Null, |snapshot| snapshot["channels"].clone()); + if let (Some(list), Some(snapshot)) = (channels.as_array_mut(), snapshot.as_ref()) { + output::channels_in_order( + list, + snapshot["teams"].as_array().map_or(&[], Vec::as_slice), + ); + } + let effective = output::effective_mode(connection["mode"].as_str(), workspace["mode"].as_str()); + let value = json!({ + "connection_id": connection["id"], + "personal_mode": connection["mode"], + "institution_id": connection["institution_id"], + "connection_policy_epoch": connection["policy_epoch"], + "effective_mode": effective, + "workspace": workspace, + "channels": channels, + }); + let directory = snapshot + .as_ref() + .map(Directory::from_snapshot) + .unwrap_or_default(); + Ok(Reply::Show( + value, + Box::new(api.human(directory).with_view(output::View::Privacy)), + )) +} + async fn privacy(api: &Api, command: PrivacyCommand) -> Result { Ok(match command { - PrivacyCommand::Show => { - let connection = api.connection().await?; - let snapshot = api.snapshot().await?; - let value = json!({"connection_id":connection["id"],"personal_mode":connection["mode"],"institution_id":connection["institution_id"],"connection_policy_epoch":connection["policy_epoch"],"workspace":snapshot["workspace"],"channels":snapshot["channels"]}); - api.show_with(value, Directory::from_snapshot(&snapshot)) - } + PrivacyCommand::Show => privacy_show(api).await?, PrivacyCommand::SetPersonal { mode, institution_id, - } => { - let connection = api.connection().await?; - let mut input = serde_json::Map::new(); - for key in [ - "name", - "ssh_target", - "port", - "identity_file", - "proxy_jump", - "socket_path", - "owner_uid", - "workspace_id", - "workspace_public_key", - "remote_root", - "remote_execution", - "cluster_connection_id", - "institution_id", - ] { - if let Some(value) = connection.get(key) { - input.insert(key.into(), value.clone()); - } - } - input.insert("mode".into(), json!(mode.as_str())); - if let Some(institution_id) = institution_id { - input.insert("institution_id".into(), json!(institution_id)); - } - api.show( - api.client - .request("PATCH", &api.path("").await?, Some(Value::Object(input))) - .await?, - ) - } + confirm, + } => set_personal(api, mode, institution_id, confirm.as_deref()).await?, PrivacyCommand::SetWorkspace { mode, institution_id, + confirm, } => { + if matches!(mode, PrivacyMode::Public) { + let connection = api.connection().await?; + let snapshot = api.snapshot().await.ok(); + let already = snapshot + .as_ref() + .is_some_and(|snapshot| snapshot["workspace"]["mode"] == "public"); + if !already { + let workspace = snapshot + .as_ref() + .and_then(workspace_name_in) + .unwrap_or_else(|| connection_name(&connection)); + let shown = name_text(&workspace); + confirm_public( + api, + &workspace, + confirm.as_deref(), + vec![format!("Allow Public in {shown}? Members will be able to choose Public. Agents with access will need permission again.")], + format!("Allowing Public in {shown} needs its name typed. There is no terminal to ask in, so confirm with --confirm {}.", safe_text(&shell_word(&workspace))), + ) + .await?; + } + } let mut policy = json!({"mode":mode.as_str()}); if let Some(institution_id) = institution_id { policy["institution_id"] = json!(institution_id); @@ -2930,7 +5112,12 @@ mod tests { pub(super) code: Option, pub(super) broker_code: Option, pub(super) institution_refusal: Option, + pub(super) connection_institution: Option, pub(super) message: String, + pub(super) detail: Option, + pub(super) modes: Option<(String, String)>, + /// The refusal's other fields, as `DaemonRefusal::fields` keeps them. + pub(super) fields: serde_json::Map, } impl std::fmt::Display for FakeRefusal { @@ -2951,7 +5138,11 @@ mod tests { code: code.map(str::to_owned), broker_code: None, institution_refusal: None, + connection_institution: None, message: message.to_owned(), + detail: None, + modes: None, + fields: Default::default(), } .into() } @@ -2964,7 +5155,11 @@ mod tests { code: Some("crew_request_refused".into()), broker_code: Some(broker_code.into()), institution_refusal: None, + connection_institution: None, message: message.to_owned(), + detail: None, + modes: None, + fields: Default::default(), } .into() } @@ -3092,6 +5287,7 @@ mod tests { interactive: false, poll: Duration::ZERO, connection_id: tokio::sync::OnceCell::new(), + channel_labels: std::sync::Mutex::default(), }; (api, fake) } @@ -3099,7 +5295,9 @@ mod tests { fn said(reply: Reply) -> Vec { match reply { Reply::Say(_, lines) => lines, - Reply::Show(value, options) => vec![output::render_text(&value, &options)], + Reply::Show(value, options) | Reply::Last(value, options) => { + vec![output::render_text(&value, &options)] + } Reply::Streamed => Vec::new(), } } @@ -3130,6 +5328,7 @@ mod tests { channel: METHODS.into(), member: format!("@{BOB}"), former: false, + yes: true, }, ) .await @@ -3172,6 +5371,7 @@ mod tests { channel: "#methods".into(), member: "@bob".into(), former: false, + yes: true, }, ) .await @@ -3189,10 +5389,7 @@ mod tests { fake.broker_call("membership.revoke").expect("remove"), json!({"channel_id": METHODS, "principal_id": BOB, "expected_username": "bob", "idempotency_key": "req-1"}) ); - assert_eq!( - lines, - ["Removed \"\u{2068}Bob Lee\u{2069}\" (@bob) from #methods."] - ); + assert_eq!(lines, ["Removed \"Bob Lee\" (@bob) from #methods."]); assert!( !lines.iter().any(|line| line.contains(BOB)), "no ID by default" @@ -3233,7 +5430,7 @@ mod tests { fake.broker_call("invitation.create").expect("invite"), json!({"kind": kind, "target_id": target, "principal_id": BOB, "expected_username": "bob", "idempotency_key": "req-1"}) ); - assert!(lines[0].starts_with("Invited \"\u{2068}Bob Lee\u{2069}\" (@bob) to ")); + assert!(lines[0].starts_with("Invited \"Bob Lee\" (@bob) to ")); } let (api, fake) = api_with(OutputFormat::Json, standard); @@ -3243,6 +5440,7 @@ mod tests { channel: "methods".into(), member: "@carol".into(), former: true, + yes: true, }, ) .await @@ -3264,6 +5462,7 @@ mod tests { &api, CrewCommand::Channels(ChannelCommand::Archive { channel: "general".into(), + yes: true, }), ) .await @@ -3290,15 +5489,96 @@ mod tests { channel: "raw-data".into(), member: "@Bob".into(), former: false, + yes: true, }, ) .await .expect_err("unknown names are refused"); assert_eq!( message(&error), - "You're not in a channel called #raw-data.\nThere's no member @Bob in this workspace. Did you mean @bob? Usernames must match exactly." + "No channel you're in is called #raw-data.\nThere's no member @Bob in this workspace. Did you mean @bob? Usernames must match exactly." ); assert!(fake.broker_calls().is_empty()); + // DW-10: the JSON error carries the resolver's code, as a daemon refusal does. + assert_eq!(error_code(&error).as_deref(), Some("unknown_name")); + assert_eq!( + failure_body(&error, &message(&error), "req-1")["code"], + "unknown_name" + ); + let (api, _) = api_with(OutputFormat::Json, standard); + let error = run(&api, history("general")) + .await + .expect_err("two teams have a #general"); + assert_eq!(error_code(&error).as_deref(), Some("ambiguous_name")); + assert!(!failure(&error, OutputFormat::Json, "req-1", false) + .to_string() + .contains("req-1")); + } + + /// AG-F13: a channel ID from another workspace, or of a channel the person left, is refused + /// before the daemon is asked, as a name would be. A snapshot that leaves channels out + /// cannot prove an ID absent, so it leaves the ID to the broker, and so do commands a host + /// may run on a channel they are not in. + #[tokio::test] + async fn a_channel_id_you_are_not_in_is_refused_like_a_name() { + const FOREIGN: &str = "f0e1d2c3-0000-4000-8000-00000000000f"; + let grant = |context: &str| { + CrewCommand::Grants(GrantCommand::Grant { + session: SESSION.into(), + channel: "methods".into(), + context_channels: vec![context.to_owned()], + }) + }; + let (api, fake) = api_with(OutputFormat::Text, standard); + let error = run(&api, grant(FOREIGN)) + .await + .expect_err("not one of yours"); + assert_eq!( + message(&error), + format!("No channel you're in has the ID {FOREIGN}.") + ); + assert_eq!(error_code(&error).as_deref(), Some("unknown_name")); + assert!( + !fake.sent().iter().any(|sent| sent.path.ends_with("/grant")), + "nothing was asked of the daemon" + ); + + // An ID of one of their channels goes through. + let granting = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/grant") { + return Ok(json!({"session_id": SESSION, "run_id": "r"})); + } + standard(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Text, granting); + run(&api, grant(GENERAL)).await.expect("one of theirs"); + assert!(fake.sent().iter().any(|sent| sent.path.ends_with("/grant"))); + + // A snapshot that leaves channels out cannot prove the ID absent. + let partial = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + let mut snapshot = snapshot(); + snapshot["totals"] = json!({"channels": 3}); + return Ok(snapshot); + } + granting(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Text, partial); + run(&api, grant(FOREIGN)).await.expect("left to the broker"); + assert!(fake.sent().iter().any(|sent| sent.path.ends_with("/grant"))); + + // A host may archive a channel they are not in, by its ID. + let (api, fake) = api_with(OutputFormat::Text, standard); + run( + &api, + CrewCommand::Channels(ChannelCommand::Archive { + channel: FOREIGN.into(), + yes: true, + }), + ) + .await + .expect("left to the broker"); + assert!(fake.broker_call("channel.archive").is_some()); } #[tokio::test] @@ -3380,7 +5660,9 @@ mod tests { std::fs::write(file.path(), "brcrew1:abc").expect("write invitation"); let error = run( &api, - CrewCommand::Connections(ConnectionCommand::JoinInvitation(join_args(file.path()))), + CrewCommand::Connections(ConnectionCommand::JoinInvitation(Box::new(join_args( + file.path(), + )))), ) .await .expect_err("an old daemon"); @@ -3514,7 +5796,7 @@ mod tests { ); assert_eq!( lines, - ["Revoked \"\u{2068}Bob Lee\u{2069}\" (@bob). Their membership, devices and agent grants no longer work."] + ["Revoked \"Bob Lee\" (@bob). Their membership, devices and agent grants no longer work."] ); assert_eq!( @@ -3554,6 +5836,74 @@ mod tests { assert_eq!(lines, ["Marked #methods as read."]); } + /// MSG2-N5, CLIDOCS-F1: the resolver labels an archived channel `#archive-me · archived` + /// for lists; a sentence names it bare, never repeating its status, and never isolated + /// because of the separator: in text and in JSON's `error`. + #[tokio::test] + async fn an_archived_channel_is_named_bare_in_sentences() { + const ARCHIVED: &str = "c4a77e10-0000-4000-8000-00000000000a"; + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path == "/crew/resolve" { + let body = body.expect("resolve body"); + let results: Vec = body["selectors"] + .as_array() + .expect("selectors") + .iter() + .map(|selector| json!({"status": "resolved", "kind": "channel", "text": selector["text"], "id": ARCHIVED, "label": "#archive-me · archived"})) + .collect(); + return Ok(json!({"connection": null, "results": results})); + } + match body.and_then(|body| body["method"].as_str()) { + Some("workspace.snapshot") => { + let mut snapshot = snapshot(); + snapshot["channels"].as_array_mut().expect("channels").push(json!({"id": ARCHIVED, "team_id": TEAM, "name": "archive-me", "classification": "restricted", "archived": true})); + Ok(snapshot) + } + Some("message.post" | "channel.rename") => Err(refuse_broker( + "channel_archived", + "channel_archived: channel is read-only", + )), + _ => standard(method, path, body), + } + }; + let send = CrewCommand::Send(SendArgs { + channel: "archive-me".into(), + text: Some("hi".into()), + input: None, + attachments: Vec::new(), + references: Vec::new(), + }); + let rename = CrewCommand::Channels(ChannelCommand::Rename { + channel: "archive-me".into(), + name: "renamed".into(), + }); + for (name, command) in [("send", send), ("rename", rename)] { + let (api, _) = api_with(OutputFormat::Json, handler); + let error = run(&api, command).await.expect_err(name); + let text = safe_lines(&error_text(&error)); + assert_eq!( + text, "#archive-me is archived, so it's read-only.", + "{name}" + ); + assert!(!text.contains('\u{2068}'), "{name}: {text}"); + let body = failure_body(&error, &text, "req-1"); + assert_eq!(body["error"], "#archive-me is archived, so it's read-only."); + } + let (api, _) = api_with(OutputFormat::Text, handler); + let lines = said( + run( + &api, + CrewCommand::Channels(ChannelCommand::MarkRead { + channel: "archive-me".into(), + cursor: None, + }), + ) + .await + .expect("mark-read"), + ); + assert_eq!(lines, ["Marked #archive-me as read."]); + } + #[tokio::test] async fn create_and_rename_echo_the_name_the_workspace_stored() { let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { @@ -3699,6 +6049,7 @@ mod tests { remote_root: None, remote_execution: false, preparation_id: None, + replace: false, } } @@ -3735,7 +6086,9 @@ mod tests { let lines = said( run( &api, - CrewCommand::Connections(ConnectionCommand::JoinInvitation(join_args(file.path()))), + CrewCommand::Connections(ConnectionCommand::JoinInvitation(Box::new(join_args( + file.path(), + )))), ) .await .expect("saved"), @@ -3755,14 +6108,16 @@ mod tests { .expect("pasted") .contains("brcrew1:abc")); assert!(bodies[1].get("advanced").is_none()); - assert!(lines.contains( - &" Hosted by \"\u{2068}Alice Chen\u{2069}\" (@alice) on hpc.ucsf.edu".to_owned() - )); + assert!(lines.contains(&" Hosted by \"Alice Chen\" (@alice) on hpc.ucsf.edu".to_owned())); assert!(lines.contains(&" Workspace privacy: Private · ucsf".to_owned())); assert!(lines.contains(&" You'll join as Private · ucsf.".to_owned())); assert_eq!( - &lines[lines.len() - 2..], - ["Saved lab.", "Next: biorouter crew --connection lab join"] + &lines[lines.len() - 3..], + [ + "Saved lab.", + "Next: biorouter crew --connection lab auth", + "Then: biorouter crew --connection lab join" + ] ); // Without --yes and without a terminal to ask in, nothing is saved. @@ -3771,7 +6126,7 @@ mod tests { args.yes = false; let error = run( &api, - CrewCommand::Connections(ConnectionCommand::JoinInvitation(args)), + CrewCommand::Connections(ConnectionCommand::JoinInvitation(Box::new(args))), ) .await .expect_err("no confirmation"); @@ -3794,7 +6149,9 @@ mod tests { let (api, _) = api_with(OutputFormat::Json, missing); let error = run( &api, - CrewCommand::Connections(ConnectionCommand::JoinInvitation(join_args(file.path()))), + CrewCommand::Connections(ConnectionCommand::JoinInvitation(Box::new(join_args( + file.path(), + )))), ) .await .expect_err("missing choices"); @@ -3804,74 +6161,754 @@ mod tests { ); } + /// SC2-N1: someone else's invitation is flagged in the preview and on a `--yes` save, a + /// connection it left behind is replaced only with `--replace`, and a save refused because + /// the workspace is already saved names the terminal's commands, keeping the daemon's code. #[tokio::test] - async fn join_shows_the_code_then_claims_once_the_host_approves() { - let statuses = Mutex::new(vec![ - json!({"status": "approved", "code": "7QK2-M9XA-3JTP-WZ4D", "workspace_name": "lab", "add_device": false}), - json!({"status": "invited", "code": "7QK2-M9XA-3JTP-WZ4D", "workspace_name": "lab", "add_device": false, - "inviter": {"username": "alice", "display_name": "Alice Chen"}}), - ]); - let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { - match (method, path.ends_with("/join")) { - ("GET", true) => Ok(statuses.lock().expect("statuses").pop().expect("a status")), - ("POST", true) => Ok( - json!({"joined": true, "status": "joined", "workspace_name": "lab", "add_device": false}), - ), - _ => standard(method, path, body), + async fn join_invitation_warns_about_another_account_and_replaces_only_when_asked() { + const OLD: &str = "c0ffee00-0000-4000-8000-00000000000b"; + let saves = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let handler = { + let saves = std::sync::Arc::clone(&saves); + move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path == "/crew/connections/from-invitation" { + let body = body.expect("body"); + if body["preview"] == true { + let mut answer = preview(&[]); + answer["preview"]["server"] = json!("44.246.189.59"); + answer["preview"]["server_label"] = json!("lab-ubuntu"); + answer["preview"]["login_mismatch"] = + json!({"config_user": "crew_trent", "invitee": "crew_bob"}); + answer["preview"]["replaceable_connection_id"] = json!(OLD); + return Ok(answer); + } + saves.lock().expect("saves").push(body.clone()); + if body.get("replace").is_none() { + return Err(FakeRefusal { + status: 409, + code: Some("crew_connection_exists".into()), + broker_code: None, + institution_refusal: None, + connection_institution: None, + message: "This computer already has “chen-lab” for this workspace, signing in as another account, and it has never connected. Replace it with this invitation, or change it in its connection settings.".into(), + detail: None, + modes: None, + fields: json!({"connection_id": OLD}).as_object().cloned().expect("object"), + } + .into()); + } + return Ok(json!({"connection": {"id": CONNECTION, "name": "lab"}})); + } + if (method, path) == ("GET", "/crew/connections") { + return Ok( + json!({"connections": [{"id": OLD, "name": "chen-lab", "ssh_target": "crew_bob@44.246.189.59", "workspace_id": "w", "status": "disconnected"}]}), + ); + } + standard(method, path, body) } }; - let (api, fake) = api_with(OutputFormat::StreamJson, handler); - run(&api, CrewCommand::Join(JoinArgs { no_wait: false })) - .await - .expect("joined"); - let joins: Vec<&str> = fake - .sent() - .iter() - .filter(|sent| sent.path.ends_with("/join")) - .map(|sent| { - if sent.method == "GET" { - "status" - } else { - "claim" - } - }) - .collect::>() - .into_iter() - .collect(); - assert_eq!(joins, ["status", "status", "claim"]); + let file = tempfile::NamedTempFile::new().expect("temp file"); + std::fs::write(file.path(), "brcrew1:abc\n").expect("write invitation"); + let join = |replace: bool| { + let mut args = join_args(file.path()); + args.username = None; + args.replace = replace; + CrewCommand::Connections(ConnectionCommand::JoinInvitation(Box::new(args))) + }; - let status = json!({"status": "invited", "code": "7QK2-M9XA-3JTP-WZ4D", "workspace_name": "lab", - "inviter": {"username": "alice", "display_name": "Alice Chen"}}); + // Without --replace the save is refused, in the terminal's words. + let (api, _) = api_with(OutputFormat::Text, handler.clone()); + let error = run(&api, join(false)).await.expect_err("already saved"); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); assert_eq!( - join_lines(&status, false), - [ - "\"\u{2068}Alice Chen\u{2069}\" (@alice) invited you to lab.", - "Send Alice this code: 7QK2-M9XA-3JTP-WZ4D" - ] + shown, + "This computer already has chen-lab for this workspace, signing in as another account, and it has never connected. Run it again with --replace, or remove it with biorouter crew --connection chen-lab connections remove." ); - let mismatch = json!({"status": "code_mismatch", "code": "7QK2-M9XA-3JTP-WZ4D", "inviter": {"username": "alice", "display_name": "alice"}}); + assert!(!shown.contains("connection settings"), "{shown}"); assert_eq!( - join_lines(&mismatch, false), - ["The code @alice entered doesn't match this computer. Send it again: 7QK2-M9XA-3JTP-WZ4D"] + error_code(&error).as_deref(), + Some("crew_connection_exists") ); + assert!(saves.lock().expect("saves")[0].get("replace").is_none()); - let expired = |method: &str, path: &str, body: Option<&Value>| -> Result { - if path.ends_with("/join") { - return Ok( - json!({"status": "expired", "inviter": {"username": "alice", "display_name": "Alice Chen"}}), - ); + // With --replace, the preview's connection is named, and the save replaces it. + let (api, _) = api_with(OutputFormat::Text, handler.clone()); + let lines = said(run(&api, join(true)).await.expect("replaced")); + assert_eq!(saves.lock().expect("saves")[1]["replace"], OLD); + assert!( + lines.contains(&" Hosted by \"Alice Chen\" (@alice) on lab-ubuntu".to_owned()), + "{lines:#?}" + ); + assert!( + lines.contains(&" This invitation is for @crew_bob, but this computer signs in to lab-ubuntu as crew_trent. Ask your host for your own invitation.".to_owned()), + "--yes still prints the warning: {lines:#?}" + ); + assert!(lines.contains(&" Saving replaces chen-lab, which signs in as another account and has never connected. Its key is deleted.".to_owned()), "{lines:#?}"); + assert!( + !lines.iter().any(|line| line.contains("44.246.189.59")), + "{lines:#?}" + ); + + // A preview without --replace says it can be replaced. + let (api, _) = api_with(OutputFormat::Text, handler.clone()); + let mut args = join_args(file.path()); + args.yes = false; + args.preview = true; + args.username = None; + let lines = said( + run( + &api, + CrewCommand::Connections(ConnectionCommand::JoinInvitation(Box::new(args))), + ) + .await + .expect("previewed"), + ); + assert!(lines.contains(&" This computer already has chen-lab for this workspace, signing in as another account, and it has never connected. Add --replace to save this invitation in its place.".to_owned()), "{lines:#?}"); + + // --replace with nothing to replace is a usage refusal, and nothing is saved. + let (api, fake) = api_with( + OutputFormat::Text, + |method: &str, path: &str, body: Option<&Value>| { + if path == "/crew/connections/from-invitation" { + return Ok(preview(&[])); + } + standard(method, path, body) + }, + ); + let error = run(&api, join(true)).await.expect_err("nothing to replace"); + assert_eq!(error_code(&error).as_deref(), Some(NOTHING_TO_REPLACE)); + assert!(failure(&error, OutputFormat::Text, "req-1", false) + .downcast_ref::() + .is_some()); + assert_eq!( + fake.sent() + .iter() + .filter(|sent| sent.path == "/crew/connections/from-invitation") + .count(), + 1, + "only the preview" + ); + + // The workspace already saved under the login saving would use. + let existing = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path == "/crew/connections/from-invitation" { + if body.expect("body")["preview"] == true { + let mut answer = preview(&[]); + answer["preview"]["existing_connection_id"] = json!(CONNECTION); + return Ok(answer); + } + return Err(FakeRefusal { + status: 409, + code: Some("crew_connection_exists".into()), + broker_code: None, + institution_refusal: None, + connection_institution: None, + message: "This computer already has “UCSF HPC” for this workspace. Change it in its connection settings instead.".into(), + detail: None, + modes: None, + fields: json!({"connection_id": CONNECTION}).as_object().cloned().expect("object"), + } + .into()); } standard(method, path, body) }; - let (api, _) = api_with(OutputFormat::Json, expired); - let error = run(&api, CrewCommand::Join(JoinArgs { no_wait: false })) + // T3-CLI-4: the daemon refuses it only because a compared setting differs, so the + // terminal says nothing was saved and names every way on, `join` only as one of them. + let (api, _) = api_with(OutputFormat::Text, existing); + let error = run(&api, join(false)).await.expect_err("already saved"); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert_eq!( + shown, + "This workspace is already saved as UCSF HPC with a different server login, port, key file, jump host or privacy, so nothing was saved.\n To compare them: biorouter crew --connection 'UCSF HPC' connections show\n To give it this invitation's privacy: biorouter crew --connection 'UCSF HPC' privacy set-personal private --institution ucsf\n To change its server login, port, key file or jump host: biorouter crew --connection 'UCSF HPC' connections update FILE\n If it has not joined yet, remove it and save this invitation again: biorouter crew --connection 'UCSF HPC' connections remove\n To use it as it is: biorouter crew --connection 'UCSF HPC' join" + ); + assert!(!shown.contains("finish joining"), "{shown}"); + assert!(!shown.contains("connection settings"), "{shown}"); + assert_eq!( + error_code(&error).as_deref(), + Some("crew_connection_exists") + ); + // JSON keeps the daemon's code and the connection it named beside the same words. + let (api, _) = api_with(OutputFormat::Json, existing); + let error = run(&api, join(false)).await.expect_err("already saved"); + let body = failure_body(&error, &safe_lines(&error_text(&error)), "req-1"); + assert_eq!(body["code"], "crew_connection_exists"); + assert_eq!(body["connection_id"], CONNECTION); + assert_eq!(body["error"], shown); + + // The privacy line names the privacy the invitation would save: a Public choice, and a + // hostile institution quoted for the shell with its controls escaped. + let with_privacy = |mode: &str, institution: Option<&str>| { + let mut answer = preview(&[]); + answer["preview"]["mode"] = json!(mode); + answer["preview"]["institution_id"] = json!(institution); + set_personal_words(&answer["preview"]) + }; + assert_eq!(with_privacy("public", None), "privacy set-personal public"); + assert_eq!( + with_privacy("private", Some(" ")), + "privacy set-personal private" + ); + assert_eq!( + with_privacy("private", Some("u c\u{1b}[31m")), + "privacy set-personal private --institution 'u c\\u{1b}[31m'" + ); + assert_eq!(with_privacy("secret", None), "privacy set-personal MODE"); + let (api, _) = api_with(OutputFormat::Text, existing); + let mut args = join_args(file.path()); + args.yes = false; + args.preview = true; + let lines = said( + run( + &api, + CrewCommand::Connections(ConnectionCommand::JoinInvitation(Box::new(args))), + ) .await - .expect_err("expired"); - assert!(message(&error).starts_with("This invitation expired. Ask ")); + .expect("previewed"), + ); + assert!(lines.contains(&" This computer already has this workspace as UCSF HPC; saving again keeps that connection if its settings match.".to_owned()), "{lines:#?}"); } - #[tokio::test] - async fn the_host_invites_by_username_and_gets_the_message_to_send() { + /// SETUPHPC2-F-C: a route to this machine says so in the preview; one through a jump host + /// or to another server does not. + #[test] + fn a_same_host_route_is_said_in_the_preview() { + let mut answer = preview(&[]); + answer["preview"]["ssh_target"] = json!("crew_iris@localhost"); + answer["preview"]["server_label"] = json!("localhost"); + let lines = invitation_summary(&answer["preview"], false); + assert!( + lines.contains(&" Crew will connect to this machine (localhost).".to_owned()), + "{lines:#?}" + ); + answer["preview"]["proxy_jump"] = json!("bastion"); + assert!(!invitation_summary(&answer["preview"], false) + .iter() + .any(|line| line.contains("this machine"))); + answer["preview"]["proxy_jump"] = Value::Null; + answer["preview"]["ssh_target"] = json!("crew_iris@hpc.ucsf.edu"); + assert!(!invitation_summary(&answer["preview"], false) + .iter() + .any(|line| line.contains("this machine"))); + } + + /// SF2-N3, CLIDOCS-F5, CLIDOCS-F12: `privacy show` says the privacy in force (`effective_mode` + /// in JSON), lists channels in the channels-list order, and while disconnected prints the + /// connection's own setting and says the workspace's cannot be checked, rather than failing. + #[tokio::test] + async fn privacy_show_says_the_privacy_in_force_online_and_offline() { + let online = |own: &'static str| { + move |method: &str, path: &str, body: Option<&Value>| -> Result { + if (method, path) == ("GET", "/crew/connections") { + return Ok( + json!({"connections": [{"id": CONNECTION, "name": "UCSF HPC", "ssh_target": "bob@hpc", "workspace_id": "w", "status": "connected", "mode": own, "institution_id": "ucsf", "policy_epoch": 2}]}), + ); + } + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + let mut snapshot = snapshot(); + // The broker lists them in no order a reader expects. + snapshot["channels"] = json!([ + {"id": METHODS, "team_id": TEAM, "name": "methods", "classification": "restricted"}, + {"id": "c4a77e10-0000-4000-8000-00000000000c", "team_id": TEAM, "name": "random", "classification": "public_safe"}, + {"id": GENERAL, "team_id": TEAM, "name": "general", "classification": "restricted"} + ]); + return Ok(snapshot); + } + standard(method, path, body) + } + }; + let (api, _) = api_with(OutputFormat::Json, online("public")); + let Reply::Show(value, options) = run(&api, CrewCommand::Privacy(PrivacyCommand::Show)) + .await + .expect("shown") + else { + panic!("a value"); + }; + assert_eq!( + value["effective_mode"], "private", + "the workspace is Private" + ); + let order: Vec<&str> = value["channels"] + .as_array() + .expect("channels") + .iter() + .filter_map(|channel| channel["name"].as_str()) + .collect(); + assert_eq!(order, ["general", "methods", "random"]); + let text = output::render_text(&value, &options); + assert!( + text.starts_with("Privacy: Private (lab is Private for everyone)"), + "{text}" + ); + + for (own, effective, first) in [ + ("private", json!("private"), "Privacy: Private"), + ( + "public", + Value::Null, + "Privacy: can't be checked while disconnected", + ), + ] { + let offline = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if (method, path) == ("GET", "/crew/connections") { + return Ok( + json!({"connections": [{"id": CONNECTION, "name": "UCSF HPC", "ssh_target": "bob@hpc", "workspace_id": "w", "status": "disconnected", "mode": own, "institution_id": "ucsf", "policy_epoch": 2}]}), + ); + } + if path.ends_with("/request") { + return Err(refuse(409, Some(NOT_CONNECTED), LEGACY_DISCONNECTED)); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Json, offline); + let Reply::Show(value, options) = run(&api, CrewCommand::Privacy(PrivacyCommand::Show)) + .await + .expect("shown offline") + else { + panic!("a value"); + }; + assert_eq!(value["effective_mode"], effective, "{own}"); + assert_eq!(value["personal_mode"], own); + assert!(value["workspace"].is_null(), "{value}"); + let text = output::render_text(&value, &options); + assert!(text.starts_with(first), "{own}: {text}"); + assert!( + text.contains("Workspace: can't be checked while disconnected"), + "{own}: {text}" + ); + assert!(text.contains("Your connection: "), "{text}"); + } + // An older daemon's untyped refusal is the same fact. + let older = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/request") { + return Err(refuse( + 400, + Some("crew_request_refused"), + LEGACY_DISCONNECTED, + )); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, older); + run(&api, CrewCommand::Privacy(PrivacyCommand::Show)) + .await + .expect("shown offline"); + } + + /// SC2-N5: a connected connection the host has not let in reads "Not joined yet", in + /// the list and in `connections show`; a member's reads Connected. The JSON says `joined`. + #[tokio::test] + async fn a_connection_that_has_not_joined_reads_not_joined_yet() { + for (state, word) in [("invited", "Not joined yet"), ("joined", "Connected")] { + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/join") && method == "GET" { + return Ok(json!({"status": state})); + } + standard(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Text, handler); + let listed = said(run(&api, CrewCommand::Status).await.expect("status")); + assert!( + listed[0].contains(&format!("bob@hpc · {word} · ")), + "{listed:#?}" + ); + if state == "invited" { + assert!( + fake.broker_calls().is_empty(), + "a non-member's workspace is not read" + ); + } + let (api, _) = api_with(OutputFormat::Json, handler); + let Reply::Show(value, _) = run(&api, CrewCommand::Status).await.expect("status") + else { + panic!("a value"); + }; + assert_eq!(value["connections"][0]["joined"], state == "joined"); + let (api, _) = api_with(OutputFormat::Text, handler); + let shown = said( + run(&api, CrewCommand::Connections(ConnectionCommand::Show)) + .await + .expect("shown"), + ); + assert!( + shown[0].contains(&format!(" Status: {word}")), + "{shown:#?}" + ); + } + } + + /// RES2-N2: a connection whose workspace server has stopped saving says so in `status` and + /// `connections show`, and tells its host what to run; nothing is said while it saves. + #[tokio::test] + async fn a_server_that_stopped_saving_is_said_before_anyone_writes() { + let stopped = |host: bool| { + move |method: &str, path: &str, body: Option<&Value>| -> Result { + match (method, path) { + ("GET", "/crew/connections") => { + return Ok(json!({"connections": [{ + "id": CONNECTION, "name": "UCSF HPC", "ssh_target": "bob@hpc", + "server_label": "hpc", "workspace_id": "w", "status": "connected", + "mode": "private", + "server_storage": {"state": "storage_failed", "code": "storage_full", "since": 1_700_000_000} + }]})); + } + (_, path) if path.ends_with("/join") => return Ok(json!({"status": "joined"})), + _ => {} + } + if !host + && body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") + { + let mut snapshot = snapshot(); + snapshot["actor"] = json!({"id": BOB, "username": "bob", "display_name": "Bob Lee", "uid": 1001}); + return Ok(snapshot); + } + standard(method, path, body) + } + }; + let (api, _) = api_with(OutputFormat::Text, stopped(true)); + let shown = said(run(&api, CrewCommand::Status).await.expect("status")).join("\n"); + assert!( + shown.contains(&format!("\n {}", output::SERVER_STOPPED_SAVING)), + "{shown}" + ); + assert!(shown.contains(" You host this workspace. Free space on hpc, then restart Crew there: biorouter-crew stop, then biorouter-crew start, each with this workspace's --state-dir."), "{shown}"); + let (api, _) = api_with(OutputFormat::Text, stopped(false)); + let shown = said( + run(&api, CrewCommand::Connections(ConnectionCommand::Show)) + .await + .expect("shown"), + ) + .join("\n"); + assert!(shown.contains(output::SERVER_STOPPED_SAVING), "{shown}"); + assert!(!shown.contains("You host"), "a member: {shown}"); + // A server that saves (or an older daemon that does not say) adds nothing. + let (api, _) = api_with(OutputFormat::Text, standard); + let shown = said(run(&api, CrewCommand::Status).await.expect("status")).join("\n"); + assert!(!shown.contains("stopped saving"), "{shown}"); + } + + /// SETUPHPC2-F-A: a refused connect to a login on this machine says what a member on the + /// server itself must fix, with the code and OpenSSH's words, in `connect` and `join`; a + /// login elsewhere keeps the ordinary sentences. + #[tokio::test] + async fn a_same_host_connect_failure_says_what_to_fix_on_this_machine() { + let refusing = |target: &'static str, code: &'static str| { + move |method: &str, path: &str, body: Option<&Value>| -> Result { + match (method, path) { + ("GET", "/crew/connections") => { + return Ok( + json!({"connections": [{"id": CONNECTION, "name": "patel-hpc", "ssh_target": target, "workspace_id": "w", "status": "disconnected"}]}), + ); + } + (_, path) if path.ends_with("/connect") => { + return Err(FakeRefusal { + status: 400, + code: Some(code.into()), + broker_code: None, + institution_refusal: None, + connection_institution: None, + message: "Couldn't sign in as crew_iris on this machine.".into(), + detail: Some( + "crew_iris@localhost: Permission denied (publickey).".into(), + ), + modes: None, + fields: Default::default(), + } + .into()); + } + (_, path) if path.ends_with("/join") => { + return Err(refuse(409, Some(NOT_CONNECTED), LEGACY_DISCONNECTED)); + } + _ => {} + } + standard(method, path, body) + } + }; + let shown = + |error: &anyhow::Error| failure(error, OutputFormat::Text, "req-1", false).to_string(); + let (api, _) = api_with( + OutputFormat::Text, + refusing("crew_iris@localhost", "crew_ssh_key_refused"), + ); + let error = run(&api, CrewCommand::Connect).await.expect_err("refused"); + assert_eq!( + shown(&error), + "Couldn't sign in as crew_iris on this machine: add your public SSH key to your own ~/.ssh/authorized_keys, then connect again.\n Code: crew_ssh_key_refused\n Details: crew_iris@localhost: Permission denied (publickey)." + ); + let body = failure_body(&error, &safe_lines(&error_text(&error)), "req-1"); + assert_eq!(body["code"], "crew_ssh_key_refused"); + assert_eq!( + body["detail"], + "crew_iris@localhost: Permission denied (publickey)." + ); + + let (api, _) = api_with( + OutputFormat::Text, + refusing("crew_iris@127.0.0.1", "crew_ssh_host_key_unknown"), + ); + let error = run(&api, CrewCommand::Join(JoinArgs { no_wait: true })) + .await + .expect_err("refused"); + assert!(shown(&error).starts_with("127.0.0.1's host key isn't in your ~/.ssh/known_hosts yet. Add this server's own key (from /etc/ssh/ssh_host_ed25519_key.pub) to it, then connect again.\n Code: crew_ssh_host_key_unknown"), "{}", shown(&error)); + + // Another server keeps the sentence for a server someone else runs. + let (api, _) = api_with( + OutputFormat::Text, + refusing("crew_iris@hpc.ucsf.edu", "crew_ssh_key_refused"), + ); + let error = run(&api, CrewCommand::Connect).await.expect_err("refused"); + assert!( + shown(&error).starts_with("The server refused this computer's SSH key."), + "{}", + shown(&error) + ); + for target in ["localhost", "bob@LOCALHOST", "bob@[::1]", "bob@127.0.0.2"] { + assert!(output::loopback_login(target).is_some(), "{target}"); + } + for target in [ + "hpc", + "bob@hpc.ucsf.edu", + "bob@10.0.0.1", + "localhost.example.org", + ] { + assert!(output::loopback_login(target).is_none(), "{target}"); + } + } + + /// SF-F3: a host saving their own workspace reads host words in the preview, not an + /// invitation to join; anyone else reads the invitation as before. + #[test] + fn a_host_saving_their_own_workspace_reads_host_words() { + let preview = preview(&[]); + let hosting = invitation_summary(&preview["preview"], true); + assert_eq!(hosting[0], "Your workspace lab"); + assert!(hosting.contains(&" You'll host it as Private · ucsf.".to_owned())); + assert!(!hosting + .iter() + .any(|line| line.contains("Invitation to") || line.contains("You'll join"))); + let joining = invitation_summary(&preview["preview"], false); + assert_eq!(joining[0], "Invitation to lab"); + assert!(joining.contains(&" You'll join as Private · ucsf.".to_owned())); + } + + /// SF-F3: `connections prepare` and `enroll prepare` are one command, used by a host before + /// `biorouter-crew start` and by a joiner before `connections save`. Both print the same + /// words, and those words tell each reader where the key and the preparation ID go, + /// without sending either of them down the other's path. + #[tokio::test] + async fn both_prepare_commands_describe_the_key_for_a_host_and_a_joiner() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if (method, path) == ("POST", "/crew/devices/prepare") { + return Ok(json!({ + "preparation_id": "9e7a0000-0000-4000-8000-000000000009", + "public_key": "ab".repeat(32), + "device_id": "de71ce00-0000-4000-8000-00000000000a", + })); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let connections = said( + run(&api, CrewCommand::Connections(ConnectionCommand::Prepare)) + .await + .expect("connections prepare"), + ); + let enroll = said( + run(&api, CrewCommand::Enroll(EnrollmentCommand::Prepare)) + .await + .expect("enroll prepare"), + ); + assert_eq!(connections, enroll, "the two names are one command"); + let text = connections.join("\n"); + for (use_, words) in [ + ( + "host: the key", + "pass this key to biorouter-crew start --bootstrap-key", + ), + ( + "joiner: the key", + "To join one, send it to the workspace's host.", + ), + ( + "host: the ID", + "connections join-invitation --preparation-id when you host", + ), + ( + "joiner: the ID", + "the descriptor for connections save when you join", + ), + ] { + assert!(text.contains(words), "{use_}: {text}"); + } + assert!(!text.contains("for hosting"), "{text}"); + } + + /// CLI-9: the next steps after saving a connection: a host signs in, then sets the + /// workspace up; a joiner signs in, then joins; a connection already up skips signing in. + #[test] + fn the_next_step_after_saving_depends_on_hosting_and_connecting() { + assert_eq!( + next_steps("lab", true, false), + [ + "Next: biorouter crew --connection lab auth", + "Then: biorouter crew --connection lab workspace bootstrap" + ] + ); + assert_eq!( + next_steps("UCSF HPC", false, false), + [ + "Next: biorouter crew --connection 'UCSF HPC' auth", + "Then: biorouter crew --connection 'UCSF HPC' join" + ] + ); + assert_eq!( + next_steps("lab", false, true), + ["Next: biorouter crew --connection lab join"] + ); + } + + /// CLI-19: `--identity-file` is sent absolute, whatever the shell left of it. + /// + /// Every expected path is built with `Path::join` and compared as a `Path` (component by + /// component), and the folders are real absolute ones, so the test reads the same on + /// Windows, where `join` writes `\` and `/etc/key` is not absolute. + #[test] + fn an_identity_file_is_made_absolute_before_it_is_sent() { + let base = std::env::temp_dir(); + let home_dir = base.join("home").join("bob"); + let work = base.join("work"); + let home = Some(home_dir.clone()); + let cwd = || Ok(work.clone()); + let made = |text: &str, home: Option| { + let made = absolute_local_path(text, home, cwd).expect(text); + assert!(Path::new(&made).is_absolute(), "{text} -> {made}"); + std::path::PathBuf::from(made) + }; + + assert_eq!( + made("~/.ssh/lab_ed25519", home.clone()), + home_dir.join(".ssh").join("lab_ed25519") + ); + assert_eq!(made("~", home.clone()), home_dir); + // The platform's own separator after `~` is the home folder too: `~\key` on Windows. + assert_eq!( + made(&format!("~{}key", std::path::MAIN_SEPARATOR), home.clone()), + home_dir.join("key") + ); + assert_eq!( + made("./keys/lab", home.clone()), + work.join("keys").join("lab") + ); + assert_eq!( + made("keys/lab", home.clone()), + work.join("keys").join("lab") + ); + // `~bob/key` is another user's home, which only a shell can read; it stays relative. + assert_eq!( + made("~bob/key", home.clone()), + work.join("~bob").join("key") + ); + assert!(absolute_local_path("~/key", None, cwd).is_err()); + + // An absolute path is sent exactly as it was given. + let absolute = base.join("keys").join("lab_ed25519"); + let absolute = absolute.to_str().expect("a Unicode temp folder"); + assert_eq!( + absolute_local_path(absolute, home.clone(), || bail!("not read")).unwrap(), + absolute + ); + assert_eq!(absolute_local_path("", home, cwd).unwrap(), ""); + + let mut args = join_args(Path::new("-")); + args.identity_file = Some("keys/lab".into()); + let body = invitation_request("brcrew1:abc", &args).expect("request"); + let sent = body["advanced"]["identity_file"] + .as_str() + .expect("identity file"); + assert!(Path::new(sent).is_absolute(), "{sent}"); + assert!( + Path::new(sent).ends_with(Path::new("keys").join("lab")), + "{sent}" + ); + } + + #[tokio::test] + async fn join_shows_the_code_then_claims_once_the_host_approves() { + let statuses = Mutex::new(vec![ + json!({"status": "approved", "code": "7QK2-M9XA-3JTP-WZ4D", "workspace_name": "lab", "add_device": false}), + json!({"status": "invited", "code": "7QK2-M9XA-3JTP-WZ4D", "workspace_name": "lab", "add_device": false, + "inviter": {"username": "alice", "display_name": "Alice Chen"}}), + ]); + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + match (method, path.ends_with("/join")) { + ("GET", true) => Ok(statuses.lock().expect("statuses").pop().expect("a status")), + ("POST", true) => Ok( + json!({"joined": true, "status": "joined", "workspace_name": "lab", "add_device": false}), + ), + _ => standard(method, path, body), + } + }; + let (api, fake) = api_with(OutputFormat::StreamJson, handler); + run(&api, CrewCommand::Join(JoinArgs { no_wait: false })) + .await + .expect("joined"); + let joins: Vec<&str> = fake + .sent() + .iter() + .filter(|sent| sent.path.ends_with("/join")) + .map(|sent| { + if sent.method == "GET" { + "status" + } else { + "claim" + } + }) + .collect::>() + .into_iter() + .collect(); + assert_eq!(joins, ["status", "status", "claim"]); + + let status = json!({"status": "invited", "code": "7QK2-M9XA-3JTP-WZ4D", "workspace_name": "lab", + "inviter": {"username": "alice", "display_name": "Alice Chen"}}); + assert_eq!( + join_lines(&status, false), + [ + "\"Alice Chen\" (@alice) invited you to lab.", + "Send Alice this code: 7QK2-M9XA-3JTP-WZ4D" + ] + ); + let mismatch = json!({"status": "code_mismatch", "code": "7QK2-M9XA-3JTP-WZ4D", "inviter": {"username": "alice", "display_name": "alice"}}); + assert_eq!( + join_lines(&mismatch, false), + ["The code @alice entered doesn't match this computer. Send it again: 7QK2-M9XA-3JTP-WZ4D"] + ); + + let expired = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/join") { + return Ok( + json!({"status": "expired", "inviter": {"username": "alice", "display_name": "Alice Chén"}}), + ); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Json, expired); + let error = run(&api, CrewCommand::Join(JoinArgs { no_wait: false })) + .await + .expect_err("expired"); + assert!(message(&error).starts_with("This invitation expired. Ask ")); + + // CLI-4: the isolates the CLI put around a name that is not ASCII reach the terminal + // as isolates, and the JSON error carries the same sentence, never the escape text. + let shown = failure(&error, OutputFormat::Text, "req-1", false).to_string(); + assert_eq!( + shown, + "This invitation expired. Ask \"\u{2068}Alice Chén\u{2069}\" (@alice) to invite you again." + ); + let body = failure_body(&error, &safe_lines(&error_text(&error)), "req-1"); + assert!(!body["error"].as_str().unwrap().contains("\\u{"), "{body}"); + } + + #[tokio::test] + async fn the_host_invites_by_username_and_gets_the_message_to_send() { let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { if path.contains("/invitation?") { return Ok( @@ -4053,7 +7090,10 @@ mod tests { json!({"team_id": TEAM, "principal_id": BOB, "expected_username": "bob", "channel_ids": [METHODS], "idempotency_key": "req-1"}) ); - assert_eq!(lines, ["Added. @bob can now see #general and #methods."]); + assert_eq!( + lines, + ["Added \"Bob Lee\" (@bob) to Analysis Lab. They can now see #general and #methods."] + ); let selectors = &fake.resolve_bodies()[0]["selectors"]; assert!( selectors @@ -4078,48 +7118,95 @@ mod tests { .await .expect("a no-op add"), ); - assert_eq!(lines, ["@bob is already in everything you chose."]); + assert_eq!( + lines, + ["\"Bob Lee\" (@bob) is already in everything you chose."] + ); } - /// Without `--team`, each channel is its own `channel.add_member`, each with its own - /// idempotency key derived from the one request ID, so a retry replays them all. + /// M11: every team has a `#general`, so a channel added on its own is named with its team + /// when another of the person's teams has a channel of that name. #[tokio::test] - async fn members_add_to_channels_gives_each_step_its_own_retry_key() { + async fn an_added_channel_names_its_team_when_another_team_shares_its_name() { + const OTHER_TEAM: &str = "7ea30000-0000-4000-8000-00000000000e"; + const OTHER_GENERAL: &str = "c4a77e10-0000-4000-8000-00000000000e"; let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { let body_value = body.cloned().unwrap_or_default(); match body_value["method"].as_str() { + Some("workspace.snapshot") => { + let mut snapshot = snapshot(); + snapshot["teams"][0]["members"] = json!([ALICE, BOB]); + snapshot["teams"] + .as_array_mut() + .unwrap() + .push(json!({"id": OTHER_TEAM, "name": "Methods Team", "general_channel_id": OTHER_GENERAL, "members": [ALICE]})); + snapshot["channels"].as_array_mut().unwrap().push( + json!({"id": OTHER_GENERAL, "team_id": OTHER_TEAM, "name": "general", "classification": "restricted"}), + ); + Ok(snapshot) + } Some("channel.add_member") => Ok(json!({ "channel_id": body_value["params"]["channel_id"], "principal_id": BOB, - "already_member": body_value["params"]["channel_id"] == GENERAL, + "already_member": false, })), _ => standard(method, path, body), } }; - let (api, fake) = api_with(OutputFormat::Text, handler); + let (api, _) = api_with(OutputFormat::Text, handler); let lines = said( run( &api, - add_member_command(None, &["#methods", "analysis-lab/general"]), + add_member_command(None, &["analysis-lab/general", "#methods"]), ) .await .expect("added"), ); - let calls: Vec = fake - .sent() - .into_iter() - .filter(|sent| sent.path.ends_with("/request")) - .filter_map(|sent| sent.body) - .filter(|body| body["method"] == "channel.add_member") - .collect(); - assert_eq!(calls.len(), 2); + assert_eq!( + lines, + ["Added \"Bob Lee\" (@bob) to Analysis Lab / #general and #methods."] + ); + } + + /// Without `--team`, each channel is its own `channel.add_member`, each with its own + /// idempotency key derived from the one request ID, so a retry replays them all. + #[tokio::test] + async fn members_add_to_channels_gives_each_step_its_own_retry_key() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + let body_value = body.cloned().unwrap_or_default(); + match body_value["method"].as_str() { + Some("channel.add_member") => Ok(json!({ + "channel_id": body_value["params"]["channel_id"], + "principal_id": BOB, + "already_member": body_value["params"]["channel_id"] == GENERAL, + })), + _ => standard(method, path, body), + } + }; + let (api, fake) = api_with(OutputFormat::Text, handler); + let lines = said( + run( + &api, + add_member_command(None, &["#methods", "analysis-lab/general"]), + ) + .await + .expect("added"), + ); + let calls: Vec = fake + .sent() + .into_iter() + .filter(|sent| sent.path.ends_with("/request")) + .filter_map(|sent| sent.body) + .filter(|body| body["method"] == "channel.add_member") + .collect(); + assert_eq!(calls.len(), 2); assert_eq!(calls[0]["params"]["channel_id"], METHODS); assert_eq!(calls[0]["params"]["idempotency_key"], "req-1"); assert_eq!(calls[0]["params"]["expected_username"], "bob"); assert_eq!(calls[1]["params"]["channel_id"], GENERAL); assert_eq!(calls[1]["params"]["idempotency_key"], "req-1:1"); assert_eq!(calls[1]["request_id"], "req-1:1"); - assert_eq!(lines, ["Added. @bob can now see #methods."]); + assert_eq!(lines, ["Added \"Bob Lee\" (@bob) to #methods."]); let (api, fake) = api_with(OutputFormat::Text, standard); let error = run(&api, add_member_command(None, &[])) @@ -4129,6 +7216,413 @@ mod tests { assert!(fake.broker_calls().is_empty()); } + /// A snapshot in which Alice owns #methods but not #general, and Bob is in their team. + fn owned_snapshot() -> Value { + let mut snapshot = snapshot(); + snapshot["workspace"]["host_principal_id"] = json!(CAROL); + snapshot["teams"][0]["members"] = json!([ALICE, BOB]); + snapshot["channels"][0]["owner_id"] = json!(CAROL); + snapshot["channels"][1]["owner_id"] = json!(ALICE); + snapshot + } + + /// CLI-11: without --team each channel is its own add, so what the person's own view + /// shows would be refused stops the command before anything is added. + #[tokio::test] + async fn members_add_to_channels_checks_every_channel_before_adding_any() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + match body.and_then(|body| body["method"].as_str()) { + Some("workspace.snapshot") => Ok(owned_snapshot()), + _ => standard(method, path, body), + } + }; + let (api, fake) = api_with(OutputFormat::Text, handler); + let error = run( + &api, + add_member_command(None, &["#methods", "analysis-lab/general"]), + ) + .await + .expect_err("#general is not Alice's"); + assert_eq!( + message(&error), + "You don't own #general. Only its owner or the workspace host can add people to it.\nNothing was added." + ); + assert!(fake.broker_call("channel.add_member").is_none()); + assert!(!failure(&error, OutputFormat::Text, "req-1", true) + .to_string() + .contains("--request-id")); + + let mut snapshot = owned_snapshot(); + snapshot["channels"][1]["archived"] = json!(true); + snapshot["teams"][0]["members"] = json!([ALICE]); + let targets = [Target { + id: METHODS.into(), + label: None, + username: None, + }]; + assert_eq!( + channel_add_problems(&snapshot, BOB, "bob", &targets), + ["#methods is archived, so no one can be added to it."] + ); + snapshot["channels"][1]["archived"] = json!(false); + assert_eq!( + channel_add_problems(&snapshot, BOB, "bob", &targets), + ["@bob isn't in the team #methods belongs to yet. Add them to the team first."] + ); + // The host may add anyone to any channel, listed or not. + snapshot["workspace"]["host_principal_id"] = json!(ALICE); + snapshot["teams"][0]["members"] = json!([ALICE, BOB]); + snapshot["channels"][1]["owner_id"] = json!(CAROL); + assert!(channel_add_problems(&snapshot, BOB, "bob", &targets).is_empty()); + } + + /// CLI-11: a channel that fails after another was added says what changed; a refusal + /// offers no retry, and a lost answer does. + #[tokio::test] + async fn a_partial_add_says_what_changed_and_offers_a_retry_only_when_uncertain() { + for (refused, retry) in [(true, false), (false, true)] { + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + let body_value = body.cloned().unwrap_or_default(); + match body_value["method"].as_str() { + Some("channel.add_member") if body_value["params"]["channel_id"] == GENERAL => { + Err(if refused { + refuse_broker( + "forbidden", + "forbidden: Only the channel's owner or the workspace host can add people to it.", + ) + } else { + anyhow!("connection reset") + }) + } + Some("channel.add_member") => Ok(json!({ + "channel_id": body_value["params"]["channel_id"], + "principal_id": BOB, + "already_member": false, + })), + _ => standard(method, path, body), + } + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let error = run( + &api, + add_member_command(None, &["#methods", "analysis-lab/general"]), + ) + .await + .expect_err("the second channel failed"); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert!( + shown.starts_with("Added @bob to #methods, then stopped at #general: "), + "{shown}" + ); + assert_eq!( + shown.contains("Retry safely with --request-id req-1"), + retry, + "{shown}" + ); + } + } + + /// CLI-15: JSON output of `members` and `channels list` carries what the text shows. + #[tokio::test] + async fn json_lists_carry_who_you_are_the_host_and_unread_counts() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + let mut snapshot = snapshot(); + snapshot["unread"] = json!({METHODS: 3}); + return Ok(snapshot); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Json, handler); + let Reply::Show(people, _) = run(&api, CrewCommand::Members(MembersArgs { command: None })) + .await + .expect("members") + else { + panic!("members is a list") + }; + assert_eq!(people[0]["id"], ALICE); + assert_eq!( + (people[0]["is_you"].clone(), people[0]["is_host"].clone()), + (json!(true), json!(true)) + ); + assert_eq!( + (people[1]["is_you"].clone(), people[1]["is_host"].clone()), + (json!(false), json!(false)) + ); + + let Reply::Show(channels, _) = run( + &api, + CrewCommand::Channels(ChannelCommand::List { team: None }), + ) + .await + .expect("channels") else { + panic!("channels is a list") + }; + assert_eq!(channels[0]["id"], GENERAL); + assert_eq!(channels[0]["unread"], 0); + assert_eq!(channels[1]["unread"], 3); + + // Without the map (an older broker), nothing is invented. + assert!(with_unread(json!([{"id": METHODS}]), &snapshot())[0] + .get("unread") + .is_none()); + } + + /// M17, F6, SF-F10: `members` and `channels list` come out in a reader's order in text and + /// JSON alike, whatever order the broker's IDs put them in, and a partial list says so. + #[tokio::test] + async fn lists_come_out_in_a_readers_order_and_say_when_they_are_partial() { + let shuffled = |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + let mut snapshot = snapshot(); + snapshot["actor"] = + json!({"id": BOB, "username": "bob", "display_name": "Bob Lee", "uid": 1001}); + snapshot["principals"] = json!([ + {"id": CAROL, "username": "carol", "display_name": "Carol Diaz", "uid": 1002}, + {"id": BOB, "username": "bob", "display_name": "Bob Lee", "uid": 1001}, + {"id": ALICE, "username": "alice", "display_name": "Alice Chen", "uid": 1000} + ]); + snapshot["channels"] = json!([ + {"id": METHODS, "team_id": TEAM, "name": "methods", "classification": "restricted"}, + {"id": GENERAL, "team_id": TEAM, "name": "general", "classification": "restricted"} + ]); + snapshot["totals"] = json!({"channels": 4, "teams": 1}); + return Ok(snapshot); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Json, shuffled); + let Reply::Show(people, _) = run(&api, CrewCommand::Members(MembersArgs { command: None })) + .await + .expect("members") + else { + panic!("members is a list") + }; + let ids: Vec<&str> = people + .as_array() + .unwrap() + .iter() + .map(|person| person["id"].as_str().unwrap()) + .collect(); + // Alice hosts, Bob is you, then Carol by name. + assert_eq!(ids, [ALICE, BOB, CAROL]); + // An older broker reports no presence, and none is invented. + assert!(people[0].get("is_online").is_none()); + let mut present = snapshot(); + present["online_principal_ids"] = json!([BOB]); + let marked = with_roles(present["principals"].clone(), &present); + assert_eq!(marked[0]["is_online"], false); + assert_eq!(marked[1]["is_online"], true); + + let (api, _) = api_with(OutputFormat::Text, shuffled); + let reply = run( + &api, + CrewCommand::Channels(ChannelCommand::List { team: None }), + ) + .await + .expect("channels"); + let Reply::Show(ref channels, _) = reply else { + panic!("channels is a list") + }; + assert_eq!(channels[0]["id"], GENERAL, "#general first"); + let text = said(reply); + assert!( + text[0].ends_with("Showing 2 of your 4 channels."), + "{text:?}" + ); + } + + /// CLI-16: a started task is named by the channel the person typed, and a revoked task's + /// status is the word every other surface uses. + #[tokio::test] + async fn a_started_task_names_its_channel_and_statuses_use_the_shared_words() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if method == "POST" && path.ends_with("/runs") { + return Ok(json!({ + "run_id": "r-1", "connection_id": CONNECTION, "channel_id": METHODS, + "session_id": "20260927_3", "status": "running" + })); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let lines = said( + run( + &api, + CrewCommand::Tasks(TaskCommand::Start { + channel: "methods".into(), + prompt: TextInput { + text: Some("Sum the counts".into()), + input: None, + }, + provider: "p".into(), + model: "m".into(), + context_channels: Vec::new(), + allow_posting: true, + }), + ) + .await + .expect("started"), + ); + assert!( + lines[0].starts_with("Task posting to #methods in Analysis Lab · Working…"), + "{lines:?}" + ); + + let lines = revoked_lines( + SESSION, + &json!({"revoked": true, "remote_revocation_confirmed": true, "task_status": "completed"}), + ) + .expect("revoked"); + assert_eq!(lines[1], "Its task's status: Done."); + } + + /// CLI-20: `tasks cancel` sends no body, so a failure offers no `--request-id` that the + /// route would ignore; running it again is the retry. + #[tokio::test] + async fn tasks_cancel_offers_no_request_id_the_route_ignores() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/runs/r-1/cancel") { + return Err(refuse( + 503, + Some("crew_revocation_unconfirmed"), + "Local cancellation requested; retry cancellation to confirm revocation.", + )); + } + standard(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Text, handler); + let error = run( + &api, + CrewCommand::Tasks(TaskCommand::Cancel { run: "r-1".into() }), + ) + .await + .expect_err("unconfirmed"); + let cancel = fake + .sent() + .into_iter() + .find(|sent| sent.path.ends_with("/cancel")) + .expect("cancel sent"); + assert_eq!((cancel.method.as_str(), cancel.body), ("POST", None)); + let shown = failure( + &error, + OutputFormat::Text, + "req-1", + api.client.sent.load(Ordering::SeqCst), + ) + .to_string(); + assert!(!shown.contains("--request-id"), "{shown}"); + } + + /// `(status checks, claims)` join sent: `GET …/join` and `POST …/join`. + fn join_requests(fake: &FakeDaemon) -> (usize, usize) { + let sent = fake.sent(); + let count = |method: &str| { + sent.iter() + .filter(|sent| sent.method == method && sent.path.ends_with("/join")) + .count() + }; + (count("GET"), count("POST")) + } + + /// Runs `join_until` with `interrupt` standing in for Ctrl-C, and fails rather than hangs + /// when the press is not heard. + async fn join_interrupted( + api: &Api, + interrupt: impl std::future::Future>, + ) -> Reply { + tokio::time::timeout( + Duration::from_secs(5), + join_until(api, JoinArgs { no_wait: false }, interrupt), + ) + .await + .expect("the press stops the wait") + .expect("stopping is not an error") + } + + /// CLI-18. The fake daemon answers within one poll, so a request can never be pending when + /// the press lands; each of these tests reaches one `select!` in `join_until` instead, and + /// fails if that select stops listening for the press. + /// + /// The status check: a press already made when join reaches it wins the biased select, so + /// `GET …/join` (which can wait on a slow SSH connect) is never sent. + #[tokio::test] + async fn a_ctrl_c_already_pressed_stops_join_before_the_status_check() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + match (method, path.ends_with("/join")) { + ("GET", true) => Ok(json!({"status": "approved", "code": "7QK2-M9XA-3JTP-WZ4D", + "workspace_name": "lab", "add_device": false})), + ("POST", true) => Ok( + json!({"joined": true, "status": "joined", "workspace_name": "lab", "add_device": false}), + ), + _ => standard(method, path, body), + } + }; + let (api, fake) = api_with(OutputFormat::StreamJson, handler); + let reply = join_interrupted(&api, async { Ok(()) }).await; + assert!(matches!(reply, Reply::Streamed)); + assert_eq!(join_requests(&fake), (0, 0), "nothing sent after the press"); + } + + /// The claim: a press made while the status was read (the fake answers "approved" and + /// presses in the same call) is heard before the claim, so `POST …/join` is never sent. + #[tokio::test] + async fn a_ctrl_c_during_the_status_check_stops_join_before_it_claims() { + let notify = Arc::new(tokio::sync::Notify::new()); + let pressed = Arc::clone(¬ify); + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + match (method, path.ends_with("/join")) { + ("GET", true) => { + pressed.notify_one(); + Ok(json!({"status": "approved", "code": "7QK2-M9XA-3JTP-WZ4D", + "workspace_name": "lab", "add_device": false})) + } + ("POST", true) => Ok( + json!({"joined": true, "status": "joined", "workspace_name": "lab", "add_device": false}), + ), + _ => standard(method, path, body), + } + }; + let (api, fake) = api_with(OutputFormat::StreamJson, handler); + let interrupt = async move { + notify.notified().await; + Ok(()) + }; + let reply = join_interrupted(&api, interrupt).await; + assert!(matches!(reply, Reply::Streamed)); + assert_eq!(join_requests(&fake), (1, 0), "no claim after the press"); + } + + /// The wait: a press made while the status was read is kept by the one listener and ends + /// the sleep that follows. The poll is an hour, so only the sleep's own select can hear it + /// in time; with a fresh listener per sleep, as before CLI-18, the press was lost. + #[tokio::test] + async fn a_ctrl_c_during_the_status_check_ends_the_wait_that_follows() { + let notify = Arc::new(tokio::sync::Notify::new()); + let pressed = Arc::clone(¬ify); + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if method == "GET" && path.ends_with("/join") { + pressed.notify_one(); + return Ok(json!({"status": "invited", "code": "7QK2-M9XA-3JTP-WZ4D", + "workspace_name": "lab"})); + } + standard(method, path, body) + }; + let (mut api, fake) = api_with(OutputFormat::StreamJson, handler); + api.poll = Duration::from_secs(60 * 60); + let interrupt = async move { + notify.notified().await; + Ok(()) + }; + let reply = join_interrupted(&api, interrupt).await; + assert!(matches!(reply, Reply::Streamed)); + assert_eq!( + join_requests(&fake), + (1, 0), + "no status check after the press" + ); + } + /// A broker that predates direct add says so, and points at the invitation that works. #[tokio::test] async fn members_add_on_an_older_broker_points_to_an_invitation() { @@ -4202,7 +7696,7 @@ mod tests { for (details, expected) in [ ( Some(json!({"model": "gpt-5.5", "approved_for": ["ucsf"], "workspace": "foreign-lab", "workspace_institution": "stanford"})), - "gpt-5.5 is approved for ucsf. foreign-lab uses stanford. Choose a model approved for it, or a local model.", + "gpt-5.5 is approved for UCSF. foreign-lab uses stanford. Choose a model approved for stanford, or a local model.", ), ( None, @@ -4216,7 +7710,11 @@ mod tests { code: Some("crew_request_refused".into()), broker_code: None, institution_refusal: details.clone(), + connection_institution: None, message: DAEMON.into(), + detail: None, + modes: None, + fields: Default::default(), } .into()); } @@ -4247,71 +7745,1486 @@ mod tests { } } - #[test] - fn crew_watch_stops_in_a_sentence_without_a_code_or_the_cursor() { - assert_eq!( - watch_stopped("#general", "You no longer have access to this channel"), - "Stopped watching #general: You no longer have access to this channel." - ); - assert_eq!( - watch_stopped( - "#general", - "Your access to a channel in this workspace changed." - ), - "Stopped watching #general: Your access to a channel in this workspace changed." - ); - assert_eq!(watch_stopped("#general", " "), "Stopped watching #general."); - let text = watch_stopped("#general", "Live updates stopped"); - assert!(!text.contains('['), "{text}"); - assert!(!text.to_ascii_lowercase().contains("cursor"), "{text}"); - } - + /// CLI-5: a streamed command ends on one JSON value per line, its last value and its + /// failure included, and a finished download ends on the word the rows used. #[tokio::test] - async fn a_broker_refusal_is_said_in_its_own_words_with_the_flag_that_acts_on_it() { - let approved = "already_approved: You already entered a code for @bob. If it didn't match their computer, enter the code they sent and choose Replace."; - let (api, _) = api_with(OutputFormat::Text, move |method, path, body| { - if path.ends_with("/request") - && body.and_then(|body| body["method"].as_str()) == Some("enrollment.approve") - { - return Err(refuse_broker("already_approved", approved)); + async fn streamed_commands_end_on_one_json_line_and_a_download_ends_saved() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path == "/crew/transfers/t-1" { + return Ok(json!({ + "id": "t-1", "connection_id": CONNECTION, "channel_id": METHODS, + "direction": "download", "state": "completed", "name": "counts.csv", + "size": 10, "offset": 10 + })); } standard(method, path, body) - }); - let error = run( + }; + let (api, _) = api_with(OutputFormat::Json, handler); + let reply = run( &api, - CrewCommand::Enroll(EnrollmentCommand::Approve { - person: "@bob".into(), - code: "7QK2-M9XA-3JTP-WZ4D".into(), - replace: false, + CrewCommand::Files(FileCommand::Watch { + transfer: "t-1".into(), }), ) .await - .expect_err("already approved"); - let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); - assert_eq!( - shown, - "You already entered a code for @bob.\nIf it didn't match their computer, run: biorouter crew enroll approve @bob --replace" - ); - assert!(!shown.contains("Daemon returned"), "{shown}"); - assert!(!shown.contains("already_approved:"), "{shown}"); - // A refusal is a definite answer, so no retry is offered, and scripts keep both codes. - assert!(!shown.contains("req-1")); - assert_eq!(error_code(&error).as_deref(), Some("crew_request_refused")); - assert_eq!( - error.chain().find_map(broker_refusal).map(|(code, _)| code), - Some("already_approved") - ); + .expect("watched"); + let Reply::Last(last, options) = reply else { + panic!("files watch ends on its last value: {reply:?}") + }; + assert_eq!(last["direction"], "download"); + assert_eq!(output::render_text(&last, &options), "Transfer Saved."); + for format in [OutputFormat::Json, OutputFormat::StreamJson] { + let line = output::formatted(&last, output::stream_format(format), &options) + .expect("formatted"); + assert!(!line.contains('\n'), "{line}"); + } - // Without a broker code (another route's refusal), the text is unchanged. - let other = refuse( + for command in [ + CrewCommand::Watch(WatchArgs { + channel: "methods".into(), + after: None, + from_start: false, + new_only: false, + }), + CrewCommand::Join(JoinArgs { no_wait: false }), + CrewCommand::Tasks(TaskCommand::Watch { run: "r".into() }), + CrewCommand::Files(FileCommand::Watch { + transfer: "t".into(), + }), + ] { + assert!(streams(&command)); + } + assert!(!streams(&CrewCommand::Status)); + let lost = anyhow!("connection reset"); + let line = failure_json(&lost, "connection reset", "req-1", OutputFormat::StreamJson) + .expect("json"); + assert!(!line.contains('\n'), "{line}"); + assert!(failure_json(&lost, "x", "req-1", OutputFormat::Json) + .expect("json") + .contains('\n')); + assert!(failure_json(&lost, "x", "req-1", OutputFormat::Text).is_none()); + } + + /// DW-17: history names each attachment, in text and in JSON (`attachment_details`, beside + /// the unchanged `attachments`), asking about each ID once; `files show ID` says which file + /// an ID is. + #[tokio::test] + async fn history_names_attachments_and_files_show_says_which_file_an_id_is() { + const CORRECTED: &str = "a1a1a1a1-0000-4000-8000-000000000001"; + const ORIGINAL: &str = "a1a1a1a1-0000-4000-8000-000000000002"; + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + let body_value = body.cloned().unwrap_or_default(); + match body_value["method"].as_str() { + Some("messages.history") => Ok(json!({"messages": [ + {"id": "m1", "channel_id": METHODS, "actor_id": BOB, "body": "Both files.", + "created_at": 0, "attachments": [CORRECTED, ORIGINAL]}, + {"id": "m2", "channel_id": METHODS, "actor_id": BOB, "body": "Again.", + "created_at": 0, "attachments": [CORRECTED]} + ], "cursor": "m2", "people": {}})), + Some("blob.status") => { + let id = body_value["params"]["blob_id"].as_str().unwrap_or_default(); + let name = if id == CORRECTED { + "counts-fixed.csv" + } else { + "counts.csv" + }; + Ok( + json!({"id": id, "channel_id": METHODS, "owner_id": BOB, "name": name, + "size": 55, "media_type": "text/csv", "complete": true}), + ) + } + _ => standard(method, path, body), + } + }; + let (api, fake) = api_with(OutputFormat::Json, handler); + let Reply::Show(page, _) = run(&api, history("methods")).await.expect("history") else { + panic!("history shows a page") + }; + assert_eq!( + page["messages"][0]["attachments"], + json!([CORRECTED, ORIGINAL]) + ); + assert_eq!( + page["messages"][0]["attachment_details"][ORIGINAL], + json!({"name": "counts.csv", "size": 55, "media_type": "text/csv"}) + ); + let lookups = fake + .broker_calls() + .into_iter() + .filter(|(method, _)| method == "blob.status") + .count(); + assert_eq!(lookups, 2, "each ID is asked about once"); + + let (mut api, _) = api_with(OutputFormat::Text, handler); + api.show_ids = true; + let text = said(run(&api, history("methods")).await.expect("history")).join("\n"); + assert!( + text.contains(&format!(" Attachment: counts-fixed.csv (55 B) [attachment ID {CORRECTED}]\n Attachment: counts.csv (55 B) [attachment ID {ORIGINAL}]")), + "{text}" + ); + + let (api, _) = api_with(OutputFormat::Text, handler); + let shown = said( + run( + &api, + CrewCommand::Files(FileCommand::Show { + attachment: ORIGINAL.into(), + }), + ) + .await + .expect("files show"), + ); + assert_eq!( + shown, + ["counts.csv · 55 B · text/csv · in #methods · shared by \"Bob Lee\" (@bob)"] + ); + } + + /// AG-F7: `crew context` leads with the grant's scope in text, taking the channel it posts + /// in from the grants list, and prints the manifest as it is in JSON. + #[tokio::test] + async fn crew_context_leads_with_the_grants_scope() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with(&format!("/sessions/{SESSION}/context")) { + return Ok(json!({"run_id": "r", "source_channels": [METHODS, GENERAL], + "messages": [], "people": {}, "channel_names": {}})); + } + if path.ends_with("/grants") { + return Ok( + json!({"grants": [{"session_id": SESSION, "channel_id": METHODS, + "source_channels": [METHODS, GENERAL], "expired": false}]}), + ); + } + standard(method, path, body) + }; + let command = || CrewCommand::Context { + session: SESSION.into(), + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let lines = said(run(&api, command()).await.expect("context")); + assert_eq!( + lines, + [ + "Access: #methods in Analysis Lab · also reads #general in Analysis Lab", + "No messages." + ] + ); + let (api, _) = api_with(OutputFormat::Json, handler); + let Reply::Show(manifest, _) = run(&api, command()).await.expect("context") else { + panic!("JSON is the manifest") + }; + assert_eq!(manifest["source_channels"], json!([METHODS, GENERAL])); + } + + /// AGT2-N3: `grants list` reads each task's status, so a task the person stopped reads + /// Stopped and one that finished reads Ended; JSON carries it as `task_status`. + #[tokio::test] + async fn a_stopped_task_reads_stopped_in_the_grants_list() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/grants") { + return Ok(json!({"grants": [ + {"session_id": "20260924_4", "run_id": "run-stopped", "kind": "task", "channel_id": METHODS, "source_channels": [METHODS], "expired": true, "revocation": "confirmed"}, + {"session_id": "20260924_5", "run_id": "run-done", "kind": "task", "channel_id": METHODS, "source_channels": [METHODS], "expired": true, "revocation": "confirmed"}, + {"session_id": SESSION, "run_id": "run-chat", "kind": "chat", "channel_id": METHODS, "source_channels": [METHODS], "expired": true, "revocation": "confirmed"} + ], "replaced_grants": []})); + } + if path.ends_with("/runs") { + return Ok(json!({"runs": [ + {"run_id": "run-stopped", "status": "cancelled", "session_id": "20260924_4"}, + {"run_id": "run-done", "status": "completed", "session_id": "20260924_5"} + ]})); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let lines = said( + run(&api, CrewCommand::Grants(GrantCommand::List)) + .await + .expect("listed"), + ) + .join("\n"); + assert!( + lines.contains("Task 20260924_4 → #methods in Analysis Lab · Stopped"), + "{lines}" + ); + assert!( + lines.contains("Task 20260924_5 → #methods in Analysis Lab · Ended"), + "{lines}" + ); + assert!( + lines.contains("Chat 20260924_2 → #methods in Analysis Lab · Revoked"), + "{lines}" + ); + let (api, _) = api_with(OutputFormat::Json, handler); + let Reply::Show(value, _) = run(&api, CrewCommand::Grants(GrantCommand::List)) + .await + .expect("listed") + else { + panic!("a value"); + }; + assert_eq!(value["grants"][0]["task_status"], "cancelled"); + assert_eq!(value["grants"][1]["task_status"], "completed"); + assert!( + value["grants"][2].get("task_status").is_none(), + "a chat has no task" + ); + } + + /// CLIDOCS-F12: an empty list says what it lists, never "No items.". + #[tokio::test] + async fn an_empty_list_names_what_it_lists() { + let empty = |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + let mut snapshot = snapshot(); + snapshot["teams"] = json!([]); + snapshot["channels"] = json!([]); + return Ok(snapshot); + } + standard(method, path, body) + }; + for (command, sentence) in [ + ( + CrewCommand::Invites(InvitationCommand::List), + "No pending invitations.", + ), + (CrewCommand::Teams(TeamCommand::List), "No teams."), + ( + CrewCommand::Channels(ChannelCommand::List { team: None }), + "No channels.", + ), + ] { + let (api, _) = api_with(OutputFormat::Text, empty); + let lines = said(run(&api, command).await.expect("listed")); + assert_eq!(lines, [sentence]); + } + } + + /// CLIDOCS-F10, CLIDOCS-F6: `files forget` says what it did in the terminal's words, and an + /// ID this computer has no receipt for says so and where to look; JSON keeps the daemon's. + #[tokio::test] + async fn forgetting_and_an_unknown_transfer_are_said_for_a_person() { + const TRANSFER: &str = "7ea55000-0000-4000-8000-00000000000d"; + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + match (method, path) { + ("GET", path) if path == format!("/crew/transfers/{TRANSFER}") => Ok( + json!({"id": TRANSFER, "connection_id": CONNECTION, "channel_id": METHODS, + "direction": "upload", "state": "needs_file_selection", "name": "counts.csv"}), + ), + ("DELETE", path) if path == format!("/crew/transfers/{TRANSFER}") => Ok(json!({ + "forgotten": true, + "message": "Receipt removed after authorized partial cleanup. Remote attachments and published downloads are not deleted." + })), + ("GET", path) if path.starts_with("/crew/transfers/") => Err(refuse( + 400, + Some("crew_request_refused"), + "Unknown transfer", + )), + _ => standard(method, path, body), + } + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let lines = said( + run( + &api, + CrewCommand::Files(FileCommand::Forget { + transfer: TRANSFER.into(), + file: None, + }), + ) + .await + .expect("forgotten"), + ); + assert_eq!( + lines, + ["Removed from your list. Files already shared stay in the channel."] + ); + let (api, _) = api_with(OutputFormat::Json, handler); + let Reply::Say(value, _) = run( + &api, + CrewCommand::Files(FileCommand::Forget { + transfer: TRANSFER.into(), + file: None, + }), + ) + .await + .expect("forgotten") else { + panic!("a sentence"); + }; + assert_eq!(value["forgotten"], true, "JSON is the daemon's answer"); + + for command in [ + FileCommand::Status { + transfer: "7ea55000-0000-4000-8000-0000000000ff".into(), + }, + FileCommand::Pause { + transfer: "7ea55000-0000-4000-8000-0000000000ff".into(), + }, + ] { + let (api, _) = api_with(OutputFormat::Text, handler); + let error = run(&api, CrewCommand::Files(command)) + .await + .expect_err("unknown"); + assert_eq!( + failure(&error, OutputFormat::Text, "req-1", false).to_string(), + files::UNKNOWN_TRANSFER + ); + assert_eq!(error_code(&error).as_deref(), Some("crew_request_refused")); + } + } + + /// SF2-N4: `context` for a chat whose access ended says why and the command that grants it + /// again, naming the chat and its channel, under the daemon's `crew_grant_ended`; an older + /// daemon's untyped settings-changed refusal is said the same way. + #[tokio::test] + async fn context_after_access_ended_says_how_to_grant_it_again() { + const SETTINGS_CHANGED: &str = + "Crew settings changed since access was granted. Grant access again from Crew."; + let ended = |code: &'static str, reason: Option<&'static str>| { + move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with(&format!("/sessions/{SESSION}/context")) { + let mut fields = serde_json::Map::new(); + if let Some(reason) = reason { + fields.insert("reason".into(), json!(reason)); + } + return Err(FakeRefusal { + status: 409, + code: Some(code.into()), + broker_code: None, + institution_refusal: None, + connection_institution: None, + message: SETTINGS_CHANGED.into(), + detail: None, + modes: None, + fields, + } + .into()); + } + if path.ends_with("/grants") { + return Ok( + json!({"grants": [{"session_id": SESSION, "channel_id": METHODS, + "source_channels": [METHODS], "expired": true}]}), + ); + } + standard(method, path, body) + } + }; + let context = || CrewCommand::Context { + session: SESSION.into(), + }; + for (code, reason, sentence) in [ + (GRANT_ENDED, Some("settings_changed"), "This chat's Crew access ended because Crew settings changed. Run biorouter crew grants grant 20260924_2 methods to grant it again."), + (GRANT_ENDED, Some("ended"), "This chat's Crew access ended. Run biorouter crew grants grant 20260924_2 methods to grant it again."), + ("crew_profile_refused", None, "This chat's Crew access ended because Crew settings changed. Run biorouter crew grants grant 20260924_2 methods to grant it again."), + ] { + let (api, _) = api_with(OutputFormat::Text, ended(code, reason)); + let error = run(&api, context()).await.expect_err("ended"); + let shown = failure(&error, OutputFormat::Text, "req-1", false).to_string(); + assert_eq!(shown, sentence, "{code} {reason:?}"); + assert!(!shown.contains("from Crew"), "{shown}"); + assert_eq!(error_code(&error).as_deref(), Some(code)); + } + // A settings refusal that is not this one keeps its own sentence. + let (api, _) = api_with( + OutputFormat::Text, + |method: &str, path: &str, body: Option<&Value>| { + if path.ends_with("/context") { + return Err(refuse( + 400, + Some("crew_profile_refused"), + "Crew couldn't read its saved settings.", + )); + } + standard(method, path, body) + }, + ); + let error = run(&api, context()).await.expect_err("refused"); + assert_eq!( + failure(&error, OutputFormat::Text, "req-1", false).to_string(), + "Crew couldn't read its saved settings." + ); + } + + /// DW-07: `files watch` names each receipt's channel as `files status` does, and shows + /// its IDs with `--show-ids`. + #[tokio::test] + async fn files_watch_rows_are_named_like_files_status() { + let (mut api, _) = api_with(OutputFormat::Text, standard); + api.show_ids = true; + let options = files::watch_options(&api).await; + let receipt = json!({"id": "t-1", "connection_id": CONNECTION, "channel_id": METHODS, + "direction": "download", "state": "downloading", "name": "counts.csv", + "size": 10, "offset": 5}); + let row = output::render_text(&receipt, &options); + assert!( + row.starts_with(&format!( + "counts.csv · download from #methods [channel ID {METHODS}] · Downloading 50%" + )), + "{row}" + ); + assert!(row.contains("[transfer ID t-1]"), "{row}"); + } + + /// R-8, AG-F17: offline, `grants list` and `files pending` say the names are unavailable + /// and show each channel's ID, rather than "this channel" on every row. + #[tokio::test] + async fn offline_lists_say_names_are_unavailable() { + let offline = |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + return Err(refuse(409, Some("crew_not_connected"), "Not connected.")); + } + if path.ends_with("/grants") { + return Ok(json!({"grants": [{"session_id": SESSION, "run_id": "r", + "connection_id": CONNECTION, "channel_id": METHODS, + "source_channels": [METHODS], "expired": false}]})); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, offline); + let lines = said( + run(&api, CrewCommand::Grants(GrantCommand::List)) + .await + .expect("listed offline"), + ); + assert_eq!( + lines, + [format!("Chat 20260924_2 → a channel in UCSF HPC (names unavailable while disconnected) [channel ID {METHODS}] · Active")] + ); + + // W2-CLI-9, CLIDOCS-F6: an older daemon's untyped refusal is the same fact. + let older = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + return Err(refuse( + 400, + Some("crew_request_refused"), + LEGACY_DISCONNECTED, + )); + } + offline(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, older); + let lines = said( + run(&api, CrewCommand::Grants(GrantCommand::List)) + .await + .expect("listed offline"), + ); + assert!( + lines[0].contains("(names unavailable while disconnected)"), + "{lines:?}" + ); + } + + /// CLIDOCS-F6: a request on a disconnected connection names the connection and the + /// command that connects it, whether the daemon typed the refusal (`crew_not_connected`) + /// or sent an older daemon's untyped sentence; JSON keeps the daemon's code. + #[tokio::test] + async fn a_disconnected_connection_is_named_with_the_command_that_connects_it() { + for (status, code) in [(409, NOT_CONNECTED), (400, "crew_request_refused")] { + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/request") { + return Err(refuse(status, Some(code), LEGACY_DISCONNECTED)); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let error = run(&api, history("methods")) + .await + .expect_err("disconnected"); + let error = api.worded_failure(error).await; + let shown = failure(&error, OutputFormat::Text, "req-1", false).to_string(); + assert_eq!( + shown, "UCSF HPC is disconnected. Run biorouter crew connect, then try again.", + "{code}" + ); + assert!(!shown.contains("Daemon returned"), "{shown}"); + assert!(!shown.contains("in Crew"), "{shown}"); + assert_eq!(error_code(&error).as_deref(), Some(code)); + + // With --connection, the command to run names it too. + let (mut api, _) = api_with(OutputFormat::Text, handler); + api.selected = Some(CONNECTION.into()); + let error = run(&api, history("methods")) + .await + .expect_err("disconnected"); + let shown = failure( + &api.worded_failure(error).await, + OutputFormat::Text, + "req-1", + false, + ) + .to_string(); + assert_eq!( + shown, + "UCSF HPC is disconnected. Run biorouter crew --connection 'UCSF HPC' connect, then try again." + ); + } + // Where the command could not name the connection, the sentence still says what to do. + let bare = refuse(409, Some(NOT_CONNECTED), LEGACY_DISCONNECTED); + assert_eq!( + failure(&bare, OutputFormat::Text, "req-1", false).to_string(), + NOT_CONNECTED_SENTENCE + ); + } + + /// CLI-6: a watch starts at the newest page by default, after the newest message with + /// `--new-only`, at the oldest with `--from-start`, and at a cursor with `--after`. + #[tokio::test] + async fn a_watch_starts_at_the_newest_messages_unless_asked_otherwise() { + let start = |after: Option<&str>, from_start: bool, new_only: bool| WatchArgs { + channel: "methods".into(), + after: after.map(str::to_owned), + from_start, + new_only, + }; + let (api, fake) = api_with(OutputFormat::Text, standard); + let shape = |(cursor, initial): (Option, Initial)| { + (cursor, serde_json::to_value(initial).expect("initial")) + }; + assert_eq!( + shape( + watch_start(&api, METHODS, &start(None, false, false)) + .await + .unwrap() + ), + (None, json!("latest")) + ); + assert_eq!( + shape( + watch_start(&api, METHODS, &start(None, true, false)) + .await + .unwrap() + ), + (None, json!("all")) + ); + assert_eq!( + shape( + watch_start(&api, METHODS, &start(Some("m-3"), false, false)) + .await + .unwrap() + ), + (Some("m-3".into()), json!("all")) + ); + assert!(fake.broker_calls().is_empty(), "no history read so far"); + assert_eq!( + shape( + watch_start(&api, METHODS, &start(None, false, true)) + .await + .unwrap() + ), + (Some("m-9".into()), json!("all")) + ); + assert_eq!( + fake.broker_call("messages.history").expect("newest"), + json!({"channel_id": METHODS, "latest": true, "limit": 1}) + ); + } + + /// CLI-5: a watch the daemon ended prints one error value, the frame and the failure in + /// one, not a frame followed by a second error object. + #[tokio::test] + async fn a_stopped_watch_is_one_error_value() { + let (api, _) = api_with(OutputFormat::StreamJson, standard); + let state = std::sync::Mutex::new(WatchState::default()); + let error = match watch_event( + &api, + &state, + "#methods", + ObserveEvent::Error { + code: "channel_access_changed".into(), + error: "You no longer have access to this channel".into(), + clear: true, + }, + ) + .await + { + Ok(_) => panic!("an error frame ends the watch"), + Err(error) => error, + }; + let message = safe_lines(&error_text(&error)); + assert_eq!( + message, + "Stopped watching #methods: You no longer have access to this channel." + ); + let body = failure_body(&error, &message, "req-1"); + assert_eq!( + body, + json!({"type": "error", "code": "channel_access_changed", "clear": true, + "error": message, "request_id": "req-1"}) + ); + } + + #[test] + fn crew_watch_stops_in_a_sentence_without_a_code_or_the_cursor() { + assert_eq!( + watch_stopped("#general", "You no longer have access to this channel"), + "Stopped watching #general: You no longer have access to this channel." + ); + assert_eq!( + watch_stopped( + "#general", + "Your access to a channel in this workspace changed." + ), + "Stopped watching #general: Your access to a channel in this workspace changed." + ); + assert_eq!(watch_stopped("#general", " "), "Stopped watching #general."); + // CLI-4: a label the CLI isolated is printed once, not escaped again. + let stopped = watch_stopped("\u{2068}#données\u{2069}", "Live updates stopped"); + assert_eq!( + stopped, + "Stopped watching \u{2068}#données\u{2069}: Live updates stopped." + ); + assert_eq!(safe_lines(&stopped), stopped); + let text = watch_stopped("#general", "Live updates stopped"); + assert!(!text.contains('['), "{text}"); + assert!(!text.to_ascii_lowercase().contains("cursor"), "{text}"); + } + + #[tokio::test] + async fn a_broker_refusal_is_said_in_its_own_words_with_the_flag_that_acts_on_it() { + let approved = "already_approved: You already entered a code for @bob. If it didn't match their computer, enter the code they sent and choose Replace."; + let (api, _) = api_with(OutputFormat::Text, move |method, path, body| { + if path.ends_with("/request") + && body.and_then(|body| body["method"].as_str()) == Some("enrollment.approve") + { + return Err(refuse_broker("already_approved", approved)); + } + standard(method, path, body) + }); + let error = run( + &api, + CrewCommand::Enroll(EnrollmentCommand::Approve { + person: "@bob".into(), + code: "7QK2-M9XA-3JTP-WZ4D".into(), + replace: false, + }), + ) + .await + .expect_err("already approved"); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert_eq!( + shown, + "You already entered a code for @bob.\nIf it didn't match their computer, run: biorouter crew enroll approve @bob --replace" + ); + assert!(!shown.contains("Daemon returned"), "{shown}"); + assert!(!shown.contains("already_approved:"), "{shown}"); + // A refusal is a definite answer, so no retry is offered, and scripts keep both codes. + assert!(!shown.contains("req-1")); + assert_eq!(error_code(&error).as_deref(), Some("crew_request_refused")); + assert_eq!( + error.chain().find_map(broker_refusal).map(|(code, _)| code), + Some("already_approved") + ); + + // Without a broker code (another route's refusal), the daemon's own sentence is + // printed as it is, without the status (FILES2-N4). + let other = refuse( 404, Some("crew_grant_not_found"), "No Crew grant for this session.", ); assert_eq!( - failure(&other, OutputFormat::Text, "req-1", false).to_string(), - "Daemon returned 404: No Crew grant for this session." + failure(&other, OutputFormat::Text, "req-1", false).to_string(), + "No Crew grant for this session." + ); + } + + /// FILES2-N4, CLIDOCS-F6: a daemon refusal with a code and a sentence of its own prints + /// that sentence alone, whatever the code; only a refusal with no code keeps the + /// `Daemon returned NNN:` prefix. JSON keeps the code, and the sentence is its `error`. + #[test] + fn a_typed_daemon_refusal_prints_its_own_sentence_without_the_status() { + let shown = + |error: &anyhow::Error| failure(error, OutputFormat::Text, "req-1", false).to_string(); + for (code, sentence) in [ + ("crew_folder_shared", "Choose a folder owned by your account that other accounts can't change."), + ("crew_file_name_hidden", "“.Rprofile” starts with a dot, which Crew doesn't save into your home. Choose a name without the leading dot."), + ("crew_file_is_program", "“run.sh” is a program. Choose another name, or save it in another folder."), + ("crew_file_is_credential", "“id_ed25519” looks like a credential file. Crew doesn't share credential files."), + ("crew_profile_refused", "Crew couldn't read its saved settings."), + ("crew_transfer_refused", "That file selection expired. Select the file again."), + ("crew_invitation_conflict", "This invitation doesn't match “lab”, which this computer already has for the same workspace. Ask your host to send it again, and compare the fingerprint."), + ("crew_grant_not_found", "No Crew grant for this session."), + ("crew_connection_not_found", "No such connection."), + ] { + let error = refuse(400, Some(code), sentence); + let text = shown(&error); + assert_eq!(text, sentence, "{code}"); + assert!(!text.contains("Daemon returned"), "{code}: {text}"); + let body = failure_body(&error, &safe_lines(&error_text(&error)), "req-1"); + assert_eq!(body["code"], code); + assert_eq!(body["error"], sentence); + } + // An existing file names the flag that replaces it, not the desktop's Replace button. + let exists = refuse( + 400, + Some("crew_destination_exists"), + "A file named “counts.csv” already exists. Replace it, or choose another name.", + ); + assert_eq!( + shown(&exists), + "A file named “counts.csv” already exists. Add --overwrite to replace it, or save it under another name." + ); + // A refusal with no code has no sentence of its own the CLI can vouch for. + let untyped = refuse(400, None, "Something went wrong"); + assert_eq!(shown(&untyped), "Daemon returned 400: Something went wrong"); + // An empty sentence keeps the status too. + let empty = refuse(409, Some("crew_request_refused"), " "); + assert!(shown(&empty).starts_with("Daemon returned 409:")); + } + + /// SF2-N6, CLIDOCS-F8: a command with several saved connections and no `--connection`, and + /// `auth` with no terminal, are usage refusals: exit status 2 (`NeedsTerminal` is what + /// `main` exits 2 on), a code in JSON, and nothing sent. + #[tokio::test] + async fn a_missing_connection_choice_and_auth_without_a_terminal_exit_with_the_usage_status() { + let two = |method: &str, path: &str, body: Option<&Value>| -> Result { + if (method, path) == ("GET", "/crew/connections") { + return Ok(json!({"connections": [ + {"id": CONNECTION, "name": "UCSF HPC", "ssh_target": "bob@hpc", "workspace_id": "w", "status": "connected"}, + {"id": "c0ffee00-0000-4000-8000-000000000009", "name": "Stanford", "ssh_target": "bob@su", "workspace_id": "v", "status": "connected"} + ]})); + } + standard(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Json, two); + let error = run(&api, history("methods")).await.expect_err("which one?"); + assert!(fake.broker_calls().is_empty(), "nothing was sent"); + assert_eq!(error_code(&error).as_deref(), Some(CONNECTION_REQUIRED)); + let exit = failure(&error, OutputFormat::Json, "req-1", false); + assert!(exit.downcast_ref::().is_some(), "exit 2"); + assert!(exit.to_string().contains("--connection NAME"), "{exit}"); + let body = failure_body(&error, &safe_lines(&error_text(&error)), "req-1"); + assert_eq!(body["code"], CONNECTION_REQUIRED); + + let auth: anyhow::Error = + needs_terminal::require(false, crate::daemon_client::AUTH_NEEDS_A_TERMINAL) + .unwrap_err() + .into(); + let exit = failure(&auth, OutputFormat::Json, "req-1", false); + assert!(exit.downcast_ref::().is_some(), "exit 2"); + let body = failure_body(&auth, &safe_lines(&error_text(&auth)), "req-1"); + assert_eq!(body["code"], NEEDS_TERMINAL_CODE); + assert!(body["error"] + .as_str() + .is_some_and(|text| text.contains("biorouter crew connect"))); + } + + /// AGT2-N5: a JSON failure carries the daemon's structured refusal fields, so a script + /// reads the modes or institutions that failed without parsing the sentence. What the CLI + /// writes itself (`error`, `request_id`, `code`) is never replaced by the daemon's. + #[test] + fn a_json_failure_keeps_the_daemons_structured_refusal_fields() { + let mode: anyhow::Error = DaemonRefusal::for_test( + 400, + json!({ + "code": "crew_mode_mismatch", + "error": "Crew refused this.", + "actual_mode": "private", + "expected_mode": "public", + "request_id": "the-daemons-own", + }), + ) + .into(); + let body = failure_body(&mode, &safe_lines(&error_text(&mode)), "req-1"); + assert_eq!(body["code"], "crew_mode_mismatch"); + assert_eq!(body["actual_mode"], "private"); + assert_eq!(body["expected_mode"], "public"); + assert_eq!(body["request_id"], "req-1", "the CLI's own request ID"); + assert_eq!( + body["error"], + "Your connection is Private, but this request required Public. Nothing was sent." + ); + + let details = json!({"model": "gpt-5.5", "approved_for": ["ucsf"], "workspace": "okafor-lab", "workspace_institution": "stanford-synthetic"}); + let institution: anyhow::Error = DaemonRefusal::for_test( + 400, + json!({ + "code": "crew_institution_mismatch", + "error": "Crew institution does not match the model's resolved affiliation; choose a local model or a model approved for this institution", + "institution_refusal": details, + "workspace": "okafor-lab", + }), + ) + .into(); + // Worded by a command, the refusal is still its source, so its fields still come out. + let worded = anyhow::Error::from(Worded { + sentence: "Said for a person.".into(), + source: institution, + }); + let body = failure_body(&worded, &safe_lines(&error_text(&worded)), "req-1"); + assert_eq!(body["institution_refusal"], details); + assert_eq!(body["workspace"], "okafor-lab"); + assert_eq!(body["code"], "crew_institution_mismatch"); + assert_eq!(body["error"], "Said for a person."); + + // T3-CLI-10: a save refused because its institution is not the workspace's own + // (T3-BE-4) keeps `workspace_institution`, the one institution a script can retry with. + let save: anyhow::Error = DaemonRefusal::for_test( + 400, + json!({ + "code": "crew_institution_mismatch", + "error": "This connection is for ucsf, but okafor-lab belongs to stanford-synthetic. Use stanford-synthetic here.", + "connection_institution": "ucsf", + "workspace_institution": "stanford-synthetic", + "workspace": "okafor-lab", + }), + ) + .into(); + let text = safe_lines(&error_text(&save)); + assert_eq!( + text, + "This connection is for ucsf, but okafor-lab belongs to stanford-synthetic. Use stanford-synthetic here." + ); + let body = failure_body(&save, &text, "req-1"); + assert_eq!(body["code"], "crew_institution_mismatch"); + assert_eq!(body["workspace_institution"], "stanford-synthetic"); + assert_eq!(body["connection_institution"], "ucsf"); + assert_eq!(body["workspace"], "okafor-lab"); + + // AGT2-N6: a refusal's diagnostic detail reaches JSON, and never the sentence. + let unreadable: anyhow::Error = DaemonRefusal::for_test( + 409, + json!({ + "code": "crew_registry_unreadable", + "error": "Crew's saved settings can't be read, so nothing was changed. Update Biorouter, then try again.", + "detail": "unknown variant `bogus_future_variant`, expected one of `chat`, `task`", + }), + ) + .into(); + let text = safe_lines(&error_text(&unreadable)); + assert_eq!( + text, + "Crew's saved settings can't be read, so nothing was changed. Update Biorouter, then try again." + ); + let body = failure_body(&unreadable, &text, "req-1"); + assert_eq!(body["code"], "crew_registry_unreadable"); + assert_eq!( + body["detail"], + "unknown variant `bogus_future_variant`, expected one of `chat`, `task`" + ); + } + + /// T3-CLI-10: every field of the daemon's `CrewError`, as the generated OpenAPI document + /// describes it, is either copied into a JSON failure (`REFUSAL_FIELDS`) or written by the + /// CLI itself (`CLI_WRITTEN_FIELDS`). A field the daemon adds then fails here instead of + /// silently leaving `--output-format json`, as `workspace_institution` did. The reverse + /// holds too, so a misspelt entry that could never match is caught. + #[test] + fn every_crew_error_field_reaches_json_output_or_is_written_by_the_cli() { + use crate::daemon_client::{CLI_WRITTEN_FIELDS, REFUSAL_FIELDS}; + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ui/desktop/openapi.json"); + let document: Value = serde_json::from_str( + &std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("read {}: {error}", path.display())), + ) + .expect("openapi.json is JSON"); + let properties = document["components"]["schemas"]["CrewError"]["properties"] + .as_object() + .expect("openapi.json describes CrewError's properties"); + assert!( + properties.len() > CLI_WRITTEN_FIELDS.len(), + "CrewError has only {} properties", + properties.len() + ); + let dropped: Vec<&str> = properties + .keys() + .map(String::as_str) + .filter(|key| !REFUSAL_FIELDS.contains(key) && !CLI_WRITTEN_FIELDS.contains(key)) + .collect(); + assert!( + dropped.is_empty(), + "CrewError fields a JSON failure would drop: {dropped:?}. Add each to REFUSAL_FIELDS in daemon_client.rs, or write it in failure_body and list it in CLI_WRITTEN_FIELDS." + ); + let unknown: Vec<&str> = REFUSAL_FIELDS + .iter() + .chain(CLI_WRITTEN_FIELDS) + .copied() + .filter(|key| !properties.contains_key(*key)) + .collect(); + assert!( + unknown.is_empty(), + "fields the CLI keeps that CrewError does not have: {unknown:?}" + ); + for key in REFUSAL_FIELDS { + assert!( + !CLI_WRITTEN_FIELDS.contains(key), + "{key} is both copied and written by the CLI" + ); + } + } + + /// CLI-7: a connect the server refused is said in words with what to run, then the code + /// the manual's table is keyed on and OpenSSH's own words; JSON keeps the code and detail. + #[tokio::test] + async fn a_refused_connect_says_what_to_run_with_the_code_and_details() { + const TRANSPORT: &str = "Crew SSH failure [ssh_eof; child_before_cleanup=exit_255]: ssh exited; reconnect. Submitted operation outcome may be unknown; inspect history before retrying"; + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/connect") { + return Err(FakeRefusal { + status: 400, + code: Some("crew_ssh_auth_required".into()), + broker_code: None, + institution_refusal: None, + connection_institution: None, + message: TRANSPORT.into(), + detail: Some( + "bob@hpc: Permission denied (publickey,password).\nsecond line".into(), + ), + modes: None, + fields: Default::default(), + } + .into()); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let error = run(&api, CrewCommand::Connect).await.expect_err("refused"); + let shown = failure(&error, OutputFormat::Text, "req-1", false).to_string(); + assert_eq!( + shown, + "The server wants your password or a verification code. Run biorouter crew auth to sign in.\n Code: crew_ssh_auth_required\n Details: bob@hpc: Permission denied (publickey,password).\n second line" + ); + assert!(!shown.contains("Submitted operation"), "{shown}"); + let body = failure_body(&error, &shown, "req-1"); + assert_eq!(body["code"], "crew_ssh_auth_required"); + assert_eq!( + body["detail"], + "bob@hpc: Permission denied (publickey,password).\nsecond line" + ); + + for code in [ + "crew_ssh_auth_required", + "crew_ssh_key_refused", + "crew_broker_not_running", + "crew_ssh_host_key_unknown", + "crew_ssh_host_key_changed", + "crew_ssh_unreachable", + "crew_bridge_missing", + "crew_ssh_failed", + "crew_workspace_identity_mismatch", + ] { + let text = output::connect_failure_text(code).expect(code); + assert!(text.ends_with('.'), "{code}: {text}"); + } + assert!(output::connect_failure_text("crew_request_refused").is_none()); + // Another refusal keeps its own sentence. + let other = refuse( + 404, + Some("crew_connection_not_found"), + "No such connection.", + ); + assert_eq!( + failure(&other, OutputFormat::Text, "req-1", false).to_string(), + "No such connection." + ); + } + + /// A daemon whose broker refuses `method` with `code: text`, as a daemon with `broker_code` + /// forwards it; `blob.status` places every attachment in #general. + fn refusing( + method: &'static str, + code: &'static str, + text: &'static str, + ) -> impl Fn(&str, &str, Option<&Value>) -> Result { + move |method_: &str, path: &str, body: Option<&Value>| match body + .and_then(|body| body["method"].as_str()) + { + Some(called) if called == method => Err(refuse_broker(code, text)), + Some("blob.status") => { + Ok(json!({"id": "b", "channel_id": GENERAL, "name": "counts.csv"})) + } + _ => standard(method_, path, body), + } + } + + /// DW-11, M20, FILES-F9: a refusal names the channel the command acted on, and where a + /// file came from, in text; JSON keeps both codes. + #[tokio::test] + async fn a_refusal_names_the_channel_the_command_acted_on() { + let shown = + |error: &anyhow::Error| failure(error, OutputFormat::Text, "req-1", true).to_string(); + let (api, _) = api_with( + OutputFormat::Text, + refusing( + "channel.rename", + "forbidden", + "forbidden: current owner required", + ), + ); + let error = run( + &api, + CrewCommand::Channels(ChannelCommand::Rename { + channel: "methods".into(), + name: "methods-2".into(), + }), + ) + .await + .expect_err("not the owner"); + assert_eq!(shown(&error), "Only #methods's owner can do this."); + let body = failure_body(&error, &safe_lines(&error_text(&error)), "req-1"); + assert_eq!(body["code"], "crew_request_refused"); + assert_eq!(body["broker_code"], "forbidden"); + assert_eq!(body["error"], "Only #methods's owner can do this."); + + // An ID the resolver never saw is named from the person's snapshot. + let (api, _) = api_with( + OutputFormat::Text, + refusing( + "message.post", + "channel_archived", + "channel_archived: channel is read-only", + ), + ); + let send = |channel: &str, attachments: Vec| { + CrewCommand::Send(SendArgs { + channel: channel.into(), + text: Some("hi".into()), + input: None, + attachments, + references: Vec::new(), + }) + }; + let error = run(&api, send(METHODS, Vec::new())) + .await + .expect_err("archived"); + assert_eq!(shown(&error), "#methods is archived, so it's read-only."); + + let (api, _) = api_with( + OutputFormat::Text, + refusing( + "message.post", + "forbidden", + "forbidden: attachment provenance cannot be dropped", + ), + ); + let error = run(&api, send("methods", vec!["b".into()])) + .await + .expect_err("shared elsewhere"); + assert_eq!( + shown(&error), + "That file was shared in #general. Share it there, or upload it again here." + ); + } + + /// R-2: a server that can no longer save tells its host what to do, and a member whom to + /// ask; neither is told to "restart and recover". + #[tokio::test] + async fn a_server_that_cannot_save_tells_the_host_what_to_do_and_members_whom_to_ask() { + const WEDGED: &str = "storage_failed: restart and recover before further mutations"; + let (api, _) = api_with( + OutputFormat::Text, + refusing("message.post", "storage_failed", WEDGED), + ); + let post = || { + CrewCommand::Send(SendArgs { + channel: "methods".into(), + text: Some("hi".into()), + input: None, + attachments: Vec::new(), + references: Vec::new(), + }) + }; + let error = run(&api, post()).await.expect_err("wedged"); + // Alice hosts lab (her UID is the host's), on the server her login names. + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert!( + shown.starts_with("The workspace server can't save changes right now.\nYou host this workspace. Free space on hpc if it is full, then restart Crew there"), + "{shown}" + ); + assert!(!shown.contains("--request-id"), "a refusal: {shown}"); + + // W2-BRK-3: a change that may have been saved offers the one safe retry. + const MAYBE: &str = "storage_full: The workspace server ran out of disk space while saving this change, so it may not have been saved. Reading still works. Ask the host to free space on the server and restart Crew."; + let (api, _) = api_with( + OutputFormat::Text, + refusing("message.post", "storage_full", MAYBE), + ); + let error = run(&api, post()).await.expect_err("maybe saved"); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert!( + shown.ends_with("Retry safely with --request-id req-1"), + "{shown}" + ); + + let member = move |method: &str, path: &str, body: Option<&Value>| -> Result { + match body.and_then(|body| body["method"].as_str()) { + Some("workspace.snapshot") => { + let mut snapshot = snapshot(); + snapshot["actor"] = json!({"id": BOB, "username": "bob", "display_name": "Bob Lee", "uid": 1001}); + Ok(snapshot) + } + Some("message.post") => Err(refuse_broker("storage_failed", WEDGED)), + _ => standard(method, path, body), + } + }; + let (api, _) = api_with(OutputFormat::Text, member); + let error = run(&api, post()).await.expect_err("wedged"); + assert_eq!( + failure(&error, OutputFormat::Text, "req-1", true).to_string(), + "The workspace server can't save changes right now. Ask \"Alice Chen\" (@alice) to restart Crew." + ); + } + + /// MSG2-N7: a message over the workspace's 64 KB limit is refused before anything is sent, + /// as a usage error with the size and what to do instead; the broker's own refusal of one + /// is said in the same words. + #[tokio::test] + async fn a_message_too_long_is_refused_before_it_is_sent() { + let (api, fake) = api_with(OutputFormat::Text, standard); + let error = run( + &api, + CrewCommand::Send(SendArgs { + channel: "methods".into(), + text: Some("x".repeat(65_537)), + input: None, + attachments: Vec::new(), + references: Vec::new(), + }), + ) + .await + .expect_err("too long"); + assert!(fake.sent().is_empty(), "nothing was sent"); + let exit = failure(&error, OutputFormat::Text, "req-1", false); + assert!(exit.downcast_ref::().is_some(), "exit 2"); + assert_eq!(exit.to_string(), output::MESSAGE_TOO_LONG); + assert_eq!(error_code(&error).as_deref(), Some(MESSAGE_TOO_LONG_CODE)); + // Control characters count as the broker counts them, escaped. + let (api, fake) = api_with(OutputFormat::Text, standard); + run( + &api, + CrewCommand::Send(SendArgs { + channel: "methods".into(), + text: Some("\u{1}".repeat(30_000)), + input: None, + attachments: Vec::new(), + references: Vec::new(), + }), + ) + .await + .expect_err("too long escaped"); + assert!(fake.sent().is_empty()); + // At the limit, it is posted. + let (api, fake) = api_with(OutputFormat::Text, standard); + run( + &api, + CrewCommand::Send(SendArgs { + channel: "methods".into(), + text: Some("x".repeat(65_536)), + input: None, + attachments: Vec::new(), + references: Vec::new(), + }), + ) + .await + .expect("posted"); + assert!(fake.broker_call("message.post").is_some()); + // The broker's refusal of one says the same. + let refused = refuse_broker("invalid_params", "invalid_params: message too long"); + assert_eq!( + failure(&refused, OutputFormat::Text, "req-1", false).to_string(), + output::MESSAGE_TOO_LONG + ); + } + + /// MSG2-N6: a full workspace tells its host what the host can do, never to "ask the host"; + /// a member still reads the member's words. + #[tokio::test] + async fn a_full_workspace_tells_its_host_what_to_do() { + let post = || { + CrewCommand::Send(SendArgs { + channel: "methods".into(), + text: Some("hi".into()), + input: None, + attachments: Vec::new(), + references: Vec::new(), + }) + }; + for (text, host_says) in [ + ( + "quota_exceeded: workspace logical state is full; reads remain available and the host can still remove members and change policy, but further changes require a new workspace; in-place pruning of history is not supported", + "This workspace is full. Reading still works, and you can still remove members and change its privacy. To keep posting, start a new workspace.", + ), + ( + "quota_exceeded: workspace logical state exceeds 16 MiB; reads remain available but further mutations require a new workspace or a supported retention upgrade; in-place pruning is not supported", + "This workspace has grown past the size Crew supports and cannot take more changes. To keep working together, start a new workspace.", + ), + ] { + // Alice hosts lab. + let (api, _) = api_with( + OutputFormat::Text, + move |method: &str, path: &str, body: Option<&Value>| match body + .and_then(|body| body["method"].as_str()) + { + Some("message.post") => Err(refuse_broker("quota_exceeded", text)), + _ => standard(method, path, body), + }, + ); + let error = run(&api, post()).await.expect_err("full"); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert_eq!(shown, host_says); + assert!(!shown.contains("Ask the host"), "{shown}"); + let body = failure_body(&error, &safe_lines(&error_text(&error)), "req-1"); + assert_eq!(body["broker_code"], "quota_exceeded"); + + // Bob is a member. + let (api, _) = api_with( + OutputFormat::Text, + move |method: &str, path: &str, body: Option<&Value>| match body + .and_then(|body| body["method"].as_str()) + { + Some("message.post") => Err(refuse_broker("quota_exceeded", text)), + Some("workspace.snapshot") => { + let mut snapshot = snapshot(); + snapshot["actor"] = json!({"id": BOB, "username": "bob", "display_name": "Bob Lee", "uid": 1001}); + Ok(snapshot) + } + _ => standard(method, path, body), + }, + ); + let error = run(&api, post()).await.expect_err("full"); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert!(shown.ends_with("Ask the host about starting a new workspace."), "{shown}"); + } + } + + /// SF-F4, DW-12: the daemon's typed institution and privacy-mode refusals are said from + /// their details, naming both sides, and keep their codes. + #[tokio::test] + async fn institution_and_mode_refusals_name_both_sides() { + let typed = |code: &'static str, + message: &'static str, + details: Option, + modes: Option<(&str, &str)>| { + let modes = modes.map(|(actual, expected)| (actual.to_owned(), expected.to_owned())); + move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/grant") + || path.ends_with("/request") + && body.and_then(|body| body["method"].as_str()) == Some("message.post") + { + return Err(FakeRefusal { + status: 400, + code: Some(code.into()), + broker_code: None, + institution_refusal: details.clone(), + connection_institution: None, + message: message.into(), + detail: None, + modes: modes.clone(), + fields: Default::default(), + } + .into()); + } + standard(method, path, body) + } + }; + let details = json!({"model": "gpt-5.5", "approved_for": ["ucsf"], "workspace": "okafor-lab", "workspace_institution": "stanford"}); + // The daemon's model refusal as `check_provider` words it (`crew/institution.rs`). + let (api, _) = api_with( + OutputFormat::Text, + typed( + "crew_institution_mismatch", + "Crew institution does not match the model's resolved affiliation; choose a local model or a model approved for this institution", + Some(details), + None, + ), + ); + let error = run( + &api, + CrewCommand::Grants(GrantCommand::Grant { + session: SESSION.into(), + channel: "methods".into(), + context_channels: Vec::new(), + }), + ) + .await + .expect_err("another institution"); + assert_eq!( + failure(&error, OutputFormat::Text, "req-1", true).to_string(), + "gpt-5.5 is approved for UCSF. okafor-lab uses stanford. Choose a model approved for stanford, or a local model." + ); + assert_eq!( + error_code(&error).as_deref(), + Some("crew_institution_mismatch") + ); + + let (api, _) = api_with( + OutputFormat::Text, + typed( + "crew_mode_mismatch", + "Crew refused this.", + None, + Some(("private", "public")), + ), + ); + let error = run( + &api, + CrewCommand::Send(SendArgs { + channel: "methods".into(), + text: Some("hi".into()), + input: None, + attachments: Vec::new(), + references: Vec::new(), + }), + ) + .await + .expect_err("the other mode"); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert_eq!( + shown, + "Your connection is Private, but this request required Public. Nothing was sent." ); + assert!(!shown.contains("privacy changed"), "{shown}"); + assert_eq!(error_code(&error).as_deref(), Some("crew_mode_mismatch")); + } + + /// W2-CLI-7: admission refuses a connection set to another institution than the + /// workspace's before it judges any model, and its refusal carries the model's details as + /// well as `connection_institution`. That is the daemon's own sentence, as the desktop + /// prints it, never the model sentence: "gpt-5.5 is approved for ucsf. lab uses ucsf." + /// contradicts itself, and its advice (a local model) is refused too. + #[tokio::test] + async fn a_connection_set_to_another_institution_keeps_the_daemons_sentence() { + // `crew/institution.rs` admission, for a connection set to stanford in lab (ucsf). + const SENTENCE: &str = "This connection is for stanford, but lab belongs to ucsf."; + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/grant") || path.ends_with("/runs") && method == "POST" { + return Err(FakeRefusal { + status: 400, + code: Some(INSTITUTION_MISMATCH.into()), + broker_code: None, + institution_refusal: Some(json!({ + "model": "gpt-5.5", + "approved_for": ["ucsf"], + "workspace": "lab", + "workspace_institution": "ucsf", + })), + connection_institution: Some("stanford".into()), + message: SENTENCE.into(), + detail: None, + modes: None, + fields: Default::default(), + } + .into()); + } + standard(method, path, body) + }; + let grant = CrewCommand::Grants(GrantCommand::Grant { + session: SESSION.into(), + channel: "methods".into(), + context_channels: Vec::new(), + }); + let start = CrewCommand::Tasks(TaskCommand::Start { + channel: "#methods".into(), + prompt: TextInput { + text: Some("Summarize".into()), + input: None, + }, + provider: "versa_azure".into(), + model: "gpt-5.5".into(), + context_channels: Vec::new(), + allow_posting: true, + }); + for (name, command) in [("grants grant", grant), ("tasks start", start)] { + let (api, _) = api_with(OutputFormat::Text, handler); + let error = run(&api, command).await.expect_err(name); + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert_eq!(shown, SENTENCE, "{name}"); + assert!(!shown.contains("approved for"), "{name}: {shown}"); + assert!(!shown.contains("local model"), "{name}: {shown}"); + assert_eq!( + error_code(&error).as_deref(), + Some(INSTITUTION_MISMATCH), + "{name}" + ); + } + // The same details under the model refusal's words are still the model sentence, and + // so is a daemon's model refusal that names no connection institution. + for message in [ + "Crew institution does not match the model's resolved affiliation; choose a local model or a model approved for this institution", + "Crew refused this model.", + ] { + assert!(is_model_refusal(message, None), "{message}"); + } + assert!(is_model_refusal( + "Crew institution does not match the model's resolved affiliation; choose a local model or a model approved for this institution", + Some("stanford"), + )); + assert!(!is_model_refusal(SENTENCE, Some("stanford"))); + } + + /// W2-CLI-14: a request the daemon never sent is said in the daemon's own sentence, which + /// names the cause and whether trying again can help, in text and in JSON's `error`, under + /// the code `crew_not_sent` and with no retry line. These are `lost_request`'s sentences in + /// `crates/biorouter/src/crew/mod.rs`, for workspace lab and `bob@hpc`. + #[tokio::test] + async fn a_request_the_daemon_never_sent_keeps_the_daemons_sentence() { + const SIGN_IN_REFUSED: &str = "Couldn't sign in to hpc as bob: the server refused this computer's SSH key. Nothing was sent."; + const SERVER_UNREACHABLE: &str = + "lab's server couldn't be reached, so nothing was sent. Try again once Crew reconnects."; + const DROPPED: &str = + "The connection to lab dropped before it answered. Nothing changed; try again."; + const UNREACHABLE: &str = "Biorouter couldn't reach lab, so nothing was sent."; + for sentence in [SIGN_IN_REFUSED, SERVER_UNREACHABLE, DROPPED, UNREACHABLE] { + let (api, fake) = api_with( + OutputFormat::Text, + move |method: &str, path: &str, body: Option<&Value>| { + if body.and_then(|body| body["method"].as_str()) == Some("message.post") { + return Err(FakeRefusal { + status: 503, + code: Some(NOT_SENT.into()), + broker_code: None, + institution_refusal: None, + connection_institution: None, + message: sentence.into(), + detail: None, + modes: None, + fields: Default::default(), + } + .into()); + } + standard(method, path, body) + }, + ); + let error = run( + &api, + CrewCommand::Send(SendArgs { + channel: "methods".into(), + text: Some("hi".into()), + input: None, + attachments: Vec::new(), + references: Vec::new(), + }), + ) + .await + .expect_err(sentence); + assert!( + fake.broker_calls() + .iter() + .any(|(method, _)| method == "message.post"), + "the post reached the daemon" + ); + // The post carried the request ID, so a retry line would be offered if the daemon's + // answer were uncertain. It is not: nothing was sent. + let shown = failure(&error, OutputFormat::Text, "req-1", true).to_string(); + assert_eq!(shown, sentence); + assert!(!shown.contains("--request-id"), "{shown}"); + let line = failure_json( + &error, + &safe_lines(&error_text(&error)), + "req-1", + OutputFormat::Json, + ) + .expect("JSON output"); + let body: Value = serde_json::from_str(&line).expect("one JSON value"); + assert_eq!(body["error"], sentence); + assert_eq!(body["code"], NOT_SENT); + } + // A key the server refuses is refused every time: nothing tells the person to run the + // same command again. + let refused = refuse(503, Some(NOT_SENT), SIGN_IN_REFUSED); + let shown = failure(&refused, OutputFormat::Text, "req-1", true).to_string(); + assert!(shown.contains("refused this computer's SSH key"), "{shown}"); + assert!(!shown.contains("run it again"), "{shown}"); } #[test] @@ -4325,6 +9238,416 @@ mod tests { assert!(shown.contains("\\u{1b}[2J"), "{shown}"); } + /// CLI-14: a confirmation with no terminal to ask in is the usage refusal, status 2, in + /// text and JSON alike, and nothing is sent. + #[tokio::test] + async fn a_confirmation_without_a_terminal_exits_with_the_usage_status() { + let revoke = CrewCommand::Enroll(EnrollmentCommand::Revoke { + member: "@bob".into(), + confirm: None, + }); + let (api, fake) = api_with(OutputFormat::Text, standard); + let error = run(&api, revoke).await.expect_err("no terminal"); + let shown = failure(&error, OutputFormat::Text, "req-1", false); + assert!(shown.downcast_ref::().is_some(), "{shown:?}"); + assert!(shown.to_string().contains("--confirm @bob"), "{shown}"); + assert_eq!( + failure_body(&error, "m", "req-1")["code"], + NEEDS_TERMINAL_CODE + ); + assert!(fake.broker_call("enrollment.revoke").is_none()); + + let file = tempfile::NamedTempFile::new().expect("temp file"); + std::fs::write(file.path(), "brcrew1:abc").expect("write invitation"); + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path == "/crew/connections/from-invitation" { + return Ok(preview(&[])); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Json, handler); + let mut args = join_args(file.path()); + args.yes = false; + let error = run( + &api, + CrewCommand::Connections(ConnectionCommand::JoinInvitation(Box::new(args))), + ) + .await + .expect_err("no terminal"); + let shown = failure(&error, OutputFormat::Json, "req-1", false); + assert!(shown.downcast_ref::().is_some(), "{shown:?}"); + + // Any other failure keeps the ordinary status. + let refused = refuse(404, Some("crew_grant_not_found"), "No Crew grant."); + assert!(failure(&refused, OutputFormat::Text, "req-1", false) + .downcast_ref::() + .is_none()); + } + + /// CLI-10: archiving a channel and removing someone from it ask first, as the desktop + /// does; without a terminal they need --yes, and nothing is sent without it. + #[tokio::test] + async fn archive_and_remove_member_ask_first_and_need_yes_without_a_terminal() { + let (api, fake) = api_with(OutputFormat::Text, standard); + let error = run( + &api, + CrewCommand::Channels(ChannelCommand::Archive { + channel: "methods".into(), + yes: false, + }), + ) + .await + .expect_err("no terminal to ask in"); + assert!(error.downcast_ref::().is_some(), "{error:?}"); + assert_eq!( + message(&error), + "Archiving #methods is permanent for everyone. There is no terminal to ask in, so add --yes." + ); + let error = run( + &api, + CrewCommand::RemoveMember { + channel: "methods".into(), + member: "@bob".into(), + former: false, + yes: false, + }, + ) + .await + .expect_err("no terminal to ask in"); + assert!(error.downcast_ref::().is_some(), "{error:?}"); + assert!(message(&error).starts_with("Removing @bob from #methods needs a yes.")); + assert!(fake.broker_call("channel.archive").is_none()); + assert!(fake.broker_call("membership.revoke").is_none()); + + assert_eq!( + yes_or_ask(false, true, "Archive?".into(), "no".into()).expect("asks"), + Consent::Ask("Archive?".into()) + ); + assert_eq!( + yes_or_ask(true, false, "Archive?".into(), "no".into()).expect("given"), + Consent::Given + ); + } + + /// A daemon with one saved connection whose snapshot is `snapshot`, and which removes it. + fn removing(snapshot: Value) -> impl Fn(&str, &str, Option<&Value>) -> Result { + move |method: &str, path: &str, body: Option<&Value>| { + if method == "DELETE" && path == format!("/crew/connections/{CONNECTION}") { + return Ok(json!({"removed": true})); + } + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + return Ok(snapshot.clone()); + } + standard(method, path, body) + } + } + + fn remove_connection_command(confirm: Option<&str>, give_up: bool) -> CrewCommand { + CrewCommand::Connections(ConnectionCommand::Remove { + confirm: confirm.map(str::to_owned), + give_up_host_controls: give_up, + }) + } + + fn deleted(fake: &FakeDaemon) -> bool { + fake.sent().iter().any(|sent| sent.method == "DELETE") + } + + /// CLI-1: removing a connection deletes this computer's device key, so the name is typed + /// again (or given with --confirm), and a host's only computer is refused unless + /// --give-up-host-controls says so. + #[tokio::test] + async fn removing_a_connection_is_confirmed_and_never_silently_ends_the_host_controls() { + // Alice hosts lab (her UID is the host's) and no other computer of hers is enrolled. + let (api, fake) = api_with(OutputFormat::Text, removing(snapshot())); + let error = run(&api, remove_connection_command(Some("UCSF HPC"), false)) + .await + .expect_err("the host's only computer"); + assert_eq!( + error_code(&error).as_deref(), + Some("crew_host_controls_would_end") + ); + let shown = message(&error); + assert!(shown.contains("You host lab"), "{shown}"); + assert!( + shown.contains("biorouter crew enroll invite @alice --add-device"), + "{shown}" + ); + assert!(shown.contains("--give-up-host-controls"), "{shown}"); + assert!(!deleted(&fake)); + + let lines = said( + run(&api, remove_connection_command(Some(" ucsf hpc "), true)) + .await + .expect("given up on purpose"), + ); + assert!(deleted(&fake)); + assert_eq!(lines, ["Removed UCSF HPC from this computer."]); + + // Bob is a member, not the host. + let mut member = snapshot(); + member["actor"] = + json!({"id": BOB, "username": "bob", "display_name": "Bob Lee", "uid": 1001}); + let (api, fake) = api_with(OutputFormat::Text, removing(member.clone())); + let error = run(&api, remove_connection_command(None, false)) + .await + .expect_err("no terminal, no --confirm"); + assert!(error.downcast_ref::().is_some(), "{error:?}"); + assert!( + message(&error).ends_with("so confirm with --confirm 'UCSF HPC'."), + "{}", + message(&error) + ); + let error = run(&api, remove_connection_command(Some("Elsewhere"), false)) + .await + .expect_err("the wrong name"); + assert_eq!( + message(&error), + "Not done: --confirm Elsewhere doesn't match UCSF HPC." + ); + assert!(!deleted(&fake)); + run(&api, remove_connection_command(Some("UCSF HPC"), false)) + .await + .expect("a member's confirmed removal"); + assert!(deleted(&fake)); + + let mut named = snapshot(); + named["workspace"]["host_principal_id"] = json!(BOB); + assert_eq!(host_standing(&named, None), HostStanding::NotHost); + assert_eq!(host_standing(&json!({}), None), HostStanding::Unknown); + assert_eq!( + typed_or_ask("UCSF HPC", None, true, "no".into()).expect("asks"), + Consent::Ask("Type UCSF HPC to confirm:".into()) + ); + } + + /// CLI-1: the snapshot's list of a host's computers never shrinks when one of them removes + /// its connection (the broker drops a device only when its whole member is revoked), so two + /// listed computers do not prove the host controls survive removing this one. The removal + /// goes ahead, but only after saying which other computers must still have the workspace + /// saved, and this computer is never among them. + #[tokio::test] + async fn a_host_with_another_listed_computer_is_told_the_host_controls_depend_on_it() { + const HERE: &str = "70dcffaf1751a59b0123456789abcdef0123456789abcdef0123456789abcdef"; + let mut two = snapshot(); + two["actor"]["devices"] = json!([ + {"fingerprint": "70DC FFAF 1751 A59B", "added_at": 1_789_000_000, "added_via": "bootstrap"}, + {"fingerprint": "3F2A 9C1E 77B0 D4E1", "added_at": 1_790_200_000, "added_via": "invitation_code"} + ]); + let laptop = two["actor"]["devices"][1].clone(); + // Every listed entry being this computer leaves no other one to name. + let mut twice = two.clone(); + twice["actor"]["devices"][1]["fingerprint"] = json!("70dc-ffaf-1751-a59b"); + assert_eq!( + host_standing(&twice, Some(HERE)), + HostStanding::OnlyHostComputer { + username: "alice".into() + } + ); + let connection = json!({"id": CONNECTION, "name": "UCSF HPC", "device_id": HERE}); + assert_eq!(own_device_id(&connection), Some(HERE)); + assert_eq!(own_device_id(&json!({"device_id": ""})), None); + let standing = host_standing(&two, own_device_id(&connection)); + assert_eq!( + standing, + HostStanding::HostElsewhereToo { + others: vec![laptop.clone()] + } + ); + + // Both refusal flags aside, it is said, never refused, and names only the laptop. + let options = HumanOptions::in_utc_at(1_790_214_655); + for give_up in [false, true] { + let notice = host_removal_notice(&standing, "lab", true, give_up, &options) + .expect("not refused: another computer is listed") + .expect("hosting is always mentioned"); + assert_eq!( + notice, + "You host lab. Its host controls continue only if one of your other enrolled computers still has lab saved:\n 3F2A 9C1E 77B0 D4E1 · added Sep 23, 2026 · via invitation code\nA computer stays on this list after its connection is removed there. If none of them still has lab saved, removing it here ends the host controls for good." + ); + assert!(!notice.contains("70DC"), "{notice}"); + } + + // Without this computer's device ID nothing can be left out, and the notice says so. + let unknown = host_standing(&two, None); + assert_eq!( + unknown, + HostStanding::HostElsewhereToo { + others: two["actor"]["devices"] + .as_array() + .cloned() + .unwrap_or_default() + } + ); + let notice = host_removal_notice(&unknown, "lab", false, false, &options) + .expect("not refused") + .expect("said"); + assert!( + notice.contains("These computers are enrolled as you, this one among them:"), + "{notice}" + ); + assert!(notice.contains("70DC FFAF 1751 A59B"), "{notice}"); + + // A member is told nothing about hosting. + let mut member = two.clone(); + member["workspace"]["host_uid"] = json!(1001); + assert_eq!( + host_removal_notice( + &host_standing(&member, Some(HERE)), + "lab", + true, + false, + &options + ) + .expect("not refused"), + None + ); + + // End to end: without a terminal it still needs the name, and with it the removal + // goes ahead without --give-up-host-controls. + let snapshot = two.clone(); + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if method == "GET" && path == "/crew/connections" { + return Ok(json!({"connections": [{ + "id": CONNECTION, "name": "UCSF HPC", "ssh_target": "bob@hpc", + "workspace_id": "w", "status": "connected", "public_key": "k", + "device_id": HERE + }]})); + } + removing(snapshot.clone())(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Text, handler); + let error = run(&api, remove_connection_command(None, false)) + .await + .expect_err("no terminal, no --confirm"); + assert!(error.downcast_ref::().is_some(), "{error:?}"); + assert!(!deleted(&fake)); + let lines = said( + run(&api, remove_connection_command(Some("UCSF HPC"), false)) + .await + .expect("another listed computer may still hold the host controls"), + ); + assert!(deleted(&fake)); + assert_eq!(lines, ["Removed UCSF HPC from this computer."]); + } + + /// CLI-10: making the connection or the workspace public is confirmed by typing the + /// workspace's name, as the desktop's typed confirmation is; making it private is not. + #[tokio::test] + async fn going_public_needs_the_workspace_name_typed() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if method == "PATCH" { + return Ok(json!({"id": CONNECTION, "mode": "public"})); + } + standard(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Text, handler); + let workspace = |mode: PrivacyMode, confirm: Option<&str>| { + CrewCommand::Privacy(PrivacyCommand::SetWorkspace { + mode, + institution_id: None, + confirm: confirm.map(str::to_owned), + }) + }; + let error = run(&api, workspace(PrivacyMode::Public, None)) + .await + .expect_err("no terminal"); + assert!(error.downcast_ref::().is_some(), "{error:?}"); + assert!(message(&error).starts_with("Allowing Public in lab needs its name typed.")); + let error = run(&api, workspace(PrivacyMode::Public, Some("lab2"))) + .await + .expect_err("the wrong name"); + assert_eq!( + message(&error), + "Not done: --confirm lab2 doesn't match lab." + ); + assert!(fake.broker_call("policy.set").is_none()); + run(&api, workspace(PrivacyMode::Public, Some("LAB"))) + .await + .expect("confirmed"); + assert_eq!( + fake.broker_call("policy.set").expect("set")["mode"], + "public" + ); + let (api, fake) = api_with(OutputFormat::Text, handler); + run(&api, workspace(PrivacyMode::Private, None)) + .await + .expect("private needs no typed name"); + assert!(fake.broker_call("policy.set").is_some()); + + let personal = |confirm: Option<&str>| { + CrewCommand::Privacy(PrivacyCommand::SetPersonal { + mode: PrivacyMode::Public, + institution_id: None, + confirm: confirm.map(str::to_owned), + }) + }; + let (api, fake) = api_with(OutputFormat::Text, handler); + let error = run(&api, personal(None)).await.expect_err("no terminal"); + // SF-F1: lab is Private for everyone, so the prompt says nothing changes yet. + assert!( + message(&error).starts_with("Making your lab connection public needs its name typed."), + "{}", + message(&error) + ); + assert!( + message(&error).starts_with("Making your lab connection public needs its name typed.") + ); + assert!(!fake.sent().iter().any(|sent| sent.method == "PATCH")); + let saved = said(run(&api, personal(Some("lab"))).await.expect("confirmed")); + assert!(fake.sent().iter().any(|sent| sent.method == "PATCH")); + // SF-F1: saved, and said to change nothing while lab is Private for everyone. + assert!( + saved[0].ends_with("Nothing changes while lab is Private for everyone."), + "{saved:?}" + ); + } + + /// SF-F1: `status` and `connections list` ask a connected Public connection's workspace + /// for its privacy, and give the effective privacy; a Private one is Private without + /// asking, and an offline Public one says its workspace's can't be read. + #[tokio::test] + async fn connection_lists_give_the_effective_privacy() { + let listing = |method: &str, path: &str, body: Option<&Value>| -> Result { + if method == "GET" && path == "/crew/connections" { + return Ok(json!({"connections": [ + {"id": CONNECTION, "name": "lab", "ssh_target": "bob@hpc", "status": "connected", + "mode": "public", "institution_id": "ucsf"}, + {"id": "c0ffee00-0000-4000-8000-000000000009", "name": "other", "ssh_target": "bob@other", + "status": "disconnected", "mode": "public"}, + {"id": "c0ffee00-0000-4000-8000-00000000000a", "name": "clinic", "ssh_target": "bob@clinic", + "status": "connected", "mode": "private", "institution_id": "ucsf"} + ]})); + } + standard(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Json, listing); + let Reply::Show(listed, _) = run(&api, CrewCommand::Status).await.expect("status") else { + panic!("status shows the list") + }; + let connections = listed["connections"].as_array().unwrap(); + assert_eq!(connections[0]["effective_mode"], "private"); + assert_eq!(connections[0]["workspace_mode"], "private"); + assert_eq!(connections[0]["workspace_name"], "lab"); + assert_eq!(connections[1]["effective_mode"], Value::Null); + assert_eq!(connections[2]["effective_mode"], "private"); + let asked = fake + .broker_calls() + .into_iter() + .filter(|(method, _)| method == "workspace.snapshot") + .count(); + assert_eq!( + asked, 1, + "only the connected Public connection's workspace is asked" + ); + let (api, _) = api_with(OutputFormat::Text, listing); + let text = said(run(&api, CrewCommand::Status).await.expect("status")).join("\n"); + assert!( + text.contains("lab · bob@hpc · Connected · Private because lab is Private for everyone · your connection: Public"), + "{text}" + ); + } + #[test] fn a_device_code_is_checked_for_its_length_whatever_separates_it() { for code in [ @@ -4368,6 +9691,53 @@ mod tests { assert_eq!(pending_lines(&json!([]), 0), ["No one is waiting to join."]); } + /// DW-13: a snapshot without `pending_joins` is not an empty queue. The broker leaves it + /// out for anyone but the host, who is refused as every other `enroll` command refuses + /// them; a host whose server cannot join by name is told so. Both exit non-zero. + #[tokio::test] + async fn enroll_pending_without_the_list_says_why_instead_of_an_empty_queue() { + let pending = || CrewCommand::Enroll(EnrollmentCommand::Pending); + let as_member = |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + let mut snapshot = snapshot(); + snapshot["actor"] = + json!({"id": BOB, "username": "bob", "display_name": "Bob Lee", "uid": 1001}); + return Ok(snapshot); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Json, as_member); + let error = run(&api, pending()).await.expect_err("not the host"); + assert_eq!( + message(&error), + "Only the workspace host can see who is waiting to join." + ); + assert_eq!(error_code(&error).as_deref(), Some("crew_host_required")); + + // Alice hosts lab, and her server's snapshot has no list. + let (api, _) = api_with(OutputFormat::Text, standard); + let error = run(&api, pending()).await.expect_err("no joining by name"); + assert_eq!( + message(&error), + "This workspace's server doesn't support joining by name." + ); + + // The host's own empty list is an empty queue. + let empty = |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + let mut snapshot = snapshot(); + snapshot["pending_joins"] = json!([]); + return Ok(snapshot); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, empty); + assert_eq!( + said(run(&api, pending()).await.expect("an empty queue")), + ["No one is waiting to join."] + ); + } + #[tokio::test] async fn a_request_id_is_offered_only_after_an_uncertain_mutation() { let (api, _) = api_with(OutputFormat::Text, standard); @@ -4380,6 +9750,7 @@ mod tests { &api, CrewCommand::Channels(ChannelCommand::Archive { channel: "methods".into(), + yes: true, }), ) .await @@ -4403,6 +9774,53 @@ mod tests { .to_string() .ends_with(hint)); + // R-3: an outcome the daemon could not confirm may have landed, whatever its status, so + // the retry is offered, after the daemon's own sentence and with its own code. + const UNCONFIRMED: &str = "Crew couldn't confirm whether this reached lab. Check the channel, then retry with the same request ID."; + for status in [400, 503] { + let unknown = refuse(status, Some("crew_outcome_unknown"), UNCONFIRMED); + let shown = failure(&unknown, OutputFormat::Text, "req-1", true).to_string(); + assert_eq!(shown, format!("{UNCONFIRMED}\n{hint}")); + assert_eq!( + failure_body(&unknown, UNCONFIRMED, "req-1")["code"], + "crew_outcome_unknown" + ); + } + // A request the daemon never sent is a definite answer: no retry ID, and it says so in + // the daemon's own words (a_request_the_daemon_never_sent_keeps_the_daemons_sentence), + // or, from a daemon that sent none, in these. + let not_sent = refuse(503, Some("crew_not_sent"), " "); + assert_eq!( + failure(¬_sent, OutputFormat::Text, "req-1", true).to_string(), + "Nothing was sent; run it again." + ); + // Nor did one it held back while dialling a broken bridge again (W2-DMN-6). + const RECONNECTING_TEXT: &str = + "Reconnecting to lab. Nothing was sent; try again in a moment."; + let reconnecting = refuse(503, Some("crew_reconnecting"), RECONNECTING_TEXT); + assert_eq!( + failure(&reconnecting, OutputFormat::Text, "req-1", true).to_string(), + RECONNECTING_TEXT + ); + // The daemon's other typed refusals are its own sentence, without "Daemon returned". + let fixed = refuse( + 409, + Some("crew_model_fixed"), + "This chat's model is fixed by its Crew access. Start a new chat to use another model.", + ); + assert_eq!( + failure(&fixed, OutputFormat::Text, "req-1", true).to_string(), + "This chat's model is fixed by its Crew access. Start a new chat to use another model." + ); + // M16: nor did one the connection here handed back unsent, though it carried the ID. + let unsent: anyhow::Error = crate::daemon_client::NotSent::for_test().into(); + let shown = failure(&unsent, OutputFormat::Text, "req-1", true).to_string(); + assert_eq!( + shown, + "The daemon connection wasn't ready. Nothing was sent; run it again." + ); + assert_eq!(error_code(&unsent).as_deref(), Some("crew_not_sent")); + assert_eq!( failure( &anyhow!("one\u{1b}[2J\ntwo"), @@ -4452,9 +9870,11 @@ mod tests { assert_eq!(name_text("مختبر"), "\u{2068}مختبر\u{2069}"); assert_eq!(name_text("a\u{1b}b"), "a\\u{1b}b"); assert_eq!(channel_text("#methods"), "#methods"); + // F10: a name that holds no right-to-left text is left bare. + assert_eq!(person_text("bob", Some("Bob Lee")), "\"Bob Lee\" (@bob)"); assert_eq!( - person_text("bob", Some("Bob Lee")), - "\"\u{2068}Bob Lee\u{2069}\" (@bob)" + person_text("dana", Some("דנה לוי")), + "\"\u{2068}דנה לוי\u{2069}\" (@dana)" ); assert_eq!(person_text("bob", Some("BOB")), "@bob"); assert_eq!(shell_word("UCSF HPC"), "'UCSF HPC'"); diff --git a/crates/biorouter-cli/src/commands/crew/output.rs b/crates/biorouter-cli/src/commands/crew/output.rs index dd3b1d5de..a21c2a7c1 100644 --- a/crates/biorouter-cli/src/commands/crew/output.rs +++ b/crates/biorouter-cli/src/commands/crew/output.rs @@ -4,12 +4,14 @@ //! terminal-safe and nothing else, so scripts keep every ID. Text output is for people: //! type-specific formatters name people, teams and channels, and machine IDs appear only when //! [`HumanOptions::show_ids`] asks for them ("Machine IDs stay internal" in -//! `docs/research/biorouter-crew/naming-design.md`). +//! `docs/crew/design/naming-design.md`). //! //! A person renders as `"Display name" (@username)`. The display name is text a colleague -//! chose, so it is quoted, escaped and wrapped in Unicode isolates (U+2068 … U+2069): right-to- -//! left text inside it cannot reorder the `@username` that follows. When the display name equals -//! the username, `@username` alone is printed, so the username is on every line either way. +//! chose, so it is quoted and escaped, and a name that is not ASCII is wrapped in Unicode +//! isolates (U+2068 … U+2069): right-to-left text inside it cannot reorder the `@username` that +//! follows. ASCII holds no right-to-left text, so it is printed bare, and a copied name holds no +//! invisible characters. When the display name equals the username, `@username` alone is +//! printed, so the username is on every line either way. //! //! Names come from a [`Directory`]: the value being printed (a snapshot, or a message page's //! `people` and `channel_names` maps) plus whatever the caller already knows, such as the @@ -40,17 +42,81 @@ fn terminal_control(ch: char) -> bool { ch.is_control() || biorouter::utils::is_invisible_formatting(ch) } +/// Whether a zero-width joiner or non-joiner between `before` and `after` is part of the text +/// a person sees (M14), so it is printed as itself rather than as `\u{200d}`. +/// +/// Emoji sequences are joined with U+200D (👩🏽‍🔬, 👨‍👩‍👧‍👦, 🏳️‍🌈), and Persian, Arabic and +/// the Indic scripts use U+200C and U+200D between letters and marks (می‌خواهم, क्‍ष). Printed +/// as escape text, every one of them broke. A joiner beside ASCII, or beside whitespace, a +/// control or another invisible character, stays escaped: there it is invisible and joins +/// nothing, and `crew\u{200d}_alice` would read as `crew_alice`. A username is ASCII, so no +/// joiner the rule keeps can touch one. +/// +/// Kept here rather than by narrowing `biorouter::utils::is_invisible_formatting`, which prompt +/// labels rely on. +fn joins_visible_text(before: Option, after: Option) -> bool { + let visible = |ch: Option| { + ch.is_some_and(|ch| !ch.is_ascii() && !ch.is_whitespace() && !terminal_control(ch)) + }; + visible(before) && visible(after) +} + pub fn safe_text(value: &str) -> String { - value - .chars() - .flat_map(|ch| { - if terminal_control(ch) { - ch.escape_default().collect::>() - } else { - vec![ch] + let chars: Vec = value.chars().collect(); + let mut out = String::with_capacity(value.len()); + for (index, &ch) in chars.iter().enumerate() { + let joiner = matches!(ch, '\u{200c}' | '\u{200d}'); + let before = index.checked_sub(1).map(|at| chars[at]); + let after = chars.get(index + 1).copied(); + if terminal_control(ch) && !(joiner && joins_visible_text(before, after)) { + out.extend(ch.escape_default()); + } else { + out.push(ch); + } + } + out +} + +/// [`safe_text`] for text the CLI already made safe once and may have isolated: a balanced +/// U+2068 … U+2069 pair passes through, and everything else, any other isolate included, is +/// escaped as [`safe_text`] escapes it. +/// +/// The CLI wraps a display name in that pair (`quoted_name`, `display_text`) so right-to-left +/// text in it cannot reorder the words around it. Such a name reaches an error sentence, and +/// escaping the sentence again printed the pair as the literal text `\u{2068}` (CLI-4). A +/// balanced pair can only keep what is inside it from reordering what is outside, so passing +/// it through gives a hostile name nothing: every raw isolate inside a name was escaped when +/// the name was made safe, and one that is not part of a pair is escaped here. +/// +/// The text between pairs is made safe as a whole, not a character at a time, so a joiner in +/// it keeps its neighbours ([`safe_text`]). +pub fn safe_text_keeping_isolates(value: &str) -> String { + let chars: Vec = value.chars().collect(); + let mut out = String::with_capacity(value.len()); + let mut plain = String::new(); + let mut index = 0; + while index < chars.len() { + let ch = chars[index]; + if ch == '\u{2068}' { + let rest = &chars[index + 1..]; + let next = rest + .iter() + .position(|c| matches!(c, '\u{2066}'..='\u{2069}')); + if let Some(end) = next.filter(|&end| rest[end] == '\u{2069}') { + out.push_str(&safe_text(&plain)); + plain.clear(); + out.push(ch); + out.push_str(&safe_text(&rest[..end].iter().collect::())); + out.push('\u{2069}'); + index += end + 2; + continue; } - }) - .collect() + } + plain.push(ch); + index += 1; + } + out.push_str(&safe_text(&plain)); + out } /// Print `value` with the default text rendering: no IDs, names from the value alone. @@ -67,6 +133,11 @@ pub fn emit_with(value: &Value, format: OutputFormat, options: &HumanOptions) -> Ok(()) } +/// `value` as [`emit_with`] prints it, without the line break. +pub fn formatted(value: &Value, format: OutputFormat, options: &HumanOptions) -> Result { + format_output(value, format, options) +} + fn format_output(value: &Value, format: OutputFormat, options: &HumanOptions) -> Result { Ok(match format { OutputFormat::Json => json_terminal_safe(serde_json::to_string_pretty(value)?), @@ -110,8 +181,63 @@ const STORAGE_FULL_PREFIXES: [&str; 4] = [ "retained audit journal exceeds", "journal exceeds", "workspace logical state exceeds", - "workspace operation quota requires maintenance", + LEGACY_OPERATION_QUOTA, +]; +/// The operation quota an older broker refused with once its table of remembered request IDs +/// was full. The broker no longer writes it (that table now evicts), but a workspace still +/// running an older broker does, so it is still said as [`STORAGE_FULL`]. +const LEGACY_OPERATION_QUOTA: &str = "workspace operation quota requires maintenance"; +/// The limits that stop ordinary changes a little short of full, so the host can still remove +/// members and change policy (`commit`'s admin headroom in `broker.rs`). +const FULL_BUT_HOST_CAN_ADMINISTER: &str = "This workspace is full. Reading still works, and the host can still remove members and change its privacy, but nothing else can change. Ask the host about starting a new workspace."; +/// How the `quota_exceeded` texts that [`FULL_BUT_HOST_CAN_ADMINISTER`] rewords begin, in +/// lowercase. +const FULL_BUT_HOST_CAN_ADMINISTER_PREFIXES: [&str; 2] = [ + "workspace logical state is full", + "retained audit journal is nearly full", ]; +/// [`STORAGE_FULL`] and [`FULL_BUT_HOST_CAN_ADMINISTER`] as the host reads them (MSG2-N6): the +/// host is the one to ask, so they say what the host can still do. The desktop's +/// `refusalCopy.storageFullHost` and `refusalCopy.fullButHostCanAdministerHost`, byte for byte. +const STORAGE_FULL_FOR_HOST: &str = "This workspace has grown past the size Crew supports and cannot take more changes. To keep working together, start a new workspace."; +const FULL_BUT_HOST_CAN_ADMINISTER_FOR_HOST: &str = "This workspace is full. Reading still works, and you can still remove members and change its privacy. To keep posting, start a new workspace."; + +/// Which "this workspace is full" a `quota_exceeded` refusal is, if it is one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Full { + /// Nothing more can change ([`STORAGE_FULL`]). + Storage, + /// Only the host's removals and privacy changes can ([`FULL_BUT_HOST_CAN_ADMINISTER`]). + HostCanAdminister, +} + +/// `broker.rs`'s limits that mean "this workspace is full" (the audit journal and state-size +/// limits in `commit` and the operation quota in `apply_mutation`, which are what a request +/// meets, plus the journal limit in `open_inner`, which only stops the broker starting). The +/// join quota's own sentence means "too many people are waiting", and is not one. `message` is +/// the broker's `code: text` or the text alone. +fn workspace_full(code: &str, message: &str) -> Option { + if code != "quota_exceeded" { + return None; + } + let message = message.trim(); + let text = message + .split_once(": ") + .filter(|(prefix, _)| *prefix == code) + .map_or(message, |(_, rest)| rest.trim()) + .to_ascii_lowercase(); + if STORAGE_FULL_PREFIXES.iter().any(|p| text.starts_with(p)) { + Some(Full::Storage) + } else if FULL_BUT_HOST_CAN_ADMINISTER_PREFIXES + .iter() + .any(|p| text.starts_with(p)) + { + Some(Full::HostCanAdminister) + } else { + None + } +} + const IDENTITY_CONFLICT_UNNAMED: &str = "Another active member already has this username. Remove the old member first."; @@ -202,9 +328,12 @@ const TECHNICAL_TEXTS: &[(&str, &str)] = &[ "signed device required", "This computer isn't signed in to this workspace.", ), + // Only a channel the person is not in (or that does not exist) is unavailable: an archived + // one stays readable to its members, so "it may be archived" was never the reason (M20). + ("channel unavailable", "You're not in that channel."), ( - "channel unavailable", - "That channel isn't available to you. It may be archived, or you may not be in it.", + "attachment unavailable", + "That file isn't available to you. It may have been removed, or you may not be in its channel.", ), ( "principal unavailable", @@ -214,8 +343,186 @@ const TECHNICAL_TEXTS: &[(&str, &str)] = &[ "invalid grant", "This task's access to the workspace has ended.", ), + // `message.post`'s size limit (MSG2-N7). + ("message too long", MESSAGE_TOO_LONG), ]; +/// A message over the workspace's size limit, before it is sent or as the broker refuses it +/// (MSG2-N7). The desktop says "Attach long text as a file"; here that is `files upload`. +pub const MESSAGE_TOO_LONG: &str = "Messages can be up to 64 KB. Save the text to a file and share it with biorouter crew files upload."; + +/// What a refusal can name when the command knows it (DW-11, M20, R-2, FILES-F9): the channel +/// it acted on, where a shared file came from, whether the person hosts the workspace, and the +/// server. Every text is already made safe by the caller. Without it the sentence names none of +/// them and stays true. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RefusalPlace { + /// The channel the request named, as a person reads it: `#methods`. + pub channel: Option, + /// The channel an attachment or remote reference was shared in. + pub shared_in: Option, + /// Whether the person hosts the workspace, when that could be read. + pub host: Option, + /// The host, as a person reads them: `"Alice Chen" (@alice)`. + pub host_label: Option, + /// The server the workspace runs on. + pub server: Option, +} + +/// What a refusal can name, and so what the command should read for it: the channel the +/// request named, where a shared file came from, or who hosts a server that can no longer save. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RefusalSubject { + Channel, + SharedIn(SharedKind), + Storage, +} + +/// What was shared in another channel. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SharedKind { + Attachment, + Reference, +} + +/// What `code: message` could name that its text does not ([`placed_refusal`]), or `None`. +pub fn refusal_subject(code: &str, message: &str) -> Option { + if matches!(code, "storage_failed" | "storage_full") || workspace_full(code, message).is_some() + { + return Some(RefusalSubject::Storage); + } + let text = message + .trim() + .split_once(": ") + .filter(|(prefix, _)| *prefix == code) + .map_or(message.trim(), |(_, rest)| rest.trim()) + .trim_end_matches(['.', '!', '?']) + .to_ascii_lowercase(); + match text.as_str() { + "current owner required" | "channel is read-only" => Some(RefusalSubject::Channel), + "attachment provenance cannot be dropped" => { + Some(RefusalSubject::SharedIn(SharedKind::Attachment)) + } + "reference provenance cannot be dropped" => { + Some(RefusalSubject::SharedIn(SharedKind::Reference)) + } + _ => None, + } +} + +/// The workspace's host as a person reads them, when the directory names them. +pub fn host_label(directory: &Directory) -> Option { + let host = directory.host_id()?; + directory.people.get(host).map(Person::label) +} + +/// A request whose required privacy (`--expected-mode`) is not the connection's (DW-12): both +/// modes named, and that nothing was sent. +pub fn mode_mismatch_text(actual: &str, expected: &str) -> String { + format!( + "Your connection is {}, but this request required {}. Nothing was sent.", + mode_word(Some(actual)), + mode_word(Some(expected)) + ) +} + +/// The broker's role, archive, provenance and storage refusals in words for a person, naming +/// what `place` knows. `key` is the refusal's text after its code, lowercase, without a closing +/// full stop. `None` for any other refusal. +fn placed_refusal(code: &str, sentence: &str, key: &str, place: &RefusalPlace) -> Option { + if matches!(code, "storage_failed" | "storage_full") { + return Some(storage_refusal(code, sentence, place)); + } + Some(match key { + // `manager()`'s check is the host's account, not one of its devices. + "workspace host device required" + | "human host policy decision required" + | "human host decision required" + | "only host account can stop broker" => "Only the workspace host can do this.".to_owned(), + "current owner required" => match &place.channel { + Some(channel) => format!("Only {channel}'s owner can do this."), + None => "Only the channel's owner can do this.".to_owned(), + }, + "team owner required" => "Only the team's owner can do this.".to_owned(), + "team creator required" => "Only the team's creator can do this.".to_owned(), + "channel is read-only" => match &place.channel { + Some(channel) => format!("{channel} is archived, so it's read-only."), + None => "That channel is archived, so it's read-only.".to_owned(), + }, + "attachment provenance cannot be dropped" => format!( + "That file was shared in {}. Share it there, or upload it again here.", + place.shared_in.as_deref().unwrap_or("another channel") + ), + "reference provenance cannot be dropped" => format!( + "That remote reference was shared in {}. Share it there, or add it again here.", + place.shared_in.as_deref().unwrap_or("another channel") + ), + _ => return None, + }) +} + +/// A workspace server that cannot save a change (R-2, W2-BRK-3): `storage_full` for a full +/// disk or quota, `storage_failed` for any other write fault. Reading still works, and only the +/// host can put it right, on the server. +/// +/// A current broker says what happened in a sentence of its own, and it matters which: the +/// change was not saved, it may not have been ([`storage_outcome_unknown`]), or nothing more +/// can be saved until Crew restarts. That sentence is kept, naming the host when this command +/// could read who they are, and a host is told, on a line of its own, what to run. An older +/// broker's "restart and recover before further mutations" was written for no one, and is said +/// plainly instead. +fn storage_refusal(code: &str, sentence: &str, place: &RefusalPlace) -> String { + const LEAD: &str = "The workspace server can't save changes right now."; + let host = place.host == Some(true); + let said = if reads_as_sentence(sentence) { + match place.host_label.as_deref().filter(|_| !host) { + Some(label) => sentence.replacen("Ask the host ", &format!("Ask {label} "), 1), + None => sentence.to_owned(), + } + } else if host { + LEAD.to_owned() + } else { + format!( + "{LEAD} Ask {} to restart Crew.", + place.host_label.as_deref().unwrap_or("the host") + ) + }; + if !host { + return said; + } + let server = place.server.as_deref().unwrap_or("the server"); + let first = match code { + "storage_full" => format!("Free space on {server}"), + _ if reads_as_sentence(sentence) => format!("Check the storage on {server}"), + // An older broker said storage_failed for a full disk too. + _ => format!("Free space on {server} if it is full"), + }; + format!("{said}\n{}", host_restart_steps(&first)) +} + +/// What the host of a server that stopped saving runs, after `first` ("Free space on hpc"). +fn host_restart_steps(first: &str) -> String { + format!( + "You host this workspace. {first}, then restart Crew there: biorouter-crew stop, then biorouter-crew start, each with this workspace's --state-dir." + ) +} + +/// Said under a connection whose workspace server has stopped saving changes, as the daemon +/// reads it from the server (`server_storage`, T3-BE-13, RES2-N2): before anyone tries to write, +/// not only after a change is refused. The host is told what to run on the server. +pub const SERVER_STOPPED_SAVING: &str = + "The workspace server has stopped saving changes. Reading still works."; + +/// Whether a storage refusal says the change may have been saved after all (the broker's +/// "…so it may not have been saved."): an outcome that is not known, so the one safe retry, +/// the same request ID, is offered. The broker has no code of its own for it. +pub fn storage_outcome_unknown(code: &str, message: &str) -> bool { + matches!(code, "storage_failed" | "storage_full") + && message + .to_ascii_lowercase() + .contains("may not have been saved") +} + /// What a refusal that carried only its code says (`stale_cursor`, with no text of its own). fn code_only_sentence(code: &str) -> &'static str { match code { @@ -266,6 +573,11 @@ fn plain_refusal(code: &str, sentence: &str) -> String { /// the same words here; anything else becomes a sentence ([`plain_refusal`]). JSON output keeps /// the code as `broker_code`, which is what scripts and support match. pub fn broker_refusal_text(code: &str, message: &str) -> String { + broker_refusal_text_in(code, message, &RefusalPlace::default()) +} + +/// [`broker_refusal_text`], naming what `place` knows about the refusal. +pub fn broker_refusal_text_in(code: &str, message: &str, place: &RefusalPlace) -> String { let message = message.trim(); let sentence = message .split_once(": ") @@ -277,22 +589,33 @@ pub fn broker_refusal_text(code: &str, message: &str) -> String { }) .map_or(message, |(_, rest)| rest.trim()); let lower = sentence.to_ascii_lowercase(); + let key = lower.trim().trim_end_matches(['.', '!', '?']); + if let Some(placed) = placed_refusal(code, sentence, key, place) { + return placed; + } let shown = match (code, username_in(sentence)) { ("identity_conflict", named) if lower.starts_with("another active member is @") => { identity_conflict(named) } ("device_conflict", _) => DEVICE_CONFLICT.to_owned(), ("identity_mismatch", _) if !reads_as_sentence(sentence) => IDENTITY_MISMATCH.to_owned(), - // `broker.rs`'s limits that mean "this workspace is full": the audit journal and - // state-size limits in `commit` and the operation quota in `apply_mutation`, which are - // what a request meets, plus the journal limit in `open_inner`, which only stops the - // broker starting. The join quota's own sentence means "too many people are waiting" and - // is kept. The desktop matches the same texts (`STORAGE_FULL_TEXT` in `refusals.ts`). - ("quota_exceeded", _) if STORAGE_FULL_PREFIXES.iter().any(|p| lower.starts_with(p)) => { - STORAGE_FULL.to_owned() + // A full workspace ([`workspace_full`]). The desktop matches the same texts + // (`STORAGE_FULL_TEXT` in `refusals.ts`). Its host reads what the host can do (MSG2-N6). + ("quota_exceeded", _) if workspace_full(code, sentence).is_some() => { + let host = place.host == Some(true); + match (workspace_full(code, sentence), host) { + (Some(Full::Storage), false) => STORAGE_FULL, + (Some(Full::Storage), true) => STORAGE_FULL_FOR_HOST, + (_, false) => FULL_BUT_HOST_CAN_ADMINISTER, + (_, true) => FULL_BUT_HOST_CAN_ADMINISTER_FOR_HOST, + } + .to_owned() } ("rate_limited", _) if !reads_as_sentence(sentence) => TOO_MANY_ATTEMPTS.to_owned(), ("already_approved", Some(name)) => already_approved(name), + // CLIDOCS-F12: the broker's "Choose Add device…" names the desktop's control; the + // hint below names this terminal's flag. + ("already_member", Some(name)) => format!("@{name} is already a member."), _ if SENTENCE_CODES.contains(&code) && reads_as_sentence(sentence) => sentence.to_owned(), _ => plain_refusal(code, sentence), }; @@ -335,32 +658,126 @@ pub fn institution_refusal_text(requested_model: &str, details: Option<&Value>) .and_then(Value::as_str) .map(str::trim) .filter(|text| !text.is_empty()) - .map(safe_text) }; - let model = field("model").unwrap_or_else(|| safe_text(requested_model)); - let workspace = field("workspace").unwrap_or_else(|| "This workspace".to_owned()); + let model = field("model").map_or_else(|| safe_text(requested_model), safe_text); + let workspace = field("workspace").map_or_else(|| "This workspace".to_owned(), safe_text); let approved_for: Option> = details .and_then(|details| details.get("approved_for")) .and_then(Value::as_array) .map(|ids| { ids.iter() .filter_map(Value::as_str) - .map(safe_text) + .map(institution_label) .collect::>() }) .filter(|ids| !ids.is_empty()); - const CHOOSE: &str = "Choose a model approved for it, or a local model."; - match (field("workspace_institution"), approved_for) { + // SF-F4: named as the desktop's `institutionMismatch` names them, the workspace's + // institution in the advice too, not "it". + match (field("workspace_institution").map(institution_label), approved_for) { (Some(institution), Some(approved)) => format!( - "{model} is approved for {}. {workspace} uses {institution}. {CHOOSE}", + "{model} is approved for {}. {workspace} uses {institution}. Choose a model approved for {institution}, or a local model.", approved.join(" and ") ), (Some(institution), None) => format!( - "{model} doesn't say which institution approved it. {workspace} uses {institution}. {CHOOSE}" + "{model} doesn't say which institution approved it. {workspace} uses {institution}. Choose a model approved for {institution}, or a local model." ), - (None, _) => { - format!("{model} isn't approved for this workspace's institution. {CHOOSE}") + (None, _) => format!( + "{model} isn't approved for this workspace's institution. Choose a model approved for it, or a local model." + ), + } +} + +/// An institution as running text names it, as the desktop's `institutionLabel` does: the name +/// the model registry publishes for that exact ID (`ucsf` is UCSF), else the ID itself. +pub fn institution_label(id: &str) -> String { + use biorouter::privacy::affiliation::{institution_display_name, InstitutionId}; + let id = id.trim(); + // Only a canonical ID can be one the registry names; nothing else is looked up. + let canonical = (1..=64).contains(&id.len()) + && id.starts_with(|ch: char| ch.is_ascii_lowercase() || ch.is_ascii_digit()) + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-'); + canonical + .then(|| InstitutionId::new(id)) + .filter(|key| key.as_str() == id) + .and_then(institution_display_name) + .map_or_else(|| safe_text(id), str::to_owned) +} + +/// The daemon's typed connect failures (`connect_refusal` in `routes/crew.rs`) in words for a +/// person, each with what to do next, as the desktop's connection-problem screens say them. +/// `crew_handoff_failed` is not here: its own text is already written for a person. +pub fn connect_failure_text(code: &str) -> Option<&'static str> { + Some(match code { + "crew_ssh_auth_required" => { + "The server wants your password or a verification code. Run biorouter crew auth to sign in." + } + // W2-DMN-5: a publickey-only refusal is a login matter, not a password one. + "crew_ssh_key_refused" => { + "The server refused this computer's SSH key. Check the server login and key file of the saved connection (biorouter crew connections show), then connect again." + } + // W2-DMN-5, R-7: the server answered, but the workspace server on it is not running. + "crew_broker_not_running" => { + "Crew isn't running on the server. If you host this workspace, start it there as the manual's After the server restarts shows, then connect again. Otherwise, ask your host to start Crew." + } + "crew_ssh_host_key_unknown" => { + "Crew can't verify the server yet: its host key isn't in your known hosts file. Get the server's fingerprint from your IT team, compare it, add the full key to your known hosts file, then connect again." + } + "crew_ssh_host_key_changed" => { + "The server's host key changed since Crew last connected. Don't connect until your IT team confirms the change; Crew won't connect while the old key is in your known hosts file." + } + "crew_ssh_unreachable" => { + "Couldn't reach the server. Check your network or your VPN, then connect again." + } + "crew_bridge_missing" => { + "Crew isn't set up for your account on the server: ~/.local/bin/biorouter-crew is missing there. Install it yourself, or ask your host or IT team to, then connect again." + } + "crew_ssh_failed" => { + "SSH or Crew on the server failed. If the server restarted, ask your host to start the workspace again, then connect again." } + "crew_workspace_identity_mismatch" => { + "This isn't the workspace you joined: the server answered with a different workspace key than the one saved. Don't continue until your host confirms what changed." + } + _ => return None, + }) +} + +/// The user and host of an SSH login that signs in to this machine over loopback +/// (`crew_iris@localhost`, `127.0.0.1`, `[::1]`): what the daemon's `SignInTarget::is_loopback` +/// in `crew/transport.rs` treats as the same host. `None` for any other login, an SSH alias +/// included, which only the person's SSH settings resolve. +pub fn loopback_login(ssh_target: &str) -> Option<(Option<&str>, &str)> { + let ssh_target = ssh_target.trim(); + let (user, host) = match ssh_target.rsplit_once('@') { + Some((user, host)) if !user.is_empty() && !host.is_empty() => (Some(user), host), + _ => (None, ssh_target), + }; + let bare = host.trim_start_matches('[').trim_end_matches(']'); + let loopback = bare.eq_ignore_ascii_case("localhost") + || bare + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + loopback.then_some((user, host)) +} + +/// A refused connect to a login on this machine, in the daemon's same-host words (W2-DMN-2, +/// SETUPHPC2-F-A): a member on the workspace's own server needs this machine's host key in +/// their own known hosts file, and their own key in their own `authorized_keys`, so "get the +/// fingerprint from your IT team" and "check the saved connection" point the wrong way. +/// `None` for another code, or another server. +pub fn same_host_connect_failure_text(code: &str, ssh_target: &str) -> Option { + let (user, host) = loopback_login(ssh_target)?; + match code { + "crew_ssh_host_key_unknown" => Some(format!( + "{}'s host key isn't in your ~/.ssh/known_hosts yet. Add this server's own key (from /etc/ssh/ssh_host_ed25519_key.pub) to it, then connect again.", + safe_text(host) + )), + "crew_ssh_key_refused" => Some(format!( + "Couldn't sign in as {} on this machine: add your public SSH key to your own ~/.ssh/authorized_keys, then connect again.", + user.map_or_else(|| "yourself".to_owned(), safe_text) + )), + _ => None, } } @@ -373,6 +790,8 @@ pub struct HumanOptions { pub view: View, /// Names known from elsewhere, such as the workspace snapshot behind a history page. pub directory: Directory, + /// Lines printed after the value in text, such as that a list is not complete. + pub notes: Vec, clock: Clock, } @@ -394,6 +813,23 @@ impl HumanOptions { self.directory = directory; self } + + pub fn with_notes(mut self, notes: impl IntoIterator) -> Self { + self.notes.extend(notes); + self + } + + /// Dates in UTC with a fixed "now", so a test reads the same in every time zone. + #[cfg(test)] + pub fn in_utc_at(now: i64) -> Self { + Self { + clock: Clock::Fixed { + now, + offset_seconds: 0, + }, + ..Self::default() + } + } } /// The kind of value being printed, when the caller knows it better than its shape does. @@ -466,6 +902,18 @@ pub struct Directory { channels: BTreeMap, references: BTreeMap, unread: BTreeMap, + /// The people the workspace reports online. + online: std::collections::BTreeSet, + /// Why this directory could not read the workspace's names, and which workspace: an ID it + /// cannot name is then said as that, with its ID, rather than as "this channel" (R-8, + /// AG-F17). + unavailable: Option, +} + +#[derive(Clone, Debug)] +struct Unavailable { + workspace: String, + disconnected: bool, } #[derive(Clone, Debug)] @@ -485,6 +933,34 @@ struct ChannelInfo { } impl Directory { + /// No names, because the workspace snapshot could not be read. `workspace` is what the + /// person calls the workspace (the saved connection's name, unescaped); `disconnected` says + /// the connection is down, which is the usual reason. + pub fn names_unavailable(workspace: &str, disconnected: bool) -> Self { + Self { + unavailable: Some(Unavailable { + workspace: workspace.to_owned(), + disconnected, + }), + ..Self::default() + } + } + + /// "a channel in lab (names unavailable while disconnected)", for an ID this directory + /// cannot name because the names could not be read. + fn unnamed(&self, what: &str) -> Option { + let unavailable = self.unavailable.as_ref()?; + let why = if unavailable.disconnected { + "names unavailable while disconnected" + } else { + "names unavailable" + }; + Some(format!( + "{what} in {} ({why})", + display_text(&unavailable.workspace) + )) + } + #[allow(dead_code)] // The command layer passes a snapshot's names to history and lists. pub fn from_snapshot(snapshot: &Value) -> Self { let mut directory = Self::default(); @@ -492,6 +968,12 @@ impl Directory { directory } + /// Whether the workspace reports this person online (`online_principal_ids`, W2-BRK-6). + /// Presence is shown only when the broker reports it: an older one names no one. + fn online(&self, id: &str) -> bool { + self.online.contains(id) + } + /// A channel's `#name` for a sentence, or "a channel" when this directory can't name it /// (never its ID). pub fn channel_label(&self, id: &str) -> String { @@ -547,6 +1029,10 @@ impl Directory { } } } + if let Some(Value::Array(online)) = fields.get("online_principal_ids") { + self.online + .extend(online.iter().filter_map(Value::as_str).map(str::to_owned)); + } if let Some(Value::Object(unread)) = fields.get("unread") { for (id, count) in unread { if let Some(count) = count.as_u64() { @@ -665,8 +1151,14 @@ impl Person { } } -/// A display name quoted like a string literal, escaped for the terminal and isolated so its -/// direction cannot leak into the text around it. +/// A display name quoted like a string literal, escaped for the terminal, and isolated when it +/// could hold right-to-left text, so its direction cannot leak into the text around it. +/// +/// A name that is ASCII once escaped holds no right-to-left text, so it is left bare, as +/// [`display_text`] leaves a team or file name (F10): the isolates are invisible in a terminal +/// but travel into anything copied, so `"Alice Chen"` failed a search for that very text. +/// Escaping comes first, so a name's own bidi controls are printed as `\u{…}` text and cannot +/// make it look ASCII. fn quoted_name(name: &str) -> String { let mut escaped = String::with_capacity(name.len()); for ch in name.chars() { @@ -675,7 +1167,12 @@ fn quoted_name(name: &str) -> String { } escaped.push(ch); } - format!("\"\u{2068}{}\u{2069}\"", safe_text(&escaped)) + let text = safe_text(&escaped); + if text.is_ascii() { + format!("\"{text}\"") + } else { + format!("\"\u{2068}{text}\u{2069}\"") + } } /// A team name or file name: escaped, and isolated when it could hold right-to-left text. @@ -689,6 +1186,26 @@ fn display_text(name: &str) -> String { } } +/// A channel as a sentence names it when another team of the person's may have one of the +/// same name (M11): `Chen Lab / #general` when another listed team has a channel of that name, +/// else `#general`. `None` when the directory cannot name the channel. +pub fn channel_across_teams(directory: &Directory, id: &str) -> Option { + let channel = directory.channels.get(id)?; + let name = channel.name.as_deref().filter(|name| !name.is_empty())?; + let shared = directory.channels.iter().any(|(other, info)| { + other != id && info.name.as_deref() == Some(name) && info.team_id != channel.team_id + }); + let team = channel + .team_id + .as_deref() + .and_then(|team| directory.teams.get(team)) + .filter(|_| shared); + Some(match team { + Some(team) => format!("{} / {}", display_text(team), channel_name(name)), + None => channel_name(name), + }) +} + /// `"Bob Lee" (@bob)`, or `@bob` when the display name is the username. #[allow(dead_code)] // For the command layer's confirmations and refusals. pub fn person_label(directory: &Directory, principal_id: &str, show_ids: bool) -> String { @@ -707,6 +1224,52 @@ pub fn authority_label(directory: &Directory, principal_id: &str, show_ids: bool Ctx::new(directory.clone(), show_ids, Clock::System).with_id(label, "ID", Some(principal_id)) } +/// One computer enrolled as the person, as their profile lists it: its fingerprint, the date +/// it was added and how. +pub fn device_text(device: &Value, options: &HumanOptions) -> String { + Ctx::new(options.directory.clone(), options.show_ids, options.clock).device_row(device) +} + +/// A chat's Crew access for a person (AG-F7): `Access: #methods · also reads #general`, then +/// the messages it can read, oldest first (the manifest lists them newest first). +/// `destination` is the channel the chat posts in, when the grants list says; without it the +/// line names every channel the chat reads. +pub fn context_lines( + manifest: &Value, + destination: Option<&str>, + options: &HumanOptions, +) -> Vec { + let mut directory = options.directory.clone(); + directory.absorb(manifest); + let ctx = Ctx::new(directory, options.show_ids, options.clock); + let sources = str_list(manifest, "source_channels"); + let others: Vec = sources + .iter() + .filter(|source| Some(**source) != destination) + .map(|source| ctx.channel_in_team(Some(source))) + .collect(); + let mut out = vec![match destination { + Some(destination) if others.is_empty() => { + format!("Access: {}", ctx.channel_in_team(Some(destination))) + } + Some(destination) => format!( + "Access: {} · also reads {}", + ctx.channel_in_team(Some(destination)), + others.join(", ") + ), + None if others.is_empty() => "Access: no channels".to_owned(), + None => format!("Access: reads {}", others.join(", ")), + }]; + let messages = list_key(manifest, "messages"); + if messages.is_empty() { + out.push("No messages.".into()); + } + for message in messages.iter().rev() { + out.extend(ctx.message(message)); + } + out +} + /// Render `value` as text for a person. pub fn render_text(value: &Value, options: &HumanOptions) -> String { let mut directory = options.directory.clone(); @@ -716,7 +1279,91 @@ pub fn render_text(value: &Value, options: &HumanOptions) -> String { View::Auto => detect(value), view => view, }; - ctx.render(view, value).join("\n") + let mut lines = ctx.render(view, value); + lines.extend(options.notes.iter().cloned()); + lines.join("\n") +} + +/// "Showing 100 of your 140 channels." when a snapshot left some out (`totals`): the broker +/// lists as many teams and channels as fit in one answer, and the rest are still the person's. +pub fn partial_list_note(noun: &str, listed: usize, total: Option) -> Option { + let total = usize::try_from(total?).ok()?; + (total > listed).then(|| format!("Showing {listed} of your {total} {noun}.")) +} + +/// Channels as a person reads them (M17, F6, SF-F10): grouped by team, teams by name, each +/// team's `#general` first and then its channels by name. A channel whose team is not listed +/// comes last. The broker sends them in the order of their random IDs. +pub fn channels_in_order(channels: &mut [Value], teams: &[Value]) { + let team_rank: BTreeMap<&str, (String, &str)> = teams + .iter() + .filter_map(|team| { + let id = str_field(team, "id")?; + let name = display_or_name(team).unwrap_or_default().to_lowercase(); + Some(( + id, + ( + name, + str_field(team, "general_channel_id").unwrap_or_default(), + ), + )) + }) + .collect(); + let key = |channel: &Value| { + let id = str_field(channel, "id").unwrap_or_default().to_owned(); + let team = str_field(channel, "team_id").and_then(|team| team_rank.get(team)); + let name = display_or_name(channel) + .unwrap_or_default() + .trim_start_matches('#') + .to_lowercase(); + ( + team.is_none(), + team.map(|(name, _)| name.clone()).unwrap_or_default(), + str_field(channel, "team_id").unwrap_or_default().to_owned(), + team.is_none_or(|(_, general)| *general != id), + name, + id, + ) + }; + channels.sort_by_cached_key(key); +} + +/// Teams by name, case aside. +pub fn teams_in_order(teams: &mut [Value]) { + teams.sort_by_cached_key(|team| { + ( + display_or_name(team).unwrap_or_default().to_lowercase(), + str_field(team, "id").unwrap_or_default().to_owned(), + ) + }); +} + +/// People as a person reads them (SF-F10, M17): the host, then you, then everyone else by the +/// name their row leads with, a leading `@` and letter case aside, then by username. The +/// desktop's `peopleInOrder`, so the terminal lists a team the way the app does. +pub fn people_in_order(people: &mut [Value], host_id: Option<&str>, actor_id: Option<&str>) { + people.sort_by_cached_key(|person| { + let id = str_field(person, "id"); + let rank = if id.is_some() && id == host_id { + 0 + } else if id.is_some() && id == actor_id { + 1 + } else { + 2 + }; + let username = str_field(person, "username").unwrap_or_default(); + let display = str_field(person, "display_name") + .or_else(|| str_field(person, "nickname")) + .map(str::trim) + .filter(|display| { + !display.is_empty() && display.to_lowercase() != username.to_lowercase() + }); + let shown = display + .unwrap_or(username) + .trim_start_matches('@') + .to_lowercase(); + (rank, shown, username.to_lowercase(), username.to_owned()) + }); } fn detect(value: &Value) -> View { @@ -831,7 +1478,7 @@ impl Ctx { View::Grants => self.grants(value), View::Privacy => self.privacy(value), View::Transfers => self.transfers(value), - View::Transfer => self.transfer(value), + View::Transfer => self.transfer(value, true), View::Reference => self.rows( value, "references", @@ -889,19 +1536,31 @@ impl Ctx { } fn team(&self, id: Option<&str>) -> String { - let name = id - .and_then(|id| self.dir.teams.get(id)) - .map_or_else(|| "this team".to_owned(), |name| display_text(name)); - self.with_id(name, "team ID", id) + match id.and_then(|id| self.dir.teams.get(id)) { + Some(name) => self.with_id(display_text(name), "team ID", id), + None => self.unnamed_id("a team", "this team", "team ID", id), + } } fn channel(&self, id: Option<&str>) -> String { let name = id .and_then(|id| self.dir.channels.get(id)) .and_then(|channel| channel.name.as_deref()) - .filter(|name| !name.is_empty()) - .map_or_else(|| "this channel".to_owned(), channel_name); - self.with_id(name, "channel ID", id) + .filter(|name| !name.is_empty()); + match name { + Some(name) => self.with_id(channel_name(name), "channel ID", id), + None => self.unnamed_id("a channel", "this channel", "channel ID", id), + } + } + + /// An ID the directory cannot name: `fallback` ("this channel") when the value simply + /// does not say, and, when the names could not be read, what it is, where, why, and its + /// ID, which is then the only way to tell two rows apart (R-8, AG-F17). + fn unnamed_id(&self, what: &str, fallback: &str, label: &str, id: Option<&str>) -> String { + match (self.dir.unnamed(what), id) { + (Some(unnamed), Some(id)) => format!("{unnamed} [{label} {}]", safe_text(id)), + _ => self.with_id(fallback.to_owned(), label, id), + } } fn channel_in_team(&self, id: Option<&str>) -> String { @@ -961,17 +1620,36 @@ impl Ctx { ("Agent grants", "runs", Self::run_grant_row), ]; for (title, key, row) in sections { - let items = list_key(snapshot, key); - if items.is_empty() { + let mut items = list_key(snapshot, key).to_vec(); + let noun = match key { + "principals" | "former_principals" => { + people_in_order(&mut items, self.dir.host_id(), self.dir.actor_id.as_deref()); + "" + } + "teams" => { + teams_in_order(&mut items); + "teams" + } + "channels" => { + channels_in_order(&mut items, list_key(snapshot, "teams")); + "channels" + } + "invitations" => "invitations", + "references" => "remote references", + _ => "agent grants", + }; + let note = partial_list_note(noun, items.len(), snapshot["totals"][key].as_u64()); + if items.is_empty() && note.is_none() { if matches!(title, "People" | "Teams" | "Channels") { out.push(format!("{title}: none")); } continue; } out.push(format!("{title} ({}):", items.len())); - for item in items { + for item in &items { out.extend(row(self, item).into_iter().map(|line| format!(" {line}"))); } + out.extend(note.map(|note| format!(" {note}"))); } out } @@ -1008,6 +1686,9 @@ impl Ctx { if person.stale { row.push_str(" · account no longer valid"); } + if id.is_some_and(|id| self.dir.online(id)) { + row.push_str(" · online"); + } row = self.with_id(row, "ID", id); if let Some(uid) = person.uid.filter(|_| self.show_ids) { let _ = write!(row, " [UID {uid}]"); @@ -1109,7 +1790,7 @@ impl Ctx { let mut out = self.rows( value, "invitations", - "No invitations.", + "No pending invitations.", Self::invitation_row, ); if !list(value, "invitations").is_empty() { @@ -1166,18 +1847,28 @@ impl Ctx { if let Some(target) = str_field(connection, "ssh_target") { parts.push(safe_text(target)); } - if let Some(status) = str_field(connection, "status") { - parts.push(sentence_case(status)); + if let Some(status) = connection_status(connection) { + parts.push(status); } - parts.push(connection_privacy(connection)); + parts.push(match Effective::of(connection) { + None | Some(Effective::Own) => connection_privacy(connection), + Some(Effective::Workspace(workspace)) => { + format!( + "Private because {workspace} is Private for everyone · your connection: Public" + ) + } + Some(Effective::Unknown) => format!( + "Your connection: Public · workspace privacy unknown {}", + unchecked_because(connection) + ), + }); let mut out = vec![self.with_id( parts.join(" · "), "connection ID", str_field(connection, "id"), )]; - if let Some(error) = str_field(connection, "last_error") { - out.push(format!(" Last error: {}", safe_text(error))); - } + out.extend(server_storage_lines(connection)); + out.extend(last_error_lines(connection)); out } @@ -1195,15 +1886,35 @@ impl Ctx { } out.push(server); } - if let Some(status) = str_field(connection, "status") { - out.push(format!(" Status: {}", sentence_case(status))); + if let Some(status) = connection_status(connection) { + out.push(format!(" Status: {status}")); } + out.extend(server_storage_lines(connection)); let mut privacy = vec![mode_word(str_field(connection, "mode"))]; privacy.push(institution(connection)); if let Some(epoch) = connection.get("policy_epoch").and_then(Value::as_u64) { privacy.push(format!("policy epoch {epoch}")); } - out.push(format!(" Privacy: {}", privacy.join(" · "))); + // SF-F1: the effective privacy comes first, and the connection's own setting is said + // as that whenever the two differ or the workspace's cannot be read. + match Effective::of(connection) { + None | Some(Effective::Own) => { + out.push(format!(" Privacy: {}", privacy.join(" · "))); + } + Some(Effective::Workspace(workspace)) => { + out.push(format!( + " Privacy: Private ({workspace} is Private for everyone)" + )); + out.push(format!(" Your connection: {}", privacy.join(" · "))); + } + Some(Effective::Unknown) => { + out.push(format!( + " Privacy: can't be checked {}", + unchecked_because(connection) + )); + out.push(format!(" Your connection: {}", privacy.join(" · "))); + } + } if let Some(root) = str_field(connection, "remote_root") { let execution = connection.get("remote_execution").and_then(Value::as_bool); let access = if execution == Some(true) { @@ -1216,9 +1927,7 @@ impl Ctx { if let Some(file) = str_field(connection, "identity_file") { out.push(format!(" SSH key file: {}", safe_text(file))); } - if let Some(error) = str_field(connection, "last_error") { - out.push(format!(" Last error: {}", safe_text(error))); - } + out.extend(last_error_lines(connection)); out.extend(self.detail_ids(connection, CONNECTION_IDS)); out } @@ -1290,16 +1999,39 @@ impl Ctx { out } + /// A message's attachments, each by name and size where the command looked it up + /// (`attachment_details`, DW-17), so `files download ID` can be given the right one; any + /// it could not look up are counted, as before. fn message_extras(&self, message: &Value) -> Vec { let mut out = Vec::new(); let attachments = str_list(message, "attachments"); - if !attachments.is_empty() { + let details = message.get("attachment_details"); + let (named, unnamed): (Vec<&str>, Vec<&str>) = attachments + .iter() + .partition(|id| details.and_then(|details| details.get(**id)).is_some()); + for id in &named { + let blob = &details.expect("partitioned on it")[*id]; + let mut text = format!("Attachment: {}", text_or(blob, "name", "Unnamed file")); + if let Some(size) = blob.get("size").and_then(Value::as_u64) { + let _ = write!(text, " ({})", human_size(size)); + } + out.push(format!( + " {}", + self.with_id(text, "attachment ID", Some(id)) + )); + } + if !unnamed.is_empty() { + let more = if named.is_empty() { "" } else { "more " }; let mut line = format!( " {}", - count(attachments.len(), "attachment", "attachments") + count( + unnamed.len(), + &format!("{more}attachment"), + &format!("{more}attachments") + ) ); if self.show_ids { - let _ = write!(line, " [attachment IDs {}]", join_safe(&attachments)); + let _ = write!(line, " [attachment IDs {}]", join_safe(&unnamed)); } out.push(line); } @@ -1441,20 +2173,30 @@ impl Ctx { let stopped = grant.get("expired").and_then(Value::as_bool) == Some(true); let expires_at = grant.get("expires_at").and_then(Value::as_i64); let now = self.clock.now(); + // AGT2-N3: a task the person stopped reads Stopped, as its card in the channel does; one + // that finished (or whose status the command could not read) reads Ended. The grant + // cannot tell them apart, so the command adds the task's status (`task_status`). + let task_over = || { + if str_field(grant, "task_status") == Some("cancelled") { + "Stopped".to_owned() + } else { + "Ended".to_owned() + } + }; if stopped { return match str_field(grant, "revocation") { Some("unconfirmed") => { "Stopped on this device; the workspace hasn't confirmed yet".into() } Some("ended_by_workspace") => "Ended: Crew settings changed".into(), - _ if task => "Ended".into(), + _ if task => task_over(), _ => "Revoked".into(), }; } match expires_at { Some(at) if at <= now => { if task { - "Ended".into() + task_over() } else { "Expired".into() } @@ -1485,17 +2227,40 @@ impl Ctx { connection.push(format!("policy epoch {epoch}")); } let workspace = value.get("workspace").unwrap_or(&Value::Null); - let mut out = vec![ - self.with_id( - format!("Your connection: {}", connection.join(" · ")), - "connection ID", - str_field(value, "connection_id"), + // Read only while connected (SF2-N3): a `privacy show` made offline has none. + let unread = !workspace.is_object(); + // SF-F1: privacy is Public only when the connection is and the workspace allows it. + let effective = match ( + str_field(value, "personal_mode"), + str_field(workspace, "mode"), + ) { + (Some("public"), Some("private")) => format!( + "Private ({} is Private for everyone)", + str_field(workspace, "name") + .map_or_else(|| "the workspace".to_owned(), display_text) ), + (Some("public"), Some("public")) => "Public".to_owned(), + (Some("public"), _) if unread => "can't be checked while disconnected".to_owned(), + (Some("public"), _) => "can't be checked".to_owned(), + (own, _) => mode_word(own), + }; + let workspace_line = if unread { + "Workspace: can't be checked while disconnected".to_owned() + } else { self.with_id( format!("Workspace: {}", workspace_privacy(workspace)), "workspace ID", str_field(workspace, "id"), + ) + }; + let mut out = vec![ + format!("Privacy: {effective}"), + self.with_id( + format!("Your connection: {}", connection.join(" · ")), + "connection ID", + str_field(value, "connection_id"), ), + workspace_line, ]; let channels = list_key(value, "channels"); if !channels.is_empty() { @@ -1527,13 +2292,16 @@ impl Ctx { } let mut out: Vec = transfers .iter() - .flat_map(|transfer| self.transfer(transfer)) + .flat_map(|transfer| self.transfer(transfer, false)) .collect(); self.ids_hint(&mut out, "files status, resume, pause and forget"); out } - fn transfer(&self, transfer: &Value) -> Vec { + /// One transfer. `alone` is a single receipt (`files status`), whose resume command names + /// its ID: the person just typed it. In a list the ID is there only with `--show-ids`, as + /// every other ID is. + fn transfer(&self, transfer: &Value, alone: bool) -> Vec { let name = text_or(transfer, "name", "Unnamed file"); let direction = str_field(transfer, "direction").unwrap_or("upload"); let channel = self.channel(str_field(transfer, "channel_id")); @@ -1559,7 +2327,25 @@ impl Ctx { None => {} } let mut out = vec![self.with_id(row, "transfer ID", str_field(transfer, "id"))]; - if let Some(error) = str_field(transfer, "error") { + if matches!(state, "paused" | "needs_file_selection") { + // CLIDOCS-F10: a pause is a state, not an error. Its reason in the desktop's few + // words, then this terminal's way on, which the daemon's "Reselect the original + // local file" is not. + if let Some(reason) = str_field(transfer, "error").and_then(pause_reason) { + out.push(format!(" {reason}")); + } + let what = if direction == "download" { + "the same destination" + } else { + "the original file" + }; + let id = str_field(transfer, "id") + .filter(|_| alone || self.show_ids) + .map_or_else(|| "ID".to_owned(), safe_text); + out.push(format!( + " Resume with {what}: biorouter crew files resume {id} FILE" + )); + } else if let Some(error) = str_field(transfer, "error") { out.push(format!(" Error: {}", safe_text(error))); } out.extend(self.detail_ids(transfer, TRANSFER_IDS)); @@ -1678,8 +2464,14 @@ impl Ctx { let text = if flag("detached") == Some(true) { "Stopped watching. The transfer continues.".to_owned() } else { + // The direction decides the word a finished transfer ends with: an upload is + // Ready to attach, a download is Saved. let state = str_field(value, "state").unwrap_or("unknown"); - format!("Transfer {}.", transfer_state_word(state, "", 0, None)) + let direction = str_field(value, "direction").unwrap_or(""); + format!( + "Transfer {}.", + transfer_state_word(state, direction, 0, None) + ) }; return vec![self.with_id(text, "transfer ID", Some(transfer))]; } @@ -1705,11 +2497,17 @@ impl Ctx { out } + /// A prepared device key (SF-F3). `connections prepare` and `enroll prepare` are one + /// command, and nothing in it says which use the key is for: a host passes it to + /// `biorouter-crew start` and the preparation ID to `connections join-invitation`, while a + /// joiner sends the key to the host and puts the ID in a `connections save` descriptor. So + /// both uses are named, and neither reader is sent down the other's path. fn prepared(&self, value: &Value) -> Vec { let key = str_field(value, "public_key").map_or_else(String::new, safe_text); let mut out = vec![ - "Device key prepared. Give this public key to the workspace host:".to_owned(), + "Device key prepared. Its public key:".to_owned(), format!(" {key}"), + PREPARED_KEY_USES.to_owned(), ]; if self.show_ids { out.extend(self.detail_ids( @@ -1719,8 +2517,11 @@ impl Ctx { ("device_id", "Device ID"), ], )); + out.push(format!("The preparation ID {PREPARATION_ID_USES}.")); } else { - out.push("Add --show-ids for the preparation ID that connections save takes.".into()); + out.push(format!( + "Add --show-ids for the preparation ID, which {PREPARATION_ID_USES}." + )); } out } @@ -1832,6 +2633,67 @@ impl Ctx { } } +/// A saved connection's status word, as the desktop's status row says it: "Not joined yet" for a +/// connection the host has not let in (`joined: false`, SC2-N5), which is connected but not a +/// member, before the transport's own status. +fn connection_status(connection: &Value) -> Option { + if connection.get("joined").and_then(Value::as_bool) == Some(false) { + return Some("Not joined yet".to_owned()); + } + str_field(connection, "status").map(sentence_case) +} + +/// [`SERVER_STOPPED_SAVING`] for a connection whose `server_storage` says so, and, for its host +/// (`you_host`), what to run on the server. Nothing for an absent or `null` `server_storage`: +/// the server is saving, or it is an older one that does not say. +fn server_storage_lines(connection: &Value) -> Vec { + let Some(storage) = connection.get("server_storage").filter(|s| s.is_object()) else { + return Vec::new(); + }; + let mut out = vec![format!(" {SERVER_STOPPED_SAVING}")]; + if connection.get("you_host").and_then(Value::as_bool) == Some(true) { + let server = str_field(connection, "server_label") + .or_else(|| { + str_field(connection, "ssh_target").and_then(|target| target.rsplit('@').next()) + }) + .map_or_else(|| "the server".to_owned(), safe_text); + let first = match str_field(storage, "code") { + Some("storage_full") => format!("Free space on {server}"), + _ => format!("Check the storage on {server}"), + }; + out.push(format!(" {}", host_restart_steps(&first))); + } + out +} + +/// A saved connection's last error. When the daemon types it (`last_error_code`) with a code +/// the connect failures share, the sentence that says what to do comes first and the daemon's +/// own text follows as its details (a wave-1 follow-up to CLI-7); otherwise the text is shown +/// as it is. +fn last_error_lines(connection: &Value) -> Vec { + let Some(error) = str_field(connection, "last_error") else { + return Vec::new(); + }; + let code = str_field(connection, "last_error_code"); + // A login on this machine is told what a member on the server itself must fix. + let same_host = code + .zip(str_field(connection, "ssh_target")) + .and_then(|(code, ssh_target)| same_host_connect_failure_text(code, ssh_target)); + match same_host.or_else(|| code.and_then(connect_failure_text).map(str::to_owned)) { + Some(sentence) => vec![ + format!(" Last error: {sentence}"), + format!(" Details: {}", safe_text(error)), + ], + None => vec![format!(" Last error: {}", safe_text(error))], + } +} + +/// What a prepared device key is for, both ways (`command-line.md` "Host a workspace", and +/// `cli-guide.md` "Save a connection from a descriptor"). +const PREPARED_KEY_USES: &str = "To host a workspace, pass this key to biorouter-crew start --bootstrap-key on the server. To join one, send it to the workspace's host."; +/// Where each reader puts the preparation ID, completing "The preparation ID …". +const PREPARATION_ID_USES: &str = "goes to connections join-invitation --preparation-id when you host, or into the descriptor for connections save when you join"; + const CONNECTION_IDS: &[(&str, &str)] = &[ ("id", "Connection ID"), ("workspace_id", "Workspace ID"), @@ -1978,7 +2840,7 @@ fn sentence_case(value: &str) -> String { } /// The run status words the desktop shows (`crewStatus.ts`). -fn run_status_word(status: &str) -> String { +pub(super) fn run_status_word(status: &str) -> String { match status { "starting" => "Starting…".into(), "running" => "Working…".into(), @@ -1994,6 +2856,46 @@ fn run_status_word(status: &str) -> String { } } +/// Why a paused transfer stopped, from the daemon's recovery sentence +/// (`transfer_recovery_message` in `crates/biorouter-server/src/crew/transfers.rs`), in the +/// desktop's few words (`PAUSE_REASONS` in `ui/desktop/src/components/crew/state/crewStatus.ts`), +/// with a full stop. A sentence neither knows is said as it came. +fn pause_reason(error: &str) -> Option { + const REASONS: [(&str, &str); 6] = [ + ("Transfer paused", "You paused it."), + ( + "Authenticate and reconnect the saved connection", + "The connection dropped.", + ), + ( + "Unlock the Crew credential vault", + "The credential vault is locked.", + ), + ( + "Two transfers are active", + "Two other transfers were running.", + ), + ( + "The Crew connection or privacy policy changed", + "The connection's privacy changed.", + ), + ("Transfer stopped", "It stopped."), + ]; + let text = error.trim(); + if text.is_empty() { + return None; + } + Some( + REASONS + .iter() + .find(|(start, _)| { + text.strip_prefix(start) + .is_some_and(|rest| !rest.starts_with(|ch: char| ch.is_alphanumeric())) + }) + .map_or_else(|| safe_text(text), |(_, words)| (*words).to_owned()), + ) +} + fn transfer_state_word(state: &str, direction: &str, offset: u64, size: Option) -> String { let percent = match size { Some(0) | None => 0, @@ -2046,6 +2948,54 @@ fn connection_privacy(connection: &Value) -> String { } } +/// A saved connection's effective privacy, as the CLI annotated it (`effective_mode`, +/// `workspace_mode`, `workspace_name`; SF-F1). `None` for a value it did not annotate. +enum Effective { + /// The connection's own setting is the effective one. + Own, + /// The connection is Public, but the workspace, named here, is Private for everyone. + Workspace(String), + /// The connection is Public, and the workspace's setting could not be read. + Unknown, +} + +impl Effective { + fn of(connection: &Value) -> Option { + let effective = connection.get("effective_mode")?; + let own = str_field(connection, "mode"); + Some(match effective.as_str() { + None => Self::Unknown, + Some(mode) if Some(mode) == own => Self::Own, + Some(_) => Self::Workspace( + str_field(connection, "workspace_name") + .map_or_else(|| "the workspace".to_owned(), display_text), + ), + }) + } +} + +/// Why a Public connection's workspace privacy was not read: it is disconnected, or the +/// workspace did not answer in time. +fn unchecked_because(connection: &Value) -> &'static str { + if str_field(connection, "status") == Some("connected") { + "right now" + } else { + "while disconnected" + } +} + +/// The privacy a connection in `own` mode has in a workspace in `workspace` mode (SF-F1, the +/// manual's definition): Private when either is, Public only when both allow it, and unknown +/// when the connection is Public and the workspace's mode could not be read. +pub fn effective_mode(own: Option<&str>, workspace: Option<&str>) -> Option<&'static str> { + match (own, workspace) { + (Some("public"), Some("public")) => Some("public"), + (Some("public"), Some(_)) => Some("private"), + (Some("public"), None) => None, + _ => Some("private"), + } +} + fn workspace_privacy(workspace: &Value) -> String { let mode = match str_field(workspace, "mode") { Some("private") => "Private for everyone".into(), @@ -2068,6 +3018,13 @@ fn credentials(value: &Value) -> String { (_, Some(true)) => "Locked", _ => "Unlocked", }; + // SETUPHPC2-F-C: a keyring that cannot keep a key (a headless Linux node with no Secret + // Service) is not the normal "Not set up · keyring": say so, and what to run instead. + if value.get("available").and_then(Value::as_bool) == Some(false) { + return format!( + "Credential vault: {state} · no keyring on this computer; run biorouter crew credentials init" + ); + } match str_field(value, "backend") { Some(backend) => format!("Credential vault: {state} · {}", safe_text(backend)), None => format!("Credential vault: {state}"), @@ -2179,6 +3136,37 @@ mod tests { assert!(!safe.contains('\u{202e}')); } + /// CLI-4: the CLI's own isolates around a name survive a second pass; any other isolate, + /// and every other control, is still escaped. + #[test] + fn a_balanced_isolate_pair_passes_through_and_nothing_else_does() { + let named = "Ask \"\u{2068}Alice Chen\u{2069}\" (@alice) again."; + assert_eq!(safe_text_keeping_isolates(named), named); + assert_eq!( + safe_text_keeping_isolates("\u{2068}#données\u{2069}: gone"), + "\u{2068}#données\u{2069}: gone" + ); + assert_eq!( + safe_text_keeping_isolates("\u{2068}a\u{202e}b\u{2069}"), + "\u{2068}a\\u{202e}b\u{2069}" + ); + for (hostile, escaped) in [ + ("open \u{2068}forever", "open \\u{2068}forever"), + ("\u{2069}close", "\\u{2069}close"), + ("\u{2066}ltr\u{2069}", "\\u{2066}ltr\\u{2069}"), + ( + "\u{2068}a\u{2068}b\u{2069}c\u{2069}", + "\\u{2068}a\u{2068}b\u{2069}c\\u{2069}", + ), + ("bell\u{7}", "bell\\u{7}"), + ] { + assert_eq!(safe_text_keeping_isolates(hostile), escaped, "{hostile:?}"); + } + // M14: a joiner outside the pairs keeps its neighbours too. + let joined = "Posted 👩🏽\u{200d}🔬 to \u{2068}#données\u{2069}."; + assert_eq!(safe_text_keeping_isolates(joined), joined); + } + #[test] fn terminal_controls_escape_without_losing_emoji_or_non_ascii_text() { let input = "safe🙂 café\u{202e}bidi\u{200b}zero\u{feff}bom\u{e0041}tag\n\x1b"; @@ -2193,6 +3181,46 @@ mod tests { assert!(escaped.chars().all(|ch| !terminal_control(ch))); } + /// M14: joiners inside emoji sequences and between letters of scripts that use them print + /// as themselves; a joiner anywhere else is escaped, the spoof `crew\u{200d}_alice` first. + #[test] + fn joiners_print_where_they_join_text_and_are_escaped_everywhere_else() { + for kept in [ + "👩🏽\u{200d}🔬", + "👨\u{200d}👩\u{200d}👧\u{200d}👦", + "🏳\u{fe0f}\u{200d}🌈", + "❤\u{fe0f}\u{200d}🔥", + "می\u{200c}خواهم", + "क्\u{200d}ष", + "क्\u{200c}ष", + ] { + assert_eq!(safe_text(kept), kept, "{kept:?}"); + } + for (hostile, escaped) in [ + ("crew\u{200d}_alice", "crew\\u{200d}_alice"), + ("ali\u{200c}ce", "ali\\u{200c}ce"), + ("\u{200d}🔬", "\\u{200d}🔬"), + ("🔬\u{200d}", "🔬\\u{200d}"), + ("🔬\u{200d} 🔬", "🔬\\u{200d} 🔬"), + ("🔬\u{200d}\u{200d}🔬", "🔬\\u{200d}\\u{200d}🔬"), + ("🔬\u{200d}\u{202e}🔬", "🔬\\u{200d}\\u{202e}🔬"), + ("é\u{200b}é", "é\\u{200b}é"), + ] { + assert_eq!(safe_text(hostile), escaped, "{hostile:?}"); + } + // A message body keeps its emoji on every line. + let body = plain(&message( + "m", + ALICE, + NOW, + "Emoji check: 👩🏽\u{200d}🔬 and می\u{200c}خواهم", + )); + assert!( + body.ends_with("Emoji check: 👩🏽\u{200d}🔬 and می\u{200c}خواهم"), + "{body}" + ); + } + #[test] fn json_terminal_safe_round_trips_invisible_non_bmp_tags() { let value = json!({ @@ -2286,9 +3314,13 @@ mod tests { render_text(value, &options(true, Directory::from_snapshot(snapshot))) } - /// The display-name half of a person label: quoted and isolated. + /// The display-name half of a person label: quoted, and isolated only when it is not ASCII. fn q(name: &str) -> String { - format!("\"\u{2068}{name}\u{2069}\"") + if name.is_ascii() { + format!("\"{name}\"") + } else { + format!("\"\u{2068}{name}\u{2069}\"") + } } fn alice() -> String { @@ -2451,6 +3483,57 @@ mod tests { assert_no_machine_ids(&text); } + /// DW-17: an attachment the command looked up is named with its size, beside its ID with + /// `--show-ids`; one it could not look up is counted. + #[test] + fn history_names_each_attachment_it_looked_up() { + let mut page = history(); + page["messages"][3]["attachments"] = json!([BLOB, TRANSFER]); + page["messages"][3]["attachment_details"] = + json!({BLOB: {"name": "counts.csv", "size": 56_320, "media_type": "text/csv"}}); + let text = named(&page, &alice_snapshot()); + assert!( + text.contains(" Attachment: counts.csv (55 KB)\n 1 more attachment\n"), + "{text}" + ); + assert_no_machine_ids(&text); + let ids = with_ids(&page, &alice_snapshot()); + assert!( + ids.contains(&format!(" Attachment: counts.csv (55 KB) [attachment ID {BLOB}]\n 1 more attachment [attachment IDs {TRANSFER}]")), + "{ids}" + ); + } + + /// AG-F7: `crew context` shows the grant's scope, then the messages oldest first. + #[test] + fn a_chats_context_shows_its_access_then_its_messages_oldest_first() { + let mut manifest = json!({"run_id": RUN, "policy_epoch": 4, + "source_channels": [METHODS, GENERAL], "restricted": true, + "people": {BOB: {"username": "crew_bob", "display_name": "Bob Lee"}}, + "channel_names": {METHODS: "methods", GENERAL: "general"}}); + manifest["messages"] = json!([ + message("m-2", BOB, NOW, "Second."), + message("m-1", BOB, NOW - 60, "First."), + ]); + let lines = context_lines( + &manifest, + Some(METHODS), + &options(false, Directory::default()), + ); + assert_eq!(lines[0], "Access: #methods · also reads #general"); + let first = lines + .iter() + .position(|line| line.ends_with("First.")) + .unwrap(); + let second = lines + .iter() + .position(|line| line.ends_with("Second.")) + .unwrap(); + assert!(first < second, "{lines:?}"); + let unknown = context_lines(&manifest, None, &options(false, Directory::default())); + assert_eq!(unknown[0], "Access: reads #methods, #general"); + } + #[test] fn history_without_names_degrades_to_unknown_member_and_marks_restriction() { let text = plain(&history()); @@ -2506,9 +3589,9 @@ mod tests { "Privacy: Private for everyone · institution ucsf · policy epoch 4".into(), format!("You: {}", alice()), "People (3):".into(), - " @crew_carol".into(), format!(" {} · you · host", alice()), format!(" {}", bob()), + " @crew_carol".into(), "Teams (1):".into(), " Crew QA Lab · 3 members · created by you".into(), "Channels (2):".into(), @@ -2532,6 +3615,91 @@ mod tests { assert_no_machine_ids(&text); } + /// M17, F6, SF-F10: lists read in one order everywhere, not in the order of random IDs: + /// channels by team with each `#general` first, teams by name, and people with the host + /// first, then you, then by the name their row leads with. + #[test] + fn channels_teams_and_people_are_listed_in_a_readers_order() { + let team = |id: &str, name: &str, general: &str| json!({"id": id, "name": name, "general_channel_id": general, "members": []}); + let place = |id: &str, team: &str, name: &str| json!({"id": id, "team_id": team, "name": name, "classification": "restricted"}); + let teams = vec![ + team("t-b", "bench crew", "c-4"), + team("t-a", "Chen Lab", "c-6"), + ]; + let mut channels = vec![ + place("c-1", "t-a", "msg-qa"), + place("c-2", "t-a", "random"), + place("c-3", "t-a", "methods"), + place("c-4", "t-b", "general"), + place("c-5", "t-b", "scratch"), + place("c-6", "t-a", "general"), + place("c-7", "t-gone", "orphan"), + ]; + channels_in_order(&mut channels, &teams); + let order: Vec<&str> = channels + .iter() + .map(|channel| channel["id"].as_str().unwrap()) + .collect(); + assert_eq!(order, ["c-4", "c-5", "c-6", "c-3", "c-1", "c-2", "c-7"]); + + let mut sorted_teams = teams.clone(); + teams_in_order(&mut sorted_teams); + assert_eq!(sorted_teams[0]["id"], "t-b"); + + let mut people = vec![ + json!({"id": "p-jack", "username": "crew_jack", "display_name": "Jack Moreno"}), + json!({"id": "p-mal", "username": "crew_mallory"}), + json!({"id": "p-alice", "username": "crew_alice", "display_name": "Alice Chen"}), + json!({"id": "p-carol", "username": "crew_carol", "display_name": "carol nguyen"}), + json!({"id": "p-bob", "username": "crew_bob", "display_name": "Bob Lee"}), + ]; + people_in_order(&mut people, Some("p-alice"), Some("p-mal")); + let order: Vec<&str> = people + .iter() + .map(|person| person["id"].as_str().unwrap()) + .collect(); + assert_eq!(order, ["p-alice", "p-mal", "p-bob", "p-carol", "p-jack"]); + } + + /// The broker lists as many teams and channels as fit in one answer (`totals` counts them + /// all), so a list that leaves some out says so, and one that does not says nothing more. + #[test] + fn a_partial_list_says_how_many_it_shows() { + assert_eq!( + partial_list_note("channels", 100, Some(140)).as_deref(), + Some("Showing 100 of your 140 channels.") + ); + assert_eq!(partial_list_note("channels", 3, Some(3)), None); + assert_eq!(partial_list_note("channels", 3, None), None); + let mut snapshot = alice_snapshot(); + snapshot["totals"] = + json!({"teams": 1, "channels": 5, "invitations": 1, "runs": 1, "references": 1}); + let text = plain(&snapshot); + assert!( + text.contains("Channels (2):\n #general · Crew QA Lab · Restricted · 3 members · you own it · 2 unread\n #methods"), + "{text}" + ); + assert!(text.contains("\n Showing 2 of your 5 channels."), "{text}"); + assert!(!text.contains("of your 1 teams"), "{text}"); + } + + /// M18 with W2-BRK-6: a person the broker reports online says so; a broker that reports no + /// presence shows none. + #[test] + fn members_the_broker_reports_online_say_so() { + let mut snapshot = alice_snapshot(); + snapshot["online_principal_ids"] = json!([BOB]); + let people = render_text( + &snapshot["principals"], + &options(false, Directory::from_snapshot(&snapshot)).with_view(View::Members), + ); + assert_eq!( + people, + format!("@crew_carol\n{} · you · host\n{} · online", alice(), bob()) + ); + assert!(!plain(&alice_snapshot()["principals"]).contains("online")); + } + #[test] fn enriched_snapshot_names_the_workspace_host_former_members_and_invitations() { let text = plain(&bob_snapshot()); @@ -2662,6 +3830,82 @@ mod tests { assert_eq!(plain(&json!({"connections":[]})), "No saved connections."); } + /// SF-F1: every surface gives one answer to effective privacy, Private when the connection + /// or the workspace is, and says the connection's own setting as that. + #[test] + fn a_public_connection_in_a_private_workspace_reads_private_everywhere() { + let mut public = connection(); + public["name"] = json!("okafor-lab"); + public["mode"] = json!("public"); + public["institution_id"] = json!("stanford"); + let mut annotated = public.clone(); + annotated["effective_mode"] = json!("private"); + annotated["workspace_mode"] = json!("private"); + annotated["workspace_name"] = json!("okafor-lab"); + assert_eq!( + plain(&json!({"connections": [annotated.clone()]})), + "okafor-lab · crew_bob@34.217.178.174 · Connected · Private because okafor-lab is Private for everyone · your connection: Public" + ); + let show = plain(&annotated); + assert!( + show.contains(" Privacy: Private (okafor-lab is Private for everyone)\n Your connection: Public · institution stanford · policy epoch 2"), + "{show}" + ); + // Connected but unanswered, it says it can't be checked right now. + let mut unanswered = public.clone(); + unanswered["effective_mode"] = Value::Null; + assert!(plain(&unanswered).contains(" Privacy: can't be checked right now\n")); + // Offline, the workspace's setting can't be read, and it says so. + let mut offline = public.clone(); + offline["status"] = json!("disconnected"); + offline["effective_mode"] = Value::Null; + assert!(plain(&json!({"connections": [offline.clone()]})).ends_with( + "Disconnected · Your connection: Public · workspace privacy unknown while disconnected" + )); + assert!(plain(&offline) + .contains(" Privacy: can't be checked while disconnected\n Your connection: Public")); + // A Private connection is Private whatever the workspace allows, and reads as before. + let mut private = connection(); + private["effective_mode"] = json!("private"); + assert_eq!( + plain(&json!({"connections": [private]})), + "Bob UCSF · crew_bob@34.217.178.174 · Connected · Private (ucsf)" + ); + // privacy show leads with the effective privacy. + let mut shown = privacy(); + shown["personal_mode"] = json!("public"); + shown["workspace"]["name"] = json!("okafor-lab"); + assert!(plain(&shown).starts_with( + "Privacy: Private (okafor-lab is Private for everyone)\nYour connection: Public" + )); + assert_eq!( + effective_mode(Some("public"), Some("public")), + Some("public") + ); + assert_eq!(effective_mode(Some("public"), None), None); + assert_eq!(effective_mode(Some("private"), None), Some("private")); + } + + /// A last error the daemon typed with a connect code leads with what to do; an untyped + /// one, or one typed with a code of its own (`crew_membership_ended`), reads as it is. + #[test] + fn a_typed_last_error_says_what_to_do_first() { + let mut failed = connection(); + failed["status"] = json!("disconnected"); + failed["last_error"] = json!("Crew SSH failure [ssh_eof]: ssh exited"); + failed["last_error_code"] = json!("crew_ssh_unreachable"); + let rows = plain(&json!({"connections": [failed.clone()]})); + assert!( + rows.ends_with(" Last error: Couldn't reach the server. Check your network or your VPN, then connect again.\n Details: Crew SSH failure [ssh_eof]: ssh exited"), + "{rows}" + ); + failed["last_error"] = json!("This computer is no longer a member of lab."); + failed["last_error_code"] = json!("crew_membership_ended"); + assert!( + plain(&failed).contains(" Last error: This computer is no longer a member of lab.") + ); + } + #[test] fn tasks_grants_and_privacy_name_their_channels() { let snapshot = alice_snapshot(); @@ -2692,6 +3936,7 @@ mod tests { assert_eq!( named(&privacy(), &snapshot), [ + "Privacy: Private", "Your connection: Private · institution ucsf · policy epoch 2", "Workspace: Private for everyone · institution ucsf · policy epoch 4", "Channels (1):", @@ -2739,6 +3984,19 @@ mod tests { json!({"kind": "task", "expired": true, "revocation": "confirmed", "remote_revocation_confirmed": true}), format!("Task 20260924_3 {base} · Ended · policy epoch 4"), ), + // AGT2-N3: a task the person stopped reads Stopped, as its card in the channel does. + ( + json!({"kind": "task", "expired": true, "revocation": "confirmed", "remote_revocation_confirmed": true, "task_status": "cancelled"}), + format!("Task 20260924_3 {base} · Stopped · policy epoch 4"), + ), + ( + json!({"kind": "task", "expired": true, "revocation": "confirmed", "remote_revocation_confirmed": true, "task_status": "completed"}), + format!("Task 20260924_3 {base} · Ended · policy epoch 4"), + ), + ( + json!({"kind": "task", "expires_at": NOW - 60, "task_status": "cancelled"}), + format!("Task 20260924_3 {base} · Stopped · policy epoch 4"), + ), ( json!({"kind": "task", "expires_at": NOW - 60}), format!("Task 20260924_3 {base} · Ended · policy epoch 4"), @@ -2840,6 +4098,67 @@ mod tests { assert_eq!(plain(&json!({"transfers":[]})), "No file transfers."); } + /// CLIDOCS-F10: a paused transfer is a state with its reason in the desktop's words and + /// this terminal's resume command, never an "Error:" with desktop wording. A failed one + /// keeps its error. + #[test] + fn a_paused_transfer_says_why_and_how_to_resume() { + let snapshot = alice_snapshot(); + let mut paused = transfers()["transfers"][0].clone(); + paused["state"] = json!("needs_file_selection"); + paused["error"] = + json!("Transfer paused. Reselect the original local file or destination to resume."); + let text = named(&paused, &snapshot); + assert_eq!( + text, + [ + "counts.csv · upload to #methods · Paused · 55 B", + " You paused it.", + &format!( + " Resume with the original file: biorouter crew files resume {TRANSFER} FILE" + ), + ] + .join("\n") + ); + assert!(!text.contains("Error"), "{text}"); + assert!(!text.contains("Reselect"), "{text}"); + // In a list, the ID is there only with --show-ids, as every other ID is. + let listed = named(&json!({"transfers": [paused.clone()]}), &snapshot); + assert!( + listed.contains(" Resume with the original file: biorouter crew files resume ID FILE"), + "{listed}" + ); + assert_no_machine_ids(&listed); + // A download resumes with its destination; a reason Crew doesn't know is said as is. + let mut download = transfers()["transfers"][1].clone(); + download["state"] = json!("needs_file_selection"); + download["error"] = json!("The workspace server ran out of disk space while saving this change, so it may not have been saved."); + let text = named(&download, &snapshot); + assert!(text.contains("\n The workspace server ran out of disk space while saving this change, so it may not have been saved.\n"), "{text}"); + assert!( + text.contains(" Resume with the same destination: biorouter crew files resume"), + "{text}" + ); + for (error, reason) in [ + ("Authenticate and reconnect the saved connection in Crew, then reselect the original local file or destination and resume.", "The connection dropped."), + ("Unlock the Crew credential vault for this daemon session, then reselect the original local file or destination and resume.", "The credential vault is locked."), + ("Two transfers are active; reselect and resume when one finishes", "Two other transfers were running."), + ("Transfer stopped. Reselect the original local file or destination to resume. Inspect any unconfirmed publication before retrying.", "It stopped."), + ] { + assert_eq!(pause_reason(error).as_deref(), Some(reason)); + } + assert_eq!(pause_reason(" "), None); + let mut failed = transfers()["transfers"][0].clone(); + failed["state"] = json!("failed"); + failed["error"] = json!("You're not in that channel."); + let text = named(&failed, &snapshot); + assert!( + text.ends_with("\n Error: You're not in that channel."), + "{text}" + ); + assert!(!text.contains("Resume"), "{text}"); + } + #[test] fn every_fixture_prints_no_machine_ids_by_default_and_ids_with_show_ids() { let snapshot = alice_snapshot(); @@ -2874,18 +4193,29 @@ mod tests { let hostile = json!([{"id":"p1","username":"mallory", "nickname":"Bob \"Lee\" (@bob)\\\u{202e}gnp.exe\u{2069}\n"}]); let text = plain(&hostile); + // Every control in the name is printed as escape text, so what is left is ASCII: it + // holds no right-to-left text to isolate, and no raw control survives. assert_eq!( text, - "\"\u{2068}Bob \\\"Lee\\\" (@bob)\\\\\\u{202e}gnp.exe\\u{2069}\u{2069}\" (@mallory)" + "\"Bob \\\"Lee\\\" (@bob)\\\\\\u{202e}gnp.exe\\u{2069}\" (@mallory)" ); - // One isolate pair, opened and closed by the formatter: the name's own U+2069 was - // escaped, so it cannot close the isolate early. - assert_eq!(text.matches('\u{2068}').count(), 1); - assert_eq!(text.matches('\u{2069}').count(), 1); - assert!(!text.contains('\u{202e}')); + assert!(!text.contains(['\u{202e}', '\u{2068}', '\u{2069}'])); assert!(text.ends_with("\" (@mallory)")); + // A name with right-to-left text keeps one isolate pair, opened and closed by the + // formatter: the name's own U+2069 is escaped, so it cannot close the isolate early. + let mixed = + plain(&json!([{"id":"p3","username":"eve","nickname":"דנה\u{2069}\u{202e} (@bob)"}])); + assert_eq!( + mixed, + "\"\u{2068}דנה\\u{2069}\\u{202e} (@bob)\u{2069}\" (@eve)" + ); + assert_eq!(mixed.matches('\u{2068}').count(), 1); + assert_eq!(mixed.matches('\u{2069}').count(), 1); let rtl = plain(&json!([{"id":"p2","username":"dana","nickname":"דנה"}])); assert_eq!(rtl, "\"\u{2068}דנה\u{2069}\" (@dana)"); + // F10: a plain Latin name is printed bare, so a copy of it matches "Alice Chen". + let latin = plain(&json!([{"id":"p4","username":"alice","nickname":"Alice Chen"}])); + assert_eq!(latin, "\"Alice Chen\" (@alice)"); let team = plain( &json!([{"id":"t","name":"מעבדה","created_by":"p","members":[],"general_channel_id":"g"}]), ); @@ -2949,6 +4279,30 @@ mod tests { assert!(nameless.starts_with("Former member · 01:50"), "{nameless}"); } + /// R-8, AG-F17: offline, a row says the names could not be read and shows the ID, the one + /// thing that tells two rows apart, instead of pretending with "this channel". + #[test] + fn rows_say_when_names_are_unavailable_and_show_the_id() { + let offline = options(false, Directory::names_unavailable("okafor-lab", true)); + let grant_rows = render_text(&grants(), &offline); + assert_eq!( + grant_rows, + format!("Chat 20260924_3 → a channel in okafor-lab (names unavailable while disconnected) [channel ID {METHODS}] (also reads a channel in okafor-lab (names unavailable while disconnected) [channel ID {GENERAL}]) · Active · policy epoch 4") + ); + let transfer_rows = render_text(&transfers(), &offline); + assert!( + transfer_rows.starts_with(&format!("counts.csv · upload to a channel in okafor-lab (names unavailable while disconnected) [channel ID {METHODS}] · Uploading 40%")), + "{transfer_rows}" + ); + assert!(!transfer_rows.contains("this channel"), "{transfer_rows}"); + // For another reason, it says only that the names are unavailable. + let unreadable = options(false, Directory::names_unavailable("lab", false)); + assert!(render_text(&grants(), &unreadable) + .contains("a channel in lab (names unavailable) [channel ID")); + // A value that simply does not name a channel reads as it did. + assert!(plain(&task()).starts_with("Task posting to this channel")); + } + #[test] fn message_bodies_keep_their_lines_and_escape_terminal_controls() { let body = "first\n indented\u{1b}[31m red\n\u{202e}flipped"; @@ -3031,6 +4385,16 @@ mod tests { json!({"backend":"file","initialized":true,"locked":false}), "Credential vault: Unlocked · file", ), + // Normal on a desktop, where the OS keyring keeps the keys. + ( + json!({"backend":"keyring","initialized":false,"locked":false,"available":true}), + "Credential vault: Not set up · keyring", + ), + // SETUPHPC2-F-C: a headless node's keyring cannot keep a key. + ( + json!({"backend":"keyring","initialized":false,"locked":false,"available":false}), + "Credential vault: Not set up · no keyring on this computer; run biorouter crew credentials init", + ), ( json!({"detached":true,"transfer_id":TRANSFER}), "Stopped watching. The transfer continues.", @@ -3050,12 +4414,31 @@ mod tests { fn deliverable_keys_and_tokens_are_printed_but_their_ids_are_not() { let prepared = json!({"preparation_id":CONNECTION,"public_key":KEY,"device_id":DEVICE}); let text = plain(&prepared); + // SF-F3: one command serves a host and a joiner, and nothing in it says which, so the + // key and the preparation ID are described for both, each with its own next command. assert_eq!( text, - format!("Device key prepared. Give this public key to the workspace host:\n {KEY}\nAdd --show-ids for the preparation ID that connections save takes.") + format!( + "Device key prepared. Its public key:\n {KEY}\n\ + To host a workspace, pass this key to biorouter-crew start --bootstrap-key on the server. \ + To join one, send it to the workspace's host.\n\ + Add --show-ids for the preparation ID, which goes to connections join-invitation \ + --preparation-id when you host, or into the descriptor for connections save when you join." + ) ); assert!(!text.contains(CONNECTION) && !text.contains(DEVICE)); - assert!(with_ids(&prepared, &json!({})).contains(CONNECTION)); + let shown = with_ids(&prepared, &json!({})); + assert_eq!( + shown, + format!( + "Device key prepared. Its public key:\n {KEY}\n\ + To host a workspace, pass this key to biorouter-crew start --bootstrap-key on the server. \ + To join one, send it to the workspace's host.\n \ + Preparation ID: {CONNECTION}\n Device ID: {DEVICE}\n\ + The preparation ID goes to connections join-invitation --preparation-id when you \ + host, or into the descriptor for connections save when you join." + ) + ); let token = "4f".repeat(32); let invite = json!({"invitation":token,"expires_at":NOW + 3600,"uid":10002,"device_id":DEVICE}); @@ -3194,11 +4577,27 @@ mod tests { "quota_exceeded: workspace logical state exceeds 16 MiB; reads remain available but further mutations require a new workspace or a supported retention upgrade; in-place pruning is not supported", STORAGE_FULL, ), + // An older broker's operation quota; the current one no longer writes it. ( "quota_exceeded", "quota_exceeded: workspace operation quota requires maintenance", STORAGE_FULL, ), + ( + "quota_exceeded", + "quota_exceeded: workspace logical state is full; reads remain available and the host can still remove members and change policy, but further changes require a new workspace; in-place pruning of history is not supported", + FULL_BUT_HOST_CAN_ADMINISTER, + ), + ( + "quota_exceeded", + "quota_exceeded: retained audit journal is nearly full; reads remain available and the host can still remove members and change policy; preserve the complete store and use a new workspace", + FULL_BUT_HOST_CAN_ADMINISTER, + ), + ( + "quota_exceeded", + "quota_exceeded: You have used your share of this workspace's storage. Reading still works; ask the workspace host about starting a new workspace.", + "You have used your share of this workspace's storage. Reading still works; ask the workspace host about starting a new workspace.", + ), ( "identity_conflict", "identity_conflict: another active member is @j.doe; remove the old @j.doe first", @@ -3247,7 +4646,7 @@ mod tests { ( "already_member", "already_member: @bob is already a member. Choose Add device to add another computer for them.", - "@bob is already a member. Choose Add device to add another computer for them.\nTo add another computer for them, run: biorouter crew enroll invite @bob --add-device", + "@bob is already a member.\nTo add another computer for them, run: biorouter crew enroll invite @bob --add-device", ), ( "code_mismatch", @@ -3268,7 +4667,61 @@ mod tests { ( "forbidden", "forbidden: channel unavailable", - "That channel isn't available to you. It may be archived, or you may not be in it.", + "You're not in that channel.", + ), + // DW-11, M20, FILES-F9: the role, archive and provenance texts in words. With nothing + // known about the request, each names what it can and stays true. + ( + "forbidden", + "forbidden: workspace host device required", + "Only the workspace host can do this.", + ), + ( + "forbidden", + "forbidden: current owner required", + "Only the channel's owner can do this.", + ), + ( + "forbidden", + "forbidden: team creator required", + "Only the team's creator can do this.", + ), + ( + "forbidden", + "forbidden: team owner required", + "Only the team's owner can do this.", + ), + ( + "channel_archived", + "channel_archived: channel is read-only", + "That channel is archived, so it's read-only.", + ), + ( + "forbidden", + "forbidden: attachment provenance cannot be dropped", + "That file was shared in another channel. Share it there, or upload it again here.", + ), + ( + "forbidden", + "forbidden: reference provenance cannot be dropped", + "That remote reference was shared in another channel. Share it there, or add it again here.", + ), + // R-2: never "restart and recover" to someone who cannot restart. An older broker's + // technical text is said plainly; a current broker's own sentence is kept. + ( + "storage_failed", + "storage_failed: restart and recover before further mutations", + "The workspace server can't save changes right now. Ask the host to restart Crew.", + ), + ( + "storage_full", + "storage_full: The workspace server is out of disk space, so this change was not saved. Reading still works. Ask the host to free space on the server and restart Crew.", + "The workspace server is out of disk space, so this change was not saved. Reading still works. Ask the host to free space on the server and restart Crew.", + ), + ( + "storage_failed", + "storage_failed: The workspace server could not confirm this change was saved to disk, so it may not have been saved. Reading still works. Ask the host to check the server's storage and restart Crew.", + "The workspace server could not confirm this change was saved to disk, so it may not have been saved. Reading still works. Ask the host to check the server's storage and restart Crew.", ), ( "unauthorized", @@ -3294,23 +4747,162 @@ mod tests { /// A reworded text that the broker never writes makes a check that can never fire, and a /// fixture row for it passes all the same. The storage-full rows are therefore read back /// against the broker's source, where each must appear exactly as written. + /// + /// The one exception is [`LEGACY_OPERATION_QUOTA`], which only an older broker writes and + /// which must therefore be absent from the current one. #[test] fn each_storage_full_fixture_is_the_brokers_literal_text() { const BROKER: &str = include_str!("../../../../biorouter-crew/src/broker.rs"); - let storage_full: Vec<&str> = BROKER_REFUSALS + let legacy = format!("quota_exceeded: {LEGACY_OPERATION_QUOTA}"); + let full: Vec<&str> = BROKER_REFUSALS .iter() - .filter(|(_, _, shown)| *shown == STORAGE_FULL) + .filter(|(_, _, shown)| { + *shown == STORAGE_FULL || *shown == FULL_BUT_HOST_CAN_ADMINISTER + }) .map(|(_, broker, _)| *broker) + .filter(|broker| *broker != legacy) .collect(); - // The journal limit at startup and in `commit`, the state-size limit and the operation - // quota. - assert_eq!(storage_full.len(), 4, "{storage_full:#?}"); - for text in storage_full { + // The journal limit at startup and in `commit`, the state-size limit, and the two + // limits that leave the host room to administer. + assert_eq!(full.len(), 5, "{full:#?}"); + for text in full { assert!( BROKER.contains(&format!("\"{text}\"")), "{text} is not a literal in broker.rs" ); } + assert!( + !BROKER.contains(&format!("\"{legacy}\"")), + "the broker writes {legacy} again: it is no longer legacy" + ); + assert_eq!(broker_refusal_text("quota_exceeded", &legacy), STORAGE_FULL); + } + + /// The broker's storage sentences the CLI keeps, and the words it reads an unknown outcome + /// from, are the broker's own: if they drift, this says so. + #[test] + fn each_storage_sentence_fixture_is_the_brokers_literal_text() { + const BROKER: &str = include_str!("../../../../biorouter-crew/src/broker.rs"); + let current: Vec<&str> = BROKER_REFUSALS + .iter() + .filter(|(code, broker, _)| { + matches!(*code, "storage_full" | "storage_failed") && !broker.contains("recover") + }) + .map(|(_, broker, _)| *broker) + .collect(); + assert_eq!(current.len(), 2, "{current:#?}"); + for text in current { + assert!( + BROKER.contains(&format!("\"{text}\"")), + "{text} is not a literal in broker.rs" + ); + } + assert!( + BROKER.contains("so it may not have been saved."), + "the broker no longer says an outcome is unknown this way" + ); + } + + /// A reworded text the broker never writes is a check that can never fire, so the role, + /// archive and provenance fixtures are read back against the broker's source. + #[test] + fn each_role_archive_and_provenance_fixture_is_the_brokers_literal_text() { + const BROKER: &str = include_str!("../../../../biorouter-crew/src/broker.rs"); + for text in [ + "forbidden: workspace host device required", + "forbidden: current owner required", + "forbidden: team creator required", + "forbidden: team owner required", + "channel_archived: channel is read-only", + "forbidden: attachment provenance cannot be dropped", + "forbidden: reference provenance cannot be dropped", + ] { + assert!( + BROKER.contains(&format!("\"{text}\"")), + "{text} is not a literal in broker.rs" + ); + assert!( + BROKER_REFUSALS.iter().any(|(_, broker, _)| *broker == text), + "{text} has no fixture row" + ); + } + } + + /// DW-11, M20, R-2, FILES-F9: what the command knows about the refusal is named in it. + #[test] + fn a_refusal_names_the_channel_host_and_server_the_command_knows() { + let place = RefusalPlace { + channel: Some("#methods".into()), + shared_in: Some("#raw-data".into()), + host: Some(false), + host_label: Some("\"Alice Chen\" (@alice)".into()), + server: Some("hpc.ucsf.edu".into()), + }; + let said = |code: &str, text: &str, place: &RefusalPlace| { + broker_refusal_text_in(code, text, place) + }; + assert_eq!( + said("forbidden", "forbidden: current owner required", &place), + "Only #methods's owner can do this." + ); + assert_eq!( + said( + "channel_archived", + "channel_archived: channel is read-only", + &place + ), + "#methods is archived, so it's read-only." + ); + assert_eq!( + said( + "forbidden", + "forbidden: attachment provenance cannot be dropped", + &place + ), + "That file was shared in #raw-data. Share it there, or upload it again here." + ); + assert_eq!( + said("storage_failed", "storage_failed: restart and recover before further mutations", &place), + "The workspace server can't save changes right now. Ask \"Alice Chen\" (@alice) to restart Crew." + ); + const FULL: &str = "storage_full: The workspace server is out of disk space, so this change was not saved. Reading still works. Ask the host to free space on the server and restart Crew."; + assert_eq!( + said("storage_full", FULL, &place), + "The workspace server is out of disk space, so this change was not saved. Reading still works. Ask \"Alice Chen\" (@alice) to free space on the server and restart Crew." + ); + let host = RefusalPlace { + host: Some(true), + ..place.clone() + }; + let text = said("storage_full", FULL, &host); + assert_eq!( + text, + "The workspace server is out of disk space, so this change was not saved. Reading still works. Ask the host to free space on the server and restart Crew.\nYou host this workspace. Free space on hpc.ucsf.edu, then restart Crew there: biorouter-crew stop, then biorouter-crew start, each with this workspace's --state-dir." + ); + let old = said( + "storage_failed", + "storage_failed: restart and recover before further mutations", + &host, + ); + assert_eq!( + old, + "The workspace server can't save changes right now.\nYou host this workspace. Free space on hpc.ucsf.edu if it is full, then restart Crew there: biorouter-crew stop, then biorouter-crew start, each with this workspace's --state-dir." + ); + assert!(!old.to_ascii_lowercase().contains("recover"), "{old}"); + assert!(storage_outcome_unknown( + "storage_full", + "storage_full: The workspace server ran out of disk space while saving this change, so it may not have been saved." + )); + assert!(!storage_outcome_unknown("storage_full", FULL)); + assert!(!storage_outcome_unknown( + "forbidden", + "may not have been saved" + )); + // Only the texts it rewords take the place: another refusal is unchanged. + assert_eq!( + said("forbidden", "forbidden: channel unavailable", &place), + "You're not in that channel." + ); } #[test] @@ -3345,6 +4937,9 @@ mod tests { DEVICE_CONFLICT, IDENTITY_MISMATCH, STORAGE_FULL, + STORAGE_FULL_FOR_HOST, + FULL_BUT_HOST_CAN_ADMINISTER, + FULL_BUT_HOST_CAN_ADMINISTER_FOR_HOST, TOO_MANY_ATTEMPTS, IDENTITY_CONFLICT_UNNAMED, ] { @@ -3406,15 +5001,38 @@ mod tests { "workspace": "foreign-lab", "workspace_institution": "stanford", }); + // SF-F4: institutions are named as the desktop names them, by the registry's name for + // an ID it publishes (ucsf is UCSF) and by the ID otherwise, and the advice names the + // workspace's institution rather than "it". assert_eq!( institution_refusal_text("gpt-5.5-2026-04-24", Some(&details)), - "gpt-5.5-2026-04-24 is approved for ucsf. foreign-lab uses stanford. Choose a model approved for it, or a local model." + "gpt-5.5-2026-04-24 is approved for UCSF. foreign-lab uses stanford. Choose a model approved for stanford, or a local model." ); let unstated = json!({"model": "private-model", "approved_for": null, "workspace": "lab", "workspace_institution": "ucsf"}); assert_eq!( institution_refusal_text("private-model", Some(&unstated)), - "private-model doesn't say which institution approved it. lab uses ucsf. Choose a model approved for it, or a local model." + "private-model doesn't say which institution approved it. lab uses UCSF. Choose a model approved for UCSF, or a local model." ); + assert_eq!( + institution_label("stanford-synthetic"), + "stanford-synthetic" + ); + assert_eq!( + institution_label("UCSF"), + "UCSF", + "not an ID: said as it came" + ); + assert_eq!(institution_label("ucsf-west"), "ucsf-west"); + // The desktop's own sentences (`agentCopy` in pane/copy.ts), read where both are visible. + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../ui/desktop/src/components/crew/pane/copy.ts"); + let copy = std::fs::read_to_string(&path).expect("the desktop copy deck"); + for template in [ + "`${model} is approved for ${affiliation}. ${workspace} uses ${institution}. Choose a model approved for ${institution}, or a local model.`", + "`${model} doesn\u{2019}t say which institution approved it. ${workspace} uses ${institution}. Choose a model approved for ${institution}, or a local model.`", + ] { + assert!(copy.contains(template), "{template} is not in {}", path.display()); + } // An older daemon sends no details: the model the person asked for, and nothing made up. assert_eq!( institution_refusal_text("gpt-5.5", None), diff --git a/crates/biorouter-cli/src/commands/knowledge.rs b/crates/biorouter-cli/src/commands/knowledge.rs index 274c41328..1d4dbb196 100644 --- a/crates/biorouter-cli/src/commands/knowledge.rs +++ b/crates/biorouter-cli/src/commands/knowledge.rs @@ -98,12 +98,19 @@ async fn build_completer( )); } let config = Config::global(); + let configured = config.get_biorouter_provider().ok(); let provider = provider - .or_else(|| config.get_biorouter_provider().ok()) + .or_else(|| configured.clone()) .ok_or_else(|| anyhow!("No provider configured. Run `biorouter configure` first."))?; - let model = model - .or_else(|| config.get_biorouter_model().ok()) - .ok_or_else(|| anyhow!("No model configured. Run `biorouter configure` first."))?; + // PROV-F10: the configured model belongs to the configured provider, so `--provider X` + // without `--model` runs X's own default model, as `biorouter run` does. + let model = crate::session::model_for_run( + Some(&provider), + model, + &[(configured.as_deref(), config.get_biorouter_model().ok())], + ) + .await + .ok_or_else(|| anyhow!("No model configured. Run `biorouter configure` first."))?; let model_config = ModelConfig::new(&model)?; let provider = biorouter::providers::create(&provider, model_config).await?; diff --git a/crates/biorouter-cli/src/commands/models.rs b/crates/biorouter-cli/src/commands/models.rs index 3c4723927..84a3d32c0 100644 --- a/crates/biorouter-cli/src/commands/models.rs +++ b/crates/biorouter-cli/src/commands/models.rs @@ -414,21 +414,71 @@ pub async fn handle_models_local_rm(model: String) -> Result<()> { Ok(()) } +/// How `models local pull` reports (PROV-F11): a spinner on a terminal, and plain lines +/// anywhere else. The spinner draws on stderr and draws nothing at all when stderr is not a +/// terminal, so a script, CI job or `pull 2>log` heard nothing even on success. Plain, the +/// start goes to stderr and the result to stdout, where a script reads results; a failure is +/// the error `main` prints, once. +struct PullReport { + spinner: Option, + out: Out, + err: Err, +} + +impl PullReport { + fn start(model: &str) -> Self { + use std::io::IsTerminal; + let spinner = std::io::stderr().is_terminal().then(cliclack::spinner); + Self::started(spinner, std::io::stdout(), std::io::stderr(), model) + } +} + +impl PullReport { + fn started( + spinner: Option, + out: Out, + mut err: Err, + model: &str, + ) -> Self { + let line = format!("Downloading {model} (first run can take a while)…"); + match &spinner { + Some(spinner) => spinner.start(line), + None => { + let _ = writeln!(err, "{line}"); + } + } + Self { spinner, out, err } + } + + fn done(mut self, model: &str) { + let line = format!("{model} is downloaded and cached."); + match self.spinner.take() { + Some(spinner) => spinner.stop(style(line).green()), + None => { + let _ = writeln!(self.out, "{line}"); + } + } + } + + fn failed(mut self, line: String) { + if let Some(spinner) = self.spinner.take() { + spinner.stop(style(line).red()); + } + let _ = self.err.flush(); + } +} + pub async fn handle_models_local_pull(model: String) -> Result<()> { use std::time::Duration; let source = resolve_model_source(&model) .map_err(|e| anyhow::anyhow!("Unknown local model '{}': {}", model, e))?; - let spin = cliclack::spinner(); - spin.start(format!( - "Downloading {} (first run can take a while)…", - model - )); + let report = PullReport::start(&model); let sidecar = llamacpp_sidecar::global(); if let Err(e) = sidecar.ensure(&model, &source).await { - spin.stop(style(format!("Could not start Llama Server: {e}")).red()); + report.failed(format!("Could not start Llama Server: {e}")); return Err(anyhow::anyhow!(e)); } @@ -443,12 +493,43 @@ pub async fn handle_models_local_pull(model: String) -> Result<()> { match ready { Ok(_) => { - spin.stop(style(format!("{} is downloaded and cached", model)).green()); + report.done(&model); Ok(()) } Err(e) => { - spin.stop(style(format!("{model} did not become ready: {e}")).red()); + report.failed(format!("{model} did not become ready: {e}")); Err(anyhow::anyhow!(e)) } } } + +#[cfg(test)] +mod pull_report_tests { + use super::PullReport; + + /// PROV-F11: off a terminal, `pull` says it started on stderr and that it finished on + /// stdout, where a spinner drew nothing at all. + #[test] + fn a_pull_off_a_terminal_reports_in_plain_lines() { + let (mut out, mut err) = (Vec::new(), Vec::new()); + PullReport::started(None, &mut out, &mut err, "gemma4-e2b").done("gemma4-e2b"); + assert_eq!( + String::from_utf8(err).unwrap(), + "Downloading gemma4-e2b (first run can take a while)…\n" + ); + assert_eq!( + String::from_utf8(out).unwrap(), + "gemma4-e2b is downloaded and cached.\n" + ); + + // A failure prints nothing more here: the error `main` prints says it, once. + let (mut out, mut err) = (Vec::new(), Vec::new()); + PullReport::started(None, &mut out, &mut err, "gemma4-e2b") + .failed("gemma4-e2b did not become ready: timed out".into()); + assert!(out.is_empty()); + assert_eq!( + String::from_utf8(err).unwrap(), + "Downloading gemma4-e2b (first run can take a while)…\n" + ); + } +} diff --git a/crates/biorouter-cli/src/commands/serve.rs b/crates/biorouter-cli/src/commands/serve.rs index a4de9409a..4fe4f1225 100644 --- a/crates/biorouter-cli/src/commands/serve.rs +++ b/crates/biorouter-cli/src/commands/serve.rs @@ -53,6 +53,7 @@ //! token, so the documented service deployment printed an address the operator //! could not know and refused the one they had published. +use crate::commands::apps::{launch_pages_dir, open_launch_link, open_page_in_browser}; use crate::commands::exe_path::{biorouterd_for, current_exe_resolved, daemon_file_name}; use anyhow::{bail, Context, Result}; use std::net::{TcpListener, ToSocketAddrs}; @@ -116,6 +117,7 @@ pub async fn handle_serve( std::env::var("BIOROUTER_BROWSER_TOKEN").ok(), || random_hex(32), ); + token_flag_warning(&browser_token).inspect(|note| eprintln!("{note}")); let secret_key = random_hex(32); // Fail on an occupied port here, with a clear message, rather than letting @@ -184,6 +186,9 @@ pub async fn handle_serve( .spawn() .with_context(|| format!("could not start {}", daemon.display()))?; + // The page `--open` hands the browser, removed however this ends: it holds + // the address, token and all. + let mut launch_page: Option = None; let outcome: Result<()> = async { if let Some(digest) = computer_use_digest { use tokio::io::AsyncWriteExt; @@ -206,7 +211,7 @@ pub async fn handle_serve( eprintln!(" Biorouter Copilot controls this backend host's desktop, not a remote browser's device."); } if open_browser { - let _ = webbrowser::open(&url); + launch_page = open_in_browser(&url); } tokio::select! { @@ -224,9 +229,74 @@ pub async fn handle_serve( .await; stop_daemon(&mut child, &mut stop).await; + if let Some(page) = launch_page { + let _ = std::fs::remove_file(page); + } outcome } +/// How long the page `--open` hands the browser is kept. A browser reads it the +/// moment it opens it, and the page holds the address, token and all, so it is +/// removed once any browser has had ample time, or when `serve` stops. +const LAUNCH_PAGE_KEPT: Duration = Duration::from_secs(120); + +/// Open `url` in the default browser without putting it on a command line, and +/// answer the page that does it, for the caller to remove. +/// +/// ⚠ `url` carries the browser token. It works until the daemon stops (SD-9), +/// and the cookie it is exchanged for gets the shell, the daemon's secret with +/// it, and on this daemon every app's browser surface too. An opener's +/// arguments are readable by every account on the machine: `ps` on macOS, and +/// `/proc//cmdline` on Linux for as long as a browser that was not already +/// running stays open. So the browser is handed a page only this account can +/// read, which sends it on (`apps::write_launch_page`, +/// `apps::open_page_in_browser`), and the daemon answers the token with a page +/// of its own origin so the cookie survives a navigation a `file:` page started +/// (`routes::web_ui` in `biorouter-server`). The address is on the banner, for +/// a browser that cannot read the page. +/// +/// Runs inside the runtime: the page is removed after [`LAUNCH_PAGE_KEPT`] by a +/// task of its own, and by the caller when `serve` stops, whichever is first. +fn open_in_browser(url: &str) -> Option { + let page = open_in_browser_with(url, &launch_pages_dir(), open_page_in_browser)?; + let removed_later = page.clone(); + tokio::spawn(async move { + tokio::time::sleep(LAUNCH_PAGE_KEPT).await; + let _ = std::fs::remove_file(removed_later); + }); + Some(page) +} + +/// [`open_in_browser`] with the folder and the opener passed in, for a test. +fn open_in_browser_with( + url: &str, + directory: &Path, + opener: impl FnOnce(&Path) -> std::io::Result<()>, +) -> Option { + match open_launch_link(url, directory, opener) { + Ok(page) => Some(page), + Err(error) => { + eprintln!(" {error}. Open the address above instead.\n"); + None + } + } +} + +/// The line that says where a `--token` value can be read from, or nothing for +/// a token that did not come from the flag. +/// +/// The daemon gets the token in its environment, which only this account can +/// read. The flag itself stays on this command's own command line for as long +/// as it serves, readable by every account on the machine, and the token opens +/// the interface for anyone until the daemon stops. +fn token_flag_warning(token: &BrowserToken) -> Option<&'static str> { + matches!(token, BrowserToken::Flag(_)).then_some( + " Note: --token is on this command's command line, where other accounts on this \ + machine can read it (ps). Set BIOROUTER_BROWSER_TOKEN instead: an environment is \ + readable by its own account alone.\n", + ) +} + /// What the operator reads once the daemon is answering. fn print_banner( url: &str, @@ -782,6 +852,95 @@ mod tests { assert_eq!(browser_url("[::1]", 8765, None), "http://[::1]:8765/"); } + /// `--open` hands the opener a page only this account can read, never the + /// address: the address carries a token that works until the daemon stops, + /// and an opener's arguments are readable by every account on the machine. + #[test] + fn open_hands_the_opener_a_private_page_and_never_the_token() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("app-launch"); + let token = "c0ffee".repeat(10); + let url = browser_url("127.0.0.1", 8765, Some(&token)); + let mut handed = Vec::new(); + let page = open_in_browser_with(&url, &dir, |page| { + handed.push(page.to_path_buf()); + Ok(()) + }) + .expect("the page was written and handed over"); + + assert_eq!(handed, vec![page.clone()]); + let argument = page.to_string_lossy(); + assert!(!argument.contains(&token), "{argument}"); + assert!(!argument.contains("?t="), "{argument}"); + let html = std::fs::read_to_string(&page).unwrap(); + assert!(html.contains(&format!("content=\"0;url={url}\"")), "{html}"); + assert!( + html.contains(""), + "{html}" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&page), 0o600, "the page is this account's alone"); + assert_eq!(mode(&dir), 0o700, "and so is its folder"); + } + } + + /// No browser: nothing is kept, and the address on the banner is the way in. + #[test] + fn a_failed_open_leaves_no_page_behind() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("app-launch"); + let url = browser_url("127.0.0.1", 8765, Some("tok")); + let page = open_in_browser_with(&url, &dir, |_| Err(std::io::Error::other("no display"))); + assert_eq!(page, None); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + } + + /// A source guard for the property above: this file names no opener of its + /// own, and opens the browser only through the launch page `apps` shares. + #[test] + fn nothing_but_a_launch_page_reaches_an_opener() { + let source = include_str!("serve.rs") + .split("\n#[cfg(test)]\nmod tests {") + .next() + .unwrap(); + for opener in [ + "webbrowser::", + "open::that", + "open::with", + "open::commands", + "Command::new(\"open\")", + "Command::new(\"xdg-open\")", + ] { + assert!( + !source.contains(opener), + "{opener}: an opener that is handed the URL puts the token on a command line" + ); + } + // The one opener is the one `apps` uses, and it is handed a page. + assert!( + source.contains("open_in_browser_with(url, &launch_pages_dir(), open_page_in_browser)"), + "serve must open the browser through a launch page" + ); + } + + /// `--token` leaves the token on this command's command line for as long + /// as it serves; the variable does not, and a minted token is never there. + #[test] + fn only_a_token_from_the_flag_is_flagged_as_readable_by_others() { + let note = token_flag_warning(&BrowserToken::Flag("t".into())).unwrap(); + assert!(note.contains("BIOROUTER_BROWSER_TOKEN"), "{note}"); + for quiet in [ + BrowserToken::Off, + BrowserToken::Variable("t".into()), + BrowserToken::Minted("t".into()), + ] { + assert_eq!(token_flag_warning(&quiet), None, "{quiet:?}"); + } + } + #[test] fn a_token_is_long_and_different_every_time() { let a = random_hex(32); diff --git a/crates/biorouter-cli/src/commands/session.rs b/crates/biorouter-cli/src/commands/session.rs index 5cfa93997..f5b3e9a39 100644 --- a/crates/biorouter-cli/src/commands/session.rs +++ b/crates/biorouter-cli/src/commands/session.rs @@ -533,13 +533,21 @@ async fn print_grouped_sessions( /// /// `Ok(false)` means "refused, and the reason has been printed" — the caller /// returns without writing anything. +/// +/// A chat a Crew grant restricts is an `Err` holding +/// [`CrewContextRefusal`](biorouter::session::session_manager::CrewContextRefusal) +/// instead, whose text is the plain sentence the desktop shows for the same +/// chat. An error rather than a printed refusal because nothing here can +/// satisfy it (no model, no prompt), and a script that redirected stdout to a +/// file must see a failure, not a file holding the refusal and a zero exit. async fn authorize_export_at_terminal( session_manager: &SessionManager, session_id: &str, ) -> Result { use biorouter::privacy::system_auth::AuthOutcome; use biorouter::session::session_manager::{ - authenticate_export, ExportDecision, EXPORT_NOT_PROTECTED, + authenticate_export, CrewContextRefusal, ExportDecision, CREW_EXPORT_REFUSAL, + EXPORT_NOT_PROTECTED, }; let capability = crate::session::privacy::caller_capability().await; @@ -559,6 +567,9 @@ async fn authorize_export_at_terminal( .await? { ExportDecision::Unrestricted | ExportDecision::Authorized => return Ok(true), + ExportDecision::CrewContext => { + return Err(CrewContextRefusal(CREW_EXPORT_REFUSAL).into()) + } ExportDecision::SessionNotFound => { return Err(anyhow::anyhow!("Session '{session_id}' not found.")) } @@ -1117,6 +1128,66 @@ pub async fn prompt_interactive_session_selection( } } +/// Save a Crew registry in which a grant that stands restricts each of +/// `session_ids`, for a test running in a process of its own. +/// +/// ⚠ Call it before anything in the process opens the Crew registry: it is +/// read from disk once per process, so a registry written after that first read +/// restricts nothing, and the test relying on it fails. +#[cfg(test)] +pub(crate) fn restrict_by_crew_grant(session_ids: &[&str]) { + let root = biorouter::config::paths::Paths::config_dir().join("crew"); + std::fs::create_dir_all(&root).unwrap(); + let registry = root.join("connections.json"); + assert!( + !registry.exists(), + "a Crew registry was saved before the fixture" + ); + let scopes: serde_json::Map = session_ids + .iter() + .map(|id| { + ( + id.to_string(), + serde_json::json!({ + "connection_id": "conn-cli-fixture", + "run_id": format!("run-{id}"), + "channel_id": "chan-cohort", + "source_channels": ["chan-cohort"], + "epoch": 1, + "provider_binding": "openai", + "public_provider": true, + "institution_policy": false, + "expired": false, + "expires_at": 4_000_000_000_u64, + }), + ) + }) + .collect(); + let saved = serde_json::json!({ + "connections": [{ + "id": "conn-cli-fixture", + "name": "Cohort lab", + "ssh_target": "crew@crew.invalid", + "port": null, + "identity_file": null, + "proxy_jump": null, + "socket_path": "/tmp/crew-cli-fixture.sock", + "owner_uid": 501, + "workspace_id": "workspace-cohort", + "workspace_public_key": "00".repeat(32), + "cluster_connection_id": "cluster-cohort", + "mode": "public", + "policy_epoch": 1, + "status": "disconnected", + "last_error": null, + "device_id": "11".repeat(32), + "public_key": "22".repeat(32), + }], + "scopes": scopes, + }); + std::fs::write(®istry, serde_json::to_vec(&saved).unwrap()).unwrap(); +} + #[cfg(test)] mod tests { use super::*; @@ -1484,6 +1555,68 @@ mod tests { ); } + /// `biorouter session export` refuses a chat a Crew grant restricts, with + /// the plain sentence the desktop shows for the same chat, and writes + /// nothing. It asked only the privacy tier, so a Crew chat on a public + /// workspace and model came out whole, hidden channel context included, + /// with no prompt and no record. A chat no grant restricts still exports, + /// so the refusal is Crew's and not a refusal of every export. + /// + /// Driven for real, in a process of its own: there the + /// `SessionManager::instance()` singleton the handler is bound to lives in + /// that process's own sandbox, and the Crew registry, read once per + /// process, is the one this test saves. + #[tokio::test] + async fn session_export_refuses_a_crew_chat_and_writes_nothing() { + use biorouter::session::session_manager::{CrewContextRefusal, CREW_EXPORT_REFUSAL}; + + if !crate::test_sandbox::in_a_process_of_its_own() { + return; + } + let sm = SessionManager::instance(); + let mut chats = Vec::new(); + for (name, text) in [ + ( + "Crew task", + "cohort-7 enrolment notes Summarize them.", + ), + ("Plain chat", "What changed this week?"), + ] { + let chat = sm + .create_session(std::env::temp_dir(), name.to_string(), SessionType::User) + .await + .unwrap(); + sm.add_message(&chat.id, &Message::user().with_text(text)) + .await + .unwrap(); + chats.push(chat.id); + } + restrict_by_crew_grant(&[&chats[0]]); + let out = TempDir::new().unwrap(); + + for format in ["json", "yaml", "markdown"] { + let file = out.path().join(format!("crew.{format}")); + let error = handle_session_export(chats[0].clone(), Some(file.clone()), format.into()) + .await + .expect_err("a Crew chat must not be exported from the terminal"); + assert_eq!( + error.downcast_ref::(), + Some(&CrewContextRefusal(CREW_EXPORT_REFUSAL)), + "{format}: refused for the wrong reason: {error:#}" + ); + assert_eq!(error.to_string(), CREW_EXPORT_REFUSAL); + assert!(!file.exists(), "{format}: the refused export wrote a file"); + } + + let file = out.path().join("plain.json"); + handle_session_export(chats[1].clone(), Some(file.clone()), "json".into()) + .await + .expect("a chat no grant restricts exports as before"); + assert!(std::fs::read_to_string(&file) + .unwrap() + .contains("What changed this week?")); + } + /// The subagent listing shows a child that produced NOTHING. /// /// The regression is in SQL (`INNER JOIN messages`) and this listing is the diff --git a/crates/biorouter-cli/src/commands/shared_conversation.rs b/crates/biorouter-cli/src/commands/shared_conversation.rs index 2336d1e63..64d7a626f 100644 --- a/crates/biorouter-cli/src/commands/shared_conversation.rs +++ b/crates/biorouter-cli/src/commands/shared_conversation.rs @@ -54,6 +54,112 @@ struct Conversation { renderer: TextRenderer, continuation_owner_id: String, continuation_lease: Option>, + /// What the chat's binding said when it was shown, so a turn's `PrivacyProviderPinned` + /// that agrees with it says nothing. + binding: ShownBinding, + /// "Chat … is ready", held until the turn that follows it reaches the provider (AGT2-N4): + /// a start the daemon refuses must not be preceded by it. + pending_ready: Option, +} + +/// The provider, model and privacy a chat was shown with. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct ShownBinding { + provider: Option, + model: Option, + private: bool, +} + +impl ShownBinding { + fn of(session: &Value) -> Self { + Self { + provider: session["provider_name"].as_str().map(str::to_owned), + model: session["model_config"]["model_name"] + .as_str() + .map(str::to_owned), + private: session["privacy_tier"].as_str() == Some("private"), + } + } +} + +/// The daemon's turn error for a turn whose reply stream never opened: nothing reached the +/// provider, and nothing was submitted to it. +const START_FAILED: &str = "inference_start_failed"; + +/// A turn the daemon refused to start before anything reached the provider (AGT2-N4), such as a +/// Crew chat whose access was removed. Its own sentence is the whole error: "remains available; +/// no automatic resubmission was attempted" is about a turn that ran, and this one never did. +#[derive(Debug)] +struct StartRefused { + sentence: String, +} + +impl std::fmt::Display for StartRefused { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.sentence) + } +} + +impl std::error::Error for StartRefused {} + +/// A turn's `Error` frame as the error the command ends with: a start refused before the turn +/// reached the provider is its sentence alone ([`StartRefused`]); any other keeps its code. +fn turn_error(frame: &Value, reached_provider: bool) -> anyhow::Error { + let code = frame["code"].as_str().unwrap_or("daemon_error"); + let error = frame["error"].as_str().unwrap_or("Daemon turn failed"); + if code == START_FAILED && !reached_provider { + return StartRefused { + sentence: safe_text(error), + } + .into(); + } + anyhow::anyhow!("{}: {}", safe_text(code), safe_text(error)) +} + +/// What a turn's frame means for the deferred "Chat … is ready" (AGT2-N4). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Ready { + /// The turn reached the provider: the chat is ready, and is said to be. + Show, + /// The turn ended before it reached the provider: it never was. + Drop, + /// Nothing yet says either. + Wait, +} + +/// [`Ready`] for `frame`, given whether an earlier frame already showed the turn reached the +/// provider (`reached`). A model's frames (a message, the provider pin sent on every turn that +/// reaches one, a model change) and a turn that finished show it; an error or a stop before any +/// of them means the start was refused. `TurnStarted` shows nothing: the daemon sends it before +/// any work that can fail. +fn ready_on(frame: &Value, reached: bool) -> Ready { + match frame["type"].as_str() { + Some("Message" | "PrivacyProviderPinned" | "ModelChange") => Ready::Show, + Some("Finish") + if !matches!( + frame["reason"].as_str(), + Some("cancelled" | "canceled" | "stopped" | "orphaned" | "error" | "failed") + ) => + { + Ready::Show + } + Some("Error" | "Finish") if !reached => Ready::Drop, + _ => Ready::Wait, + } +} + +/// The run's error as it is printed: a start the daemon refused is its own sentence, and any +/// other failure says the session remains and that nothing was resubmitted. +fn with_session_context(result: Result<()>, session_id: &str) -> Result<()> { + match result { + Err(error) if error.chain().any(|cause| cause.is::()) => Err(error), + result => result.with_context(|| { + format!( + "Daemon session {} remains available; no automatic resubmission was attempted", + safe_text(session_id) + ) + }), + } } enum TurnEnd { Finished, @@ -80,6 +186,8 @@ pub async fn run(options: SharedConversationOptions) -> Result<()> { renderer: TextRenderer::default(), continuation_owner_id: uuid::Uuid::new_v4().to_string(), continuation_lease: None, + binding: ShownBinding::default(), + pending_ready: None, }; conversation.event(&json!({"type":"Session","session_id":conversation.session_id}))?; let result = conversation.execute(&options).await; @@ -92,16 +200,15 @@ pub async fn run(options: SharedConversationOptions) -> Result<()> { conversation.renderer.finish()?; if let Err(error) = &result { if format != Format::Text { - emit_json(&json!({"type":"Error","session_id":conversation.session_id, - "error":format!("{error:#}"),"resubmit_automatically":false}))?; + let mut frame = json!({"type":"Error","session_id":conversation.session_id, + "error":format!("{error:#}"),"resubmit_automatically":false}); + if error.chain().any(|cause| cause.is::()) { + frame["code"] = json!(START_FAILED); + } + emit_json(&frame)?; } } - result.with_context(|| { - format!( - "Daemon session {} remains available; no automatic resubmission was attempted", - safe_text(&conversation.session_id) - ) - }) + with_session_context(result, &conversation.session_id) } fn validate_options(options: &SharedConversationOptions) -> Result { @@ -326,7 +433,10 @@ impl Conversation { && state["session"]["model_config"]["model_name"].as_str() == Some(model.as_str()), "The daemon did not confirm the requested provider/model; no prompt was submitted. Inspect this session before continuing"); } - self.show_binding(&state["session"])?; + // AGT2-N4: when a turn follows at once, "is ready" waits until it reaches the + // provider, so a start the daemon refuses is never preceded by it. + let turn_follows = !options.create_only && (options.prompt.is_some() || active.is_some()); + self.show_binding(&state["session"], turn_follows)?; if options.history { self.show_history(&state["session"])?; } @@ -417,6 +527,8 @@ impl Conversation { let mut active_id = submitted_id; let mut sequence = None; let mut bound = false; + // Whether the turn reached the provider: a model's frame arrived. + let mut reached = false; loop { let frame = tokio::select! { frame = stream.next_event() => frame?, @@ -440,12 +552,20 @@ impl Conversation { sequence = Some(seq); } self.event(&frame)?; + match ready_on(&frame, reached) { + Ready::Show => { + reached = true; + self.show_ready()?; + } + Ready::Drop => self.pending_ready = None, + Ready::Wait => {} + } match frame["type"].as_str().context("Daemon event has no type")? { "Message" => match self.handle_message(&frame["message"]).await? { Interaction::Continue => {} Interaction::Stop => return self.cancel(&active_id).await, }, - "Error" => bail!("{}: {}", safe_text(frame["code"].as_str().unwrap_or("daemon_error")), safe_text(frame["error"].as_str().unwrap_or("Daemon turn failed"))), + "Error" => return Err(turn_error(&frame, reached)), "Finish" => { self.renderer.finish()?; return match frame["reason"].as_str() { @@ -454,7 +574,12 @@ impl Conversation { }; } "UpdateConversation" => self.notice("The daemon resynchronized the conversation; use history to read the authoritative transcript.")?, - "PrivacyProviderPinned" | "ModelChange" => self.notice(&frame.to_string())?, + // AGT2-N4, CLIDOCS-F12: sentences in text, never the raw frame. + "PrivacyProviderPinned" | "ModelChange" => { + if let Some(notice) = self.binding_change(&frame) { + self.notice(¬ice)?; + } + } "Ping" | "TurnStarted" | "TurnState" | "Notification" | "ToolCallPending" | "ToolCallsRetracted" | "SteerWaiting" | "MessagesPersisted" => {} kind => bail!("Unsupported daemon event {}; use a compatible client to resume this exact session without new input", safe_text(kind)), @@ -705,16 +830,37 @@ impl Conversation { stderr.flush()?; Ok(()) } - fn show_binding(&self, session: &Value) -> Result<()> { + /// The chat's binding: the `SessionBinding` event, and on stderr "Chat … is ready" (or the + /// binding itself in the JSON formats). With `defer`, the notice waits for + /// [`Self::show_ready`], once the turn that follows reaches the provider. + fn show_binding(&mut self, session: &Value, defer: bool) -> Result<()> { let binding = json!({"type":"SessionBinding","session_id":self.session_id, "provider":session["provider_name"],"model":session["model_config"], "privacy_tier":session["privacy_tier"]}); self.event(&binding)?; + self.binding = ShownBinding::of(session); if !self.quiet { - self.notice(&binding.to_string())?; + let notice = binding_notice(self.format, &self.session_id, session, &binding); + if defer { + self.pending_ready = Some(notice); + } else { + self.notice(¬ice)?; + } } Ok(()) } + + /// The deferred binding notice, once, now that the turn reached the provider. + fn show_ready(&mut self) -> Result<()> { + match self.pending_ready.take() { + Some(notice) => self.notice(¬ice), + None => Ok(()), + } + } + + fn binding_change(&mut self, frame: &Value) -> Option { + binding_change(&mut self.binding, self.format, frame) + } fn show_history(&self, session: &Value) -> Result<()> { if self.format == Format::StreamJson { return self.event(&json!({"type":"History","session_id":self.session_id,"conversation":session["conversation"]})); @@ -748,6 +894,41 @@ impl Conversation { } } +/// What a turn's `PrivacyProviderPinned` or `ModelChange` frame says for a person (AGT2-N4), +/// given what the chat was shown with (`shown`, which it updates). The daemon sends the pin on +/// every turn that reaches a provider, so it is said only when it differs from what was shown: +/// the chat became private, or runs on another model. The JSON formats keep the frame as it is. +fn binding_change(shown: &mut ShownBinding, format: Format, frame: &Value) -> Option { + if format != Format::Text { + return Some(frame.to_string()); + } + let model = frame["model"].as_str().filter(|model| !model.is_empty())?; + if frame["type"] == "ModelChange" { + let mode = frame["mode"].as_str().filter(|mode| !mode.is_empty()); + return Some(match mode { + Some(mode) => format!("Model changed to {model} in {mode} mode."), + None => format!("Model changed to {model}."), + }); + } + let pinned = ShownBinding { + provider: frame["provider"].as_str().map(str::to_owned), + model: Some(model.to_owned()), + private: frame["privacy_tier"].as_str() == Some("private"), + }; + let before = std::mem::replace(shown, pinned.clone()); + let on = match &pinned.provider { + Some(provider) => format!("{provider}/{model}"), + None => model.to_owned(), + }; + if pinned.private && !before.private { + Some(format!("This chat is private now and stays on {on}.")) + } else if (pinned.provider != before.provider) || (pinned.model != before.model) { + Some(format!("This chat now uses {on}.")) + } else { + None + } +} + #[derive(Default)] struct TextRenderer { message_id: Option, @@ -975,12 +1156,155 @@ async fn terminal_input( Ok(None) } +/// What the conversation says on stderr once it knows its chat (AG-F7): in text, a sentence +/// naming the chat, its provider and model; in JSON formats, the binding itself, as before. +/// Text mode printed the raw `SessionBinding` JSON. +fn binding_notice(format: Format, session_id: &str, session: &Value, binding: &Value) -> String { + if format != Format::Text { + return binding.to_string(); + } + let provider = session["provider_name"] + .as_str() + .filter(|provider| !provider.is_empty()); + let model = session["model_config"]["model_name"] + .as_str() + .filter(|model| !model.is_empty()); + match (provider, model) { + (Some(provider), Some(model)) => { + format!("Chat {session_id} is ready ({provider}/{model}).") + } + (Some(provider), None) => format!("Chat {session_id} is ready ({provider})."), + _ => format!("Chat {session_id} is ready."), + } +} + #[cfg(test)] mod tests { use super::{ - json_terminal_safe, safe_text, sensitive_schema, terminal_control, validate_id, - validate_options, validate_prompt, Format, SharedConversationOptions, + binding_change, binding_notice, json_terminal_safe, ready_on, safe_text, sensitive_schema, + terminal_control, turn_error, validate_id, validate_options, validate_prompt, + with_session_context, Format, Ready, SharedConversationOptions, ShownBinding, StartRefused, }; + + /// AGT2-N4: "Chat … is ready" waits for the turn that follows it to reach the provider, + /// and is dropped when the daemon refuses the start first; `TurnStarted`, sent before any + /// work that can fail, says neither. + #[test] + fn the_ready_line_waits_for_the_turn_to_reach_the_provider() { + let frame = |kind: &str| serde_json::json!({"type": kind}); + assert_eq!(ready_on(&frame("TurnStarted"), false), Ready::Wait); + assert_eq!(ready_on(&frame("Ping"), false), Ready::Wait); + for kind in ["Message", "PrivacyProviderPinned", "ModelChange"] { + assert_eq!(ready_on(&frame(kind), false), Ready::Show, "{kind}"); + } + let refused = serde_json::json!({"type": "Error", "code": "inference_start_failed"}); + assert_eq!(ready_on(&refused, false), Ready::Drop); + assert_eq!(ready_on(&refused, true), Ready::Wait, "already shown"); + let finished = serde_json::json!({"type": "Finish", "reason": "stop"}); + assert_eq!(ready_on(&finished, false), Ready::Show); + let stopped = serde_json::json!({"type": "Finish", "reason": "cancelled"}); + assert_eq!(ready_on(&stopped, false), Ready::Drop); + } + + /// AGT2-N4, CLIDOCS-F12: a pin that agrees with what the chat was shown with says nothing + /// (the daemon sends one on every turn); one that makes the chat private, or moves it, + /// says so in a sentence. Text never gets the raw frame; the JSON formats keep it. + #[test] + fn a_privacy_pin_is_a_sentence_only_when_it_changes_something() { + let frame = serde_json::json!({"type": "PrivacyProviderPinned", "provider": "versa_azure", + "model": "gpt-5.5-2026-04-24", "privacy_tier": "private", "privacy_reason": "mcp:crew", + "seq": 2, "turn_id": "turn-3"}); + let mut shown = ShownBinding { + provider: Some("versa_azure".into()), + model: Some("gpt-5.5-2026-04-24".into()), + private: false, + }; + assert_eq!( + binding_change(&mut shown, Format::Text, &frame).as_deref(), + Some("This chat is private now and stays on versa_azure/gpt-5.5-2026-04-24.") + ); + // The next turn's pin agrees with what was said. + assert_eq!(binding_change(&mut shown, Format::Text, &frame), None); + let moved = serde_json::json!({"type": "PrivacyProviderPinned", "provider": "versa_azure", + "model": "gpt-5.5-mini", "privacy_tier": "private"}); + assert_eq!( + binding_change(&mut shown, Format::Text, &moved).as_deref(), + Some("This chat now uses versa_azure/gpt-5.5-mini.") + ); + for text in [ + binding_change(&mut ShownBinding::default(), Format::Text, &frame), + binding_change(&mut shown, Format::Text, &moved), + ] + .into_iter() + .flatten() + { + assert!(!text.contains('{'), "{text}"); + } + let change = + serde_json::json!({"type": "ModelChange", "model": "gpt-5.5-mini", "mode": "worker"}); + assert_eq!( + binding_change(&mut shown, Format::Text, &change).as_deref(), + Some("Model changed to gpt-5.5-mini in worker mode.") + ); + for format in [Format::Json, Format::StreamJson] { + assert_eq!( + binding_change(&mut shown, format, &frame), + Some(frame.to_string()) + ); + } + } + + /// AGT2-N4: a start the daemon refused before anything reached the provider is its own + /// sentence, without the code or the "remains available" wrapper; a turn that failed after + /// it reached the provider keeps both. + #[test] + fn a_refused_start_is_the_daemons_sentence_alone() { + const REMOVED: &str = + "This chat's Crew access was removed. Grant access again to use Crew from this chat."; + let frame = serde_json::json!({"type": "Error", "code": "inference_start_failed", "error": REMOVED}); + let refused = turn_error(&frame, false); + assert!(refused.is::()); + let shown = with_session_context(Err(refused), "20260928_2").unwrap_err(); + assert_eq!(format!("{shown:#}"), REMOVED); + assert_eq!(format!("{shown:?}"), REMOVED); + + let later = turn_error(&frame, true); + assert!(!later.is::()); + let failed = serde_json::json!({"type": "Error", "code": "provider_failure", "error": "Authentication failed."}); + for error in [later, turn_error(&failed, false)] { + let shown = format!( + "{:#}", + with_session_context(Err(error), "20260928_2").unwrap_err() + ); + assert!( + shown.starts_with("Daemon session 20260928_2 remains available; no automatic resubmission was attempted: "), + "{shown}" + ); + } + assert!(with_session_context(Ok(()), "s").is_ok()); + } + + /// AG-F7: text mode says what was created in words; the JSON formats keep the binding. + #[test] + fn the_binding_is_a_sentence_in_text_and_json_otherwise() { + let session = serde_json::json!({"provider_name": "versa_azure", + "model_config": {"model_name": "gpt-5.5"}, "privacy_tier": "private"}); + let binding = serde_json::json!({"type": "SessionBinding", "session_id": "20260927_1"}); + assert_eq!( + binding_notice(Format::Text, "20260927_1", &session, &binding), + "Chat 20260927_1 is ready (versa_azure/gpt-5.5)." + ); + for format in [Format::Json, Format::StreamJson] { + assert_eq!( + binding_notice(format, "20260927_1", &session, &binding), + binding.to_string() + ); + } + assert_eq!( + binding_notice(Format::Text, "s", &serde_json::json!({}), &binding), + "Chat s is ready." + ); + } use std::path::PathBuf; fn options() -> SharedConversationOptions { @@ -1029,6 +1353,8 @@ mod tests { renderer: TextRenderer::default(), continuation_owner_id: "owner-a".into(), continuation_lease: None, + binding: Default::default(), + pending_ready: None, } } diff --git a/crates/biorouter-cli/src/commands/term.rs b/crates/biorouter-cli/src/commands/term.rs index efcbbb086..ade6566d5 100644 --- a/crates/biorouter-cli/src/commands/term.rs +++ b/crates/biorouter-cli/src/commands/term.rs @@ -142,7 +142,7 @@ pub async fn handle_term_init( // every shell that sourced the script. The guard resolves the // configured default itself, which is why no flags are passed — // `term init` accepts none. - crate::cli::refuse_unconfigured_before_creating_a_row(None, None)?; + crate::cli::refuse_unconfigured_before_creating_a_row(None, None).await?; let session = session_manager .create_session( working_dir, diff --git a/crates/biorouter-cli/src/daemon_client.rs b/crates/biorouter-cli/src/daemon_client.rs index 46446392a..72698fbc8 100644 --- a/crates/biorouter-cli/src/daemon_client.rs +++ b/crates/biorouter-cli/src/daemon_client.rs @@ -1,9 +1,11 @@ //! Human-operated clients for the profile's shared daemon. +use crate::commands::needs_terminal; use anyhow::{bail, ensure, Context, Result}; use biorouter::crew::observation::{ObserveEvent, ObserveRequest}; use biorouter::daemon_runtime::{self, Descriptor, Identity}; use serde_json::{json, Value}; use std::io::{IsTerminal, Read}; +#[cfg(unix)] use std::time::Duration; use zeroize::Zeroizing; @@ -23,7 +25,83 @@ pub struct DaemonRefusal { /// The daemon's `institution_refusal` beside its institution refusal: the model, who /// approved it, the workspace and its institution, for the desktop's sentence (Q2-76). pub institution_refusal: Option, + /// The daemon's `connection_institution` beside an institution refusal about this + /// computer's connection rather than the model (admission's "This connection is for …, + /// but … belongs to …."). That refusal carries the model's details too, so this is what + /// tells the two apart; it is never printed. + pub connection_institution: Option, message: String, + /// The daemon's `detail`: OpenSSH's own words beside a failed connect, each line made + /// terminal-safe on its own so the lines stay lines. + detail: Option, + /// `actual_mode` and `expected_mode` beside a privacy-mode refusal (`crew_mode_mismatch`): + /// the connection's mode and the one the request required. + modes: Option<(String, String)>, + /// The refusal's other machine-readable fields, as the daemon sent them ([`REFUSAL_FIELDS`]), + /// bounded: what a script reads beside `code`, such as `actual_mode`, `institution_refusal`, + /// `reason` or `connection_id` (AGT2-N5). Never printed as text. + fields: serde_json::Map, +} + +/// The optional fields of the daemon's `CrewError` a JSON failure carries through, beside the +/// `error`, `code`, `broker_code` and `detail` the CLI writes itself ([`CLI_WRITTEN_FIELDS`]). +/// The daemon's `request_id` is left out: the CLI's own is the one to retry with. A test in +/// `commands::crew` reads `CrewError` from `ui/desktop/openapi.json` and fails when a field is +/// in neither list, so a field the daemon adds is never silently dropped from JSON output. +pub const REFUSAL_FIELDS: &[&str] = &[ + "host", + "connection_id", + "reason", + "missing", + "ssh_code", + "workspace", + "actual_mode", + "expected_mode", + "institution_refusal", + "connection", + "connection_institution", + "institution", + "workspace_institution", + "institutions", + "kind", + "text", + "candidates", + "session_id", + "run_id", + "stopped_on_this_device", + "remote_revocation_confirmed", + "task_status", + "task_status_error", +]; + +/// The `CrewError` fields a JSON failure writes from the CLI's own reading rather than copying +/// ([`REFUSAL_FIELDS`]): the sentence, the code, the CLI's request ID, and the broker's code and +/// OpenSSH's detail, which are checked before they are kept. +pub const CLI_WRITTEN_FIELDS: &[&str] = &["error", "code", "request_id", "broker_code", "detail"]; + +/// A daemon value kept for JSON output, bounded so a malformed answer cannot grow it: strings +/// to 4096 characters, lists to 64 items, objects to 32 fields, three levels deep. +#[cfg(any(unix, test))] +fn bounded_field(value: &Value, depth: usize) -> Value { + match value { + Value::String(text) => Value::String(text.chars().take(4096).collect()), + Value::Array(items) if depth > 0 => Value::Array( + items + .iter() + .take(64) + .map(|item| bounded_field(item, depth - 1)) + .collect(), + ), + Value::Object(fields) if depth > 0 => Value::Object( + fields + .iter() + .take(32) + .map(|(key, item)| (key.clone(), bounded_field(item, depth - 1))) + .collect(), + ), + Value::Array(_) | Value::Object(_) => Value::Null, + other => other.clone(), + } } impl DaemonRefusal { @@ -32,6 +110,200 @@ impl DaemonRefusal { pub fn message(&self) -> &str { &self.message } + + /// OpenSSH's own words beside a failed connect (`detail`), terminal-safe, when the daemon + /// sent them. + pub fn detail(&self) -> Option<&str> { + self.detail.as_deref() + } + + /// The connection's mode and the mode the request required, beside a privacy-mode refusal. + pub fn modes(&self) -> Option<(&str, &str)> { + self.modes + .as_ref() + .map(|(actual, expected)| (actual.as_str(), expected.as_str())) + } + + /// The refusal's other fields ([`REFUSAL_FIELDS`]) that the daemon sent. + pub fn fields(&self) -> &serde_json::Map { + &self.fields + } + + /// One of [`Self::fields`] as text, such as `reason` or `connection_id`. + pub fn field_text(&self, key: &str) -> Option<&str> { + self.fields.get(key).and_then(Value::as_str) + } + + /// A refusal as the daemon would answer it with `body`, for a test elsewhere in the crate. + /// It builds on every platform, like the parser it calls: the crew tests that use it are + /// not Unix-only, and a Windows test build must still compile them. + #[cfg(test)] + pub fn for_test(status: u16, body: Value) -> Self { + daemon_refusal(status, Some(&body), "Daemon refused the request") + } +} + +/// A refusal said in words for a person, keeping a stable code for JSON output. It is a +/// definite answer: nothing was changed, so no retry is offered. +#[derive(Debug)] +pub struct Restated { + message: String, + pub code: Option<&'static str>, +} + +impl Restated { + pub fn new(message: impl Into, code: Option<&'static str>) -> Self { + Self { + message: message.into(), + code, + } + } +} + +impl std::fmt::Display for Restated { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for Restated {} + +/// A request the daemon never received (M16): hyper handed it back unsent, because the +/// connection could not take it (it was not ready yet, or it closed first). Nothing reached the +/// daemon, so running the command again is always safe, and no retry ID is needed. +#[derive(Debug)] +pub struct NotSent { + cause: String, +} + +impl NotSent { + /// What a person reads: the connection, then that nothing was sent. + pub const SENTENCE: &'static str = + "The daemon connection wasn't ready. Nothing was sent; run it again."; + /// The JSON code beside [`Self::SENTENCE`], the daemon's own code for the same outcome. + pub const CODE: &'static str = "crew_not_sent"; + + #[cfg(unix)] + fn new(cause: impl std::fmt::Display) -> Self { + Self { + cause: cause.to_string(), + } + } + + /// One for a test elsewhere in the crate. + #[cfg(test)] + pub fn for_test() -> Self { + Self { + cause: "connection was not ready".to_owned(), + } + } + + /// hyper's own words, for a log or a bug report. + pub fn cause(&self) -> &str { + &self.cause + } +} + +impl std::fmt::Display for NotSent { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(Self::SENTENCE) + } +} + +impl std::error::Error for NotSent {} + +/// Send `request` once `sender`'s connection can take it, and wait up to `timeout` for the +/// answer. +/// +/// Every connection here carries two requests: the identity check, then the request itself. +/// hyper lets the first through at once, but takes the next only after the connection task +/// has finished the previous answer, and that task runs on another thread. Sending without +/// waiting was refused as "connection was not ready" about once in a thousand (M16). A request +/// the connection hands back unsent is [`NotSent`]; one that failed after it was written keeps +/// hyper's error, because its outcome is not known. +#[cfg(unix)] +async fn send_when_ready( + sender: &mut hyper::client::conn::http1::SendRequest, + request: hyper::Request, + timeout: Duration, +) -> Result> +where + B: hyper::body::Body + 'static, +{ + match tokio::time::timeout(Duration::from_secs(5), sender.ready()).await { + Ok(Ok(())) => {} + Ok(Err(error)) => return Err(NotSent::new(error).into()), + Err(_) => return Err(NotSent::new("the connection did not become ready in time").into()), + } + match tokio::time::timeout(timeout, sender.try_send_request(request)).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(mut error)) => { + if error.take_message().is_some() { + Err(NotSent::new(error.into_error()).into()) + } else { + Err(error.into_error().into()) + } + } + Err(elapsed) => Err(elapsed.into()), + } +} + +/// What `daemon status` and `--no-start` say when no daemon is running, whether it never +/// started or it died and left its discovery record behind. +pub const DAEMON_NOT_RUNNING: &str = "No Biorouter daemon is running for this profile."; +/// The JSON code beside [`DAEMON_NOT_RUNNING`]. +pub const DAEMON_NOT_RUNNING_CODE: &str = "crew_daemon_not_running"; +/// An approval secret the running daemon did not accept. +pub const WRONG_APPROVAL_SECRET: &str = "That approval secret doesn't match the running Biorouter daemon. Type the secret you chose when it started."; +/// The JSON code beside [`WRONG_APPROVAL_SECRET`]. +pub const WRONG_APPROVAL_SECRET_CODE: &str = "crew_approval_secret_mismatch"; +/// Crew from a terminal needs the shared daemon's Unix socket. +pub const PLATFORM_UNSUPPORTED: &str = + "Shared Crew daemon IPC is unavailable on this platform. biorouter crew runs on macOS and Linux."; +/// The JSON code beside [`PLATFORM_UNSUPPORTED`]. +pub const PLATFORM_UNSUPPORTED_CODE: &str = "crew_platform_unsupported"; +/// A hidden prompt with no terminal to show it on. +const SECRET_NEEDS_A_TERMINAL: &str = "Biorouter needs a terminal to ask for this secret without showing it. In a script, add --approval-key-stdin and send the secret as the first line of standard input."; +/// The prompt when a command is about to start a new daemon. +const NEW_DAEMON_SECRET_PROMPT: &str = "No Biorouter daemon is running for this profile, so this command starts one. Choose its Crew approval secret (32 to 4096 printable ASCII characters, no spaces):"; +/// The prompt of `daemon start`. +const START_DAEMON_SECRET_PROMPT: &str = "Choose the Crew approval secret for the new Biorouter daemon (32 to 4096 printable ASCII characters, no spaces):"; +const APPROVAL_SECRET_AGAIN: &str = "Type the same approval secret again:"; +const APPROVAL_SECRETS_DIFFER: &str = + "The two approval secrets don't match. No daemon was started."; +const NEW_VAULT_PASSPHRASE: &str = + "New vault passphrase (different from the Crew approval secret):"; +const VAULT_PASSPHRASE: &str = "Vault passphrase (different from the Crew approval secret):"; +const VAULT_PASSPHRASE_AGAIN: &str = "Type the same vault passphrase again:"; +const VAULT_PASSPHRASES_DIFFER: &str = "The two passphrases don't match. The vault was not set up."; + +/// Crew needs the shared daemon's Unix socket; on any other platform every command says so +/// before it asks for anything. +pub fn require_supported_platform(unix: bool) -> Result<()> { + if unix { + Ok(()) + } else { + Err(Restated::new(PLATFORM_UNSUPPORTED, Some(PLATFORM_UNSUPPORTED_CODE)).into()) + } +} + +/// The daemon's answer to a proof it did not accept, in words: the approval secret is wrong. +/// Anything else is left as it is. +fn wrong_approval_secret(error: anyhow::Error) -> anyhow::Error { + let wrong = error + .downcast_ref::() + .is_some_and(|refused| { + refused.status == 403 && refused.kind.as_deref() == Some("crew_user_action_required") + }); + if wrong { + Restated::new(WRONG_APPROVAL_SECRET, Some(WRONG_APPROVAL_SECRET_CODE)).into() + } else { + error + } +} + +fn daemon_not_running() -> anyhow::Error { + Restated::new(DAEMON_NOT_RUNNING, Some(DAEMON_NOT_RUNNING_CODE)).into() } impl std::fmt::Display for DaemonRefusal { @@ -46,7 +318,7 @@ impl std::fmt::Display for DaemonRefusal { impl std::error::Error for DaemonRefusal {} -#[cfg(unix)] +#[cfg(any(unix, test))] fn daemon_refusal(status: u16, value: Option<&Value>, fallback: &str) -> DaemonRefusal { let message = value .and_then(|value| value.get("error").or_else(|| value.get("message"))) @@ -60,8 +332,32 @@ fn daemon_refusal(status: u16, value: Option<&Value>, fallback: &str) -> DaemonR .and_then(broker_code_text) .or_else(|| legacy.as_ref().map(|(code, _)| code.clone())); let message = legacy.as_ref().map_or(message, |(_, text)| text.as_str()); + let detail = value + .and_then(|value| value.get("detail")) + .and_then(Value::as_str) + .map(|detail| terminal_safe_lines(&detail.chars().take(4096).collect::())) + .filter(|detail| !detail.trim().is_empty()); + // A mode is one of two words: anything else is not one and is dropped, not printed. + let mode = |key: &str| { + value + .and_then(|value| value.get(key)) + .and_then(Value::as_str) + .filter(|mode| matches!(*mode, "private" | "public")) + .map(str::to_owned) + }; + let modes = mode("actual_mode").zip(mode("expected_mode")); + let fields = REFUSAL_FIELDS + .iter() + .filter_map(|key| { + let field = value?.get(*key)?; + (!field.is_null()).then(|| ((*key).to_owned(), bounded_field(field, 3))) + }) + .collect(); DaemonRefusal { status, + detail, + modes, + fields, kind: value .and_then(|value| value.get("status").or_else(|| value.get("code"))) .and_then(Value::as_str) @@ -71,17 +367,23 @@ fn daemon_refusal(status: u16, value: Option<&Value>, fallback: &str) -> DaemonR .and_then(|value| value.get("institution_refusal")) .filter(|details| details.is_object()) .cloned(), + connection_institution: value + .and_then(|value| value.get("connection_institution")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|institution| !institution.is_empty()) + .map(|institution| terminal_safe(&institution.chars().take(128).collect::())), message: terminal_safe(&message.chars().take(1024).collect::()), } } /// What an older daemon wrote before the broker's JSON error object. -#[cfg(unix)] +#[cfg(any(unix, test))] const LEGACY_BROKER_REFUSAL: &str = "Crew broker refused request: "; /// A broker code as the broker writes one (`[a-z][a-z0-9_]*`), capped like `kind`. Anything /// else is not a code and is dropped rather than printed. -#[cfg(unix)] +#[cfg(any(unix, test))] fn broker_code_text(code: &str) -> Option { let mut chars = code.chars(); let shaped = chars.next().is_some_and(|first| first.is_ascii_lowercase()) @@ -91,7 +393,7 @@ fn broker_code_text(code: &str) -> Option { /// `Crew broker refused request: {"code":…,"message":…}` from a daemon that predates /// `broker_code`: the code, and the broker's own text (its message, else the code alone). -#[cfg(unix)] +#[cfg(any(unix, test))] fn legacy_broker_refusal(text: &str) -> Option<(String, String)> { let encoded = text.trim().strip_prefix(LEGACY_BROKER_REFUSAL)?; let error = serde_json::from_str::(encoded).ok()?; @@ -110,7 +412,7 @@ fn legacy_broker_refusal(text: &str) -> Option<(String, String)> { /// would act on escaped: control characters and invisible formatting. `escape_debug` used to /// run here and printed `the model\'s` in text and `model\\'s` in JSON, so a script matching /// the daemon's canonical sentence missed it. -#[cfg(unix)] +#[cfg(any(unix, test))] fn terminal_safe(message: &str) -> String { let mut out = String::with_capacity(message.len()); for ch in message.chars() { @@ -123,6 +425,15 @@ fn terminal_safe(message: &str) -> String { out } +/// [`terminal_safe`] line by line: the line breaks stay, and nothing else a terminal acts on. +#[cfg(any(unix, test))] +fn terminal_safe_lines(text: &str) -> String { + text.split('\n') + .map(|line| terminal_safe(line.strip_suffix('\r').unwrap_or(line))) + .collect::>() + .join("\n") +} + impl CrewClient { #[cfg(all(test, unix))] pub(crate) fn for_test(descriptor: Descriptor, proof: &str) -> Self { @@ -140,13 +451,19 @@ impl CrewClient { let descriptor = match discover_shared_daemon().await? { Some(descriptor) => descriptor, None => { - ensure!( - !no_start, - "No live shared daemon is available; run biorouter crew daemon start" - ); - let proof = read_approval_secret( - "Crew approval secret (held separately from your profile):", + if no_start { + return Err(Restated::new( + format!("{DAEMON_NOT_RUNNING} Start one with biorouter crew daemon start, then run this command again."), + Some(DAEMON_NOT_RUNNING_CODE), + ) + .into()); + } + // This secret becomes the new daemon's, for as long as it runs: say so, and + // ask twice on a terminal, so a typo cannot become a secret nobody knows. + let proof = choose_approval_secret( + NEW_DAEMON_SECRET_PROMPT, approval_key_stdin, + read_secret, ) .await?; let descriptor = start_daemon(&proof).await?; @@ -163,7 +480,11 @@ impl CrewClient { ) .await?; let client = Self { descriptor, proof }; - client.request("GET", "/crew/connections", None).await?; + // This request only proves the secret, so a refused proof means a wrong secret. + client + .request("GET", "/crew/connections", None) + .await + .map_err(wrong_approval_secret)?; Ok(client) } @@ -276,9 +597,16 @@ impl CrewClient { request = request.header("Content-Type", "application/json"); } let request = request.body(Full::new(bytes::Bytes::from(bytes)))?; - let response = tokio::time::timeout(Duration::from_secs(180), sender.send_request(request)) + let response = send_when_ready(&mut sender, request, Duration::from_secs(180)) .await - .context("Daemon response timed out; inspect session state before retrying")??; + .map_err(|error| { + if error.is::() { + error + .context("Daemon response timed out; inspect session state before retrying") + } else { + error + } + })?; if !response.status().is_success() { let status = response.status(); let bytes = bounded_response(response, 16 * 1024) @@ -296,14 +624,17 @@ impl CrewClient { Ok((response, connection)) } - pub async fn observe( + /// Follow the daemon's observer at `path`, handing each frame to `on_frame`, which may + /// wait (to look up an attachment's name, say) before the next frame is read. + pub async fn observe( &self, path: &str, body: &ObserveRequest, on_frame: F, ) -> Result> where - F: FnMut(ObserveEvent) -> Result>>, + F: FnMut(ObserveEvent) -> Fut, + Fut: std::future::Future>>>, { #[cfg(not(unix))] { @@ -330,9 +661,7 @@ impl CrewClient { .header("X-Daemon-Instance", &self.descriptor.instance_id) .header("X-User-Action", self.proof.as_str()) .body(Full::new(Bytes::from(serde_json::to_vec(body)?)))?; - let response = - tokio::time::timeout(Duration::from_secs(180), sender.send_request(request)) - .await??; + let response = send_when_ready(&mut sender, request, Duration::from_secs(180)).await?; ensure!( response.status().is_success(), "Crew observer refused ({}); check the connection, daemon and approval secret", @@ -369,10 +698,9 @@ impl CrewClient { }; use futures::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message; - ensure!( - std::io::stdin().is_terminal() && std::io::stdout().is_terminal(), - "SSH authentication needs an interactive terminal for native host-key and MFA prompts" - ); + // SF2-N6: the usage refusal (exit 2, `crew_needs_terminal`), like every other + // command that needs a person at a terminal. + sign_in_possible(std::io::stdin().is_terminal() && std::io::stdout().is_terminal())?; ensure!( connection_id .bytes() @@ -380,7 +708,10 @@ impl CrewClient { "Invalid connection ID" ); let controller = uuid::Uuid::new_v4().to_string(); - let (cols, rows) = terminal::size()?; + // A pty a script opened (`ssh -tt`) can report 0x0, which the daemon refuses. + let (cols, rows) = terminal::size().map_or(DEFAULT_SIGN_IN_SIZE, |(cols, rows)| { + sign_in_terminal_size(cols, rows) + }); let started = self.request("POST", &format!("/crew/connections/{connection_id}/authentication"), Some(json!({"request_id": uuid::Uuid::new_v4().to_string(), "controller_id": controller, "cols": cols, "rows": rows}))).await?; let id = started["authentication_id"] .as_str() @@ -420,7 +751,10 @@ impl CrewClient { _ => (), }, event = input.next() => match event { - Some(Ok(Event::Resize(cols, rows))) => socket.send(Message::Text(json!({"type":"resize","cols":cols,"rows":rows}).to_string().into())).await?, + Some(Ok(Event::Resize(cols, rows))) => { + let (cols, rows) = sign_in_terminal_size(cols, rows); + socket.send(Message::Text(json!({"type":"resize","cols":cols,"rows":rows}).to_string().into())).await?; + } Some(Ok(Event::Key(key))) if key.kind != KeyEventKind::Release => { let data = match key.code { KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) && c.is_ascii() => Some(((c.to_ascii_lowercase() as u8) & 0x1f) as char), @@ -446,6 +780,32 @@ impl CrewClient { } } +/// What `crew auth` says with no terminal to show the server's prompts in (SF2-N6). A server that +/// takes this computer's SSH key alone needs no prompt, so `connect` is named for it. +pub const AUTH_NEEDS_A_TERMINAL: &str = "Signing in needs a terminal, to show the server's host-key and verification-code prompts. Run biorouter crew auth in a terminal. If the server takes this computer's SSH key alone, biorouter crew connect signs in without one."; + +/// `Ok` when `crew auth` has a terminal to sign in at, else the usage refusal (exit 2). +#[cfg(unix)] +fn sign_in_possible(terminal: bool) -> Result<(), needs_terminal::NeedsTerminal> { + needs_terminal::require(terminal, AUTH_NEEDS_A_TERMINAL) +} + +/// The size a sign-in terminal gets when the real one reports none (a pty a script opened can +/// report 0x0): the classic 80 by 24. +#[cfg(unix)] +const DEFAULT_SIGN_IN_SIZE: (u16, u16) = (80, 24); + +/// A terminal size the daemon's sign-in accepts (20 to 500 columns, 5 to 200 rows, +/// `dimensions` in `crew/authentication.rs`): an unknown size (a zero) is 80 by 24, and any +/// other is clamped into range, so a very small or very large window still signs in. +#[cfg(unix)] +fn sign_in_terminal_size(cols: u16, rows: u16) -> (u16, u16) { + if cols == 0 || rows == 0 { + return DEFAULT_SIGN_IN_SIZE; + } + (cols.clamp(20, 500), rows.clamp(5, 200)) +} + // Match the daemon turn replay hard ceiling, with room for SSE field framing. // This is a client refusal bound, not a claim that every server event is capped. #[cfg(unix)] @@ -638,8 +998,7 @@ async fn verified_observer_connection( .header("Host", "localhost") .header("X-Secret-Key", &descriptor.api_secret) .body(Full::new(Bytes::new()))?; - let response = - tokio::time::timeout(Duration::from_secs(5), sender.send_request(request)).await??; + let response = send_when_ready(&mut sender, request, Duration::from_secs(5)).await?; ensure!( response.status().is_success(), "Shared daemon identity was refused" @@ -660,12 +1019,13 @@ async fn verified_observer_connection( } #[cfg(unix)] -async fn read_observer_frames( +async fn read_observer_frames( mut body: hyper::body::Incoming, mut on_frame: F, ) -> Result> where - F: FnMut(ObserveEvent) -> Result>>, + F: FnMut(ObserveEvent) -> Fut, + Fut: std::future::Future>>>, { use http_body_util::BodyExt; const MAX_FRAME: usize = 1024 * 1024; @@ -687,7 +1047,7 @@ where let value = serde_json::from_slice(&pending).context("Invalid Crew observer frame")?; pending.clear(); - if let std::ops::ControlFlow::Break(cursor) = on_frame(value)? { + if let std::ops::ControlFlow::Break(cursor) = on_frame(value).await? { return Ok(cursor); } } @@ -738,11 +1098,7 @@ async fn authenticated_terminal_socket( .header("Host", "localhost") .header("X-Secret-Key", &descriptor.api_secret) .body(Full::new(Bytes::new()))?; - let response = tokio::time::timeout( - Duration::from_secs(5), - sender.send_request(identity_request), - ) - .await??; + let response = send_when_ready(&mut sender, identity_request, Duration::from_secs(5)).await?; ensure!( response.status().is_success(), "Shared daemon identity was refused before terminal authentication" @@ -777,11 +1133,12 @@ async fn authenticated_terminal_socket( .headers_mut() .insert("X-Daemon-Instance", descriptor.instance_id.parse()?); let (parts, _) = upgrade.into_parts(); - let mut response = tokio::time::timeout( + let mut response = send_when_ready( + &mut sender, + Request::from_parts(parts, Full::new(Bytes::new())), Duration::from_secs(15), - sender.send_request(Request::from_parts(parts, Full::new(Bytes::new()))), ) - .await??; + .await?; validate_terminal_upgrade(&response, &expected_accept)?; let upgraded = tokio::time::timeout(Duration::from_secs(5), hyper::upgrade::on(&mut response)).await??; @@ -840,6 +1197,81 @@ async fn read_approval_secret(prompt: &'static str, from_stdin: bool) -> Result< validate_approval_secret(&secret)?; Ok(secret) } + +/// A new secret, chosen now: read once, checked, and on a terminal read again and compared, +/// because a hidden typo would become a secret nobody knows. From stdin the script supplies +/// one line and owns its correctness, as it does for every other line it sends. +async fn new_secret( + prompts: [&'static str; 3], + from_stdin: bool, + validate: fn(&str) -> Result<()>, + mut read: R, +) -> Result> +where + R: FnMut(&'static str, bool) -> F, + F: std::future::Future>>, +{ + let [prompt, again, differ] = prompts; + let secret = read(prompt, from_stdin).await?; + validate(&secret)?; + if !from_stdin { + let repeated = read(again, from_stdin).await?; + ensure!(secret.as_str() == repeated.as_str(), "{differ}"); + } + Ok(secret) +} + +/// The approval secret of a daemon about to start. +async fn choose_approval_secret( + prompt: &'static str, + from_stdin: bool, + read: R, +) -> Result> +where + R: FnMut(&'static str, bool) -> F, + F: std::future::Future>>, +{ + new_secret( + [prompt, APPROVAL_SECRET_AGAIN, APPROVAL_SECRETS_DIFFER], + from_stdin, + validate_approval_secret, + read, + ) + .await +} + +/// The passphrase for `credentials init` (asked twice on a terminal) or `unlock` (once). +async fn vault_passphrase( + action: &str, + from_stdin: bool, + read: R, +) -> Result> +where + R: FnMut(&'static str, bool) -> F, + F: std::future::Future>>, +{ + if action == "init" { + new_secret( + [ + NEW_VAULT_PASSPHRASE, + VAULT_PASSPHRASE_AGAIN, + VAULT_PASSPHRASES_DIFFER, + ], + from_stdin, + |_| Ok(()), + read, + ) + .await + } else { + let mut read = read; + read(VAULT_PASSPHRASE, from_stdin).await + } +} + +/// `Ok` when a hidden prompt can be shown, else the refusal that exits with the usage status. +fn secret_prompt_possible(terminal: bool) -> Result<(), needs_terminal::NeedsTerminal> { + needs_terminal::require(terminal, SECRET_NEEDS_A_TERMINAL) +} fn validate_approval_secret(secret: &str) -> Result<()> { ensure!( (32..=4096).contains(&secret.len()) @@ -856,20 +1288,30 @@ async fn read_secret(prompt: &'static str, from_stdin: bool) -> Result Result { pub async fn daemon_control(action: &str, approval_key_stdin: bool) -> Result { match action { - "status" => { - let descriptor = daemon_runtime::read_descriptor()?; - verify_identity(&descriptor).await?; - Ok(serde_json::to_value(descriptor.identity())?) - } + // A missing discovery record and one a crashed daemon left behind (its socket gone or + // refusing) both mean no daemon is running, in one sentence with one code; anything + // else keeps its own error. + "status" => match discover_shared_daemon().await? { + Some(descriptor) => Ok(serde_json::to_value(descriptor.identity())?), + None => Err(daemon_not_running()), + }, "start" => { - let proof = read_secret( - "Create/use your separately held Crew approval secret (at least 32 bytes):", - approval_key_stdin, - ) - .await?; + ensure!( + discover_shared_daemon().await?.is_none(), + "A Biorouter daemon is already running for this profile, with the approval secret it was started with. Stop it first with biorouter crew daemon stop to choose a new one." + ); + let proof = + choose_approval_secret(START_DAEMON_SECRET_PROMPT, approval_key_stdin, read_secret) + .await?; let descriptor = start_daemon(&proof).await?; Ok(serde_json::to_value(descriptor.identity())?) } @@ -1248,17 +1692,41 @@ pub async fn daemon_control(action: &str, approval_key_stdin: bool) -> Result Result<()> { + let sentence = match status["backend"].as_str() { + Some("encrypted_vault") if status["initialized"].as_bool() != Some(false) => return Ok(()), + Some("file") => NO_VAULT_TO_UNLOCK_FILES, + _ if status["available"].as_bool() == Some(false) => NO_VAULT_NO_KEYRING, + _ => NO_VAULT_TO_UNLOCK, + }; + Err(Restated::new(sentence, Some(NO_VAULT_TO_UNLOCK_CODE)).into()) +} + pub async fn credentials_control(action: &str, approval_key_stdin: bool) -> Result { let client = CrewClient::connect_with_input(true, approval_key_stdin).await?; match action { "status" => client.request("GET", "/crew/credentials", None).await, "lock" => client.request("POST", "/crew/credentials/lock", None).await, "init" | "unlock" => { - let passphrase = read_secret( - "Vault passphrase (different from the Crew approval secret):", - approval_key_stdin, - ) - .await?; + if action == "unlock" { + // CLIDOCS-F6: with no vault there is nothing to unlock, so say so before asking + // for a passphrase that could only be refused. + let status = client.request("GET", "/crew/credentials", None).await?; + no_vault_to_unlock(&status)?; + } + let passphrase = vault_passphrase(action, approval_key_stdin, read_secret).await?; ensure!( passphrase.as_str() != client.proof.as_str(), "The vault passphrase must differ from the human approval secret" @@ -1278,9 +1746,17 @@ pub async fn credentials_control(action: &str, approval_key_stdin: bool) -> Resu #[cfg(all(test, unix))] mod tests { use super::{ - daemon_refusal, open_daemon_owner_lock, read_observer_frames, wait_for_daemon_stop, - CrewClient, DaemonRefusal, EventDecoder, MAX_SSE_FRAME, + choose_approval_secret, daemon_control, daemon_refusal, no_vault_to_unlock, + open_daemon_owner_lock, read_observer_frames, require_supported_platform, + secret_prompt_possible, sign_in_possible, sign_in_terminal_size, vault_passphrase, + wait_for_daemon_stop, wrong_approval_secret, CrewClient, DaemonRefusal, EventDecoder, + Restated, APPROVAL_SECRET_AGAIN, AUTH_NEEDS_A_TERMINAL, DAEMON_NOT_RUNNING, + DAEMON_NOT_RUNNING_CODE, MAX_SSE_FRAME, NEW_DAEMON_SECRET_PROMPT, NEW_VAULT_PASSPHRASE, + NO_VAULT_NO_KEYRING, NO_VAULT_TO_UNLOCK, NO_VAULT_TO_UNLOCK_CODE, NO_VAULT_TO_UNLOCK_FILES, + VAULT_PASSPHRASE, VAULT_PASSPHRASE_AGAIN, WRONG_APPROVAL_SECRET, + WRONG_APPROVAL_SECRET_CODE, }; + use crate::commands::needs_terminal::NeedsTerminal; use biorouter::crew::observation::ObserveEvent; use biorouter::daemon_runtime::{self, Descriptor, Endpoint, Identity}; use bytes::Bytes; @@ -1644,11 +2120,11 @@ mod tests { let cursor = read_observer_frames(body, |frame| { let reconnect = matches!(&frame, ObserveEvent::Reconnect { .. }); frames.push(frame); - if reconnect { + std::future::ready(if reconnect { Ok(std::ops::ControlFlow::Break(Some("cursor-2".into()))) } else { Ok(std::ops::ControlFlow::Continue(())) - } + }) }) .await .expect("split NDJSON frames parse"); @@ -1670,17 +2146,21 @@ mod tests { #[tokio::test] async fn observer_parser_rejects_malformed_and_oversized_framing() { let malformed = observer_body(vec![b"{not-json}\n".to_vec()]).await; - let error = read_observer_frames(malformed, |_| Ok(std::ops::ControlFlow::Continue(()))) - .await - .expect_err("malformed observer JSON must fail closed"); + let error = read_observer_frames(malformed, |_| { + std::future::ready(Ok(std::ops::ControlFlow::Continue(()))) + }) + .await + .expect_err("malformed observer JSON must fail closed"); assert!(error.to_string().contains("Invalid Crew observer frame")); let mut oversized = vec![b'x'; 1_048_577]; oversized.push(b'\n'); let oversized = observer_body(vec![oversized]).await; - let error = read_observer_frames(oversized, |_| Ok(std::ops::ControlFlow::Continue(()))) - .await - .expect_err("oversized observer frame must fail closed"); + let error = read_observer_frames(oversized, |_| { + std::future::ready(Ok(std::ops::ControlFlow::Continue(()))) + }) + .await + .expect_err("oversized observer frame must fail closed"); assert!(error.to_string().contains("exceeds 1 MiB")); } @@ -1857,6 +2337,71 @@ mod tests { "fallback", ); assert_eq!(older.institution_refusal, None, "only an object is kept"); + assert_eq!( + refusal.connection_institution, None, + "the model refusal names none" + ); + } + + /// W2-CLI-7: admission's refusal of a connection set to another institution carries the + /// model's details as well, and `connection_institution` is what marks it as being about + /// the connection, so it is kept beside them. + #[test] + fn a_connection_institution_refusal_keeps_the_connections_institution() { + const SENTENCE: &str = "This connection is for stanford, but lab belongs to ucsf."; + let details = serde_json::json!({ + "model": "gpt-5.5", + "approved_for": ["ucsf"], + "workspace": "lab", + "workspace_institution": "ucsf", + }); + let refusal = daemon_refusal( + 400, + Some(&serde_json::json!({ + "code": "crew_institution_mismatch", + "error": SENTENCE, + "connection_institution": "stanford", + "institution_refusal": details, + })), + "fallback", + ); + assert_eq!(refusal.connection_institution.as_deref(), Some("stanford")); + assert_eq!(refusal.institution_refusal.as_ref(), Some(&details)); + assert_eq!(refusal.message(), SENTENCE); + for absent in [ + serde_json::json!(""), + serde_json::json!(7), + serde_json::json!(null), + ] { + let refusal = daemon_refusal( + 400, + Some(&serde_json::json!({ + "code": "crew_institution_mismatch", + "error": SENTENCE, + "connection_institution": absent, + })), + "fallback", + ); + assert_eq!(refusal.connection_institution, None, "{absent}"); + } + } + + /// W2-CLI-14: the route's body for a request the daemon never sent (`lost_request`) keeps + /// its code and its sentence, which the crew commands then print as they are. + #[test] + fn a_request_the_daemon_never_sent_keeps_its_code_and_sentence() { + const SENTENCE: &str = "Couldn't sign in to hpc as bob: the server refused this computer's SSH key. Nothing was sent."; + let refusal = daemon_refusal( + 503, + Some(&serde_json::json!({ + "code": "crew_not_sent", + "error": SENTENCE, + "ssh_code": "crew_ssh_key_refused", + })), + "fallback", + ); + assert_eq!(refusal.kind.as_deref(), Some("crew_not_sent")); + assert_eq!(refusal.message(), SENTENCE); } #[test] @@ -2045,6 +2590,317 @@ mod tests { assert!(!text.contains("model\\'s"), "{text}"); } + /// The prompts a scripted reader was asked, in order. + type Asked = Arc>>; + /// One scripted answer. + type Answer = std::future::Ready>>; + + /// A reader that answers each prompt with the next scripted line and records the prompts. + fn scripted(answers: &[&str]) -> (Asked, impl FnMut(&'static str, bool) -> Answer) { + let asked = Arc::new(Mutex::new(Vec::new())); + let seen = Arc::clone(&asked); + let mut answers: Vec = answers.iter().rev().map(|a| (*a).to_owned()).collect(); + (asked, move |prompt: &'static str, _stdin: bool| { + seen.lock().expect("prompts").push(prompt); + std::future::ready( + answers + .pop() + .map(zeroize::Zeroizing::new) + .ok_or_else(|| anyhow::anyhow!("no more input")), + ) + }) + } + + const SECRET: &str = "correct-horse-battery-staple-0123456789"; + + /// CLI-3: a new daemon's approval secret is asked for twice at the terminal, a mismatch + /// starts nothing, and the first answer is checked before the second is asked for. + #[tokio::test] + async fn a_new_daemons_secret_is_typed_twice_and_a_typo_starts_nothing() { + let (asked, read) = scripted(&[SECRET, SECRET]); + let secret = choose_approval_secret(NEW_DAEMON_SECRET_PROMPT, false, read) + .await + .expect("the same secret twice"); + assert_eq!(secret.as_str(), SECRET); + assert_eq!( + *asked.lock().unwrap(), + [NEW_DAEMON_SECRET_PROMPT, APPROVAL_SECRET_AGAIN] + ); + assert!(NEW_DAEMON_SECRET_PROMPT.starts_with("No Biorouter daemon is running")); + + let (_, read) = scripted(&[SECRET, "correct-horse-battery-staple-0123456788"]); + let error = choose_approval_secret(NEW_DAEMON_SECRET_PROMPT, false, read) + .await + .expect_err("a typo"); + assert_eq!( + error.to_string(), + "The two approval secrets don't match. No daemon was started." + ); + + let (asked, read) = scripted(&["short"]); + choose_approval_secret(NEW_DAEMON_SECRET_PROMPT, false, read) + .await + .expect_err("too short"); + assert_eq!(asked.lock().unwrap().len(), 1, "no second prompt"); + + // A script sends one line and owns it. + let (asked, read) = scripted(&[SECRET]); + choose_approval_secret(NEW_DAEMON_SECRET_PROMPT, true, read) + .await + .expect("one line from stdin"); + assert_eq!(asked.lock().unwrap().len(), 1); + } + + /// CLI-2: `credentials init` asks for the new passphrase twice and refuses a mismatch + /// before anything is sent; `unlock` asks once. + #[tokio::test] + async fn a_new_vault_passphrase_is_typed_twice() { + let (asked, read) = scripted(&["vault words", "vault words"]); + let passphrase = vault_passphrase("init", false, read) + .await + .expect("the same passphrase twice"); + assert_eq!(passphrase.as_str(), "vault words"); + assert_eq!( + *asked.lock().unwrap(), + [NEW_VAULT_PASSPHRASE, VAULT_PASSPHRASE_AGAIN] + ); + + let (_, read) = scripted(&["vault words", "vault wrods"]); + let error = vault_passphrase("init", false, read) + .await + .expect_err("a typo"); + assert_eq!( + error.to_string(), + "The two passphrases don't match. The vault was not set up." + ); + + let (asked, read) = scripted(&["vault words"]); + vault_passphrase("unlock", false, read) + .await + .expect("unlock"); + assert_eq!(*asked.lock().unwrap(), [VAULT_PASSPHRASE]); + + let (asked, read) = scripted(&["vault words"]); + vault_passphrase("init", true, read) + .await + .expect("one line from stdin"); + assert_eq!(asked.lock().unwrap().len(), 1); + } + + /// CLI-7: a failed connect keeps OpenSSH's words, line by line, terminal-safe. + #[test] + fn a_connect_refusal_keeps_its_detail_line_by_line() { + let refused = daemon_refusal( + 400, + Some(&serde_json::json!({ + "code": "crew_ssh_host_key_unknown", + "error": "Crew SSH failure [host_key; child_before_cleanup=exit_255]: x", + "detail": "Host key verification failed.\r\nfingerprint SHA256:abc\u{1b}[2J" + })), + "fallback", + ); + assert_eq!(refused.kind.as_deref(), Some("crew_ssh_host_key_unknown")); + assert_eq!( + refused.detail(), + Some("Host key verification failed.\nfingerprint SHA256:abc\\u{1b}[2J") + ); + let none = daemon_refusal(400, Some(&serde_json::json!({"error": "x"})), "fallback"); + assert_eq!(none.detail(), None); + } + + /// CLI-3: the one refusal the secret check can give is said as a wrong secret. + #[test] + fn a_refused_proof_is_said_as_a_wrong_approval_secret() { + let refused = daemon_refusal( + 403, + Some(&serde_json::json!({ + "code": "crew_user_action_required", + "error": "Authorize this action in the Crew panel or native Crew CLI with your human approval secret." + })), + "fallback", + ); + let error = wrong_approval_secret(refused.into()); + assert_eq!(error.to_string(), WRONG_APPROVAL_SECRET); + assert_eq!( + error.downcast_ref::().and_then(|r| r.code), + Some(WRONG_APPROVAL_SECRET_CODE) + ); + + let other = daemon_refusal( + 403, + Some(&serde_json::json!({"code": "crew_human_authority_unavailable", "error": "x"})), + "fallback", + ); + let error = wrong_approval_secret(other.into()); + assert!(error.downcast_ref::().is_some()); + } + + /// CLI-12: no discovery record, and one a killed daemon left behind, both say that no + /// daemon is running, with one code, instead of the operating system's error. + #[tokio::test] + #[serial] + async fn status_says_no_daemon_is_running_whatever_it_left_behind() { + let directory = runtime_dir(); + let socket = directory.join("daemon.sock"); + // Nothing left; a record whose socket is gone; a record whose socket nobody listens on. + for left in ["nothing", "record", "dead socket"] { + let _ = fs::remove_file(&socket); + if left != "nothing" { + daemon_runtime::write_private( + &daemon_runtime::descriptor_path(), + &expected_descriptor(&directory), + ) + .expect("stale descriptor writes"); + } + if left == "dead socket" { + drop(std::os::unix::net::UnixListener::bind(&socket).expect("socket binds")); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)) + .expect("socket is private"); + } + let error = daemon_control("status", false) + .await + .expect_err("no daemon"); + assert_eq!(error.to_string(), DAEMON_NOT_RUNNING, "left: {left}"); + assert_eq!( + error.downcast_ref::().and_then(|r| r.code), + Some(DAEMON_NOT_RUNNING_CODE) + ); + } + let _ = fs::remove_file(daemon_runtime::descriptor_path()); + let _ = fs::remove_file(&socket); + } + + /// CLI-13 and CLI-14: the platform refusal comes before any prompt, and a hidden prompt + /// without a terminal is the usage refusal. + #[test] + fn platform_and_terminal_refusals_are_typed() { + require_supported_platform(true).expect("unix"); + let error = require_supported_platform(false).expect_err("elsewhere"); + assert!(error + .to_string() + .starts_with("Shared Crew daemon IPC is unavailable on this platform.")); + assert_eq!( + error.downcast_ref::().and_then(|r| r.code), + Some("crew_platform_unsupported") + ); + secret_prompt_possible(true).expect("a terminal"); + let refusal: NeedsTerminal = secret_prompt_possible(false).expect_err("no terminal"); + assert!(refusal.to_string().contains("--approval-key-stdin")); + } + + /// SF2-N6: `auth` with no terminal is the usage refusal every other such command gives + /// (exit 2), and names `connect` for a server that takes the key alone. + #[test] + fn auth_without_a_terminal_is_the_usage_refusal_and_names_connect() { + sign_in_possible(true).expect("a terminal"); + let refusal: NeedsTerminal = sign_in_possible(false).expect_err("no terminal"); + let text = refusal.to_string(); + assert_eq!(text, AUTH_NEEDS_A_TERMINAL); + assert!(text.contains("biorouter crew connect"), "{text}"); + assert!(!text.contains("MFA"), "{text}"); + // It survives the `?` into an anyhow error, which is what `main` downcasts. + let error: anyhow::Error = sign_in_possible(false).unwrap_err().into(); + assert!(error.chain().any(|cause| cause.is::())); + } + + /// SETUPHPC2-F-B: a terminal size the daemon would refuse (a 0x0 pty from `ssh -tt`) is + /// never sent; an unknown size is 80 by 24 and any other is clamped into its range. + #[test] + fn a_sign_in_terminal_size_is_always_one_the_daemon_accepts() { + assert_eq!(sign_in_terminal_size(0, 0), (80, 24)); + assert_eq!(sign_in_terminal_size(0, 50), (80, 24)); + assert_eq!(sign_in_terminal_size(120, 0), (80, 24)); + assert_eq!(sign_in_terminal_size(10, 2), (20, 5)); + assert_eq!(sign_in_terminal_size(900, 900), (500, 200)); + assert_eq!(sign_in_terminal_size(132, 43), (132, 43)); + for cols in [0, 1, 19, 20, 80, 500, 501, u16::MAX] { + for rows in [0, 1, 4, 5, 24, 200, 201, u16::MAX] { + let (cols, rows) = sign_in_terminal_size(cols, rows); + assert!((20..=500).contains(&cols) && (5..=200).contains(&rows)); + } + } + } + + /// CLIDOCS-F6: `credentials unlock` with no vault refuses before any passphrase is asked, + /// in a sentence that names what to run, with its own code. + #[test] + fn unlocking_without_a_vault_is_refused_before_the_passphrase() { + for (status, sentence) in [ + ( + serde_json::json!({"backend": "keyring", "initialized": false, "locked": false, "available": true}), + NO_VAULT_TO_UNLOCK, + ), + ( + serde_json::json!({"backend": "keyring", "initialized": false, "locked": false, "available": false}), + NO_VAULT_NO_KEYRING, + ), + ( + serde_json::json!({"backend": "file", "initialized": false, "locked": false, "available": true}), + NO_VAULT_TO_UNLOCK_FILES, + ), + ] { + let error = no_vault_to_unlock(&status).expect_err("no vault"); + assert_eq!(error.to_string(), sentence); + assert_eq!( + error.downcast_ref::().and_then(|r| r.code), + Some(NO_VAULT_TO_UNLOCK_CODE) + ); + } + for sentence in [NO_VAULT_TO_UNLOCK, NO_VAULT_NO_KEYRING] { + assert!( + sentence.contains("biorouter crew credentials init"), + "{sentence}" + ); + } + no_vault_to_unlock(&serde_json::json!({"backend": "encrypted_vault", "initialized": true, "locked": true, "available": true})) + .expect("a locked vault can be unlocked"); + } + + /// AGT2-N5: a refusal keeps the daemon's machine-readable fields for JSON output, bounded, + /// and never the daemon's own `request_id`. + #[test] + fn a_refusal_keeps_the_daemons_structured_fields() { + let refusal = daemon_refusal( + 400, + Some(&serde_json::json!({ + "code": "crew_mode_mismatch", + "error": "Your connection is Private, but this request required Public.", + "actual_mode": "private", + "expected_mode": "public", + "workspace": "lab", + "request_id": "the-daemons", + "candidates": (0..100).map(|n| format!("c{n}")).collect::>(), + "host": "x".repeat(5000), + "reason": null, + "unrelated": "dropped", + })), + "fallback", + ); + let fields = refusal.fields(); + assert_eq!(fields["actual_mode"], "private"); + assert_eq!(fields["expected_mode"], "public"); + assert_eq!(fields["workspace"], "lab"); + assert_eq!(refusal.field_text("workspace"), Some("lab")); + assert!(!fields.contains_key("request_id")); + assert!(!fields.contains_key("unrelated")); + assert!(!fields.contains_key("reason"), "a null is not kept"); + assert_eq!(fields["candidates"].as_array().map(Vec::len), Some(64)); + assert_eq!(fields["host"].as_str().map(str::len), Some(4096)); + let institution = daemon_refusal( + 400, + Some(&serde_json::json!({ + "code": "crew_institution_mismatch", + "error": "x", + "institution_refusal": {"model": "gpt-5.5", "approved_for": ["ucsf"], "workspace": "lab", "workspace_institution": "stanford"}, + })), + "fallback", + ); + assert_eq!( + institution.fields()["institution_refusal"]["approved_for"], + serde_json::json!(["ucsf"]) + ); + } + fn decode_sse(input: &[u8]) -> anyhow::Result<(Vec, EventDecoder)> { let mut decoder = EventDecoder::default(); let mut events = Vec::new(); @@ -2113,4 +2969,129 @@ mod tests { .expect_err("the byte after the bound must be refused"); assert!(error.to_string().contains("exceeds 32 MiB")); } + + /// A daemon that serves every connection it is offered: `/daemon/identity` answers + /// `descriptor`'s identity and every other path answers `{"ok":true}`. + async fn serving_daemon_fixture() -> Descriptor { + let directory = runtime_dir(); + let socket_path = directory.join("daemon.sock"); + let _ = fs::remove_file(&socket_path); + let listener = tokio::net::UnixListener::bind(&socket_path).expect("fixture socket binds"); + fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600)) + .expect("fixture socket is private"); + let mut descriptor = expected_descriptor(&directory); + descriptor.endpoint = Endpoint::Unix { path: socket_path }; + daemon_runtime::write_private(&daemon_runtime::descriptor_path(), &descriptor) + .expect("fixture descriptor writes"); + let identity = descriptor.identity(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let identity = identity.clone(); + tokio::spawn(async move { + let service = service_fn(move |request: Request| { + let body = if request.uri().path() == "/daemon/identity" { + serde_json::to_vec(&identity).expect("identity serializes") + } else { + br#"{"ok":true}"#.to_vec() + }; + async move { + Ok::<_, std::convert::Infallible>( + Response::builder() + .header("content-type", "application/json") + .body(Full::new(Bytes::from(body))) + .expect("fixture response builds"), + ) + } + }); + let _ = server_http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .await; + }); + } + }); + descriptor + } + + /// M16: a request the connection hands back unsent is [`NotSent`], which says that nothing + /// was sent; a connection whose task is gone takes nothing. + #[tokio::test] + async fn a_request_the_connection_never_took_is_not_sent() { + let (client_end, _server_end) = tokio::net::UnixStream::pair().expect("a socket pair"); + let (mut sender, connection) = http1::handshake::<_, Full>(TokioIo::new(client_end)) + .await + .expect("handshake"); + // The connection task is never run: its dispatcher is gone, so it can take nothing. + drop(connection); + let request = Request::builder() + .uri("/crew/connections") + .header("Host", "localhost") + .body(Full::new(Bytes::new())) + .expect("request builds"); + let error = super::send_when_ready(&mut sender, request, Duration::from_secs(1)) + .await + .expect_err("nothing can be sent"); + let not_sent = error + .downcast_ref::() + .expect("the request was never written"); + assert_eq!( + not_sent.to_string(), + "The daemon connection wasn't ready. Nothing was sent; run it again." + ); + assert!(!not_sent.cause().is_empty()); + } + + /// M16: every request is the identity check and then the request itself, on one connection. + /// hyper takes the second only once the connection task has finished the first answer, and + /// that task runs on another thread (the CLI's `block_on` host thread is not a worker). A + /// second send that got there first was refused as "connection was not ready" about once + /// in a thousand, which failed `crew send` and every watch at random. Each send now waits + /// for the connection to be ready. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[serial] + async fn back_to_back_requests_on_one_connection_never_fail_as_not_ready() { + // Before the fix this failed a few times in every few thousand. Four callers on their + // own threads, as the CLI's host thread is, make a regression all but certain to show. + const CALLERS: usize = 4; + const REQUESTS: usize = 5_000; + let descriptor = serving_daemon_fixture().await; + let client = Arc::new(CrewClient { + descriptor, + proof: zeroize::Zeroizing::new("synthetic-human-proof-01234567890123456789".into()), + }); + let runtime = tokio::runtime::Handle::current(); + let callers: Vec<_> = (0..CALLERS) + .map(|_| { + let client = Arc::clone(&client); + let runtime = runtime.clone(); + std::thread::spawn(move || { + runtime.block_on(async move { + let mut failures = Vec::new(); + for _ in 0..REQUESTS { + if let Err(error) = + client.request("GET", "/crew/connections", None).await + { + failures.push(format!("{error:#}")); + } + } + failures + }) + }) + }) + .collect(); + let failures: Vec = tokio::task::spawn_blocking(move || { + callers + .into_iter() + .flat_map(|caller| caller.join().expect("a caller thread")) + .collect() + }) + .await + .expect("the callers finish"); + assert!( + failures.is_empty(), + "{} of {} requests failed; the first: {:?}", + failures.len(), + CALLERS * REQUESTS, + failures.first() + ); + } } diff --git a/crates/biorouter-cli/src/main.rs b/crates/biorouter-cli/src/main.rs index c31d67392..85dec92ec 100644 --- a/crates/biorouter-cli/src/main.rs +++ b/crates/biorouter-cli/src/main.rs @@ -1,5 +1,5 @@ use biorouter::agents::turn_abort::{exit as abort_exit, TurnFailed}; -use biorouter_cli::cli::cli; +use biorouter_cli::cli::{cli, read_command_line}; use biorouter_cli::commands::needs_terminal::{self, NeedsTerminal}; use std::process::ExitCode; @@ -37,6 +37,9 @@ async fn async_main() -> ExitCode { // restrictions and `execve` the target program. Never returns in that case; // a normal invocation falls straight through. Must run before any real work. biorouter_mcp::run_shell_sandbox_helper_if_invoked(); + // Before logging and the privacy loads below, every one of which writes: `--help`, + // `--version` and a usage mistake are answered here, and touch nothing on disk (SF-F8). + let command_line = read_command_line(); tune_allocator(); if let Err(e) = biorouter_cli::logging::setup_logging(None, None) { eprintln!("Warning: Failed to initialize logging: {}", e); @@ -70,7 +73,9 @@ async fn async_main() -> ExitCode { // kind of silence. biorouter::privacy::load_mixing_policy_from_record(); - match cli().await { + let result = cli(command_line).await; + stop_local_model_server().await; + match result { Ok(()) => ExitCode::from(abort_exit::OK), Err(e) => { // A command that needs a person at a terminal and was run without @@ -94,6 +99,26 @@ async fn async_main() -> ExitCode { } } +/// Stop the Llama Server this run started, if it started one (PROV-F11). +/// +/// Its owner is a static, and statics never drop, so the child's `kill_on_drop` never fires at +/// exit: `biorouter run --provider llamacpp` left a llama-server holding 4 to 6.5 GB behind, +/// reaped only by the next local-model start under the same data root. `biorouterd` stops it +/// the same way on its way out. Every return from a command passes here, a turn stopped with +/// Ctrl-C included; a terminal's Ctrl-C at any other moment reaches the server too, as it runs +/// in the same process group; and a kill is left to the pidfile the server's start wrote. +async fn stop_local_model_server() { + let stop = biorouter::providers::llamacpp_sidecar::global().stop(); + if tokio::time::timeout(std::time::Duration::from_secs(5), stop) + .await + .is_err() + { + eprintln!( + "Warning: Llama Server did not stop in time; the next local model start stops it." + ); + } +} + /// Lower jemalloc's dirty/muzzy decay so freed pages return to the OS within /// ~1s instead of being retained (jemalloc's own defaults — `muzzy_decay_ms:0`, /// `retain:true` — retain aggressively). Applied to all current arenas diff --git a/crates/biorouter-cli/src/session/builder.rs b/crates/biorouter-cli/src/session/builder.rs index 9a18b57f0..677d3df75 100644 --- a/crates/biorouter-cli/src/session/builder.rs +++ b/crates/biorouter-cli/src/session/builder.rs @@ -620,6 +620,57 @@ fn close_ephemeral_store_blocking(ephemeral_store_dir: Option } } +/// The model a run on `provider` uses (PROV-F10): the one typed with `--model`; else the first +/// of `candidates` (the saved chat's, the workflow's and the configured model, each with the +/// provider it belongs to) that belongs to `provider`; else `provider`'s own default model. +/// +/// Provider and model used to be resolved separately, so `--provider codex` with nothing else +/// sent the configured Versa model to Codex, which refused it; worse, a configured model name +/// that the chosen provider also has ran silently, a model nobody chose. The in-app workspace +/// tool already used the provider's default. A provider the registry does not know falls back +/// to the first model named, so its own refusal (an unknown provider) is what is shown. +pub(crate) async fn model_for_run( + provider: Option<&str>, + typed: Option, + candidates: &[(Option<&str>, Option)], +) -> Option { + if let Some(model) = owned_model(provider, typed, candidates) { + return Some(model); + } + if let Some(provider) = provider { + if let Some(model) = provider_default_model(provider).await { + return Some(model); + } + } + candidates.iter().find_map(|(_, model)| model.clone()) +} + +/// [`model_for_run`] without the registry: the typed model, else the first candidate that +/// belongs to `provider`. A candidate with no provider belongs to any. +pub(crate) fn owned_model( + provider: Option<&str>, + typed: Option, + candidates: &[(Option<&str>, Option)], +) -> Option { + typed.or_else(|| { + candidates + .iter() + .filter(|(owner, _)| owner.is_none() || provider.is_none() || *owner == provider) + .find_map(|(_, model)| model.clone()) + }) +} + +/// A provider's own default model, from the provider registry. +pub(crate) async fn provider_default_model(provider: &str) -> Option { + biorouter::providers::providers() + .await + .into_iter() + .map(|(metadata, _)| metadata) + .find(|metadata| metadata.name == provider) + .map(|metadata| metadata.default_model) + .filter(|model| !model.is_empty()) +} + /// The message for a run that cannot name a provider or a model, or `None` /// when both are resolved. /// @@ -766,6 +817,11 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { // somebody mailed them, the global default — so "why will this chat not // start" has four answers and only one of them is obvious. let workflow_provider = workflow_settings.and_then(|s| s.biorouter_provider.clone()); + let global_provider = config.get_biorouter_provider().ok(); + // A workflow's model belongs to the provider it pins, else to the configured one. + let workflow_model_owner = workflow_provider + .clone() + .or_else(|| global_provider.clone()); let (resolved_provider, provider_source) = match ( session_config.provider, saved_provider.clone(), @@ -774,16 +830,29 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession { (Some(p), _, _) => (Some(p), ProviderSource::CliFlag), (None, Some(p), _) => (Some(p), ProviderSource::SavedSession), (None, None, Some(p)) => (Some(p), ProviderSource::Workflow), - (None, None, None) => ( - config.get_biorouter_provider().ok(), - ProviderSource::GlobalDefault, - ), + (None, None, None) => (global_provider.clone(), ProviderSource::GlobalDefault), }; - let resolved_model = session_config - .model - .or_else(|| saved_model_config.as_ref().map(|mc| mc.model_name.clone())) - .or_else(|| workflow_settings.and_then(|s| s.biorouter_model.clone())) - .or_else(|| config.get_biorouter_model().ok()); + // PROV-F10: each fallback model belongs to a provider, and only the resolved provider's + // own is used; a provider nothing names a model for runs its own default model. + let resolved_model = model_for_run( + resolved_provider.as_deref(), + session_config.model, + &[ + ( + saved_provider.as_deref(), + saved_model_config.as_ref().map(|mc| mc.model_name.clone()), + ), + ( + workflow_model_owner.as_deref(), + workflow_settings.and_then(|s| s.biorouter_model.clone()), + ), + ( + global_provider.as_deref(), + config.get_biorouter_model().ok(), + ), + ], + ) + .await; // A fresh install has neither, and this is the first thing it reaches. Both // slots used to be `.expect()`, so the answer to "you have not configured a @@ -1523,6 +1592,75 @@ mod tests { close_ephemeral_store(Some(dir)).await; } + /// PROV-F10: `--provider X` without `--model` never sends another provider's model to X. + #[test] + fn a_fallback_model_is_used_only_by_the_provider_it_belongs_to() { + let configured = [(Some("versa_azure"), Some("gpt-5.5".to_owned()))]; + assert_eq!( + owned_model(Some("codex"), None, &configured), + None, + "the configured Versa model is not Codex's" + ); + assert_eq!( + owned_model(Some("versa_azure"), None, &configured).as_deref(), + Some("gpt-5.5") + ); + assert_eq!( + owned_model(Some("codex"), Some("gpt-6-astra".into()), &configured).as_deref(), + Some("gpt-6-astra"), + "--model always wins" + ); + // A resumed chat keeps its own model on its own provider; a workflow's model goes with + // the provider it pins. + let chain = [ + (Some("anthropic"), Some("claude-opus-5".to_owned())), + (Some("openai"), Some("gpt-6-sol".to_owned())), + (Some("versa_azure"), Some("gpt-5.5".to_owned())), + ]; + assert_eq!( + owned_model(Some("anthropic"), None, &chain).as_deref(), + Some("claude-opus-5") + ); + assert_eq!( + owned_model(Some("openai"), None, &chain).as_deref(), + Some("gpt-6-sol") + ); + assert_eq!(owned_model(Some("codex"), None, &chain), None); + // A model with no provider of its own belongs to any. + assert_eq!( + owned_model(Some("codex"), None, &[(None, Some("m".to_owned()))]).as_deref(), + Some("m") + ); + } + + /// PROV-F10: the chosen provider's own default model, from the registry, when no + /// fallback belongs to it; an unknown provider keeps the first model named, so its own + /// refusal is what the person sees. + #[tokio::test] + async fn a_provider_nothing_names_a_model_for_runs_its_own_default() { + let configured = [(Some("versa_azure"), Some("gpt-5.5".to_owned()))]; + let codex_default = provider_default_model("codex") + .await + .expect("codex is a registered provider with a default model"); + assert_ne!(codex_default, "gpt-5.5"); + assert_eq!( + model_for_run(Some("codex"), None, &configured).await, + Some(codex_default) + ); + assert_eq!( + model_for_run(Some("no_such_provider_exists"), None, &configured) + .await + .as_deref(), + Some("gpt-5.5") + ); + assert_eq!( + model_for_run(Some("versa_azure"), None, &configured) + .await + .as_deref(), + Some("gpt-5.5") + ); + } + /// A run that cannot name a provider or a model is refused with something /// the user can act on. /// diff --git a/crates/biorouter-cli/src/session/mod.rs b/crates/biorouter-cli/src/session/mod.rs index 07bcce7b5..b98404a1f 100644 --- a/crates/biorouter-cli/src/session/mod.rs +++ b/crates/biorouter-cli/src/session/mod.rs @@ -28,6 +28,18 @@ fn knowledge_service_for_enrichment( biorouter_mcp::knowledge::service::KnowledgeService::new_default() } +/// How a `/workflow` capture ended. The person has already been told; this is +/// what the tests read. +#[derive(Debug, PartialEq, Eq)] +enum WorkflowCapture { + /// A Crew grant restricts this chat: nothing was generated, shown or saved. + Refused, + /// Generation or the save failed, and the reason has been printed. + NotSaved, + /// Written to this path. + Saved(PathBuf), +} + use std::io::{IsTerminal, Write}; use std::str::FromStr; use tokio::signal::ctrl_c; @@ -42,6 +54,7 @@ use biorouter::permission::PermissionConfirmation; use biorouter::providers::base::Provider; use biorouter::utils::safe_truncate; pub use builder::{build_session, unconfigured_precondition, SessionBuilderConfig}; +pub(crate) use builder::{model_for_run, owned_model}; use console::Color; use anyhow::Result; @@ -679,7 +692,8 @@ impl CliSession { } InputResult::Workflow(filepath_opt) => { history.save(editor); - self.handle_workflow(filepath_opt).await; + // The person has been told how it ended; the outcome is for tests. + let _ = self.handle_workflow(filepath_opt).await; } InputResult::Compact => { history.save(editor); @@ -988,7 +1002,29 @@ impl CliSession { /// conversation produced a workflow with no `extensions:`, no /// `knowledge_bases:` and no `author:` here, and a fully populated one in /// the app. - async fn handle_workflow(&mut self, filepath_opt: Option) { + /// + /// ⚠ **A Crew chat is refused FIRST**, before the model is asked to + /// summarise the conversation, with the sentence the desktop shows + /// ([`biorouter::workflow::service::refuse_crew_source`]). The workflow a + /// model writes from a Crew chat restates the channel's context, and a saved + /// workflow is shared and run outside the channel's permissions. This door + /// asked nothing, so it wrote one to the library. + async fn handle_workflow(&mut self, filepath_opt: Option) -> WorkflowCapture { + use biorouter::session::session_manager::CrewContextRefusal; + + if let Err(error) = biorouter::workflow::service::refuse_crew_source(&self.session_id).await + { + // The refusal alone, or the chain of a registry that could not be + // read: either way nothing was generated, read or written. + match error.downcast_ref::() { + Some(refusal) => output::render_error(&refusal.to_string()), + None => output::render_error(&format!( + "Couldn't check this chat's Crew access, so no workflow was made: {error:#}" + )), + } + return WorkflowCapture::Refused; + } + println!("{}", console::style("Generating Workflow").green()); output::show_thinking(); @@ -1003,7 +1039,7 @@ impl CliSession { console::style("Failed to generate workflow").red(), e ); - return; + return WorkflowCapture::NotSaved; } }; @@ -1021,6 +1057,12 @@ impl CliSession { &mut workflow, enrichment, ), + // A grant made while the model was writing: the draft now + // restates Crew context, so it is neither saved nor shown. + Err(e) if e.downcast_ref::().is_some() => { + output::render_error(&e.to_string()); + return WorkflowCapture::Refused; + } Err(e) => println!( "{}", console::style(format!( @@ -1040,10 +1082,13 @@ impl CliSession { } match self.save_workflow(&workflow, filepath_opt.as_deref()) { - Ok(path) => println!( - "{}", - console::style(format!("Saved workflow to {}", path.display())).green() - ), + Ok(path) => { + println!( + "{}", + console::style(format!("Saved workflow to {}", path.display())).green() + ); + WorkflowCapture::Saved(path) + } // ⚠ Print the CHAIN and then the draft. `service::save` wraps the // validation failure in "refusing to save a workflow that does not // validate", and `anyhow`'s `Display` prints only that outermost @@ -1077,6 +1122,7 @@ impl CliSession { .red() ), } + WorkflowCapture::NotSaved } } } @@ -2720,6 +2766,80 @@ mod tests { .await } + /// `/workflow` refuses a chat a Crew grant restricts BEFORE the model is + /// asked to summarise it, with the sentence the desktop shows, and saves + /// nothing. It asked nothing, so the workflow a model wrote from the + /// channel's context landed in the library, where it can be shared and run + /// outside the channel's permissions. A chat no grant restricts goes on to + /// generation as before (which fails here, as this agent has no model), so + /// the refusal is Crew's and not a refusal of every capture. + /// + /// In a process of its own, because the Crew registry is read once per + /// process and this test saves the one it reads. + #[tokio::test] + async fn slash_workflow_refuses_a_crew_chat_before_the_model_is_asked() { + use biorouter::agents::AgentConfig; + use biorouter::config::permission::PermissionManager; + use biorouter::session::session_manager::SessionType; + + if !crate::test_sandbox::in_a_process_of_its_own() { + return; + } + let dir = tempfile::TempDir::new().unwrap(); + let manager = std::sync::Arc::new(biorouter::session::SessionManager::new( + dir.path().to_path_buf(), + )); + let mut chats = Vec::new(); + for name in ["Crew task", "Plain chat"] { + chats.push( + manager + .create_session( + dir.path().to_path_buf(), + name.to_string(), + SessionType::User, + ) + .await + .unwrap() + .id, + ); + } + crate::commands::session::restrict_by_crew_grant(&[&chats[0]]); + + for (id, expected) in [ + (&chats[0], WorkflowCapture::Refused), + (&chats[1], WorkflowCapture::NotSaved), + ] { + let agent = Agent::with_config(AgentConfig::new( + manager.clone(), + PermissionManager::instance(), + None, + BioRouterMode::Auto, + )); + let mut cli = CliSession::new( + agent, + id.clone(), + false, + None, + None, + None, + None, + "text".to_string(), + ) + .await; + cli.push_message(Message::user().with_text( + "cohort-7 enrolment notes Summarize them.", + )); + let target = dir.path().join(format!("{id}.yaml")); + assert_eq!( + cli.handle_workflow(Some(target.display().to_string())) + .await, + expected, + "{id}" + ); + assert!(!target.exists(), "{id}: a workflow was written"); + } + } + /// #40: the full decision matrix for tool-confirmation prompts. /// `interactive && stdin_is_tty` is authoritative: those runs keep the /// prompt regardless of output format (all prompt UI renders on stderr, diff --git a/crates/biorouter-cli/tests/apps_serve_lifecycle.rs b/crates/biorouter-cli/tests/apps_serve_lifecycle.rs index 109fea243..e7ef80842 100644 --- a/crates/biorouter-cli/tests/apps_serve_lifecycle.rs +++ b/crates/biorouter-cli/tests/apps_serve_lifecycle.rs @@ -174,6 +174,24 @@ struct Served { root: tempfile::TempDir, } +/// `biorouter apps ` against the store, state and port of a test root, +/// touching nothing of the developer's own configuration, sessions or keychain. +fn apps_command(root: &std::path::Path, port: u16, args: &[&str]) -> Command { + let mut command = Command::new(biorouter()); + command + .arg("apps") + .args(args) + .env("HOME", root.join("home")) + .env("BIOROUTER_PATH_ROOT", root.join("biorouter")) + .env("BIOROUTER_PORT", port.to_string()) + .env("BIOROUTER_DISABLE_KEYRING", "true") + // The key of the daemon under test must come from its record, which is + // what this file measures, never from the shell running the tests. + .env_remove("BIOROUTER_SERVER__SECRET_KEY") + .stdin(Stdio::null()); + command +} + impl Served { fn start() -> Self { require_a_daemon_from_this_tree(); @@ -195,15 +213,7 @@ impl Served { let reserved = reserved_port::reserve(); let port = reserved.port; - let serve = Command::new(biorouter()) - .args(["apps", "serve", APP_ID]) - // Nothing here may touch the developer's own configuration, - // sessions or keychain. - .env("HOME", &home) - .env("BIOROUTER_PATH_ROOT", &path_root) - .env("BIOROUTER_PORT", port.to_string()) - .env("BIOROUTER_DISABLE_KEYRING", "true") - .stdin(Stdio::null()) + let serve = apps_command(root.path(), port, &["serve", APP_ID]) .stdout(log.try_clone().unwrap()) .stderr(log) .spawn() @@ -259,9 +269,24 @@ impl Served { std::fs::read_to_string(self.root.path().join("serve.log")).unwrap_or_default() } + /// The record through which a later `apps open` or `apps serve` reaches + /// this daemon's key. + fn daemon_record(&self) -> PathBuf { + self.root + .path() + .join("biorouter") + .join("state") + .join("apps-daemon") + .join(format!("{}.json", self.port)) + } + /// Stop the command with `name` and assert that it exits and takes the /// daemon with it. - fn stop_with(mut self, name: &str) -> ExitStatus { + /// + /// ⚠ Borrows, and must: dropping `self` deletes [`Self::root`], and with it + /// every file the command left behind, so a check on those files after a + /// stop that consumed `self` would pass whatever the command did. + fn stop_with(&mut self, name: &str) -> ExitStatus { signal(self.serve.id(), name); let mut status = None; let took = wait_for(STOP_BUDGET, || { @@ -368,3 +393,220 @@ fn a_daemon_whose_apps_serve_was_killed_outright_stops_itself() { served.port ); } + +/// The launch path in the first line of `output` that carries one. +fn launch_path(output: &str) -> Option { + let at = output.find(&format!("/apps/{APP_ID}/?t="))?; + let path: String = output + .get(at..)? + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || "/?=-_".contains(*c)) + .collect(); + let token = path.rsplit('=').next()?; + (token.len() == 64).then_some(path) +} + +/// The status of `GET path` and whether the answer set a cookie. +fn redeem(port: u16, path: &str) -> Option<(u16, bool)> { + let mut stream = TcpStream::connect_timeout( + &SocketAddr::from((Ipv4Addr::LOCALHOST, port)), + Duration::from_millis(500), + ) + .ok()?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?; + write!( + stream, + "GET {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ) + .ok()?; + let mut answer = Vec::new(); + stream.read_to_end(&mut answer).ok()?; + let answer = String::from_utf8_lossy(&answer); + let head = answer.split("\r\n\r\n").next()?; + let status = head.split_whitespace().nth(1)?.parse().ok()?; + let cookie = head.lines().any(|line| { + line.to_ascii_lowercase() + .starts_with("set-cookie: biorouter_app_") + }); + Some((status, cookie)) +} + +/// W2-HRD-1, the dead end: `apps serve` printed one link that works once and +/// for five minutes, and "run this again for another". Its daemon's key was +/// kept to itself, so the second run found a daemon on the port it did not know +/// and refused it, and so did `apps open`, which the daemon's own refusal page +/// recommends. The only way on was to stop the first `apps serve`. +/// +/// Fails the shipped command: the second run exits non-zero with "does not +/// know", and no record exists for it to read. +#[test] +fn a_second_apps_serve_mints_another_link_on_the_running_daemon() { + let mut served = Served::start(); + let first = wait_for(READY_BUDGET, || launch_path(&served.log()).is_some()) + .and_then(|_| launch_path(&served.log())) + .unwrap_or_else(|| panic!("apps serve printed no launch link:\n{}", served.log())); + assert!( + served + .log() + .contains(&format!("biorouter apps open {APP_ID}`")), + "the advice under the link must name a command that works while this serves:\n{}", + served.log() + ); + let record = served.daemon_record(); + assert!( + record.is_file(), + "the daemon's key was not recorded at {}", + record.display() + ); + + let second = apps_command(served.root.path(), served.port, &["serve", APP_ID]) + .output() + .expect("run a second apps serve"); + let second_output = format!( + "{}{}", + String::from_utf8_lossy(&second.stdout), + String::from_utf8_lossy(&second.stderr) + ); + assert!( + second.status.success(), + "a second apps serve refused the running daemon ({}):\n{second_output}", + second.status + ); + let again = launch_path(&second_output) + .unwrap_or_else(|| panic!("the second apps serve printed no link:\n{second_output}")); + assert_ne!(first, again, "each run gets a link of its own"); + assert!( + second_output.contains("reusing the daemon already running"), + "{second_output}" + ); + + // Both links are this daemon's, and each works once. + for link in [&again, &first] { + assert_eq!( + redeem(served.port, link), + Some((200, true)), + "{link} did not open the app" + ); + let spent = redeem(served.port, link).expect("the daemon answers"); + assert!( + spent != (200, true), + "{link} opened the app twice: {spent:?}" + ); + } + assert_eq!( + served.only_child(), + served.daemon, + "the second run must reuse the daemon, not start another" + ); + + // Stopping the command that started the daemon removes its record. The + // folder holding it is still there: `served`, which owns the temp root, is + // dropped only at the end of this function. + let status = served.stop_with("TERM"); + assert!( + status.success(), + "a requested stop is a clean exit: {status}" + ); + assert!( + record.parent().is_some_and(|folder| folder.is_dir()), + "the record's folder is gone, so the check below would measure nothing" + ); + assert!( + !record.exists(), + "the record of a stopped daemon was left at {}", + record.display() + ); +} + +/// The other way `apps serve` stops a daemon it started: the daemon came up and +/// was recorded, and then would not hand out a link. The command must stop that +/// daemon and remove its record too, or the key of a daemon that is gone stays +/// on disk, and a later `apps open` offers it to whatever daemon holds the port +/// next. +/// +/// The daemon is made to refuse with an app whose manifest is a symbolic link: +/// the command's own check that the app exists follows the link, and the +/// daemon's store refuses any link below its root, so it answers 404. If the +/// command ever refuses such an app before starting a daemon, this test says so +/// and needs another way to reach the refusal. +#[test] +fn an_apps_serve_whose_link_is_refused_stops_its_daemon_and_forgets_it() { + require_a_daemon_from_this_tree(); + + let root = tempfile::tempdir().expect("temp dir"); + std::fs::create_dir_all(root.path().join("home")).unwrap(); + let elsewhere = root.path().join("elsewhere.json"); + std::fs::write( + &elsewhere, + format!(r#"{{"id":"{APP_ID}","title":"Lifecycle","kind":"static","updated_at":1}}"#), + ) + .unwrap(); + let app = root + .path() + .join("biorouter") + .join("config") + .join("agent_drafter") + .join(APP_ID); + std::fs::create_dir_all(&app).unwrap(); + std::os::unix::fs::symlink(&elsewhere, app.join("manifest.json")).unwrap(); + + let log_path = root.path().join("serve.log"); + let log = std::fs::File::create(&log_path).unwrap(); + let reserved = reserved_port::reserve(); + let port = reserved.port; + let mut serve = apps_command(root.path(), port, &["serve", APP_ID]) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .expect("spawn biorouter apps serve"); + + let mut status = None; + let exited = wait_for(READY_BUDGET + STOP_BUDGET, || { + status = serve.try_wait().ok().flatten(); + status.is_some() + }); + let read_log = || std::fs::read_to_string(&log_path).unwrap_or_default(); + let Some(status) = exited.and(status) else { + let _ = serve.kill(); + let _ = serve.wait(); + panic!( + "apps serve was still running with a link it could not get:\n{}", + read_log() + ); + }; + let output = read_log(); + + // On the path under test: the daemon started, its key was recorded, and + // only then did it refuse the link. + assert!( + output.contains(&format!("would not open '{APP_ID}': it has no such app")), + "apps serve did not reach the daemon's refusal, so this test measured nothing:\n{output}" + ); + assert!( + !output.contains("could not record"), + "the daemon's key was never recorded, so there was nothing to remove:\n{output}" + ); + assert!(!status.success(), "a refused link is a failure: {status}"); + + // `stop_daemon` reaps the daemon before the command exits. + assert!( + !port_is_open(port), + "port {port} is still accepting connections after apps serve gave up:\n{output}" + ); + let record = root + .path() + .join("biorouter") + .join("state") + .join("apps-daemon") + .join(format!("{port}.json")); + assert!( + record.parent().is_some_and(|folder| folder.is_dir()), + "the record's folder does not exist, so the check below would measure nothing" + ); + assert!( + !record.exists(), + "the record of a daemon apps serve stopped was left at {}", + record.display() + ); + drop(reserved); +} diff --git a/crates/biorouter-cli/tests/non_interactive.rs b/crates/biorouter-cli/tests/non_interactive.rs index 01d8607fb..146323bc5 100644 --- a/crates/biorouter-cli/tests/non_interactive.rs +++ b/crates/biorouter-cli/tests/non_interactive.rs @@ -99,20 +99,76 @@ fn snapshot(dir: &Path) -> Vec<(PathBuf, Vec)> { files } +/// SF-F8: `biorouter --version` names the program, and a version or help probe, the first +/// thing an install script runs, writes nothing: no `config.yaml`, no privacy master-switch +/// record, no log directory. The root starts empty and stays empty. +#[test] +fn a_version_or_help_probe_names_the_program_and_writes_nothing() { + let dir = tempfile::TempDir::new().unwrap(); + let home = dir.path().join("home"); + let root = dir.path().join("root"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&root).unwrap(); + let probe = |args: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_biorouter")) + .args(args) + .env("HOME", &home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("XDG_DATA_HOME", home.join(".local/share")) + .env("XDG_STATE_HOME", home.join(".local/state")) + .env("BIOROUTER_PATH_ROOT", &root) + .env("BIOROUTER_DISABLE_KEYRING", "true") + .stdin(Stdio::null()) + .output() + .expect("run the biorouter binary") + }; + let version = probe(&["--version"]); + assert_eq!(version.status.code(), Some(0), "{}", text(&version.stderr)); + assert_eq!( + text(&version.stdout), + format!("biorouter {}\n", env!("CARGO_PKG_VERSION")) + ); + for args in [ + &["--help"][..], + &["session", "--help"], + &["help", "run"], + &["-V"], + ] { + let out = probe(args); + assert_eq!( + out.status.code(), + Some(0), + "{args:?}: {}", + text(&out.stderr) + ); + } + for written in [&root, &home] { + assert_eq!( + snapshot(written), + Vec::new(), + "a probe wrote under {}", + written.display() + ); + let entries: Vec<_> = std::fs::read_dir(written).unwrap().collect(); + assert!(entries.is_empty(), "a probe created {entries:?}"); + } +} + /// `echo | biorouter configure` exits 2 with the sentence, and the whole /// configuration directory is byte-identical afterwards. /// -/// The sandbox is started once first. Every `biorouter` process — `--version` -/// included — runs the privacy master switch's one-time migration at start-up, +/// The sandbox is started once first. Every `biorouter` process that runs a +/// command runs the privacy master switch's one-time migration at start-up, /// which writes `privacy-tiers.json` beside a config that lacks one; a real /// install already has it. Snapshotting after that start makes the comparison /// about what `configure` does, not about what start-up does once per install. +/// `info` starts up and only reads; `--version` no longer starts up at all. #[test] fn configure_under_a_pipe_exits_2_with_guidance_and_leaves_config_byte_identical() { let sandbox = Sandbox::new(); let original = b"# hand-edited\nBIOROUTER_PROVIDER: versa_azure\nBIOROUTER_MODEL: gpt-5.5\n"; std::fs::write(sandbox.config_yaml(), original).unwrap(); - let warm = sandbox.run(&["--version"], ""); + let warm = sandbox.run(&["info"], ""); assert_eq!(warm.status.code(), Some(0), "{}", text(&warm.stderr)); let before = snapshot(&sandbox.root.join("config")); assert!( diff --git a/crates/biorouter-cli/tests/serve_lifecycle.rs b/crates/biorouter-cli/tests/serve_lifecycle.rs index e84546934..21dbfe2f7 100644 --- a/crates/biorouter-cli/tests/serve_lifecycle.rs +++ b/crates/biorouter-cli/tests/serve_lifecycle.rs @@ -118,6 +118,35 @@ fn http_status(port: u16, path: &str) -> Option { .ok() } +/// The status of `GET /?t=` and whether the answer set the session +/// cookie: what a browser gets for the address `serve` printed. A token the +/// daemon holds is a `200` page that sets the cookie and moves on to `/` (a +/// page, not a `303`, so the cookie survives a navigation `serve --open`'s +/// `file:` page started); any other token is a `401`. +fn exchange(port: u16, token: &str) -> Option<(u16, bool)> { + let mut stream = TcpStream::connect_timeout( + &SocketAddr::from((Ipv4Addr::LOCALHOST, port)), + Duration::from_millis(500), + ) + .ok()?; + stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?; + write!( + stream, + "GET /?t={token} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ) + .ok()?; + let mut answer = Vec::new(); + stream.read_to_end(&mut answer).ok()?; + let answer = String::from_utf8_lossy(&answer); + let head = answer.split("\r\n\r\n").next()?; + let status = head.split_whitespace().nth(1)?.parse().ok()?; + let cookie = head.lines().any(|line| { + line.to_ascii_lowercase() + .starts_with(&format!("set-cookie: biorouter_session={token};")) + }); + Some((status, cookie)) +} + /// Whether `pid` is a process that has not yet exited. A zombie has exited: it /// is waiting to be reaped by a parent, and holds no port and no memory. fn is_running(pid: u32) -> bool { @@ -351,22 +380,22 @@ impl Drop for Served { /// whole purpose is a token that survives a restart — could not work as written. /// /// The daemon holds the token and nothing reads it back, so the only honest -/// check is to present the operator's token to the running daemon: 303 is the -/// exchange for a cookie, 401 is a token it has never heard of. +/// check is to present the operator's token to the running daemon: a page that +/// sets the cookie is the exchange, 401 is a token it has never heard of. /// /// Fails the shipped command with `Some(401)`. #[test] fn serve_hands_the_daemon_the_token_the_operator_set_in_its_environment() { let served = Served::start_with(&[], &[("BIOROUTER_BROWSER_TOKEN", "token-from-the-file")]); assert_eq!( - http_status(served.port, "/?t=token-from-the-file"), - Some(303), + exchange(served.port, "token-from-the-file"), + Some((200, true)), "the operator's own token must open the interface:\n{}", served.log() ); assert_eq!( - http_status(served.port, "/?t=some-other-token"), - Some(401), + exchange(served.port, "some-other-token"), + Some((401, false)), "and nothing else may" ); } @@ -378,8 +407,8 @@ fn the_token_flag_outranks_the_environment() { &["--token", TOKEN], &[("BIOROUTER_BROWSER_TOKEN", "from-the-file")], ); - assert_eq!(http_status(served.port, &format!("/?t={TOKEN}")), Some(303)); - assert_eq!(http_status(served.port, "/?t=from-the-file"), Some(401)); + assert_eq!(exchange(served.port, TOKEN), Some((200, true))); + assert_eq!(exchange(served.port, "from-the-file"), Some((401, false))); } /// `--no-token` is a refusal to have a gate. A token this shell exports must not diff --git a/crates/biorouter-crew/README.md b/crates/biorouter-crew/README.md index 961ef1fd3..2d4d999eb 100644 --- a/crates/biorouter-crew/README.md +++ b/crates/biorouter-crew/README.md @@ -41,4 +41,4 @@ The host's home directory, journal, and device authority remain private to its U - The broker has explicit journal, state, message and attachment limits. Reaching a quota refuses new mutations and leaves existing reads available. Review the capacity and preservation section of the protocol before deploying a long-lived workspace. - SSH encryption alone does not establish HIPAA compliance. Institutional endpoint approval, retention, access review, workstation controls and the documented host trust boundary remain necessary deployment considerations. -See the [protocol and rootless setup contract](../../docs/research/biorouter-crew/protocol-contract.md), [implementation plan](../../docs/research/biorouter-crew/implementation-plan.md), and [acceptance ledger](../../docs/research/biorouter-crew/implementation-status.md). Unexecuted matrix rows are not compatibility claims. +See the [protocol and rootless setup contract](../../docs/crew/design/protocol-contract.md), [implementation plan](../../docs/history/biorouter-crew/implementation-plan.md), and [acceptance ledger](../../docs/history/biorouter-crew/implementation-status.md). Unexecuted matrix rows are not compatibility claims. diff --git a/crates/biorouter-crew/src/broker.rs b/crates/biorouter-crew/src/broker.rs index c0f7f4736..0fc841dc4 100644 --- a/crates/biorouter-crew/src/broker.rs +++ b/crates/biorouter-crew/src/broker.rs @@ -3,7 +3,7 @@ use anyhow::{anyhow, bail, ensure, Context, Result}; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use serde_json::json; use sha2::{Digest, Sha256}; -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::os::fd::AsRawFd; @@ -343,6 +343,10 @@ struct Enrollment { struct Cached { digest: String, result: Value, + /// When the result was cached (seconds since the Unix epoch). A result is kept for + /// [`Quotas::dedupe_ttl_secs`]; one cached before this field existed is pruned first. + #[serde(default, skip_serializing_if = "Option::is_none")] + at: Option, } struct RunPolicyConsent { public_provider: bool, @@ -506,6 +510,9 @@ fn apply_patch(state: &mut Value, patch: Patch) -> Result<()> { #[derive(Default)] pub struct Connection { challenges: BTreeMap, + /// The person whose signed device last spoke on this connection, for presence. A member's + /// bridge holds one connection for as long as their computer is connected. + principal: Option, } impl Connection { pub fn new() -> Self { @@ -518,7 +525,12 @@ pub struct Broker { journal: File, _lock: File, checksum: String, - poisoned: bool, + /// Set once a journal write or sync failed: the broker then saves nothing more until it is + /// restarted (fail-stop), and says so in `hello`, `status` and every refused change. + storage_fault: Option, + /// A test's injected journal failure (feature `test-seams`). + #[cfg(feature = "test-seams")] + injected_journal_fault: Option<(JournalCall, i32)>, /// The account database every UID-to-name check goes through. directory: Box, /// Where sibling runtime directories (`crew--/broker.sock`) live: `/tmp`. @@ -526,10 +538,160 @@ pub struct Broker { /// Per-actor times of recent name-collision refusals (in memory only), for the rate limit /// that bounds the name-existence oracle (D5). name_refusals: BTreeMap>, + /// The limits this broker enforces. + quotas: Quotas, + /// Journal bytes each actor's records take, tallied at replay and kept up to date by + /// [`Broker::commit_with`], for [`Quotas::member_journal_bytes`]. + journal_actor_bytes: BTreeMap, + /// The runs retention removed, so `run.revoke` can still answer their owners. + removed_runs: RemovedRuns, + /// Who is connected, per principal ([`Presence`]). In memory only: never journaled, never + /// counted in the state or any quota, and consulted by no authorization. + presence: BTreeMap, + /// How long after a person's last signed request they still count as online without a + /// connection open. + presence_window: Duration, + /// The serialized size of the committed state, kept by every commit for the host's + /// `usage`. + state_bytes: usize, #[cfg(feature = "join-by-name")] join_runtime: join::Runtime, } +/// One person's presence: when their device last made a signed request, and how many +/// connections it made them on are still open. +#[derive(Default)] +struct Presence { + last_request: Option, + open_connections: usize, +} +/// A person counts as online while a connection they signed on is open, and for this long +/// after their last signed request. The member's daemon heartbeats an idle connection every +/// 120 s, so this outlasts one missed beat. +const PRESENCE_WINDOW: Duration = Duration::from_secs(180); +/// Which journal call a test makes fail ([`Broker::inject_journal_fault`]). +#[cfg(feature = "test-seams")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JournalCall { + /// The record's `write`: nothing of it is saved. + Write, + /// The `fsync` after it: whether the record was saved is unknown. + Sync, +} +/// Why the broker stopped saving changes. Kept in memory only: a restart repairs the journal +/// (a torn tail is set aside) and starts clean. +#[derive(Clone, Debug)] +struct StorageFault { + /// The disk or the account's quota is full (`ENOSPC`, `EDQUOT`), rather than another + /// storage error. + full: bool, + /// When it happened, in seconds since the Unix epoch. + at: u64, + /// The operating system's error, for the host's log only. Never sent to a member. + detail: String, + /// The journal's length before the record that failed. + committed: u64, + /// Whether the line for it is in `broker.log` yet. A full disk refuses that write too, so + /// it is tried again on later requests until space is freed. + logged: bool, +} +impl StorageFault { + fn code(&self) -> &'static str { + if self.full { + "storage_full" + } else { + "storage_failed" + } + } + /// The sentence every change is refused with once the broker has stopped saving, and what + /// `hello` reports, without its code. + fn stopped_sentence(&self) -> &'static str { + if self.full { + STORAGE_FULL_STOPPED + } else { + STORAGE_FAILED_STOPPED + } + } + fn refusal(&self) -> anyhow::Error { + anyhow!("{}: {}", self.code(), self.stopped_sentence()) + } + /// What the host is told to do, naming the state directory. + fn host_instruction(&self, root: &Path) -> String { + let first = if self.full { + "Free space on this server" + } else { + "Check this server's storage" + }; + format!( + "{first}, then restart Crew: biorouter-crew stop --state-dir {root}, then \ + biorouter-crew start --state-dir {root}.", + root = root.display() + ) + } +} +const STORAGE_FULL_STOPPED: &str = "The workspace server ran out of disk space and has stopped saving changes. Reading still works. Ask the host to free space on the server and restart Crew."; +const STORAGE_FAILED_STOPPED: &str = "The workspace server could not save a change to disk and has stopped saving changes. Reading still works. Ask the host to check the server's storage and restart Crew."; +const STORAGE_FULL_NOT_SAVED: &str = "storage_full: The workspace server is out of disk space, so this change was not saved. Reading still works. Ask the host to free space on the server and restart Crew."; +const STORAGE_FULL_UNCERTAIN: &str = "storage_full: The workspace server ran out of disk space while saving this change, so it may not have been saved. Reading still works. Ask the host to free space on the server and restart Crew."; +const STORAGE_FAILED_NOT_SAVED: &str = "storage_failed: The workspace server could not write this change to disk, so it was not saved. Reading still works. Ask the host to check the server's storage and restart Crew."; +const STORAGE_FAILED_UNCERTAIN: &str = "storage_failed: The workspace server could not confirm this change was saved to disk, so it may not have been saved. Reading still works. Ask the host to check the server's storage and restart Crew."; +/// A storage error outside the journal (an attachment's file): nothing was recorded, so the +/// broker keeps saving, and the same request can be sent again once there is space. +const STORAGE_FULL_RETRY: &str = "storage_full: The workspace server is out of disk space, so this could not be saved. Ask the host to free space on the server, then try again."; +const STORAGE_FAILED_RETRY: &str = "storage_failed: The workspace server could not read or write its storage. Ask the host to check the server's storage, then try again."; +/// Whether `error` means the disk, or the account's disk quota, is full. +fn is_space_error(error: &std::io::Error) -> bool { + matches!(error.raw_os_error(), Some(code) if code == libc::ENOSPC || code == libc::EDQUOT) +} +/// `at` (seconds since the Unix epoch) as UTC, `2026-09-27T21:03:04Z`. +fn utc_timestamp(at: u64) -> String { + let days = at / 86_400; + let seconds = at % 86_400; + // Howard Hinnant's civil_from_days. + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + seconds / 3_600, + seconds % 3_600 / 60, + seconds % 60 + ) +} +/// A refusal as the wire carries it: the code is the message's `code:` prefix, else +/// `request_denied`. A raw operating-system error (an attachment's file could not be written) +/// is a storage fault, said in words, never `request_denied` with the OS's own text. +fn protocol_error(error: &anyhow::Error) -> ProtocolError { + let code_of = |message: &str| { + message + .split(':') + .next() + .filter(|s| s.chars().all(|c| c.is_ascii_lowercase() || c == '_')) + .map(str::to_owned) + }; + let mut message = error.to_string(); + if code_of(&message).is_none() { + if let Some(io) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + message = if is_space_error(io) { + STORAGE_FULL_RETRY + } else { + STORAGE_FAILED_RETRY + } + .to_owned(); + } + } + let code = code_of(&message).unwrap_or_else(|| "request_denied".to_owned()); + ProtocolError { code, message } +} /// Collision refusals allowed per actor within [`NAME_REFUSAL_WINDOW_SECS`] before every /// name-bearing create or rename is answered with the generic [`NAME_RATE_LIMITED`]. const NAME_REFUSAL_LIMIT: usize = 10; @@ -568,6 +730,143 @@ const SIBLING_PROBE_LIMIT: usize = 32; /// How long one sibling broker may take to answer `hello` during the probe. const SIBLING_PROBE_TIMEOUT: Duration = Duration::from_secs(1); +const MIB: u64 = 1024 * 1024; +const DAY_SECS: u64 = 24 * 60 * 60; + +/// The workspace's resource limits. Every broker enforces [`Quotas::STANDARD`]; test builds may +/// scale them down (`Broker::set_quotas`, feature `test-seams`). +/// +/// Three rules keep one member from exhausting what everyone shares: +/// +/// - **Shares.** No member other than the host may hold more than a set part of any +/// workspace-wide budget: a quarter of the state, the journal, the attachment bytes and +/// counts and the references, a tenth of the teams and channels, and a bounded number of +/// live invitations. The host, who owns the workspace, has no share. +/// - **Headroom.** Ordinary changes stop short of the state and journal limits, so the host can +/// always remove a member, change policy and let people in once they are reached. +/// - **Retention.** Idempotency results, unfinished uploads, long-expired invitations and runs +/// age out, so none of them becomes a permanent cap. Messages and completed attachments are +/// history and are never removed. +#[derive(Clone, Copy, Debug)] +pub struct Quotas { + /// The serialized logical state. + pub state_bytes: usize, + /// The part of `state_bytes` only the host's administrative operations and enrollment + /// may use. + pub state_admin_headroom: usize, + /// The retained journal. + pub journal_bytes: u64, + /// The part of `journal_bytes` only the host's administrative operations, enrollment and + /// the broker's own records may use. + pub journal_admin_headroom: u64, + /// The state one member (not the host) may hold in records they created: their messages, + /// runs, attachments, references, invitations, teams and channels. + pub member_state_bytes: usize, + /// The journal one member (not the host) may write. + pub member_journal_bytes: u64, + /// Teams one member (not the host) may create. + pub member_teams: usize, + /// Channels one member (not the host) may create, including their teams' `#general`. + pub member_channels: usize, + /// Attachments one member (not the host) may hold, finished or in progress. + pub member_blobs: usize, + /// Declared attachment bytes one member (not the host) may hold. + pub member_blob_bytes: u64, + /// Remote references one member (not the host) may create. + pub member_references: usize, + /// Unexpired invitations one member (not the host) may have outstanding. + pub member_live_invitations: usize, + /// How long an idempotency result is kept. + pub dedupe_ttl_secs: u64, + /// At most this many idempotency results are kept per principal (newest first) ... + pub dedupe_actor_entries: usize, + /// ... in at most this many serialized bytes. + pub dedupe_actor_bytes: usize, + /// At most this many idempotency results are kept in all (newest first). + pub dedupe_entries: usize, +} + +impl Quotas { + pub const STANDARD: Quotas = Quotas { + state_bytes: 16 * MIB as usize, + state_admin_headroom: MIB as usize, + journal_bytes: 1024 * MIB, + journal_admin_headroom: 16 * MIB, + member_state_bytes: 4 * MIB as usize, + member_journal_bytes: 256 * MIB, + member_teams: 10, + member_channels: 100, + member_blobs: 2_500, + member_blob_bytes: 10 * 1024 * MIB / 4, + member_references: 2_500, + member_live_invitations: 100, + dedupe_ttl_secs: DAY_SECS, + dedupe_actor_entries: 512, + dedupe_actor_bytes: 128 * 1024, + dedupe_entries: 100_000, + }; +} + +/// An upload that has not begun or received a chunk for this long is removed. +const BLOB_UPLOAD_TTL_SECS: u64 = DAY_SECS; +/// An invitation is removed this long after it expired (its inviter sees it marked expired +/// until then). +const INVITATION_RETENTION_SECS: u64 = 7 * DAY_SECS; +/// A run (and its grant) is removed this long after it expired. +const RUN_RETENTION_SECS: u64 = DAY_SECS; +/// At most this many records of each kind are pruned by one mutation, so one journal record +/// stays small however much has aged out at once. +const PRUNE_BATCH: usize = 1024; +/// The largest single attachment, and every attachment together. +const BLOB_MAX_BYTES: u64 = 1024 * MIB; +const WORKSPACE_BLOB_BYTES: u64 = 10 * 1024 * MIB; +const WORKSPACE_BLOBS: usize = 10_000; +/// The host's own operations that may use the administrative headroom. +const ADMIN_METHODS: [&str; 6] = [ + "enrollment.invite", + "enrollment.approve", + "enrollment.cancel", + "enrollment.revoke", + "policy.set", + "workspace.rename", +]; +/// Operations that only take access away. Anyone may use the administrative headroom for them, +/// so a full workspace never keeps a person in a channel or an agent running: each adds at +/// most a bounded idempotency result, and one member's results are capped. +const ACCESS_REMOVAL_METHODS: [&str; 3] = ["run.revoke", "membership.revoke", "channel.archive"]; +/// Methods that add records to the actor's share of the state: a member past +/// [`Quotas::member_state_bytes`] is refused them. Everything else changes records in place, +/// removes them, or adds a bounded amount (a read position, a membership). +const SHARE_METHODS: [&str; 8] = [ + "message.post", + "run.project", + "run.create", + "blob.begin", + "reference.create", + "invitation.create", + "team.create", + "channel.create", +]; +const MEMBER_STATE_QUOTA: &str = "quota_exceeded: You have used your share of this workspace's storage. Reading still works; ask the workspace host about starting a new workspace."; +const MEMBER_JOURNAL_QUOTA: &str = "quota_exceeded: You have made as many changes as one member's share of this workspace's audit journal allows. Reading still works; ask the workspace host about starting a new workspace."; +/// A snapshot's invitations, runs and references are each capped at this many serialized +/// bytes, and its teams and channels take what the rest leaves under the frame limit less +/// [`SNAPSHOT_FRAME_MARGIN`], so what other members do can never push a snapshot past the frame +/// limit. The full counts are in `totals`. +const SNAPSHOT_SECTION_BYTES: usize = 64 * 1024; +/// What a snapshot leaves unused of the frame: the response around it (`{"id":…,"result":…}` +/// and its newline) and room to spare. +const SNAPSHOT_FRAME_MARGIN: usize = 1024; +/// A worker's `context.manifest` carries at most this many serialized bytes of messages. +const CONTEXT_MANIFEST_BYTES: usize = 640 * 1024; +/// A worker's `messages.history` or `messages.search` page carries at most this many +/// serialized bytes of messages, and always at least one: a single message stays under the +/// frame limit even at the workspace's attachment and reference limits. +const WORKER_HISTORY_BYTES: usize = CONTEXT_MANIFEST_BYTES; +/// A message body's JSON form: twice its 65,536-byte limit, what quotes and backslashes can +/// double it to. Only control characters escape to more. +const MESSAGE_ESCAPED_BYTES: usize = 2 * 65_536; + #[cfg(feature = "join-by-name")] mod join; #[derive(Clone)] @@ -585,13 +884,69 @@ struct JournalReplay { sequence: u64, committed: usize, torn_tail: Option>, + /// Journal bytes of each actor's complete records. + actor_bytes: BTreeMap, + /// The runs the journal's records removed from the state. + removed_runs: RemovedRuns, +} +/// The runs retention removed from the state ([`prune_retained`]), each as a short digest of +/// its ID with a short digest of its owner's principal ID. Kept in memory only, and rebuilt at +/// every open from the journal, which keeps the record of every removal for as long as the +/// workspace exists: it costs the logical state nothing and never forgets a run. The journal +/// also bounds it: each removed run was created by a record of several hundred bytes, so even a +/// full 1 GiB journal names at most a couple of million, at about 40 bytes each here. +/// +/// ⚠ **Grant and revocation state; needs human review.** `run.revoke` asks it. A daemon that +/// stopped a grant while the workspace was out of reach asks to revoke the run whenever it +/// reconnects, however long after the run expired, and a daemon that replaced or re-granted a +/// chat's grant asks about the earlier run for a week past its end. Once retention removed the +/// run that request was refused as a run the owner does not hold, so the stop could never be +/// confirmed and was asked about again at every reconnect. A removed run is honored by nothing +/// (it expired, and its grant went with it), so its owner is now told it is revoked; anyone +/// else is answered as for a run that never existed. +#[derive(Default)] +struct RemovedRuns(HashMap<[u8; 16], [u8; 16]>); +impl RemovedRuns { + fn key(text: &str) -> [u8; 16] { + let digest = Sha256::digest(text.as_bytes()); + let mut key = [0; 16]; + key.copy_from_slice(&digest[..16]); + key + } + fn insert(&mut self, run_id: &str, owner_id: &str) { + self.0.insert(Self::key(run_id), Self::key(owner_id)); + } + /// Whether `run_id` was removed while `owner_id` owned it. + fn owned_by(&self, run_id: &str, owner_id: &str) -> bool { + self.0.get(&Self::key(run_id)) == Some(&Self::key(owner_id)) + } + /// Note a run `patch` removes from `state`, before it is applied. + fn note(&mut self, state: &Value, patch: &Patch) { + if let Patch::Remove { path } = patch { + if let [map, run_id] = path.as_slice() { + if map == "runs" { + if let Some(owner) = state + .get("runs") + .and_then(|runs| runs.get(run_id)) + .and_then(|run| run.get("owner_id")) + .and_then(Value::as_str) + { + self.insert(run_id, owner); + } + } + } + } + } } +/// Replay one complete journal record onto `state_value`, noting the runs it removes in +/// `removed_runs`; returns the record's actor. fn replay_record( line: &[u8], state_value: &mut Value, checksum: &mut String, sequence: &mut u64, -) -> Result<()> { + removed_runs: &mut RemovedRuns, +) -> Result { let envelope: Value = serde_json::from_slice(line).context("journal_corrupt: complete record is invalid")?; match envelope.get("version").and_then(Value::as_u64) { @@ -630,6 +985,7 @@ fn replay_record( .get("state") .cloned() .ok_or_else(|| anyhow!("journal_corrupt: state missing"))?; + Ok(record.actor) } Some(2) => { let record: DeltaRecord = @@ -652,14 +1008,15 @@ fn replay_record( "journal_corrupt: checksum mismatch" ); for patch in record.patches { + removed_runs.note(state_value, &patch); apply_patch(state_value, patch)?; } *checksum = actual; *sequence = record.sequence; + Ok(record.actor) } _ => bail!("journal_corrupt: unsupported journal version"), } - Ok(()) } fn replay_journal(journal: &File) -> Result { let mut replay = BufReader::new(journal.try_clone()?); @@ -668,6 +1025,8 @@ fn replay_journal(journal: &File) -> Result { let mut checksum = String::new(); let mut sequence = 0; let mut committed = 0; + let mut actor_bytes: BTreeMap = BTreeMap::new(); + let mut removed_runs = RemovedRuns::default(); loop { let mut line = Vec::new(); let count = std::io::Read::by_ref(&mut replay) @@ -684,7 +1043,14 @@ fn replay_journal(journal: &File) -> Result { torn_tail = Some(line); break; } - replay_record(&line, &mut state_value, &mut checksum, &mut sequence)?; + let actor = replay_record( + &line, + &mut state_value, + &mut checksum, + &mut sequence, + &mut removed_runs, + )?; + *actor_bytes.entry(actor).or_default() += line.len() as u64; ensure!( state_value.get("sequence").and_then(Value::as_u64) == Some(sequence), "journal_corrupt: state sequence mismatch" @@ -701,6 +1067,8 @@ fn replay_journal(journal: &File) -> Result { sequence, committed, torn_tail, + actor_bytes, + removed_runs, }) } fn recovered_state( @@ -770,6 +1138,44 @@ fn recovered_state( }; Ok(state) } +/// Which limits a commit answers to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Allowance { + /// May use the state's administrative headroom, up to the full state limit: the broker's + /// own records, enrollment, the host's administrative operations and anyone's removal of + /// access. Everything else stops short of the limit by the headroom. + state_headroom: bool, + /// May use the journal's administrative headroom, up to the full journal limit: the + /// broker's own records, enrollment and the host's administrative operations only. A + /// member's removals of access do not get it, since repeating one writes a record each + /// time and would let one member use it up. + journal_headroom: bool, + /// Answers to the actor's own journal share: everyone but the host. + member: bool, +} +impl Allowance { + const FULL: Allowance = Allowance { + state_headroom: true, + journal_headroom: true, + member: false, + }; +} +/// Counts the bytes written to it. +struct ByteCount(usize); +impl Write for ByteCount { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 += bytes.len(); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} +/// The length of `value`'s JSON serialization, without building it. +fn json_len(value: &T) -> usize { + let mut count = ByteCount(0); + serde_json::to_writer(&mut count, value).map_or(usize::MAX, |()| count.0) +} impl Broker { pub fn open(root: &Path, bootstrap_key: &str) -> Result { Self::open_inner(root, bootstrap_key, Box::new(SystemDirectory), None) @@ -784,12 +1190,43 @@ impl Broker { ) -> Result { Self::open_inner(root, bootstrap_key, directory, None) } - /// Point the sibling-workspace probe of `workspace.rename` at another directory than - /// `/tmp`. Test builds only. + /// Point the sibling-workspace probe of `workspace.rename`, and the runtime directory + /// [`Broker::prepare_runtime`] binds, at another directory than `/tmp`. Test builds only. #[cfg(feature = "test-seams")] pub fn set_runtime_root(&mut self, runtime_root: &Path) { self.runtime_root = runtime_root.to_path_buf(); } + /// What `serve` does before it binds its socket: read the recorded runtime, choose (and + /// journal) the runtime directory under the runtime root, and return the socket path. + /// `node_id` stands in for the node identity `runtime.json` must name. Test builds only. + #[cfg(feature = "test-seams")] + pub fn prepare_runtime(&mut self, node_id: &str) -> Result { + persisted_runtime(self, node_id) + } + /// Enforce `quotas` instead of [`Quotas::STANDARD`], so a test can reach a limit without + /// writing a gigabyte. Test builds only. + #[cfg(feature = "test-seams")] + pub fn set_quotas(&mut self, quotas: Quotas) { + self.quotas = quotas; + } + /// Count a person as online for `window` after their last signed request instead of + /// [`PRESENCE_WINDOW`], so a test need not wait three minutes. Test builds only. + #[cfg(feature = "test-seams")] + pub fn set_presence_window(&mut self, window: Duration) { + self.presence_window = window; + } + /// Make the next journal `call` fail with `errno` (`ENOSPC`, `EIO`, ...), as a full or + /// failing disk would. Test builds only. + #[cfg(feature = "test-seams")] + pub fn inject_journal_fault(&mut self, call: JournalCall, errno: i32) { + self.injected_journal_fault = Some((call, errno)); + } + /// The authoritative state as JSON, for tests that check what is retained. Test builds + /// only. + #[cfg(feature = "test-seams")] + pub fn state_json(&self) -> Value { + serde_json::to_value(&self.state).expect("state serializes") + } /// Open (or initialize) a workspace. `initial_name` names a workspace this call creates; an /// existing workspace keeps its stored name. fn open_inner( @@ -839,6 +1276,8 @@ impl Broker { sequence, committed, torn_tail, + actor_bytes, + removed_runs, } = replay_journal(&journal)?; let state = recovered_state(state_value, sequence, bootstrap_key, initial_name)?; if let Some(torn_bytes) = torn_tail { @@ -857,31 +1296,59 @@ impl Broker { journal, _lock: lock, checksum, - poisoned: false, + storage_fault: None, + #[cfg(feature = "test-seams")] + injected_journal_fault: None, directory, runtime_root: PathBuf::from("/tmp"), name_refusals: BTreeMap::new(), + quotas: Quotas::STANDARD, + journal_actor_bytes: actor_bytes, + removed_runs, + presence: BTreeMap::new(), + presence_window: PRESENCE_WINDOW, + state_bytes: 0, #[cfg(feature = "join-by-name")] join_runtime: join::Runtime::default(), }; + broker.state_bytes = json_len(&broker.state); if sequence == 0 { broker.commit(broker.state.clone(), "system", "workspace.initialize")?; } Ok(broker) } - fn commit(&mut self, mut state: State, actor: &str, operation: &str) -> Result<()> { - ensure!( - !self.poisoned, - "storage_failed: restart and recover before further mutations" - ); + /// Commit with the full limits: the broker's own records, enrollment and the host's + /// administrative operations. + fn commit(&mut self, state: State, actor: &str, operation: &str) -> Result<()> { + self.commit_with(state, actor, operation, Allowance::FULL) + } + fn commit_with( + &mut self, + mut state: State, + actor: &str, + operation: &str, + allowance: Allowance, + ) -> Result<()> { + if let Some(fault) = &self.storage_fault { + return Err(fault.refusal()); + } + let quotas = self.quotas; state.sequence = self.state.sequence + 1; let after = serde_json::to_value(&state)?; - ensure!(serde_json::to_vec(&after)?.len()<=16*1024*1024,"quota_exceeded: workspace logical state exceeds 16 MiB; reads remain available but further mutations require a new workspace or a supported retention upgrade; in-place pruning is not supported"); let before = if self.state.sequence == 0 { Value::Null } else { serde_json::to_value(&self.state)? }; + let size = json_len(&after); + // A commit that does not grow the state (removing what aged out) is never refused + // for size: it can only help a workspace at its limit. + let shrinks = size <= json_len(&before); + if allowance.state_headroom { + ensure!(shrinks || size <= quotas.state_bytes, "quota_exceeded: workspace logical state exceeds 16 MiB; reads remain available but further mutations require a new workspace or a supported retention upgrade; in-place pruning is not supported"); + } else { + ensure!(shrinks || size <= quotas.state_bytes - quotas.state_admin_headroom, "quota_exceeded: workspace logical state is full; reads remain available and the host can still remove members and change policy, but further changes require a new workspace; in-place pruning of history is not supported"); + } let mut patches = Vec::new(); delta(&before, &after, &mut Vec::new(), &mut patches); let mut record = DeltaRecord { @@ -905,23 +1372,175 @@ impl Broker { ))?); let mut bytes = serde_json::to_vec(&record)?; bytes.push(b'\n'); + let length = bytes.len() as u64; + let committed = self.journal.metadata()?.len(); + let journal = committed + length; ensure!( - bytes.len() <= 16 * 1024 * 1024 - && self.journal.metadata()?.len() + bytes.len() as u64 <= 1024 * 1024 * 1024, + bytes.len() <= 16 * 1024 * 1024 && journal <= quotas.journal_bytes, "quota_exceeded: retained audit journal exceeds 1 GiB; preserve the complete store and use a new workspace; in-place audit deletion is not supported" ); - self.poisoned = true; - self.journal.write_all(&bytes)?; - self.journal.sync_all()?; - sync_dir(&self.root)?; + ensure!( + allowance.journal_headroom + || journal <= quotas.journal_bytes - quotas.journal_admin_headroom, + "quota_exceeded: retained audit journal is nearly full; reads remain available and the host can still remove members and change policy; preserve the complete store and use a new workspace" + ); + let written = self.journal_actor_bytes.get(actor).copied().unwrap_or(0); + ensure!( + !allowance.member || written.saturating_add(length) <= quotas.member_journal_bytes, + MEMBER_JOURNAL_QUOTA + ); + if let Err((written, error)) = self.append_record(&bytes) { + return Err(self.stop_saving(written, &error, committed)); + } self.state = state; + self.state_bytes = size; self.checksum = record.checksum; - self.poisoned = false; + *self + .journal_actor_bytes + .entry(actor.to_owned()) + .or_default() += length; Ok(()) } + /// Write `bytes` to the journal and make them durable. On failure, whether any of the + /// record may have reached the file (`true` once the write itself succeeded) and why. + fn append_record(&mut self, bytes: &[u8]) -> std::result::Result<(), (bool, std::io::Error)> { + #[cfg(feature = "test-seams")] + let injected = self.injected_journal_fault.take(); + #[cfg(feature = "test-seams")] + if let Some((JournalCall::Write, errno)) = injected { + return Err((false, std::io::Error::from_raw_os_error(errno))); + } + self.journal + .write_all(bytes) + .map_err(|error| (false, error))?; + #[cfg(feature = "test-seams")] + if let Some((JournalCall::Sync, errno)) = injected { + return Err((true, std::io::Error::from_raw_os_error(errno))); + } + self.journal.sync_all().map_err(|error| (true, error))?; + File::open(&self.root) + .and_then(|directory| directory.sync_all()) + .map_err(|error| (true, error)) + } + /// Stop saving (fail-stop) after the journal write or sync failed with `error`, tell the + /// host in `broker.log`, and return the refusal for the request that hit it. `written`: + /// the record's write succeeded, so whether it was saved is unknown. The broker cannot + /// know what reached the disk, so nothing is saved again until a restart repairs the + /// journal (a torn tail is set aside) and replays what was really written. + fn stop_saving( + &mut self, + written: bool, + error: &std::io::Error, + committed: u64, + ) -> anyhow::Error { + let full = is_space_error(error); + self.storage_fault = Some(StorageFault { + full, + at: now(), + detail: error.to_string(), + committed, + logged: false, + }); + self.log_storage_fault(); + anyhow!(match (full, written) { + (true, false) => STORAGE_FULL_NOT_SAVED, + (true, true) => STORAGE_FULL_UNCERTAIN, + (false, false) => STORAGE_FAILED_NOT_SAVED, + (false, true) => STORAGE_FAILED_UNCERTAIN, + }) + } + /// Append the line for the storage fault to `broker.log` in the state directory, once. + /// Best effort: on a full disk this fails too, and a later request tries again. + fn log_storage_fault(&mut self) { + let root = &self.root; + let Some(fault) = self.storage_fault.as_mut().filter(|fault| !fault.logged) else { + return; + }; + let line = format!( + "{} {}: Crew stopped saving changes: {}. journal.jsonl held {} bytes before the change that failed. Reading still works. {}\n", + utc_timestamp(fault.at), + fault.code(), + fault.detail, + fault.committed, + fault.host_instruction(root) + ); + fault.logged = private_file(&root.join("broker.log"), true) + .and_then(|mut log| { + log.write_all(line.as_bytes())?; + log.sync_all()?; + Ok(()) + }) + .is_ok(); + } pub fn workspace(&self) -> &Workspace { &self.state.workspace } + /// `principal`'s device made a signed request on `conn`. + fn note_presence(&mut self, conn: &mut Connection, principal: &str) { + let now = Instant::now(); + if conn.principal.as_deref() == Some(principal) { + // The connection is already counted: only the time moves. + if let Some(presence) = self.presence.get_mut(principal) { + presence.last_request = Some(now); + return; + } + } + if let Some(previous) = conn.principal.take() { + self.release_presence(&previous); + } + let presence = self.presence.entry(principal.to_owned()).or_default(); + presence.open_connections += 1; + presence.last_request = Some(now); + conn.principal = Some(principal.to_owned()); + } + fn release_presence(&mut self, principal: &str) { + if let Some(presence) = self.presence.get_mut(principal) { + presence.open_connections = presence.open_connections.saturating_sub(1); + } + } + /// `connection` has closed: the person it was signed on for stops counting as connected + /// through it. They stay online until [`PRESENCE_WINDOW`] after their last request. + pub fn connection_closed(&mut self, connection: &mut Connection) { + if let Some(principal) = connection.principal.take() { + self.release_presence(&principal); + } + } + /// The active people who are online: a connection they signed on is open, or they made a + /// signed request within the presence window. + fn online_principal_ids<'s>(&self, s: &'s State) -> Vec<&'s str> { + let now = Instant::now(); + s.principals + .values() + .filter(|p| p.active) + .filter(|p| { + self.presence.get(&p.id).is_some_and(|presence| { + presence.open_connections > 0 + || presence + .last_request + .is_some_and(|at| now.duration_since(at) <= self.presence_window) + }) + }) + .map(|p| p.id.as_str()) + .collect() + } + /// How full the workspace's non-renewable budgets are, for its host: the logical state, + /// the audit journal and the attachment space. Only the host can act on them (a new + /// workspace, removals), so only the host's snapshot carries this. + fn usage(&self, s: &State) -> Value { + let quotas = &self.quotas; + json!({ + "state_bytes": self.state_bytes, + "state_limit": quotas.state_bytes, + "state_admin_headroom": quotas.state_admin_headroom, + "journal_bytes": self.journal.metadata().map(|m| m.len()).unwrap_or_default(), + "journal_limit": quotas.journal_bytes, + "journal_admin_headroom": quotas.journal_admin_headroom, + "attachment_bytes": s.blobs.values().map(|b| b.size).sum::(), + "attachment_limit": WORKSPACE_BLOB_BYTES, + "attachments": s.blobs.len(), + "attachments_limit": WORKSPACE_BLOBS, + }) + } pub fn handle(&mut self, uid: u32, connection: &mut Connection, request: Request) -> Response { let result = self.process(uid, connection, &request); match result { @@ -930,20 +1549,11 @@ impl Broker { result: Some(value), error: None, }, - Err(error) => { - let message = error.to_string(); - let code = message - .split(':') - .next() - .filter(|s| s.chars().all(|c| c.is_ascii_lowercase() || c == '_')) - .unwrap_or("request_denied") - .to_owned(); - Response { - id: request.id, - result: None, - error: Some(ProtocolError { code, message }), - } - } + Err(error) => Response { + id: request.id, + result: None, + error: Some(protocol_error(&error)), + }, } } fn process(&mut self, uid: u32, conn: &mut Connection, req: &Request) -> Result { @@ -951,6 +1561,7 @@ impl Broker { req.version == 1 && !req.id.is_empty() && req.id.len() <= 128, "invalid_request: version/id" ); + self.log_storage_fault(); if req.method == "hello" { return self.hello(req); } @@ -970,6 +1581,10 @@ impl Broker { Admission::Actor(actor) => *actor, Admission::Replay(result) => return Ok(result), }; + // A person's signed device, not an agent's grant: presence is about people. + if actor.run.is_none() { + self.note_presence(conn, &actor.id); + } if matches!( req.method.as_str(), "workspace.snapshot" @@ -1108,37 +1723,16 @@ impl Broker { canonical(&req.params) ]))?); if let Some(saved) = self.state.dedupe.get(&key) { - if let Some(channel) = req.params.get("channel_id").and_then(Value::as_str) { - // The host may add people to a channel it is not in (direct add), so its - // retry of that add is re-authorized as the host, not as a member. - let host_add = req.method == "channel.add_member" - && self.manager(&self.state, &actor.id).is_ok(); - if !host_add { - self.channel(&self.state, &actor.id, channel, false)?; - } - } - if let Some(blob_id) = req.params.get("blob_id").and_then(Value::as_str) { - let blob = self - .state - .blobs - .get(blob_id) - .ok_or_else(|| anyhow!("forbidden: attachment unavailable"))?; - self.blob_authorized(&self.state, actor, blob)?; - } + self.recheck_replay(actor, req)?; ensure!( saved.digest == fingerprint, "conflict: idempotency key reused with different request" ); return Ok(saved.result.clone()); } - ensure!( - !self.poisoned, - "storage_failed: restart and recover before further mutations" - ); - ensure!( - self.state.dedupe.len() < 100_000, - "quota_exceeded: workspace operation quota requires maintenance" - ); + if let Some(fault) = &self.storage_fault { + return Err(fault.refusal()); + } let naming = NAME_METHODS.contains(&req.method.as_str()); if naming { // Checked before anything about the name is evaluated, so the answer to a @@ -1148,35 +1742,117 @@ impl Broker { NAME_RATE_LIMITED ); } + let now = now(); let mut state = self.state.clone(); #[cfg(feature = "join-by-name")] - join::prune_expired(&mut state, now()); + join::prune_expired(&mut state, now); + let Pruned { + uploads: expired_uploads, + runs: removed_runs, + } = prune_retained(&mut state, now, &self.quotas); + // Noted now, before the commit and before `run.revoke` might ask about one of them: a + // run removed here expired more than a day ago, so nothing honors it whether or not + // this commit lands, and the index is asked only about a run the state does not hold. + for (run_id, owner_id) in &removed_runs { + self.removed_runs.insert(run_id, owner_id); + } let result = match self.mutate(&mut state, actor, req) { Ok(result) => result, Err(error) => { if naming && error.to_string().starts_with("name_taken:") { - self.record_name_refusal(&actor.id, now()); + self.record_name_refusal(&actor.id, now); } return Err(error); } }; - state.dedupe.insert( - key, - Cached { - digest: fingerprint, - result: result.clone(), - }, - ); - if let Err(error) = self.commit(state, &actor.id, &req.method) { - if req.method == "blob.begin" && !self.poisoned { + let host = self.manager(&self.state, &actor.id).is_ok(); + let method = req.method.as_str(); + let administrative = host && actor.run.is_none() && ADMIN_METHODS.contains(&method); + let allowance = Allowance { + state_headroom: administrative || ACCESS_REMOVAL_METHODS.contains(&method), + journal_headroom: administrative, + member: !host, + }; + let committed = (|| -> Result<()> { + let share = self.quotas.member_state_bytes; + // A terminal projection ends its run, and with it the grant, so a member still + // within their share before it may post it even when it takes them past: an agent + // task finishes with its result rather than failing for the last few bytes. Only + // from within the share, so a member is never more than one message past it, + // however many runs they created while under it. Past it, a terminal projection is + // refused like any other addition, and the run's owner still ends the run with + // `run.revoke`, which is never refused for space. + let terminal = method == "run.project" + && result["status"] + .as_str() + .is_some_and(|status| status != "progress"); + ensure!( + host || !SHARE_METHODS.contains(&method) + || member_state_bytes(&state, &actor.id) <= share + || (terminal && member_state_bytes(&self.state, &actor.id) <= share), + MEMBER_STATE_QUOTA + ); + // A message is re-projected from the stored message on replay, so its cached + // result needs only what finds it (and the status a terminal replay checks). + let cached = match req.method.as_str() { + "message.post" | "run.project" => { + json!({"id": result["id"], "status": result["status"]}) + } + _ => result.clone(), + }; + remember( + &mut state, + &self.quotas, + &actor.id, + key, + Cached { + digest: fingerprint, + result: cached, + at: Some(now), + }, + ); + self.commit_with(state, &actor.id, &req.method, allowance) + })(); + if let Err(error) = committed { + if req.method == "blob.begin" && self.storage_fault.is_none() { if let Some(blob_id) = result.get("id").and_then(Value::as_str) { let _ = fs::remove_file(self.root.join("blobs").join(blob_id)); } } return Err(error); } + // Only once the removal is journaled: a failed commit keeps both. + for blob_id in expired_uploads { + let _ = fs::remove_file(self.root.join("blobs").join(blob_id)); + } Ok(result) } + /// A retried mutation's cached result is returned only while the actor may still reach + /// what it names: the channel (as a member) and the attachment. + fn recheck_replay(&self, actor: &Actor, req: &Request) -> Result<()> { + if let Some(channel) = req.params.get("channel_id").and_then(Value::as_str) { + // The host may add people to a channel it is not in (direct add), so its retry of + // that add is re-authorized as the host, not as a member. Likewise a member's retry + // of leaving a channel (they are no longer in it, and the result is the one they + // were given), and the host's of acting on a channel whose owner left the workspace. + let host_add = + req.method == "channel.add_member" && self.manager(&self.state, &actor.id).is_ok(); + let outside = Self::is_leave(req, &actor.id) + || self.stewards_orphaned_channel(&self.state, actor, req); + if !host_add && !outside { + self.channel(&self.state, &actor.id, channel, false)?; + } + } + if let Some(blob_id) = req.params.get("blob_id").and_then(Value::as_str) { + let blob = self + .state + .blobs + .get(blob_id) + .ok_or_else(|| anyhow!("forbidden: attachment unavailable"))?; + self.blob_authorized(&self.state, actor, blob)?; + } + Ok(()) + } fn hello(&self, req: &Request) -> Result { let secret: [u8; 32] = hex::decode(&self.state.workspace_signing_key)? .try_into() @@ -1232,9 +1908,21 @@ impl Broker { ) .to_bytes(), ); - Ok( - json!({"protocol":1,"workspace_id":workspace.id,"host_uid":workspace.host_uid,"mode":workspace.mode,"institution_id":workspace.institution_id,"policy_epoch":workspace.policy_epoch,"name":workspace.name,"workspace_public_key":public_key,"node_id":node_id,"workspace_key_fingerprint":digest(&key.verifying_key().to_bytes()),"challenge_nonce":nonce,"signature":signature,"signature_v2":signature_v2,"capabilities":capabilities,"unsupported":["arbitrary_shell","remote_filesystem","network_filesystem","cross_workspace_release"]}), - ) + let mut hello = json!({"protocol":1,"workspace_id":workspace.id,"host_uid":workspace.host_uid,"mode":workspace.mode,"institution_id":workspace.institution_id,"policy_epoch":workspace.policy_epoch,"name":workspace.name,"workspace_public_key":public_key,"node_id":node_id,"workspace_key_fingerprint":digest(&key.verifying_key().to_bytes()),"challenge_nonce":nonce,"signature":signature,"signature_v2":signature_v2,"capabilities":capabilities,"unsupported":["arbitrary_shell","remote_filesystem","network_filesystem","cross_workspace_release"]}); + // Unsigned, for display only: whether this broker is still saving changes. An older + // broker sends no `state`, which a client reads as unknown. + match &self.storage_fault { + None => hello["state"] = json!("running"), + Some(fault) => { + hello["state"] = json!("storage_failed"); + hello["storage"] = json!({ + "code": fault.code(), + "message": fault.stopped_sentence(), + "since": fault.at, + }); + } + } + Ok(hello) } /// What `hello` advertises, in the order it advertises it (the v2 signature covers the /// order). @@ -1248,6 +1936,7 @@ impl Broker { "human_names_v1", "unique_names_v1", "direct_add_v1", + "presence_v1", ]; #[cfg(feature = "join-by-name")] capabilities.push("join_by_name_v1"); @@ -1752,13 +2441,8 @@ impl Broker { _ => Ok(result), } } - fn read_workspace_snapshot(&self, actor: &Actor, _req: &Request) -> Result { + fn read_workspace_snapshot(&self, actor: &Actor, req: &Request) -> Result { let s = &self.state; - let protected_channel_ids: Vec<&str> = Self::protected_channel_ids(s) - .into_iter() - .filter(|channel| self.channel(s, &actor.id, channel, false).is_ok()) - .collect(); - let (positions, unread) = self.read_state(s, actor); let host = self.manager(s, &actor.id).is_ok(); let now = now(); let index = PeopleIndex::new(s); @@ -1772,24 +2456,118 @@ impl Broker { .values() .filter(|c| c.members.contains(&actor.id)) .collect(); - // An invitee no longer sees an invitation once it has expired (it can never be - // accepted); its inviter still does, marked `expired`. - let invitations: Vec<&Invitation> = s + let (invitations, invitations_wire, invitations_total) = + Self::snapshot_invitations(s, &index, &actor.id, now); + let mut workspace = json!(s.workspace); + workspace["host_principal_id"] = json!(host_principal_id(s)); + let principals = self.snapshot_principals(s, &index, host); + let actor_wire = Self::actor_wire(s, &index, &actor.id); + // The actor's live runs only (a revoked, expired or superseded run grants nothing), + // newest first. + let mut runs: Vec<&Run> = s + .runs + .values() + .filter(|r| { + r.owner_id == actor.id + && !r.revoked + && r.expires_at >= now + && r.policy_epoch == s.workspace.policy_epoch + }) + .collect(); + runs.sort_by_key(|r| std::cmp::Reverse(r.expires_at)); + let runs_total = runs.len(); + let runs = within_budget(runs); + // Other members add references to shared channels: the actor's own come first, then + // each other owner's in turn. + let (mut references, others): (Vec<&RemoteReference>, Vec<&RemoteReference>) = s + .references + .values() + .filter(|r| self.reference_authorized(s, actor, r).is_ok()) + .partition(|r| r.owner_id == actor.id); + references.extend(in_turns(others, |r| r.owner_id.as_str())); + let references_total = references.len(); + let references = within_budget(references); + let totals = json!({"invitations": invitations_total, "runs": runs_total, "references": references_total, "teams": teams.len(), "channels": channels.len()}); + let mut snapshot = json!({"workspace":workspace,"protected_channel_ids":[],"actor":actor_wire,"principals":principals,"former_principals":[],"teams":[],"channels":[],"invitations":invitations_wire,"runs":runs,"read_positions":{},"unread":{},"references":references,"totals":totals}); + // Presence is display only: it is in memory, never journaled, and grants nothing. + snapshot["online_principal_ids"] = json!(self.online_principal_ids(s)); + if host { + snapshot["usage"] = self.usage(s); + let refusals: BTreeMap<&str, usize> = self + .name_refusals + .keys() + .map(|id| (id.as_str(), self.recent_name_refusals(id, now))) + .filter(|(_, count)| *count > 0) + .collect(); + snapshot["name_collision_refusals"] = json!(refusals); + #[cfg(feature = "join-by-name")] + { + snapshot["pending_joins"] = join::project_for_manager(self, s); + } + } + self.fill_places( + &mut snapshot, + actor, + req, + &index, + &teams, + &channels, + &invitations, + ); + Ok(snapshot) + } + /// Every invitation the actor sees, the wires of those a snapshot lists, and how many there + /// are in all. An invitee no longer sees an invitation once it has expired (it can never be + /// accepted); its inviter still does, marked `expired`, until it is pruned. What the invitee + /// can act on comes first, taking each inviter's newest in turn so no one inviter can crowd + /// out the rest, then the actor's own live and expired invitations, newest first, all within + /// one section budget. The full list is returned, not only what is listed, because the + /// former members it names are all listed ([`Self::fill_places`]). + fn snapshot_invitations<'s>( + s: &'s State, + index: &PeopleIndex<'_>, + actor_id: &str, + now: u64, + ) -> (Vec<&'s Invitation>, Vec, usize) { + let mut invitations: Vec<&Invitation> = s .invitations .values() .filter(|i| { - i.inviter_id == actor.id || (i.principal_id == actor.id && i.expires_at >= now) + i.inviter_id == actor_id || (i.principal_id == actor_id && i.expires_at >= now) }) .collect(); - let mut workspace = json!(s.workspace); - workspace["host_principal_id"] = json!(host_principal_id(s)); + invitations.sort_by_key(|i| { + ( + i.principal_id != actor_id, + i.expires_at < now, + std::cmp::Reverse(i.expires_at), + ) + }); + let received = invitations + .iter() + .take_while(|i| i.principal_id == actor_id) + .count(); + let sent = invitations.split_off(received); + let mut invitations = in_turns(invitations, |i| i.inviter_id.as_str()); + invitations.extend(sent); + let total = invitations.len(); + let wire = within_budget( + invitations + .iter() + .map(|invitation| Self::invitation_wire(s, index, invitation, now)) + .collect(), + ); + (invitations, wire, total) + } + /// The workspace's active members as a snapshot lists them; the host's also marks those + /// whose account no longer matches ([`Self::stale_principals`]). + fn snapshot_principals(&self, s: &State, index: &PeopleIndex<'_>, host: bool) -> Vec { let stale = if host { self.stale_principals(s) } else { BTreeSet::new() }; - let principals: Vec = s - .principals + s.principals .values() .filter(|p| p.active) .map(|p| { @@ -1799,45 +2577,197 @@ impl Broker { } wire }) + .collect() + } + /// Fill `snapshot`'s teams and channels, with what hangs off them (`protected_channel_ids`, + /// `read_positions`, `unread`), and the former members the actor's teams, channels and + /// invitations name, once everything else is in it. + /// + /// Teams and channels take the room everything else leaves under the frame limit. Their + /// size is not the actor's to choose: any team owner may add any member to their teams and + /// channels without asking, and each lists every member, so one member with a full share of + /// teams and channels could otherwise push everyone else's snapshot past the limit. What + /// does not fit is left out, whole, and counted in `totals`; nothing listed is ever cut + /// short. + /// + /// `former_principals` does not shrink with them: it names every former member any of the + /// actor's teams, channels or invitations names, listed or not. A resolver matches a former + /// member's username against that list, and former members may share a username (only + /// active ones may not), so a list short of one would resolve `@name` to the other. + #[allow(clippy::too_many_arguments)] + fn fill_places( + &self, + snapshot: &mut Value, + actor: &Actor, + req: &Request, + index: &PeopleIndex<'_>, + teams: &[&Team], + channels: &[&Channel], + invitations: &[&Invitation], + ) { + let s = &self.state; + let former_principals = json!(Self::former_principals( + s, + index, + teams, + channels, + invitations, + )); + let reserved = json_len(&*snapshot) + .saturating_add(json_len(&req.id)) + .saturating_add(SNAPSHOT_FRAME_MARGIN) + .saturating_add(json_len(&former_principals)); + let places = Self::places_within( + s, + &actor.id, + teams, + channels, + MAX_FRAME.saturating_sub(reserved), + ); + let protected = Self::protected_channel_ids(s); + let protected_channel_ids: Vec<&str> = places + .channels + .iter() + .map(|channel| channel.id.as_str()) + .filter(|channel| protected.contains(channel)) .collect(); - let former_principals = Self::former_principals(s, &index, &teams, &channels, &invitations); - let actor_wire = Self::actor_wire(s, &index, &actor.id); - let teams_wire = Self::teams_wire(&teams); - let channels_wire = Self::channels_wire(&channels); - let invitations_wire: Vec = invitations + let (positions, unread) = self.read_state(s, actor, &places.channels); + snapshot["protected_channel_ids"] = json!(protected_channel_ids); + snapshot["former_principals"] = former_principals; + snapshot["teams"] = Value::Array(places.teams_wire); + snapshot["channels"] = Value::Array(places.channels_wire); + snapshot["read_positions"] = json!(positions); + snapshot["unread"] = json!(unread); + } + /// Of the teams and channels the actor is in, those that fit in `room` serialized bytes, + /// with their wires, each in state order. Nothing is cut short: a team or channel is listed + /// whole or left out. + /// + /// Who gets the room first: what the actor created (their teams) or owns (their channels), + /// then the host's, then every other owner's, taking turns so that no one owner can crowd + /// the rest out; within a turn a team comes before a channel. A channel comes with its team + /// when the actor is in that team, so it can be shown where it belongs. Something that does + /// not fit is skipped and what follows is still tried, so one large team or channel never + /// stops the smaller ones after it. + fn places_within<'s>( + s: &'s State, + actor_id: &str, + teams: &[&'s Team], + channels: &[&'s Channel], + room: usize, + ) -> Places<'s> { + #[derive(Clone, Copy)] + enum Place { + Team(usize), + Channel(usize), + } + // Every team and channel the actor is in, before any is left out, so that each listed + // one's `name_conflict` still counts those that are not: a resolver holding only the + // listed ones reads it to tell whether a name it found is the only one of its kind. + let teams_wire = Self::teams_wire(teams); + let channels_wire = Self::channels_wire(channels); + let team_cost: Vec = teams_wire.iter().map(|wire| json_len(wire) + 1).collect(); + // A channel also has its `read_positions` entry (an opaque message token, or null), its + // `unread` count and perhaps its `protected_channel_ids` entry, each keyed by its ID. + let channel_cost: Vec = channels .iter() - .map(|invitation| Self::invitation_wire(s, &index, invitation, now)) + .zip(&channels_wire) + .map(|(channel, wire)| json_len(wire) + 1 + 3 * (json_len(&channel.id) + 2) + 96) .collect(); - let mut snapshot = json!({"workspace":workspace,"protected_channel_ids":protected_channel_ids,"actor":actor_wire,"principals":principals,"former_principals":former_principals,"teams":teams_wire,"channels":channels_wire,"invitations":invitations_wire,"runs":s.runs.values().filter(|r|r.owner_id==actor.id).collect::>(),"read_positions":positions,"unread":unread,"references":s.references.values().filter(|r|self.reference_authorized(s,actor,r).is_ok()).collect::>()}); - if host { - let refusals: BTreeMap<&str, usize> = self - .name_refusals - .keys() - .map(|id| (id.as_str(), self.recent_name_refusals(id, now))) - .filter(|(_, count)| *count > 0) - .collect(); - snapshot["name_collision_refusals"] = json!(refusals); - #[cfg(feature = "join-by-name")] - { - snapshot["pending_joins"] = join::project_for_manager(self, s); + let team_of: BTreeMap<&str, usize> = teams + .iter() + .enumerate() + .map(|(position, team)| (team.id.as_str(), position)) + .collect(); + let host = host_principal_id(s); + let owner = |place: &Place| -> &'s str { + match *place { + Place::Team(position) => teams[position].created_by.as_str(), + Place::Channel(position) => channels[position].owner_id.as_str(), + } + }; + let rank = |place: &Place| { + let owner = owner(place); + if owner == actor_id { + 0 + } else if Some(owner) == host { + 1 + } else { + 2 + } + }; + // Teams first, so the stable sort keeps a team ahead of a channel of the same rank, + // and each owner's own turns do too. + let mut order: Vec = (0..teams.len()) + .map(Place::Team) + .chain((0..channels.len()).map(Place::Channel)) + .collect(); + order.sort_by_key(rank); + let others = order.split_off( + order + .iter() + .position(|place| rank(place) == 2) + .unwrap_or(order.len()), + ); + order.extend(in_turns(others, owner)); + let mut room = room; + let mut kept_teams = vec![false; teams.len()]; + let mut kept_channels = vec![false; channels.len()]; + for place in order { + match place { + Place::Team(position) => { + if !kept_teams[position] && team_cost[position] <= room { + room -= team_cost[position]; + kept_teams[position] = true; + } + } + Place::Channel(position) => { + let team = team_of + .get(channels[position].team_id.as_str()) + .copied() + .filter(|team| !kept_teams[*team]); + let cost = channel_cost[position] + team.map_or(0, |team| team_cost[team]); + if cost <= room { + room -= cost; + kept_channels[position] = true; + if let Some(team) = team { + kept_teams[team] = true; + } + } + } } } - Ok(snapshot) + let mut places = Places { + teams: Vec::new(), + teams_wire: Vec::new(), + channels: Vec::new(), + channels_wire: Vec::new(), + }; + for ((team, wire), kept) in teams.iter().zip(teams_wire).zip(kept_teams) { + if kept { + places.teams.push(*team); + places.teams_wire.push(wire); + } + } + for ((channel, wire), kept) in channels.iter().zip(channels_wire).zip(kept_channels) { + if kept { + places.channels.push(*channel); + places.channels_wire.push(wire); + } + } + places } - /// Per visible channel, the actor's read watermark (as an opaque message token) and the + /// Per listed channel, the actor's read watermark (as an opaque message token) and the /// number of unread messages from others. fn read_state( &self, s: &State, actor: &Actor, + channels: &[&Channel], ) -> (BTreeMap, BTreeMap) { let mut positions = BTreeMap::new(); let mut unread = BTreeMap::new(); - for channel in s - .channels - .values() - .filter(|c| c.members.contains(&actor.id)) - { + for channel in channels { let sequence = *s .read_positions .get(&format!("{}:{}", actor.id, channel.id)) @@ -1883,8 +2813,9 @@ impl Broker { }) } /// Inactive principals referenced by the actor's visible objects: the members, creator, - /// owner and pending owner of visible teams and channels, and the invitee and inviter of - /// visible invitations. Display only; bounded by team and channel sizes. + /// owner and pending owner of the teams and channels the actor is in, and the invitee and + /// inviter of the invitations the actor sees, whether or not the snapshot lists them. + /// Bounded by the number of former members. fn former_principals( s: &State, index: &PeopleIndex, @@ -1923,9 +2854,9 @@ impl Broker { .collect() } /// Visible teams with their computed, never stored, name fields: the sanitized - /// `display_name`, the `handle` a resolver matches against, `name_conflict` (another team - /// **the viewer can see** has the same name) and `name_invalid` (a legacy name the current - /// rules refuse). + /// `display_name`, the `handle` a resolver matches against, `name_conflict` (another of + /// `teams`, every team **the viewer is in**, has the same name, listed or not) and + /// `name_invalid` (a legacy name the current rules refuse). fn teams_wire(teams: &[&Team]) -> Vec { let keys: Vec<(String, String)> = teams .iter() @@ -1948,8 +2879,9 @@ impl Broker { .collect() } /// Visible channels with the same computed fields as [`Self::teams_wire`]; a conflict is - /// another visible channel **in the same team**. A stored name that is not a canonical - /// slug (a legacy `Data Analysis`) is `name_invalid`. + /// another of `channels`, every channel the viewer is in, **in the same team**, listed or + /// not. A stored name that is not a canonical slug (a legacy `Data Analysis`) is + /// `name_invalid`. fn channels_wire(channels: &[&Channel]) -> Vec { let keys: Vec<(String, String)> = channels .iter() @@ -2067,22 +2999,42 @@ impl Broker { && self.visible(s, actor, m) && m.body.to_lowercase().contains(&query) }); - let messages: Vec<_> = if p.get("latest").and_then(Value::as_bool) == Some(true) { - let mut latest: Vec<_> = matching.rev().take(limit).collect(); - latest.reverse(); - latest + let latest = p.get("latest").and_then(Value::as_bool) == Some(true); + // Nearest the page's anchor first: the newest for a latest window, the oldest after a + // cursor. + let mut messages: Vec<_> = if latest { + matching.rev().take(limit).collect() } else { matching.take(limit).collect() }; + // An agent's page is bounded in bytes as well: every agent task starts by reading its + // destination's newest 50, and what other members post there must never push that + // past the frame limit, or no one's agent could start in the channel. The messages + // nearest the anchor that fit are kept and `truncated` says the rest were left out: a + // latest window continues `before` its first message, any other page after `cursor`. + // A person's page is not cut short, since a client offers an older page only when the + // one it has is full: past the frame limit the request is refused `response_too_large`, + // and the client asks for fewer. + let mut truncated = false; + if actor.run.is_some() { + let asked = messages.len(); + messages = within(messages, WORKER_HISTORY_BYTES); + truncated = messages.len() < asked; + } + if latest { + messages.reverse(); + } let cursor = messages .last() .map(|message| message.id.clone()) .or_else(|| p.get("after").and_then(Value::as_str).map(str::to_owned)); let (people, channel_names) = self.message_names(s, actor, messages.iter().copied()); let messages: Vec<_> = messages.into_iter().map(Self::message_wire).collect(); - Ok( - json!({"messages":messages,"cursor":cursor,"people":people,"channel_names":channel_names}), - ) + let mut page = json!({"messages":messages,"cursor":cursor,"people":people,"channel_names":channel_names}); + if truncated { + page["truncated"] = json!(true); + } + Ok(page) } fn read_run_remote_scope(&self, actor: &Actor, _req: &Request) -> Result { let run = actor @@ -2114,7 +3066,11 @@ impl Broker { .rev() .take(200) .collect(); + // Decided over all 200, before the byte budget drops any. let restricted = messages.iter().any(|message| message.restricted); + // Every agent turn asks for this manifest, so what other members post in a source + // channel must never push it past the frame limit: the newest messages that fit. + let messages = within(messages, CONTEXT_MANIFEST_BYTES); let (people, channel_names) = self.message_names(s, actor, messages.iter().copied()); let messages: Vec<_> = messages.into_iter().map(Self::message_wire).collect(); Ok( @@ -2386,6 +3342,8 @@ impl Broker { actor.run.is_none(), "forbidden: human host policy decision required" ); + let requested_mode = mode(p, "mode")?; + let mut institution = s.workspace.institution_id.clone(); if let Some(value) = p.get("institution_id") { let requested: Option = serde_json::from_value(value.clone()).map_err(|_| { @@ -2395,9 +3353,16 @@ impl Broker { ensure!(is_canonical_institution_id(institution), "invalid_params: institution_id must be 1..64 lowercase ASCII letters, digits, underscores or hyphens, starting with a letter or digit"); } ensure!(s.workspace.institution_id.is_none() || s.workspace.institution_id == requested, "privacy_denied: workspace institution cannot be cleared or changed; use a new workspace"); - s.workspace.institution_id = requested; + institution = requested; + } + // Re-sending the policy the workspace already has changes nothing: the epoch moves, + // and every grant ends, only when the mode or the institution really changes. A host + // who re-runs a setup step must not silently end every member's agent access. + if requested_mode == s.workspace.mode && institution == s.workspace.institution_id { + return Ok(json!(s.workspace)); } - s.workspace.mode = mode(p, "mode")?; + s.workspace.institution_id = institution; + s.workspace.mode = requested_mode; s.workspace.policy_epoch += 1; for run in s.runs.values_mut() { run.revoked = true; @@ -2407,7 +3372,22 @@ impl Broker { fn mutate_team_create(&self, s: &mut State, actor: &Actor, req: &Request) -> Result { let p = &req.params; let who = &actor.id; - ensure!(s.teams.len() < 100, "quota_exceeded: maximum teams"); + ensure!( + s.teams.len() < 100 && s.channels.len() < 1000, + "quota_exceeded: maximum teams" + ); + ensure!( + self.manager(s, who).is_ok() + || s.teams.values().filter(|t| t.created_by == *who).count() + < self.quotas.member_teams, + "quota_exceeded: You have created as many teams as one member may in this workspace." + ); + ensure!( + self.manager(s, who).is_ok() + || s.channels.values().filter(|c| c.created_by == *who).count() + < self.quotas.member_channels, + "quota_exceeded: You have created as many channels as one member may in this workspace." + ); let name = Self::team_name_for(s, p, None)?; let team_id = id(); let channel_id = id(); @@ -2449,6 +3429,12 @@ impl Broker { "forbidden: team unavailable" ); ensure!(s.channels.len() < 1000, "quota_exceeded: maximum channels"); + ensure!( + self.manager(s, who).is_ok() + || s.channels.values().filter(|c| c.created_by == *who).count() + < self.quotas.member_channels, + "quota_exceeded: You have created as many channels as one member may in this workspace." + ); let name = Self::channel_name_for(s, p, team_id, None)?; let classification = match p.get("classification") { Some(v) => serde_json::from_value(v.clone())?, @@ -2611,6 +3597,26 @@ impl Broker { _ => bail!("invalid_params: invitation kind"), } check_expected_username(s, p, principal)?; + // One invitation per inviter, invitee and target: inviting again renews it (same ID, + // a fresh day) rather than adding another to the invitee's snapshot. + if let Some(existing) = s.invitations.values_mut().find(|i| { + i.inviter_id == *who + && i.principal_id == principal + && i.kind == kind + && i.target_id == target + }) { + existing.expires_at = now() + 86400; + return Ok(json!(existing)); + } + ensure!( + self.manager(s, who).is_ok() + || s.invitations + .values() + .filter(|i| i.inviter_id == *who && i.expires_at >= now()) + .count() + < self.quotas.member_live_invitations, + "quota_exceeded: You have as many invitations waiting as one member may. Wait for some to be accepted or to expire." + ); let invitation = Invitation { id: id(), kind: kind.into(), @@ -2845,13 +3851,40 @@ impl Broker { ); Ok(target.id.clone()) } + /// `channel.archive`, `channel.transfer` and `membership.revoke`: the channel's current + /// owner archives it, offers it to another member, or removes a member. + /// + /// Two others may remove. A member leaves a channel they do not own by removing themselves + /// ([`Self::is_leave`]), archived or not: a team owner may add any member to their channels + /// without asking, and leaving is how that member undoes it. And a channel whose owner is no + /// longer an active member of the workspace has nobody left to manage it, so the host + /// archives it or removes its members, from outside it too + /// ([`Self::stewards_orphaned_channel`]). Nobody removes a channel's owner, the host never + /// overrides an owner who is still a member, and only the owner transfers. fn mutate_channel_archive(&self, s: &mut State, actor: &Actor, req: &Request) -> Result { let p = &req.params; let who = &actor.id; + let method = req.method.as_str(); let channel = text(p, "channel_id")?; - let c = self.channel(s, who, channel, true)?; - ensure!(c.owner_id == *who, "forbidden: current owner required"); - match req.method.as_str() { + let stewarding = self.stewards_orphaned_channel(s, actor, req); + let c = if stewarding { + s.channels + .get(channel) + .ok_or_else(|| anyhow!("forbidden: channel unavailable"))? + } else { + self.channel(s, who, channel, false)? + }; + let owner = c.owner_id == *who; + let leaving = !owner && Self::is_leave(req, who); + ensure!( + leaving || !c.archived, + "channel_archived: channel is read-only" + ); + ensure!( + owner || leaving || stewarding, + "forbidden: current owner required" + ); + match method { "channel.transfer" => { let successor = text(p, "successor_id")?; // The successor must still be an active principal: an offboarded member's ID @@ -2866,13 +3899,16 @@ impl Broker { } "membership.revoke" => { let target = text(p, "principal_id")?; - ensure!(target != who, "forbidden: transfer before owner removal"); + ensure!( + target != c.owner_id, + "forbidden: transfer before owner removal" + ); check_expected_username(s, p, target)?; } _ => {} } let c = s.channels.get_mut(channel).expect("authorized channel"); - match req.method.as_str() { + match method { "channel.archive" => c.archived = true, "channel.transfer" => { c.pending_owner = Some(text(p, "successor_id")?.into()); @@ -2885,9 +3921,45 @@ impl Broker { } } } - s.invitations.retain(|_, i| i.target_id != channel); + let result = json!(c); + if method == "membership.revoke" && !owner { + // Someone who leaves, or whom the host removes, loses only their own invitations + // to the channel: nobody else's pending invitation is theirs to cancel. + let target = text(p, "principal_id")?; + s.invitations + .retain(|_, i| i.target_id != channel || i.principal_id != target); + } else { + s.invitations.retain(|_, i| i.target_id != channel); + } s.workspace.policy_epoch += 1; - Ok(json!(c)) + Ok(result) + } + /// Whether `req` removes the actor from a channel: `membership.revoke` naming the actor's + /// own principal. For anyone but the channel's owner that is leaving it. + fn is_leave(req: &Request, actor_id: &str) -> bool { + req.method == "membership.revoke" + && req.params.get("principal_id").and_then(Value::as_str) == Some(actor_id) + } + /// Whether `actor` is the host, as a person, archiving (`channel.archive`) or removing a + /// member from (`membership.revoke`) a channel whose owner is no longer an active member of + /// the workspace, and so can act on it without being in it. An offboarded owner's channels + /// would otherwise stay as they are for good, with every member someone added to them. + fn stewards_orphaned_channel(&self, s: &State, actor: &Actor, req: &Request) -> bool { + matches!(req.method.as_str(), "channel.archive" | "membership.revoke") + && actor.run.is_none() + && self.manager(s, &actor.id).is_ok() + && req + .params + .get("channel_id") + .and_then(Value::as_str) + .and_then(|channel| s.channels.get(channel)) + .is_some_and(|channel| { + channel.owner_id != actor.id + && !s + .principals + .get(&channel.owner_id) + .is_some_and(|owner| owner.active) + }) } fn mutate_transfer_accept(&self, s: &mut State, actor: &Actor, req: &Request) -> Result { let p = &req.params; @@ -2943,7 +4015,10 @@ impl Broker { .get("body") .and_then(Value::as_str) .ok_or_else(|| anyhow!("invalid_params: body must be a string"))?; - ensure!(body.len() <= 65536, "invalid_params: message too long"); + ensure!( + body.len() <= 65536 && json_len(body) - 2 <= MESSAGE_ESCAPED_BYTES, + "invalid_params: message too long" + ); let channel = if let Some(run) = &actor.run { run.channel_id.as_str() } else { @@ -3104,10 +4179,10 @@ impl Broker { (1..=3600).contains(&expires), "invalid_params: expiry must be 1..3600 seconds" ); - let remote_root = p - .get("remote_root") - .and_then(Value::as_str) - .map(str::to_owned); + let remote_root = match p.get("remote_root") { + None | Some(Value::Null) => None, + Some(_) => Some(display_text(p, "remote_root", 4096)?.to_owned()), + }; let remote_execution = p .get("remote_execution") .and_then(Value::as_bool) @@ -3154,7 +4229,7 @@ impl Broker { owner_id: who.clone(), channel_id: channel.into(), source_channels: sources, - provider_policy_id: text(p, "provider_policy_id")?.into(), + provider_policy_id: display_text(p, "provider_policy_id", 1024)?.into(), public_provider: consent.public_provider, personal_mode: consent.personal_mode, policy_epoch: s.workspace.policy_epoch, @@ -3170,13 +4245,20 @@ impl Broker { fn mutate_run_revoke(&self, s: &mut State, actor: &Actor, req: &Request) -> Result { let p = &req.params; let who = &actor.id; - let run = s - .runs - .get_mut(text(p, "run_id")?) - .filter(|r| r.owner_id == *who) - .ok_or_else(|| anyhow!("forbidden: owned run unavailable"))?; - run.revoked = true; - Ok(json!(run)) + let run_id = text(p, "run_id")?; + match s.runs.get_mut(run_id) { + Some(run) if run.owner_id == *who => { + run.revoked = true; + Ok(json!(run)) + } + // Retention removed it, a day after it expired ([`RemovedRuns`]). Nothing honors + // it, so it is revoked, and its owner is told so however long after it ended they + // ask. Anyone else is answered as for a run that never existed. + None if self.removed_runs.owned_by(run_id, who) => { + Ok(json!({"id": run_id, "owner_id": who, "revoked": true, "removed": true})) + } + _ => bail!("forbidden: owned run unavailable"), + } } fn mutate_reference_create( &self, @@ -3188,10 +4270,11 @@ impl Broker { let who = &actor.id; let channel = text(p, "channel_id")?; self.channel(s, who, channel, true)?; - let path = text(p, "path")?; + let path = display_text(p, "path", 4096).map_err(|_| { + anyhow!("invalid_params: absolute remote path without parent traversal required; at most 4096 bytes, without control or invisible formatting characters") + })?; ensure!( - path.len() <= 4096 - && Path::new(path).is_absolute() + Path::new(path).is_absolute() && !Path::new(path) .components() .any(|c| matches!(c, std::path::Component::ParentDir)), @@ -3215,11 +4298,13 @@ impl Broker { s.references.len() < 10000, "quota_exceeded: remote reference limit" ); - let label = text(p, "label")?; ensure!( - label.len() <= 255 && !label.chars().any(char::is_control), - "invalid_params: reference label" + self.manager(s, who).is_ok() + || s.references.values().filter(|r| r.owner_id == *who).count() + < self.quotas.member_references, + "quota_exceeded: You have created as many remote references as one member may." ); + let label = shared_name(p, "label", SharedName::Label)?; let reference = RemoteReference { id: id(), channel_id: channel.into(), @@ -3250,31 +4335,49 @@ impl Broker { let c = self.channel(s, who, channel, true)?; let size = number(p, "size")?; ensure!( - size <= 1024 * 1024 * 1024, + size <= BLOB_MAX_BYTES, "quota_exceeded: maximum attachment is 1 GiB" ); + // Declared sizes count, finished or not: an unfinished upload is a reservation, and it + // lapses a day after its last chunk (`prune_retained`). ensure!( - s.blobs.len() < 10000 - && s.blobs.values().map(|b| b.size).sum::() + size <= 10 * 1024 * 1024 * 1024, + s.blobs.len() < WORKSPACE_BLOBS + && s.blobs.values().map(|b| b.size).sum::() + size <= WORKSPACE_BLOB_BYTES, "quota_exceeded: workspace attachment quota" ); + if self.manager(s, who).is_err() { + let (count, bytes) = s + .blobs + .values() + .filter(|b| b.owner_id == *who) + .fold((0usize, 0u64), |(count, bytes), b| { + (count + 1, bytes + b.size) + }); + ensure!( + count < self.quotas.member_blobs + && bytes + size <= self.quotas.member_blob_bytes, + "quota_exceeded: You have used your share of this workspace's attachment space. Unfinished uploads free their space a day after their last progress." + ); + } let sha = text(p, "sha256")?; ensure!( sha.len() == 64 && hex::decode(sha)?.len() == 32, "invalid_params: sha256" ); - let name = text(p, "name")?; + let name = shared_name(p, "name", SharedName::File)?; + let media_type = text(p, "media_type")?; ensure!( - name.len() <= 255 && !name.chars().any(char::is_control), - "invalid_params: attachment display name" + media_type.len() <= 255 && media_type.bytes().all(|b| (0x20..0x7f).contains(&b)), + "invalid_params: media_type must be 1 to 255 printable ASCII characters" ); let blob = Blob { + touched_at: Some(now()), run_id: actor.run.as_ref().map(|r| r.id.clone()), id: id(), owner_id: who.clone(), channel_id: channel.into(), name: name.into(), - media_type: text(p, "media_type")?.into(), + media_type: media_type.into(), size, sha256: sha.to_lowercase(), offset: 0, @@ -3341,6 +4444,7 @@ impl Broker { file.sync_all()?; let blob = s.blobs.get_mut(blob_id).expect("authorized blob"); blob.offset += bytes.len() as u64; + blob.touched_at = Some(now()); Ok(json!(blob)) } fn mutate_blob_finish(&self, s: &mut State, actor: &Actor, req: &Request) -> Result { @@ -3411,6 +4515,231 @@ impl Broker { } } +/// `items` reordered so each group (by `group`) takes a turn: every group's first item, then +/// every group's second, and so on, groups in order of first appearance and each group in its +/// own order. A budget applied afterwards then shares its room among the groups. +fn in_turns(items: Vec, group: impl Fn(&T) -> K) -> Vec { + let mut seen: BTreeMap = BTreeMap::new(); + let mut ranked: Vec<(usize, usize, T)> = items + .into_iter() + .enumerate() + .map(|(position, item)| { + let turn = seen.entry(group(&item)).or_default(); + *turn += 1; + (*turn, position, item) + }) + .collect(); + ranked.sort_by_key(|(turn, position, _)| (*turn, *position)); + ranked.into_iter().map(|(_, _, item)| item).collect() +} +/// The leading `items` whose JSON fits in [`SNAPSHOT_SECTION_BYTES`] (at least the first one), +/// so a snapshot section other members can grow never pushes the snapshot past the frame +/// limit. The snapshot's `totals` says how many there were. +fn within_budget(items: Vec) -> Vec { + within(items, SNAPSHOT_SECTION_BYTES) +} +/// The leading `items` whose JSON fits in `budget` bytes (at least the first one). +fn within(items: Vec, budget: usize) -> Vec { + let mut used: usize = 0; + items + .into_iter() + .enumerate() + .take_while(|(index, item)| { + used = used.saturating_add(json_len(item)).saturating_add(1); + *index == 0 || used <= budget + }) + .map(|(_, item)| item) + .collect() +} + +/// Remove, from the state a mutation is about to commit, what no longer serves anyone: +/// idempotency results older than [`Quotas::dedupe_ttl_secs`] (and any cached before results +/// carried a time), unfinished uploads untouched for [`BLOB_UPLOAD_TTL_SECS`], invitations +/// expired more than [`INVITATION_RETENTION_SECS`] ago, and runs, with their grants, expired +/// more than [`RUN_RETENTION_SECS`] ago. At most [`PRUNE_BATCH`] of each per call, so one +/// journal record stays small however much aged out at once. +fn prune_retained(s: &mut State, now: u64, quotas: &Quotas) -> Pruned { + fn aged(at: Option, now: u64, ttl: u64) -> bool { + at.is_none_or(|at| now.saturating_sub(at) >= ttl) + } + let dedupe_ttl = quotas.dedupe_ttl_secs; + let stale: Vec = s + .dedupe + .iter() + .filter(|(_, cached)| aged(cached.at, now, dedupe_ttl)) + .map(|(key, _)| key.clone()) + .take(PRUNE_BATCH) + .collect(); + for key in stale { + s.dedupe.remove(&key); + } + let uploads: Vec = s + .blobs + .values() + .filter(|blob| !blob.complete && aged(blob.touched_at, now, BLOB_UPLOAD_TTL_SECS)) + .map(|blob| blob.id.clone()) + .take(PRUNE_BATCH) + .collect(); + for id in &uploads { + s.blobs.remove(id); + } + let invitations: Vec = s + .invitations + .values() + .filter(|i| i.expires_at.saturating_add(INVITATION_RETENTION_SECS) <= now) + .map(|i| i.id.clone()) + .take(PRUNE_BATCH) + .collect(); + for id in invitations { + s.invitations.remove(&id); + } + let runs: BTreeMap = s + .runs + .values() + .filter(|run| run.expires_at.saturating_add(RUN_RETENTION_SECS) <= now) + .map(|run| (run.id.clone(), run.owner_id.clone())) + .take(PRUNE_BATCH) + .collect(); + if !runs.is_empty() { + s.runs.retain(|id, _| !runs.contains_key(id)); + s.grants.retain(|_, run| !runs.contains_key(run)); + } + Pruned { uploads, runs } +} +/// What [`prune_retained`] removed that outlives the state. +struct Pruned { + /// Unfinished uploads, whose files are deleted once the commit lands. + uploads: Vec, + /// Runs, by ID, with their owners (see [`RemovedRuns`]). + runs: BTreeMap, +} + +/// Cache `cached` under `key` for `actor`, first dropping the actor's oldest results so it +/// keeps at most [`Quotas::dedupe_actor_entries`] in [`Quotas::dedupe_actor_bytes`] (the new +/// result always stays), then the workspace's oldest beyond [`Quotas::dedupe_entries`]. A +/// retry comes within seconds or minutes, long before hundreds of newer results; bounding +/// the cache is what keeps it from ever becoming a cap on the workspace. The actor's agents' +/// results go before the person's own, so a burst of agent projections never evicts the +/// message the person may be about to retry. +fn remember(s: &mut State, quotas: &Quotas, actor: &str, key: String, cached: Cached) { + let size = |key: &str, cached: &Cached| json_len(key) + json_len(cached); + let prefix = format!("{actor}:"); + let human = format!("{actor}:human:"); + let mut own: Vec<(bool, Option, String, usize)> = s + .dedupe + .range(prefix.clone()..) + .take_while(|(k, _)| k.starts_with(&prefix)) + .map(|(k, c)| (k.starts_with(&human), c.at, k.clone(), size(k, c))) + .collect(); + own.sort(); + let mut count = own.len(); + let mut bytes = own + .iter() + .fold(0usize, |total, (_, _, _, size)| total.saturating_add(*size)); + let incoming = size(&key, &cached); + for (_, _, old, size) in own { + if count < quotas.dedupe_actor_entries + && bytes.saturating_add(incoming) <= quotas.dedupe_actor_bytes + { + break; + } + s.dedupe.remove(&old); + count -= 1; + bytes = bytes.saturating_sub(size); + } + if s.dedupe.len() >= quotas.dedupe_entries { + let mut all: Vec<(Option, String)> = + s.dedupe.iter().map(|(k, c)| (c.at, k.clone())).collect(); + all.sort(); + // Down to 99% of the limit at once, so the sort runs once per hundredth of the + // limit rather than on every mutation. + let target = quotas.dedupe_entries.max(1) - quotas.dedupe_entries / 100 - 1; + let excess = s.dedupe.len().saturating_sub(target); + for (_, old) in all.into_iter().take(excess) { + s.dedupe.remove(&old); + } + } + s.dedupe.insert(key, cached); +} + +/// The serialized bytes of the records `principal` created and holds against +/// [`Quotas::member_state_bytes`]: their messages (their agents' included), runs with their +/// grants, attachments, references, invitations sent, and the teams and channels they +/// created. Idempotency results and read positions are bounded separately. +fn member_state_bytes(s: &State, principal: &str) -> usize { + fn total(items: impl Iterator) -> usize { + items.fold(0, |sum, item| sum.saturating_add(json_len(&item))) + } + let runs = s.runs.values().filter(|r| r.owner_id == principal); + // Each run also holds one grant: a 64-hex digest naming its ID. + let grants = runs.clone().count().saturating_mul(110); + [ + total(s.messages.iter().filter(|m| m.actor_id == principal)), + total(runs), + grants, + total(s.blobs.values().filter(|b| b.owner_id == principal)), + total(s.references.values().filter(|r| r.owner_id == principal)), + total(s.invitations.values().filter(|i| i.inviter_id == principal)), + total(s.teams.values().filter(|t| t.created_by == principal)), + total(s.channels.values().filter(|c| c.created_by == principal)), + ] + .into_iter() + .fold(0, usize::saturating_add) +} + +/// `p[key]` as display text of 1 to `max_bytes` bytes with no control, format, line or +/// paragraph separator characters: nothing that hides, reorders or spoofs the text around it, +/// and nothing JSON escapes to more than two bytes, so its stored size is bounded too. +fn display_text<'a>(p: &'a Value, key: &str, max_bytes: usize) -> Result<&'a str> { + use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; + let value = text(p, key)?; + ensure!( + value.len() <= max_bytes + && !value.chars().any(|c| matches!( + c.general_category(), + GeneralCategory::Control + | GeneralCategory::Format + | GeneralCategory::LineSeparator + | GeneralCategory::ParagraphSeparator + )), + "invalid_params: {key} must be 1 to {max_bytes} bytes without control or invisible formatting characters" + ); + Ok(value) +} + +/// What a [`shared_name`] names, for its refusal. +#[derive(Clone, Copy)] +enum SharedName { + /// A new attachment's name. + File, + /// A reference's label. + Label, +} + +/// `p[key]`, the name a new attachment is shown by or a reference's label: 1 to 255 bytes, and +/// every character shown as itself (`hidden_in_shared_name`: no control, format or separator +/// character, nothing default-ignorable, no Hangul filler or braille blank), so no name can hide +/// its real extension or pass for another (T3-BE-11). Each refusal is a sentence a person can +/// act on (T3-BE-10); it used to be `invalid_params: attachment display name`, which clients +/// printed as "Attachment display name.". Names already stored are not judged again. +fn shared_name<'a>(p: &'a Value, key: &str, what: SharedName) -> Result<&'a str> { + const MAX_BYTES: usize = 255; + let value = text(p, key)?; + let (subject, fix) = match what { + SharedName::File => ("This file's name", "Rename the file, then share it again."), + SharedName::Label => ("This reference's label", "Choose another label."), + }; + ensure!( + value.len() <= MAX_BYTES, + "invalid_params: {subject} is longer than {MAX_BYTES} bytes. {fix}" + ); + ensure!( + shared_name_shows_every_character(value), + "invalid_params: {subject} has an invisible or formatting character. {fix}" + ); + Ok(value) +} + /// The host's active principal, injected into the snapshot's `workspace` at projection time /// and never stored (it would be journaled otherwise). fn host_principal_id(s: &State) -> Option<&str> { @@ -3439,6 +4768,14 @@ fn check_expected_username(s: &State, params: &Value, principal_id: &str) -> Res Ok(()) } +/// The teams and channels a snapshot lists ([`Broker::places_within`]), with their wires. +struct Places<'s> { + teams: Vec<&'s Team>, + teams_wire: Vec, + channels: Vec<&'s Channel>, + channels_wire: Vec, +} + /// Display names for one projection. The username keys of every principal (active or /// former) are computed once, so projecting N people costs N key computations rather than N². struct PeopleIndex<'a> { @@ -3540,16 +4877,30 @@ fn read_frame(reader: &mut impl BufRead) -> Result>> { Ok(Some(bytes)) } fn validate_socket(path: &Path, owner: u32) -> Result<()> { + runtime_root_of(path)?; + check_runtime_socket(path, owner) +} +/// The node-local temporary directory (`/tmp`) a runtime socket path's directory sits +/// directly in, or `unsafe_socket` when the path is not shaped like one. +fn runtime_root_of(path: &Path) -> Result<&Path> { ensure!( path.is_absolute(), "unsafe_socket: absolute socket path required" ); - let parent = path.parent().ok_or_else(|| anyhow!("unsafe_socket"))?; + let root = path + .parent() + .and_then(Path::parent) + .ok_or_else(|| anyhow!("unsafe_socket"))?; ensure!( - parent.parent() == Some(Path::new("/tmp")) - || parent.parent() == Some(Path::new("/private/tmp")), + root == Path::new("/tmp") || root == Path::new("/private/tmp"), "unsafe_socket: runtime must be dedicated node-local temporary directory" ); + Ok(root) +} +/// `path` is a socket `owner` owns, in a directory (not a symbolic link) `owner` owns that no +/// other account can write into. Only `owner` and root can make either. +fn check_runtime_socket(path: &Path, owner: u32) -> Result<()> { + let parent = path.parent().ok_or_else(|| anyhow!("unsafe_socket"))?; let dir = fs::symlink_metadata(parent)?; let socket = fs::symlink_metadata(path)?; use std::os::unix::fs::FileTypeExt; @@ -3563,6 +4914,18 @@ fn validate_socket(path: &Path, owner: u32) -> Result<()> { ); Ok(()) } +/// Whether `name` is shaped like a runtime directory of `uid`: `crew--` and 32 lowercase +/// hex digits. Any account can create an entry with such a name in `/tmp`; the shape says +/// nothing about who did. +fn runtime_basename_of(name: &str, uid: u32) -> bool { + name.strip_prefix(&format!("crew-{uid}-")) + .is_some_and(|suffix| { + suffix.len() == 32 + && suffix + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) +} /// The workspace names running sibling brokers of `uid` answer `hello` with: every /// `crew--<32 hex>/broker.sock` under `runtime_root` (at most [`SIBLING_PROBE_LIMIT`]) /// whose directory and socket `uid` owns and whose listener runs as `uid`, except `own_basename` @@ -3570,26 +4933,23 @@ fn validate_socket(path: &Path, owner: u32) -> Result<()> { /// socket is skipped, each probe is bounded by [`SIBLING_PROBE_TIMEOUT`], and nothing is /// trusted beyond the name used to refuse a duplicate. Names are what any node user can /// already learn from `hello`. +/// +/// Ownership is checked **before** the probe limit is applied: any account can create entries +/// named `crew--…` in `/tmp`, and decoys that sort first must never take the probe slots +/// of the account's real runtime directories. fn sibling_workspace_names( runtime_root: &Path, uid: u32, own_basename: Option<&str>, own_workspace_id: &str, ) -> Vec { - let prefix = format!("crew-{uid}-"); let Ok(entries) = fs::read_dir(runtime_root) else { return Vec::new(); }; let mut candidates: Vec = entries .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) - .filter(|name| { - name.strip_prefix(&prefix).is_some_and(|suffix| { - suffix.len() == 32 - && suffix - .bytes() - .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) - }) && Some(name.as_str()) != own_basename - }) + .filter(|name| runtime_basename_of(name, uid) && Some(name.as_str()) != own_basename) + .filter(|name| owned_runtime_socket(&runtime_root.join(name), uid)) .collect(); candidates.sort(); candidates.truncate(SIBLING_PROBE_LIMIT); @@ -3603,6 +4963,18 @@ fn sibling_workspace_names( }) .collect() } +/// Whether `directory` is a directory `uid` owns (not a symlink) holding a `broker.sock` socket +/// `uid` owns. Metadata only: nothing is opened or connected. +fn owned_runtime_socket(directory: &Path, uid: u32) -> bool { + use std::os::unix::fs::FileTypeExt; + let (Ok(dir), Ok(socket)) = ( + fs::symlink_metadata(directory), + fs::symlink_metadata(directory.join("broker.sock")), + ) else { + return false; + }; + dir.is_dir() && dir.uid() == uid && socket.file_type().is_socket() && socket.uid() == uid +} /// `hello` on one sibling socket: `(workspace_id, name)`. fn probe_hello(directory: &Path, socket: &Path, uid: u32) -> Result<(String, Option)> { use std::os::unix::fs::FileTypeExt; @@ -3651,6 +5023,11 @@ fn recorded_runtime(broker: &Broker, node_id: &str) -> Result> { && metadata.len() <= MAX_FRAME as u64, "unsafe_runtime: invalid private runtime descriptor" ); + // An empty descriptor records nothing: older `status` and `stop` created one when + // runtime.json was missing, and the first start must not read it as corrupt. + if metadata.len() == 0 { + return Ok(None); + } let value: Value = serde_json::from_reader(file) .context("unsafe_runtime: runtime descriptor is corrupt")?; ensure!( @@ -3668,7 +5045,7 @@ fn recorded_runtime(broker: &Broker, node_id: &str) -> Result> { .parent() .ok_or_else(|| anyhow!("unsafe_runtime: runtime directory missing"))?; ensure!( - directory.parent() == Some(Path::new("/tmp")), + directory.parent() == Some(broker.runtime_root.as_path()), "unsafe_runtime: runtime directory must be directly under /tmp" ); Some( @@ -3715,37 +5092,144 @@ fn persisted_runtime(broker: &mut Broker, node_id: &str) -> Result { state.runtime_basename = Some(basename.clone()); broker.commit(state, "system", "workspace.bind_runtime")?; } - reclaim_runtime_socket(uid, &basename) + match reclaim_runtime_socket(&broker.runtime_root, uid, &basename)? { + RuntimeDirectory::Ready(socket) => Ok(socket), + RuntimeDirectory::Unusable(reason) => relocate_runtime(broker, uid, &basename, reason), + } } -fn reclaim_runtime_socket(uid: u32, basename: &str) -> Result { - let directory = PathBuf::from("/tmp").join(basename); - match fs::symlink_metadata(&directory) { - Ok(metadata) => ensure!( +/// What [`reclaim_runtime_socket`] found at the recorded runtime path. +#[derive(Debug)] +enum RuntimeDirectory { + /// The socket path, in a directory this account owns with mode 0711, free to bind. + Ready(PathBuf), + /// The path cannot be used as it stands: another account's entry, a symbolic link or other + /// non-directory, a directory other accounts can write into, or an unexpected entry. After + /// `/tmp` is cleaned any account can create an entry at the recorded name, and a sticky + /// `/tmp` lets only that account and root remove it, so none of these is repaired in place. + /// The reason is logged. (This account's own directory with another owner-only mode is + /// repaired in place: nobody else can have written into it.) + Unusable(&'static str), +} +/// Move the workspace to a fresh runtime directory when its recorded one cannot be used +/// ([`RuntimeDirectory::Unusable`]). The new name is random and created exclusively, so nobody +/// can have claimed it first, and it is journaled before the broker binds. Members' saved +/// connections, and the old invitation line, still name the old path: a bridge of this version +/// or later finds the new directory itself ([`moved_workspace`]), and a member with an older +/// `biorouter-crew` updates it. +fn relocate_runtime( + broker: &mut Broker, + uid: u32, + previous: &str, + reason: &str, +) -> Result { + // A descriptor naming the old path must not outlive the move, or a broker stopped between + // the journal record and its new descriptor would find the two disagreeing at next start. + match fs::remove_file(broker.root.join("runtime.json")) { + Ok(()) => sync_dir(&broker.root)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).context("unsafe_runtime: cannot retire runtime descriptor") + } + } + for _ in 0..8 { + let basename = format!("crew-{uid}-{}", Uuid::new_v4().simple()); + let directory = broker.runtime_root.join(&basename); + match fs::DirBuilder::new().mode(0o700).create(&directory) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + fs::set_permissions(&directory, fs::Permissions::from_mode(0o711))?; + let metadata = fs::symlink_metadata(&directory)?; + ensure!( metadata.is_dir() && metadata.uid() == uid && metadata.mode() & 0o7777 == 0o711, - "unsafe_runtime: persisted directory ownership, type or permissions changed" - ), + "unsafe_runtime: new runtime directory ownership, type or permissions invalid" + ); + let mut state = broker.state.clone(); + state.runtime_basename = Some(basename); + broker.commit(state, "system", "workspace.move_runtime")?; + let socket = directory.join("broker.sock"); + eprintln!( + "runtime_moved: the recorded runtime directory {previous} can't be used ({reason}); this workspace now listens at {}. Members keep their connections: a biorouter-crew of this version or later in their account finds the new directory, and anyone whose Crew then can't connect updates ~/.local/bin/biorouter-crew. A saved connection is never re-pinned by pasting a new invitation line, so members don't need one.", + socket.display() + ); + return Ok(socket); + } + bail!("unsafe_runtime: could not create a new runtime directory") +} +/// Check (and prepare) the recorded runtime directory `runtime_root/basename`: create it when +/// it is missing, and remove a stale socket this account left behind. A live listener is an +/// error (`runtime_in_use`); anything that makes the path unusable is +/// [`RuntimeDirectory::Unusable`]. +fn reclaim_runtime_socket( + runtime_root: &Path, + uid: u32, + basename: &str, +) -> Result { + use RuntimeDirectory::Unusable; + let directory = runtime_root.join(basename); + match fs::symlink_metadata(&directory) { + Ok(metadata) if !metadata.is_dir() => { + return Ok(Unusable("it is not a directory")); + } + Ok(metadata) if metadata.uid() != uid => { + return Ok(Unusable("another account owns it")); + } + Ok(metadata) if metadata.mode() & 0o022 != 0 => { + return Ok(Unusable("other accounts can write into it")); + } + Ok(metadata) if metadata.mode() & 0o7777 != 0o711 => { + // This account's own directory, which no other account could ever write into (only + // its owner and root can change its mode): nothing in it can be another account's, + // so its mode is put back rather than the workspace moved, and members keep the + // path they were given. + fs::set_permissions(&directory, fs::Permissions::from_mode(0o711))?; + let repaired = fs::symlink_metadata(&directory)?; + ensure!( + repaired.is_dir() + && repaired.uid() == uid + && repaired.mode() & 0o7777 == 0o711 + && repaired.dev() == metadata.dev() + && repaired.ino() == metadata.ino(), + "unsafe_runtime: runtime directory changed while its permissions were repaired" + ); + eprintln!( + "runtime_repaired: {} had mode {:o}; set it back to 711", + directory.display(), + metadata.mode() & 0o7777 + ); + } + Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::DirBuilder::new().mode(0o700).create(&directory)?; + match fs::DirBuilder::new().mode(0o700).create(&directory) { + Ok(()) => {} + // Created by someone else between the two calls. + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return Ok(Unusable("another account created it")); + } + Err(error) => return Err(error.into()), + } fs::set_permissions(&directory, fs::Permissions::from_mode(0o711))?; } Err(error) => return Err(error.into()), } for entry in fs::read_dir(&directory)? { - ensure!( - entry?.file_name() == "broker.sock", - "unsafe_runtime: unexpected entry occupies the persisted runtime directory" - ); + if entry?.file_name() != "broker.sock" { + return Ok(Unusable("an unexpected entry occupies it")); + } } let socket = directory.join("broker.sock"); match fs::symlink_metadata(&socket) { Ok(metadata) => { use std::os::unix::fs::FileTypeExt; - ensure!( - metadata.file_type().is_socket() - && metadata.uid() == uid - && metadata.mode() & 0o7777 == 0o666, - "unsafe_runtime: socket ownership, type or permissions changed" - ); + if !(metadata.file_type().is_socket() + && metadata.uid() == uid + && metadata.mode() & 0o7777 == 0o666) + { + return Ok(Unusable( + "its socket's ownership, type or permissions changed", + )); + } match UnixStream::connect(&socket) { Ok(_) => bail!( "runtime_in_use: persisted socket has a live listener; it will not be replaced" @@ -3771,7 +5255,7 @@ fn reclaim_runtime_socket(uid: u32, basename: &str) -> Result { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error.into()), } - Ok(socket) + Ok(RuntimeDirectory::Ready(socket)) } fn write_runtime(root: &Path, info: &Value) -> Result<()> { let path = root.join(format!(".runtime-{}.tmp", Uuid::new_v4())); @@ -3837,6 +5321,8 @@ pub fn serve(root: &Path, bootstrap_key: &str, name: Option<&str>) -> Result<()> let socket = persisted_runtime(&mut broker, &node_id)?; let listener = UnixListener::bind(&socket)?; fs::set_permissions(&socket, fs::Permissions::from_mode(0o666))?; + let bound = path_identity(&socket) + .ok_or_else(|| anyhow!("unsafe_runtime: the bound socket disappeared"))?; let secret: [u8; 32] = hex::decode(&broker.state.workspace_signing_key)? .try_into() .map_err(|_| anyhow!("storage_corrupt: workspace identity"))?; @@ -3847,6 +5333,7 @@ pub fn serve(root: &Path, bootstrap_key: &str, name: Option<&str>) -> Result<()> write_runtime(root, &info)?; println!("{}", info); let shared = Arc::new(Mutex::new(broker)); + watch_runtime(socket.clone(), bound, Arc::clone(&shared)); let active = Arc::new(Mutex::new(BTreeMap::::new())); for stream in listener.incoming() { let stream = stream?; @@ -3873,17 +5360,62 @@ pub fn serve(root: &Path, bootstrap_key: &str, name: Option<&str>) -> Result<()> } Ok(()) } -fn serve_client(mut stream: UnixStream, uid: u32, broker: Arc>) -> Result<()> { +/// How often a running broker checks that its socket path still leads to the socket it bound. +const RUNTIME_WATCH_INTERVAL: Duration = Duration::from_secs(30); +/// The `(device, inode)` of the entry at `path`, without following a final symbolic link, or +/// `None` when it cannot be read. +fn path_identity(path: &Path) -> Option<(u64, u64)> { + fs::symlink_metadata(path) + .ok() + .map(|metadata| (metadata.dev(), metadata.ino())) +} +/// Whether `socket` still leads to the socket the broker bound, identified by `bound`. +fn runtime_intact(socket: &Path, bound: (u64, u64)) -> bool { + path_identity(socket) == Some(bound) +} +/// Stop the broker once its socket path no longer leads to the socket it bound: the runtime +/// directory was removed (by root or a `/tmp` cleaner) and perhaps re-created by another +/// account. Nobody can connect any more and `stop` can no longer verify the process, while it +/// still holds the writer lock, so it exits and lets the next `start` restore the path or move +/// the workspace to a new one. It waits for the request in progress, so no commit is cut short. +fn watch_runtime(socket: PathBuf, bound: (u64, u64), broker: Arc>) { + std::thread::spawn(move || loop { + std::thread::sleep(RUNTIME_WATCH_INTERVAL); + if !runtime_intact(&socket, bound) { + let _held = broker.lock(); + eprintln!( + "runtime_lost: {} no longer leads to this broker; stopping so the next start can restore or move it", + socket.display() + ); + std::process::exit(75); + } + }); +} +fn serve_client(stream: UnixStream, uid: u32, broker: Arc>) -> Result<()> { + let mut connection = Connection::new(); + let served = serve_requests(stream, uid, &broker, &mut connection); + // However the connection ended, the person it was signed on for is no longer connected + // through it. + if let Ok(mut broker) = broker.lock() { + broker.connection_closed(&mut connection); + } + served +} +fn serve_requests( + mut stream: UnixStream, + uid: u32, + broker: &Mutex, + connection: &mut Connection, +) -> Result<()> { stream.set_read_timeout(Some(Duration::from_secs(300)))?; stream.set_write_timeout(Some(Duration::from_secs(30)))?; let mut reader = BufReader::new(stream.try_clone()?); - let mut connection = Connection::new(); while let Some(bytes) = read_frame(&mut reader)? { let request: Request = serde_json::from_slice(&bytes)?; let response = broker .lock() .map_err(|_| anyhow!("broker unavailable"))? - .handle(uid, &mut connection, request); + .handle(uid, connection, request); let mut bytes = serde_json::to_vec(&response)?; if bytes.len() >= MAX_FRAME { bytes = serde_json::to_vec(&Response { @@ -3901,34 +5433,141 @@ fn serve_client(mut stream: UnixStream, uid: u32, broker: Arc>) -> } Ok(()) } -pub fn bridge(socket: &Path, owner: u32, workspace: &str) -> Result<()> { - ensure!( - cfg!(target_os = "linux"), - "unsupported: bridge requires Linux" - ); - validate_socket(socket, owner)?; +/// A connection to `workspace`'s broker, which `owner` runs, with the `hello` it answered: at +/// `socket`, the path the member's saved connection names, or, when that path no longer leads +/// to the workspace, wherever in `root` the broker has moved to ([`moved_workspace`]). +fn open_workspace( + root: &Path, + socket: &Path, + owner: u32, + workspace: &str, +) -> Result<(UnixStream, BufReader, Value)> { + match connect_workspace(socket, owner, workspace, None) { + Ok(found) => Ok(found), + Err(error) => { + let pinned = socket.parent().and_then(Path::file_name); + moved_workspace(root, owner, workspace, pinned).ok_or(error) + } + } +} +/// Connect to the broker at `socket` and check it is `workspace`'s, run by `owner`: the socket +/// and its directory are `owner`'s ([`check_runtime_socket`]), the listener runs as `owner`, +/// and it answers `hello` for `workspace`. `timeout` bounds the `hello`; the connection is +/// returned without one. +fn connect_workspace( + socket: &Path, + owner: u32, + workspace: &str, + timeout: Option, +) -> Result<(UnixStream, BufReader, Value)> { + check_runtime_socket(socket, owner)?; let mut stream = UnixStream::connect(socket)?; ensure!(peer_uid(&stream)? == owner, "identity_mismatch: broker UID"); + stream.set_read_timeout(timeout)?; + stream.set_write_timeout(timeout)?; let mut reader = BufReader::new(stream.try_clone()?); stream .write_all(b"{\"version\":1,\"id\":\"bridge-pin\",\"method\":\"hello\",\"params\":{}}\n")?; let hello: Response = serde_json::from_slice( &read_frame(&mut reader)?.ok_or_else(|| anyhow!("broker disconnected"))?, )?; + let hello = hello.result.unwrap_or(Value::Null); ensure!( - hello - .result - .as_ref() - .and_then(|v| v.get("workspace_id")) - .and_then(Value::as_str) - == Some(workspace), + hello.get("workspace_id").and_then(Value::as_str) == Some(workspace), "identity_mismatch: pinned workspace" ); - let stdin = std::io::stdin(); - let mut input = stdin.lock(); + stream.set_read_timeout(None)?; + stream.set_write_timeout(None)?; + Ok((stream, reader, hello)) +} +/// `workspace`'s broker in another runtime directory of `owner` in `root`, after it moved away +/// from the one a member's connection was saved with (`pinned`, skipped here). +/// +/// A broker moves when its recorded directory cannot be used ([`relocate_runtime`]), typically +/// because another account created an entry at that name after `/tmp` was cleaned, and every +/// saved connection still names the old path. Without this, each member would have to remove +/// their connection and enroll again, since a pasted invitation never re-pins a workspace +/// someone already saved. Only a runtime directory of `owner`'s that nobody else can write +/// into, holding a socket `owner` owns, whose listener runs as `owner` and answers `hello` for +/// `workspace`, is ever used, and no other account can make any of those. The daemon then +/// checks the workspace key's signature on its own `hello`, exactly as for the saved path. +/// As when a start checks its siblings, ownership is checked before at most +/// [`SIBLING_PROBE_LIMIT`] directories are tried, each bounded by [`SIBLING_PROBE_TIMEOUT`], so +/// entries another account creates can neither take those slots nor stall the search. +fn moved_workspace( + root: &Path, + owner: u32, + workspace: &str, + pinned: Option<&std::ffi::OsStr>, +) -> Option<(UnixStream, BufReader, Value)> { + let mut candidates: Vec = fs::read_dir(root) + .ok()? + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter(|name| { + runtime_basename_of(name, owner) && Some(std::ffi::OsStr::new(name)) != pinned + }) + .map(|name| root.join(name).join("broker.sock")) + .filter(|socket| check_runtime_socket(socket, owner).is_ok()) + .collect(); + candidates.sort(); + candidates.truncate(SIBLING_PROBE_LIMIT); + candidates.into_iter().find_map(|socket| { + connect_workspace(&socket, owner, workspace, Some(SIBLING_PROBE_TIMEOUT)).ok() + }) +} +pub fn bridge(socket: &Path, owner: u32, workspace: &str) -> Result<()> { + ensure!( + cfg!(target_os = "linux"), + "unsupported: bridge requires Linux" + ); + let root = runtime_root_of(socket)?; + let (stream, reader, _) = open_workspace(root, socket, owner, workspace)?; + // Standard input is read through a buffer the relay can see into: it waits on the input + // descriptor only when that buffer is empty. + use std::os::fd::AsFd; + let mut input = BufReader::new(File::from(std::io::stdin().as_fd().try_clone_to_owned()?)); let stdout = std::io::stdout(); let mut output = stdout.lock(); - while let Some(frame) = read_frame(&mut input)? { + relay(&mut input, &mut output, stream, reader) +} +/// What a bridge answers a request it could not hand to the broker: nothing of it reached the +/// workspace, so nothing changed and it is safe to send again once reconnected. The member's +/// daemon can then say "not sent" instead of "the outcome may be unknown". +const NOT_DELIVERED: &str = "not_delivered: The workspace server was not reachable, so this request was not sent and nothing changed. Reconnect and try again."; +/// Why a bridge exits when its broker has gone: the member's `ssh` ends with it, which is how +/// the member's daemon learns, within seconds, that the connection dropped. +const BROKER_GONE: &str = + "broker_unavailable: the workspace server closed the connection; reconnect to continue"; +/// Relay frames between the member's daemon (`input`, `output`) and the broker, until the +/// daemon closes its end (`Ok`) or the broker goes away (`Err`). +/// +/// It waits on the daemon **and** the broker together. The broker only ever answers, so while +/// no request is in flight anything it signals is a hang-up; the bridge then exits at once +/// (R-5). Waiting on the daemon alone, it noticed a broker that died only when the next +/// request failed, and the member read "Connected" for up to two and a half minutes. A request +/// that could not be written to the broker is answered [`NOT_DELIVERED`] before it exits. +fn relay( + input: &mut BufReader, + output: &mut impl Write, + mut stream: UnixStream, + mut reader: BufReader, +) -> Result<()> { + loop { + if input.buffer().is_empty() { + let (input_ready, broker_gone) = wait_for_input(input.get_ref(), &stream)?; + if broker_gone { + // A request already waiting was never sent; say so rather than leave it lost. + if input_ready { + if let Some(frame) = read_frame(input)? { + write_not_delivered(output, &frame)?; + } + } + bail!(BROKER_GONE); + } + } + let Some(frame) = read_frame(input)? else { + return Ok(()); + }; let request: Request = serde_json::from_slice(&frame)?; if request.method.starts_with("remote.") { let result = (|| -> Result { @@ -3990,14 +5629,71 @@ pub fn bridge(socket: &Path, owner: u32, workspace: &str) -> Result<()> { output.write_all(&bytes)?; output.flush()?; } else { - stream.write_all(&frame)?; - stream.flush()?; + if stream + .write_all(&frame) + .and_then(|()| stream.flush()) + .is_err() + { + // The frame did not reach the broker whole, and a partial frame is never + // processed: nothing was sent. + write_not_delivered(output, &frame)?; + bail!(BROKER_GONE); + } let response = read_frame(&mut reader)?.ok_or_else(|| anyhow!("broker disconnected"))?; output.write_all(&response)?; output.flush()?; } } +} +/// Wait until the daemon's `input` has something to read or the broker's `stream` signals +/// anything at all: `(input_ready, broker_gone)`. Readable, hung up or failed, a broker that +/// was asked nothing has gone. +fn wait_for_input(input: &File, stream: &UnixStream) -> Result<(bool, bool)> { + loop { + let mut fds = [ + libc::pollfd { + fd: input.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + libc::pollfd { + fd: stream.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + ]; + if unsafe { libc::poll(fds.as_mut_ptr(), 2, -1) } < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error.into()); + } + let input_ready = fds[0].revents != 0; + let broker_gone = fds[1].revents != 0; + if input_ready || broker_gone { + return Ok((input_ready, broker_gone)); + } + } +} +/// Answer the request in `frame` with [`NOT_DELIVERED`], under its own ID. +fn write_not_delivered(output: &mut impl Write, frame: &[u8]) -> Result<()> { + let id = serde_json::from_slice::(frame) + .ok() + .and_then(|request| request.get("id").and_then(Value::as_str).map(str::to_owned)) + .unwrap_or_default(); + let mut bytes = serde_json::to_vec(&Response { + id, + result: None, + error: Some(ProtocolError { + code: "not_delivered".into(), + message: NOT_DELIVERED.into(), + }), + })?; + bytes.push(b'\n'); + output.write_all(&bytes)?; + output.flush()?; Ok(()) } /// `start`, `status` or `stop` a broker for the state directory `root`. `name` (`start` only) @@ -4006,14 +5702,10 @@ pub fn lifecycle(command: &str, root: &Path, key: &str, name: Option<&str>) -> R match command { "start" => start(root, key, name), "status" => { - let mut file = private_file(&root.join("runtime.json"), false)?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes)?; - let info: Value = serde_json::from_slice(&bytes)?; + let info = existing_runtime_descriptor(root)?.ok_or_else(|| anyhow!(NOT_RUNNING))?; let socket = PathBuf::from(text(&info, "socket")?); let uid = number(&info, "host_uid")? as u32; - validate_socket(&socket, uid)?; - let mut stream = UnixStream::connect(socket)?; + let mut stream = connect_recorded(&info, &socket, uid)?; ensure!(peer_uid(&stream)? == uid, "identity_mismatch"); stream.set_read_timeout(Some(Duration::from_secs(3)))?; stream.write_all( @@ -4027,13 +5719,45 @@ pub fn lifecycle(command: &str, root: &Path, key: &str, name: Option<&str>) -> R == info.get("workspace_id"), "identity_mismatch" ); - Ok(info) + let hello = response.result.unwrap_or_default(); + status_answer(root, info, &hello) } "stop" => stop(root), _ => bail!("invalid_command"), } } +/// What `status` prints for the broker `info` describes, which answered `hello`: the runtime +/// descriptor with `"state":"running"` and the workspace's name, or, when the broker has +/// stopped saving changes, a refusal telling the host what to do. +fn status_answer(root: &Path, mut info: Value, hello: &Value) -> Result { + if hello.get("state").and_then(Value::as_str) == Some("storage_failed") { + let full = hello["storage"]["code"].as_str() == Some("storage_full"); + let fault = StorageFault { + full, + at: hello["storage"]["since"].as_u64().unwrap_or_default(), + detail: String::new(), + committed: 0, + logged: true, + }; + let root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + bail!( + "{}: Crew on this server stopped saving changes at {} because {}. Reading still works. {} broker.log in the state directory has the error.", + fault.code(), + utc_timestamp(fault.at), + if full { + "the disk is full" + } else { + "it could not write to its storage" + }, + fault.host_instruction(&root) + ); + } + info["state"] = json!("running"); + info["name"] = hello.get("name").cloned().unwrap_or(Value::Null); + Ok(info) +} + /// How long `start` waits for the broker it launched to answer `hello`. const START_READY_TIMEOUT: Duration = Duration::from_secs(15); @@ -4069,6 +5793,11 @@ fn start(root: &Path, key: &str, name: Option<&str>) -> Result { WORKSPACE_NAME_TAKEN ); } + // The socket members were given last time, to tell the host when the broker had to move. + let previous_socket = read_runtime_descriptor(root) + .ok() + .flatten() + .and_then(|info| info.get("socket").cloned()); let log = private_file(&root.join("broker.log"), true)?; use std::os::unix::process::CommandExt; let mut command = std::process::Command::new(std::env::current_exe()?); @@ -4113,6 +5842,15 @@ fn start(root: &Path, key: &str, name: Option<&str>) -> Result { "workspace_id": hello["workspace_id"], "name": hello["name"], }); + if previous_socket + .as_ref() + .is_some_and(|previous| Some(previous) != info.get("socket")) + { + // The recorded runtime directory could not be used (see broker.log), + // and the workspace moved. A bridge of this version or later follows + // it from the old path; an older one needs updating. + result["socket_changed"] = json!(true); + } match start_invitation(&info, &hello) { Ok(line) => result["invitation"] = json!(line), Err(error) => { @@ -4194,6 +5932,94 @@ fn read_runtime_descriptor(root: &Path) -> Result> { ); Ok(serde_json::from_reader(file).ok()) } +/// `status` and `stop` for a state directory that has no runtime descriptor. +const NOT_RUNNING: &str = "not_running: no broker is running from this state directory; start it with biorouter-crew start"; +/// The broker the runtime descriptor `info` records, connected at its `socket` after the +/// socket's ownership is checked; [`NOT_RUNNING`] when no broker is there any more (T3-BE-2). +/// A broker stopped with `stop` or SIGTERM, one that was killed, and one whose server rebooted +/// all leave `runtime.json` behind, and often the socket file too: the process it names is gone, +/// the socket file is missing, or connecting to it is refused. Each of those used to reach the +/// host as the raw error ("Connection refused (os error 111)"), for `status` and for a second +/// `stop` alike. Nothing is removed here: the next `start` reclaims the same runtime path. +fn connect_recorded(info: &Value, socket: &Path, owner: u32) -> Result { + if recorded_process_gone(info) { + bail!(NOT_RUNNING); + } + let gone = |error: &std::io::Error| { + matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) + }; + if let Err(error) = validate_socket(socket, owner) { + if error.downcast_ref::().is_some_and(&gone) { + bail!(NOT_RUNNING); + } + return Err(error); + } + match UnixStream::connect(socket) { + Ok(stream) => Ok(stream), + Err(error) if gone(&error) => bail!(NOT_RUNNING), + Err(error) => Err(error.into()), + } +} +/// Whether the process the runtime descriptor `info` names has exited. Only a definite answer +/// counts (`ESRCH`); a process another account owns, or a descriptor with no `pid`, is not known +/// to be gone, and the socket is asked instead. +fn recorded_process_gone(info: &Value) -> bool { + let Some(pid) = info + .get("pid") + .and_then(Value::as_u64) + .and_then(|pid| libc::pid_t::try_from(pid).ok()) + .filter(|pid| *pid > 0) + else { + return false; + }; + let answered = unsafe { libc::kill(pid, 0) }; + answered == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) +} +/// `runtime.json` for `status` and `stop`, read without creating anything: `None` when the +/// state directory or the descriptor is missing, or the descriptor is empty (older `status` and +/// `stop` created an empty one). The state directory must be private, and the descriptor a +/// private regular file with one link. +fn existing_runtime_descriptor(root: &Path) -> Result> { + match fs::symlink_metadata(root) { + Ok(metadata) => ensure!( + metadata.is_dir() + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o077 == 0, + "unsafe_storage: state directory must be owner-only and not a symlink" + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + } + let mut file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(root.join("runtime.json")) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).context("unsafe_runtime: cannot read runtime descriptor"), + }; + let metadata = file.metadata()?; + ensure!( + metadata.is_file() + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o077 == 0 + && metadata.nlink() == 1 + && metadata.len() <= MAX_FRAME as u64, + "unsafe_runtime: invalid private runtime descriptor" + ); + if metadata.len() == 0 { + return Ok(None); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + serde_json::from_slice(&bytes) + .map(Some) + .context("unsafe_runtime: runtime descriptor is corrupt") +} /// `hello` from the broker `info` describes, checked as `status` checks it: the socket and its /// listener belong to the host account, and the broker answers for the recorded workspace and /// key. @@ -4267,19 +6093,14 @@ fn last_log_line(root: &Path) -> String { #[cfg(target_os = "linux")] fn stop(root: &Path) -> Result { use std::os::fd::FromRawFd; - private_dir(root)?; - let mut file = private_file(&root.join("runtime.json"), false)?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes)?; - let info: Value = serde_json::from_slice(&bytes)?; + let info = existing_runtime_descriptor(root)?.ok_or_else(|| anyhow!(NOT_RUNNING))?; let owner = number(&info, "host_uid")? as u32; ensure!( owner == unsafe { libc::geteuid() }, "forbidden: only host account can stop broker" ); let socket = PathBuf::from(text(&info, "socket")?); - validate_socket(&socket, owner)?; - let mut stream = UnixStream::connect(&socket)?; + let mut stream = connect_recorded(&info, &socket, owner)?; let mut cred = std::mem::MaybeUninit::::uninit(); let mut length = std::mem::size_of::() as libc::socklen_t; ensure!( @@ -4411,3 +6232,453 @@ mod system_account_tests { assert!(is_system_account(&account(0, Some("/bin/bash")), 0)); } } + +#[cfg(test)] +mod runtime_tests { + use super::*; + + fn short_root() -> PathBuf { + let suffix: String = Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(12) + .collect(); + let path = Path::new("/tmp").join(format!("crt-u-{suffix}")); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + path + } + + #[test] + fn another_accounts_runtime_directory_is_unusable_not_an_error() { + let root = short_root(); + let uid = unsafe { libc::geteuid() }; + let basename = format!("crew-{uid}-{}", Uuid::new_v4().simple()); + // This account's own directory, as it looks to a host of another UID: exactly what a + // host sees when another account created the recorded name after /tmp was cleaned. + let directory = root.join(&basename); + fs::DirBuilder::new() + .mode(0o700) + .create(&directory) + .unwrap(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o711)).unwrap(); + let foreign = reclaim_runtime_socket(&root, uid.wrapping_add(1), &basename).unwrap(); + assert!( + matches!( + foreign, + RuntimeDirectory::Unusable("another account owns it") + ), + "{foreign:?}" + ); + // Its own host reclaims it. + let own = reclaim_runtime_socket(&root, uid, &basename).unwrap(); + assert!( + matches!(&own, RuntimeDirectory::Ready(socket) if *socket == directory.join("broker.sock")), + "{own:?}" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn the_watchdog_notices_a_removed_or_replaced_socket() { + let root = short_root(); + let socket = root.join("broker.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let bound = path_identity(&socket).unwrap(); + assert!(runtime_intact(&socket, bound)); + fs::remove_file(&socket).unwrap(); + assert!(!runtime_intact(&socket, bound), "removed"); + let _other = UnixListener::bind(&socket).unwrap(); + assert!( + !runtime_intact(&socket, bound), + "replaced by another socket" + ); + drop(listener); + let _ = fs::remove_dir_all(&root); + } + + /// A broker stand-in at `root/basename/broker.sock`, in a directory with `mode`, answering + /// every `hello` for `workspace_id` with `name`. + #[cfg(target_os = "linux")] + fn fake_broker(root: &Path, basename: &str, mode: u32, workspace_id: &str, name: &str) { + let directory = root.join(basename); + fs::create_dir(&directory).unwrap(); + fs::set_permissions(&directory, fs::Permissions::from_mode(mode)).unwrap(); + let listener = UnixListener::bind(directory.join("broker.sock")).unwrap(); + let answer = + json!({"id": "bridge-pin", "result": {"workspace_id": workspace_id, "name": name}}); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let mut line = String::new(); + if BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .is_ok() + { + let _ = stream.write_all(format!("{answer}\n").as_bytes()); + } + // Held open, as a broker holds a bridge's connection. + std::mem::forget(stream); + } + }); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_bridge_follows_its_workspace_to_the_directory_it_moved_to() { + let root = short_root(); + let uid = unsafe { libc::geteuid() }; + let workspace = Uuid::new_v4().to_string(); + let basename = |fill: char| format!("crew-{uid}-{}", fill.to_string().repeat(32)); + // The saved connection names `a…`, which the broker had to leave (another account took + // it once /tmp was cleaned); it moved to `f…`. Everything that sorts between the two is + // something the bridge must never take for the workspace: a directory other accounts + // can write into, whose listener claims the workspace, and one of this account's + // other workspaces. + let pinned = root.join(basename('a')).join("broker.sock"); + fake_broker(&root, &basename('b'), 0o777, &workspace, "writable"); + fake_broker( + &root, + &basename('c'), + 0o711, + &Uuid::new_v4().to_string(), + "sibling", + ); + std::os::unix::fs::symlink(root.join(basename('f')), root.join(basename('d'))).unwrap(); + fake_broker(&root, &basename('f'), 0o711, &workspace, "moved"); + let (_, _, hello) = open_workspace(&root, &pinned, uid, &workspace).unwrap(); + assert_eq!(hello["name"], "moved"); + + // What stands at the saved path, when it is unusable, changes nothing. + std::os::unix::fs::symlink(root.join(basename('f')), root.join(basename('a'))).unwrap(); + let (_, _, hello) = open_workspace(&root, &pinned, uid, &workspace).unwrap(); + assert_eq!(hello["name"], "moved"); + fs::remove_file(root.join(basename('a'))).unwrap(); + fake_broker(&root, &basename('a'), 0o777, &workspace, "squatted"); + let (_, _, hello) = open_workspace(&root, &pinned, uid, &workspace).unwrap(); + assert_eq!(hello["name"], "moved"); + let _ = fs::remove_dir_all(&root); + + // The saved path, while it still leads to the workspace, is used first. + let root = short_root(); + let pinned = root.join(basename('e')).join("broker.sock"); + fake_broker(&root, &basename('e'), 0o711, &workspace, "saved"); + fake_broker(&root, &basename('a'), 0o711, &workspace, "elsewhere"); + let (_, _, hello) = open_workspace(&root, &pinned, uid, &workspace).unwrap(); + assert_eq!(hello["name"], "saved"); + let _ = fs::remove_dir_all(&root); + + // With nowhere to go, the saved path's own refusal is what the member sees. + let root = short_root(); + let pinned = root.join(basename('a')).join("broker.sock"); + fake_broker(&root, &basename('b'), 0o777, &workspace, "writable"); + let error = open_workspace(&root, &pinned, uid, &workspace) + .expect_err("no directory of this account's answers for the workspace"); + assert!( + error.downcast_ref::().is_some(), + "{error:#}" + ); + let _ = fs::remove_dir_all(&root); + } +} + +#[cfg(test)] +mod status_tests { + use super::*; + + fn info() -> Value { + json!({"pid": 42, "socket": "/tmp/crew-1000-x/broker.sock", "workspace_id": "w", "host_uid": 1000}) + } + + /// SF-F7: the restart instructions tell the host to look for `"state":"running"`, so + /// `status` says it, with the workspace's name, beside the runtime descriptor. + #[test] + fn status_says_running_and_names_the_workspace() { + let root = std::env::temp_dir(); + let answer = + status_answer(&root, info(), &json!({"state": "running", "name": "lab"})).unwrap(); + assert_eq!(answer["state"], "running"); + assert_eq!(answer["name"], "lab"); + assert_eq!(answer["pid"], 42); + assert_eq!(answer["socket"], "/tmp/crew-1000-x/broker.sock"); + // A workspace with no name yet says so rather than leaving the key out. + let unnamed = status_answer(&root, info(), &json!({"state": "running"})).unwrap(); + assert_eq!(unnamed["state"], "running"); + assert!(unnamed["name"].is_null()); + } + + /// R-2: a broker that stopped saving is not reported as healthy. `status` fails, naming the + /// cause and the two commands that bring the workspace back. + #[test] + fn status_of_a_broker_that_stopped_saving_fails_and_says_what_to_run() { + let root = std::env::temp_dir(); + let canonical = fs::canonicalize(&root).unwrap(); + for (code, cause, first) in [ + ( + "storage_full", + "the disk is full", + "Free space on this server", + ), + ( + "storage_failed", + "it could not write to its storage", + "Check this server's storage", + ), + ] { + let hello = json!({ + "state": "storage_failed", + "storage": {"code": code, "message": "…", "since": 1_790_000_000u64}, + }); + let error = status_answer(&root, info(), &hello) + .expect_err("a broker that stopped saving is not running") + .to_string(); + assert!(error.starts_with(&format!("{code}: ")), "{error}"); + for expected in [ + cause.to_owned(), + first.to_owned(), + "2026-09-21T14:13:20Z".to_owned(), + format!("biorouter-crew stop --state-dir {}", canonical.display()), + format!("biorouter-crew start --state-dir {}", canonical.display()), + ] { + assert!( + error.contains(&expected), + "{expected:?} missing from {error}" + ); + } + } + } + + #[test] + fn utc_timestamps_are_civil_dates() { + assert_eq!(utc_timestamp(0), "1970-01-01T00:00:00Z"); + assert_eq!(utc_timestamp(951_782_400), "2000-02-29T00:00:00Z"); + assert_eq!(utc_timestamp(1_790_000_000), "2026-09-21T14:13:20Z"); + assert_eq!(utc_timestamp(4_107_542_399), "2100-02-28T23:59:59Z"); + } + + /// A raw operating-system error is a storage fault in words; a coded refusal keeps its + /// code, even when an I/O error is its cause. + #[test] + fn a_raw_os_error_is_worded_as_a_storage_fault() { + let full = protocol_error(&anyhow::Error::from(std::io::Error::from_raw_os_error( + libc::ENOSPC, + ))); + assert_eq!(full.code, "storage_full"); + assert_eq!(full.message, STORAGE_FULL_RETRY); + let quota = protocol_error(&anyhow::Error::from(std::io::Error::from_raw_os_error( + libc::EDQUOT, + ))); + assert_eq!(quota.code, "storage_full"); + let failed = protocol_error(&anyhow::Error::from(std::io::Error::from_raw_os_error( + libc::EIO, + ))); + assert_eq!(failed.code, "storage_failed"); + assert_eq!(failed.message, STORAGE_FAILED_RETRY); + let coded = protocol_error( + &anyhow::Error::from(std::io::Error::from_raw_os_error(libc::EACCES)) + .context("unsafe_storage: cannot read the journal"), + ); + assert_eq!(coded.code, "unsafe_storage"); + let plain = protocol_error(&anyhow!("forbidden: not yours")); + assert_eq!(plain.code, "forbidden"); + let uncoded = protocol_error(&anyhow!("Something else")); + assert_eq!(uncoded.code, "request_denied"); + } + + /// `status` against a real broker on a real socket: running with its name, then, once it + /// stopped saving, a refusal saying so. + #[cfg(target_os = "linux")] + #[test] + fn status_of_a_live_broker_follows_its_state() { + let short = |label: &str| { + let suffix: String = Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(12) + .collect(); + let path = Path::new("/tmp").join(format!("crt-{label}-{suffix}")); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + path + }; + let state = short("s"); + let runtime = short("r"); + let key = hex::encode(SigningKey::from_bytes(&[7; 32]).verifying_key().to_bytes()); + let broker = + Broker::open_inner(&state, &key, Box::new(SystemDirectory), Some("lab")).unwrap(); + let socket = runtime.join("broker.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let info = json!({ + "pid": std::process::id(), + "socket": socket, + "workspace_id": broker.workspace().id, + "host_uid": unsafe { libc::geteuid() }, + "protocol": 1, + }); + write_runtime(&state, &info).unwrap(); + let shared = Arc::new(Mutex::new(broker)); + let serving = Arc::clone(&shared); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let uid = peer_uid(&stream).unwrap(); + let broker = Arc::clone(&serving); + std::thread::spawn(move || serve_client(stream, uid, broker)); + } + }); + + let running = lifecycle("status", &state, "", None).unwrap(); + assert_eq!(running["state"], "running"); + assert_eq!(running["name"], "lab"); + assert_eq!(running["workspace_id"], info["workspace_id"]); + + shared.lock().unwrap().storage_fault = Some(StorageFault { + full: true, + at: now(), + detail: "No space left on device (os error 28)".into(), + committed: 0, + logged: true, + }); + let error = lifecycle("status", &state, "", None) + .expect_err("a broker that stopped saving is not running") + .to_string(); + assert!(error.starts_with("storage_full: "), "{error}"); + assert!( + error.contains(&format!( + "biorouter-crew start --state-dir {}", + fs::canonicalize(&state).unwrap().display() + )), + "{error}" + ); + assert!(!error.contains("os error"), "{error}"); + let _ = fs::remove_dir_all(&state); + let _ = fs::remove_dir_all(&runtime); + } +} + +#[cfg(test)] +mod bridge_tests { + use super::*; + use std::os::fd::FromRawFd; + use std::sync::mpsc; + + /// A pipe standing in for the member's `ssh` stdin: the relay's end, and the writer. + fn stdin_pipe() -> (BufReader, File) { + let mut fds = [0; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let (read, write) = unsafe { (File::from_raw_fd(fds[0]), File::from_raw_fd(fds[1])) }; + (BufReader::new(read), write) + } + + /// Run the relay against `bridge_side` of a socket pair on its own thread; its result and + /// everything it wrote to the daemon arrive on the channel. + fn spawn_relay( + mut input: BufReader, + bridge_side: UnixStream, + ) -> mpsc::Receiver<(Result<()>, Vec)> { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let reader = BufReader::new(bridge_side.try_clone().unwrap()); + let mut output = Vec::new(); + let result = relay(&mut input, &mut output, bridge_side, reader); + let _ = sender.send((result, output)); + }); + receiver + } + + fn frame(id: &str) -> Vec { + format!("{{\"version\":1,\"id\":\"{id}\",\"method\":\"message.post\",\"params\":{{}}}}\n") + .into_bytes() + } + + fn not_delivered(output: &[u8], id: &str) { + let response: Value = serde_json::from_slice(output).unwrap(); + assert_eq!(response["id"], id); + assert_eq!(response["error"]["code"], "not_delivered"); + assert_eq!(response["error"]["message"], NOT_DELIVERED); + } + + /// R-5: nothing in flight, the broker dies, and the bridge exits within seconds, so the + /// member's `ssh` ends and the daemon re-dials instead of reading "Connected". + #[test] + fn an_idle_bridge_exits_as_soon_as_its_broker_hangs_up() { + let (input, _daemon) = stdin_pipe(); + let (bridge_side, broker_side) = UnixStream::pair().unwrap(); + let finished = spawn_relay(input, bridge_side); + assert!( + finished.recv_timeout(Duration::from_millis(300)).is_err(), + "an idle bridge with a live broker keeps running" + ); + drop(broker_side); + let (result, output) = finished + .recv_timeout(Duration::from_secs(2)) + .expect("the bridge exits within 2 s of its broker hanging up"); + assert_eq!(result.unwrap_err().to_string(), BROKER_GONE); + assert!( + output.is_empty(), + "nothing was asked, so nothing is answered" + ); + } + + /// A request already waiting when the broker hung up never reached it: it is answered + /// `not_delivered`, not left to read as lost. + #[test] + fn a_request_waiting_when_the_broker_hangs_up_is_answered_not_delivered() { + let (input, mut daemon) = stdin_pipe(); + let (bridge_side, broker_side) = UnixStream::pair().unwrap(); + drop(broker_side); + daemon.write_all(&frame("post-1")).unwrap(); + let (result, output) = spawn_relay(input, bridge_side) + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + assert_eq!(result.unwrap_err().to_string(), BROKER_GONE); + not_delivered(&output, "post-1"); + } + + /// A request whose frame the broker can no longer take is answered `not_delivered`, and + /// the bridge exits rather than leaving the daemon to guess the outcome. + #[cfg(target_os = "linux")] + #[test] + fn a_request_the_broker_can_no_longer_take_is_answered_not_delivered() { + let (input, mut daemon) = stdin_pipe(); + let (bridge_side, broker_side) = UnixStream::pair().unwrap(); + // The broker stops reading without hanging up: the bridge sees nothing until it + // writes, and the write fails. + broker_side.shutdown(std::net::Shutdown::Read).unwrap(); + let finished = spawn_relay(input, bridge_side); + daemon.write_all(&frame("post-2")).unwrap(); + let (result, output) = finished.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!(result.unwrap_err().to_string(), BROKER_GONE); + not_delivered(&output, "post-2"); + drop(broker_side); + } + + /// The ordinary relay is unchanged: each request is forwarded and answered in turn, and + /// the bridge ends cleanly when the daemon closes its end. + #[test] + fn requests_are_relayed_until_the_daemon_closes_its_end() { + let (input, mut daemon) = stdin_pipe(); + let (bridge_side, broker_side) = UnixStream::pair().unwrap(); + std::thread::spawn(move || { + let mut reader = BufReader::new(broker_side.try_clone().unwrap()); + let mut writer = broker_side; + while let Ok(Some(request)) = read_frame(&mut reader) { + let request: Value = serde_json::from_slice(&request).unwrap(); + let answer = json!({"id": request["id"], "result": {"ok": true}}); + writer.write_all(format!("{answer}\n").as_bytes()).unwrap(); + } + }); + let finished = spawn_relay(input, bridge_side); + daemon.write_all(&frame("one")).unwrap(); + daemon.write_all(&frame("two")).unwrap(); + drop(daemon); + let (result, output) = finished.recv_timeout(Duration::from_secs(5)).unwrap(); + result.unwrap(); + let ids: Vec = output + .split(|b| *b == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice::(line).unwrap()["id"].clone()) + .collect(); + assert_eq!(ids, vec![json!("one"), json!("two")]); + } +} diff --git a/crates/biorouter-crew/src/broker/join.rs b/crates/biorouter-crew/src/broker/join.rs index 88fa757c9..6bac4a916 100644 --- a/crates/biorouter-crew/src/broker/join.rs +++ b/crates/biorouter-crew/src/broker/join.rs @@ -1,7 +1,7 @@ //! Joining a workspace by invitation and device code (S3a), compiled only with the //! `join-by-name` feature, which is on by default since 2026-09-25 (naming design D17). //! -//! See `docs/research/biorouter-crew/naming-design.md`, "Broker protocol (S3a)", "The device +//! See `docs/crew/design/naming-design.md`, "Broker protocol (S3a)", "The device //! code" and "Security analysis". The host invites an account by `@username`; the joiner's own //! desktop computes a device code from its saved key and the workspace key it pinned; the host //! pastes that code to approve; and `auth.join` binds a key **only** when its device code equals diff --git a/crates/biorouter-crew/src/invitation.rs b/crates/biorouter-crew/src/invitation.rs index 3dd689f24..8bb362d01 100644 --- a/crates/biorouter-crew/src/invitation.rs +++ b/crates/biorouter-crew/src/invitation.rs @@ -1,6 +1,6 @@ //! Joining a workspace (S3a): the `brcrew1:` invitation codec and the device code. //! -//! See `docs/research/biorouter-crew/naming-design.md`, "The invitation" and "The device code". +//! See `docs/crew/design/naming-design.md`, "The invitation" and "The device code". //! //! **The invitation** is the host's verified workspace descriptor (the four pinned fields: the //! workspace ID and key, the broker socket and the host's UID) plus display labels and SSH @@ -312,7 +312,7 @@ pub fn parse(text: &str) -> Result { return Err(InvitationError::TooLong); } if let Some(token) = find_token(text) { - return parse_token(token).map(|invitation| ParsedInvitation { + return parse_token(&token).map(|invitation| ParsedInvitation { source: InvitationSource::Invitation, invitation, }); @@ -377,17 +377,97 @@ fn check_optional( check(value.as_deref().is_none_or(valid), field) } -/// The base64url run after the first `brcrew1:` in `text`. -fn find_token(text: &str) -> Option<&str> { +/// The longest base64url text a [`MAX_DECODED_BYTES`] invitation encodes to. +const MAX_TOKEN_CHARS: usize = MAX_DECODED_BYTES.div_ceil(3) * 4; + +fn is_token_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '=') +} + +/// The token after the first `brcrew1:` in `text`, with any line wrapping taken out. +/// +/// Mail and ticket tools wrap a long line: at a fixed column, with the next line indented or +/// quoted (`> `), with a space instead of a newline, or with `brcrew1:` left alone on its line. +/// So the token is read as the base64url runs that follow the prefix, separated only by +/// whitespace (and, after a line break, `>` quote marks), up to a blank line or a character that +/// is neither. Of those, the shortest joined prefix that decodes to a complete JSON object is +/// the token: no proper prefix of the host's token can decode to one, and a word of prose after +/// the token is never needed to complete it, so the prose is left out. When no prefix decodes, +/// the first run is returned, so [`parse_token`] refuses it exactly as it always has. +/// +/// The host's token has no padding, so an `=` ending a run is dropped: it is what a +/// quoted-printable soft line break leaves at the end of each wrapped line. +fn find_token(text: &str) -> Option { let (_, after) = text.split_once(PREFIX)?; - let end = after - .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '='))) - .unwrap_or(after.len()); - after.get(..end) + let runs = token_runs(after); + let mut joined = String::new(); + for run in &runs { + joined.push_str(run.trim_end_matches('=')); + if joined.len() > MAX_TOKEN_CHARS { + break; + } + if decodes_to_json_object(&joined) { + return Some(joined); + } + } + runs.first().map(|run| (*run).to_owned()) +} + +/// The base64url runs after the prefix, as [`find_token`] reads them. The first run may be +/// empty (`brcrew1:` at the end of its line). +fn token_runs(after: &str) -> Vec<&str> { + let mut runs = Vec::new(); + let mut rest = after; + let mut total = 0usize; + loop { + let end = rest.find(|c: char| !is_token_char(c)).unwrap_or(rest.len()); + let (run, tail) = rest.split_at(end); + runs.push(run); + total += run.len(); + if tail.is_empty() || total > MAX_TOKEN_CHARS { + return runs; + } + let mut line_breaks = 0usize; + let mut gap_end = tail.len(); + for (index, c) in tail.char_indices() { + let separator = if c == '\n' { + line_breaks += 1; + true + } else { + c.is_whitespace() + || crate::names::is_default_ignorable(c) + || (c == '>' && line_breaks > 0) + }; + if !separator { + gap_end = index; + break; + } + } + // A blank line ends the token, and so does anything that is not a separator. + if gap_end == 0 || gap_end == tail.len() || line_breaks > 1 { + return runs; + } + let Some(next) = tail.get(gap_end..) else { + return runs; + }; + if next.starts_with(|c: char| !is_token_char(c)) { + return runs; + } + rest = next; + } +} + +fn decodes_to_json_object(token: &str) -> bool { + URL_SAFE_ANY_PADDING + .decode(token) + .ok() + .filter(|bytes| bytes.len() <= MAX_DECODED_BYTES) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .is_some_and(|value| value.is_object()) } fn parse_token(token: &str) -> Result { - if token.len() > MAX_DECODED_BYTES.div_ceil(3) * 4 { + if token.len() > MAX_TOKEN_CHARS { return Err(InvitationError::TooLong); } let bytes = URL_SAFE_ANY_PADDING diff --git a/crates/biorouter-crew/src/lib.rs b/crates/biorouter-crew/src/lib.rs index be32389cc..fc1fe6b36 100644 --- a/crates/biorouter-crew/src/lib.rs +++ b/crates/biorouter-crew/src/lib.rs @@ -17,11 +17,11 @@ pub use invitation::{ }; pub use names::{ canonical_channel_name, clean, display_name_claims_username, display_name_valid, - is_default_ignorable, is_uuid_shaped, name_key, names_collide, restriction_level_ok, - sanitize_channel_name, sanitize_display_name, sanitize_team_name, skeleton_key, - strip_ignorable, team_handle, valid_username, validate_display_name, validate_display_name_for, - validate_team_name, validate_workspace_name, workspace_name_valid, NameError, NameKind, - NameProblem, + hidden_in_shared_name, is_default_ignorable, is_uuid_shaped, name_key, names_collide, + restriction_level_ok, sanitize_channel_name, sanitize_display_name, sanitize_team_name, + shared_name_shows_every_character, skeleton_key, strip_ignorable, team_handle, valid_username, + validate_display_name, validate_display_name_for, validate_team_name, validate_workspace_name, + workspace_name_valid, NameError, NameKind, NameProblem, }; pub const PROTOCOL_VERSION: u32 = 1; @@ -216,6 +216,11 @@ pub struct Blob { pub complete: bool, pub restricted: bool, pub source_channels: BTreeSet, + /// When the upload last began or received a chunk (seconds since the Unix epoch). An + /// unfinished upload untouched for a day is removed and stops counting against the + /// attachment quotas; one begun before this field existed counts as untouched. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub touched_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -365,6 +370,8 @@ fn canonical(value: &Value) -> Value { mod broker; #[cfg(unix)] pub use broker::{bridge, lifecycle, serve, Account, Broker, Connection, Directory}; +#[cfg(all(unix, feature = "test-seams"))] +pub use broker::{JournalCall, Quotas}; #[cfg(unix)] pub mod remote; diff --git a/crates/biorouter-crew/src/names.rs b/crates/biorouter-crew/src/names.rs index 2f1805717..efd2cdd48 100644 --- a/crates/biorouter-crew/src/names.rs +++ b/crates/biorouter-crew/src/names.rs @@ -2,10 +2,10 @@ //! //! One definition shared by the broker, which enforces these rules on create and rename, and //! the desktop daemon, which resolves names against a snapshot and previews them. See -//! `docs/research/biorouter-crew/naming-design.md` ("Display-name validation", "Normalization +//! `docs/crew/design/naming-design.md` ("Display-name validation", "Normalization //! and keys", "Validation per kind" and "Confusable characters"). //! -//! - [`clean`] is the stored form of a display name or team name. +//! - [`clean`] is the stored form of a display name, and of a team name once NFKC has folded it. //! - [`name_key`] and [`skeleton_key`] decide when two names are "the same name" //! ([`names_collide`]: either key equal). Keys are computed, never stored, so a Unicode data //! update never rewrites a journal. @@ -191,7 +191,7 @@ impl fmt::Display for NameError { impl std::error::Error for NameError {} /// `NFC → trim → collapse runs of White_Space to one U+0020`: the stored form of a display name -/// or team name. +/// or (after NFKC) a team name. pub fn clean(s: &str) -> String { let nfc: String = s.nfc().collect(); let mut out = String::with_capacity(nfc.len()); @@ -379,14 +379,18 @@ where /// The team-name rules for create and rename. Returns the cleaned name to store. /// -/// After [`clean`]: 1-64 scalar values and at most 120 bytes; at least one letter or number; +/// The name is folded with NFKC first, as a channel name is, so fullwidth `Lab` is stored as +/// `Lab` rather than refused: a compatibility character is never `Identifier_Status=Allowed`, +/// and a name already accepted without the fold is left exactly as it was. Then, after +/// [`clean`]: 1-64 scalar values and at most 120 bytes; at least one letter or number; /// only letters, marks and numbers that Unicode allows in identifiers (UTS #39 /// `Identifier_Status=Allowed`), the space and `- _ . ' & ( ) +`; no default-ignorable /// character; no generic combining mark left uncomposed by NFC; no `@`, `#`, `/` or `:`; a /// [`name_key`] that is not UUID-shaped; and at most Highly Restrictive script mixing. pub fn validate_team_name(raw: &str) -> Result { let kind = NameKind::Team; - let name = clean(raw); + let compatible: String = raw.nfkc().collect(); + let name = clean(&compatible); if name.is_empty() { return Err(NameError::new(kind, NameProblem::Empty)); } @@ -540,6 +544,31 @@ pub fn workspace_name_valid(name: &str) -> bool { validate_workspace_name(name).is_ok() } +/// Whether `c` may not appear in the name of a file shared in a workspace, or a reference's +/// label (T3-BE-10, T3-BE-11): a character that hides, reorders or spoofs the text around it +/// (general category Cc, Cf, Zl or Zp), or one that renders as nothing or as a blank the eye +/// skips: every `Default_Ignorable_Code_Point` (zero-width characters, variation selectors, the +/// Hangul fillers U+115F, U+1160, U+3164 and U+FFA0) and U+2800 BRAILLE PATTERN BLANK. A run of +/// fillers pushed a file's real extension (`q3-report.pdf…….command`) off every card that shows +/// it. Message bodies are not judged by this rule. +pub fn hidden_in_shared_name(c: char) -> bool { + matches!( + c.general_category(), + GeneralCategory::Control + | GeneralCategory::Format + | GeneralCategory::LineSeparator + | GeneralCategory::ParagraphSeparator + ) || is_default_ignorable(c) + || c == '\u{2800}' +} + +/// Whether every character of `name` shows as itself ([`hidden_in_shared_name`]): what the +/// broker requires of a new attachment's name and a reference's label, and what the daemon +/// checks before it records a transfer. +pub fn shared_name_shows_every_character(name: &str) -> bool { + !name.chars().any(hidden_in_shared_name) +} + /// A character that renders as nothing or as a control: general category Cc, Cf, Co, Cs, Cn, /// Zl or Zp, or `Default_Ignorable_Code_Point`. fn is_invisible(c: char) -> bool { diff --git a/crates/biorouter-crew/src/remote.rs b/crates/biorouter-crew/src/remote.rs index 3903f87a6..95f07995d 100644 --- a/crates/biorouter-crew/src/remote.rs +++ b/crates/biorouter-crew/src/remote.rs @@ -355,10 +355,27 @@ fn job_status(method: &str, params: &Value, scope: &Value) -> Result { snapshot(id, &job) } +/// What a work-folder command's status adds when it failed and printed no error of its own +/// (T3-BE-9). The sandbox (`confine`) refuses sockets, starting another program and reading +/// `/etc`, and a command it stops that way (`sbatch`, `squeue`, anything that looks up the user) +/// usually just exits non-zero in silence: an agent spent about 22 calls working that out. The +/// limits are named so it stops at once and tells the person. +pub const CONFINED_FAILURE_NOTE: &str = "The command failed without printing an error. \ +Commands in the work folder run with no network, cannot start other programs, and cannot read \ +/etc or look up users, so cluster tools such as sbatch, squeue, sinfo and scontrol will not run \ +here. Tell the person rather than retrying."; + fn snapshot(id: &str, job: &Job) -> Result { - Ok( - json!({"job_id":id,"status":job.status,"exit_code":job.exit_code,"stdout":String::from_utf8_lossy(&job.stdout.lock().map_err(|_|anyhow!("output unavailable"))?),"stderr":String::from_utf8_lossy(&job.stderr.lock().map_err(|_|anyhow!("output unavailable"))?)}), - ) + let stderr = job + .stderr + .lock() + .map_err(|_| anyhow!("output unavailable"))? + .clone(); + let mut answer = json!({"job_id":id,"status":job.status,"exit_code":job.exit_code,"stdout":String::from_utf8_lossy(&job.stdout.lock().map_err(|_|anyhow!("output unavailable"))?),"stderr":String::from_utf8_lossy(&stderr)}); + if job.status == "failed" && stderr.iter().all(u8::is_ascii_whitespace) { + answer["message"] = json!(CONFINED_FAILURE_NOTE); + } + Ok(answer) } fn persist_job(job: &mut Job) { for reader in job.readers.drain(..) { @@ -896,6 +913,40 @@ mod tests { use super::*; const HOME: &str = "/home/bob"; + + fn finished(status: &str, exit_code: Option, stderr: &[u8]) -> Job { + Job { + child: None, + stdout: Arc::new(Mutex::new(Vec::new())), + stderr: Arc::new(Mutex::new(stderr.to_vec())), + status: status.into(), + exit_code, + run_id: "run".into(), + digest: String::new(), + record: PathBuf::new(), + readers: Vec::new(), + } + } + + /// T3-BE-9: a confined command that failed in silence names the sandbox's limits, so the + /// agent stops and tells the person; one that said why, or succeeded, is left as it is. + #[test] + fn a_silent_failure_names_the_sandboxs_limits() { + let silent = snapshot("job", &finished("failed", Some(1), b"")).unwrap(); + assert_eq!(silent["message"], CONFINED_FAILURE_NOTE); + let blank = snapshot("job", &finished("failed", Some(1), b" \n")).unwrap(); + assert_eq!(blank["message"], CONFINED_FAILURE_NOTE); + for (status, code, stderr) in [ + ("failed", Some(2), &b"python3: can't open file"[..]), + ("completed", Some(0), &b""[..]), + ("running", None, &b""[..]), + ] { + let answer = snapshot("job", &finished(status, code, stderr)).unwrap(); + assert!(answer.get("message").is_none(), "{status}: {answer}"); + } + assert!(CONFINED_FAILURE_NOTE.contains("sbatch")); + assert!(CONFINED_FAILURE_NOTE.contains("no network")); + } const BRIDGE: &str = "/home/bob/.local/bin/biorouter-crew"; fn check(root: &str, bridge: &str) -> Result<()> { diff --git a/crates/biorouter-crew/tests/broker_contract.rs b/crates/biorouter-crew/tests/broker_contract.rs index 1b524afc0..cc71a9ff7 100644 --- a/crates/biorouter-crew/tests/broker_contract.rs +++ b/crates/biorouter-crew/tests/broker_contract.rs @@ -591,6 +591,102 @@ fn policy_epoch_invalidates_existing_worker_grant() { }); } +/// SF-F2: re-sending the policy the workspace already has (a host re-running a setup step) +/// is a no-op. The epoch stays and every grant keeps working; only a real change ends them. +#[test] +fn resending_the_current_policy_keeps_every_worker_grant() { + let root = temp_root("policy-noop"); + with_cleanup(&root, || { + let (mut broker, mut connection, key) = bootstrap(&root); + let mut call = |broker: &mut Broker, id: &str, method: &str, params: Value| { + signed(broker, &mut connection, &key, id, method, params) + }; + let labelled = call( + &mut broker, + "label", + "policy.set", + json!({"mode":"private", "institution_id":"ucsf", "idempotency_key":"label"}), + ) + .result + .unwrap(); + let epoch = labelled["policy_epoch"].as_u64().unwrap(); + let team = call( + &mut broker, + "team", + "team.create", + json!({"name":"noop", "idempotency_key":"team"}), + ) + .result + .unwrap(); + let channel = team["channel"]["id"].as_str().unwrap().to_owned(); + let run = call(&mut broker, "run", "run.create", json!({ + "channel_id": channel, "source_channels": [channel], "provider_policy_id": "private-test", + "personal_mode": "private", "public_provider": false, "expires_in": 60, "idempotency_key": "run" + })) + .result + .unwrap(); + let credential = run["credential"].as_str().unwrap().to_owned(); + let worker_read = |broker: &mut Broker, connection: &mut Connection, id: &str| { + broker + .handle( + uid(), + connection, + Request { + version: 1, + id: id.into(), + method: "context.manifest".into(), + params: json!({}), + auth: None, + credential: Some(credential.clone()), + }, + ) + .error + .map(|error| error.code) + }; + let mut reader = Connection::new(); + assert_eq!(worker_read(&mut broker, &mut reader, "read-before"), None); + + // The same mode and institution, the same mode with the institution left out, and the + // same mode with the institution named again: each a new request, none a change. + for (index, params) in [ + json!({"mode":"private", "institution_id":"ucsf"}), + json!({"mode":"private"}), + json!({"mode":"private", "institution_id":"ucsf"}), + ] + .into_iter() + .enumerate() + { + let mut params = params; + params["idempotency_key"] = json!(format!("same-{index}")); + let same = call(&mut broker, &format!("same-{index}"), "policy.set", params); + let workspace = same.result.expect("an unchanged policy is accepted"); + assert_eq!(workspace["policy_epoch"], json!(epoch), "{index}"); + assert_eq!(workspace["mode"], "private"); + assert_eq!(workspace["institution_id"], "ucsf"); + assert_eq!( + worker_read(&mut broker, &mut reader, &format!("read-{index}")), + None, + "an unchanged policy ended a grant ({index})" + ); + } + + // A real change still moves the epoch and ends the grant. + let changed = call( + &mut broker, + "change", + "policy.set", + json!({"mode":"public", "idempotency_key":"change"}), + ) + .result + .unwrap(); + assert_eq!(changed["policy_epoch"], json!(epoch + 1)); + assert_eq!( + worker_read(&mut broker, &mut reader, "read-after").as_deref(), + Some("grant_expired") + ); + }); +} + #[test] fn idempotency_replays_exact_result_and_rejects_changed_payload() { let root = temp_root("idempotency"); diff --git a/crates/biorouter-crew/tests/join_contract.rs b/crates/biorouter-crew/tests/join_contract.rs index 84e10e6e6..1790702eb 100644 --- a/crates/biorouter-crew/tests/join_contract.rs +++ b/crates/biorouter-crew/tests/join_contract.rs @@ -1,5 +1,5 @@ //! Broker contract for joining a workspace by invitation and device code, slice S3a of -//! `docs/research/biorouter-crew/naming-design.md` ("Broker protocol (S3a)", "The device code", +//! `docs/crew/design/naming-design.md` ("Broker protocol (S3a)", "The device code", //! "Security analysis" and "Tests"). //! //! Most of this file needs the `join-by-name` feature, which is on by default since 2026-09-25 diff --git a/crates/biorouter-crew/tests/journal_fault_contract.rs b/crates/biorouter-crew/tests/journal_fault_contract.rs index 4ae2eb723..147325b1b 100644 --- a/crates/biorouter-crew/tests/journal_fault_contract.rs +++ b/crates/biorouter-crew/tests/journal_fault_contract.rs @@ -194,9 +194,19 @@ fn journal_fault_preserves_prior_ack_after_restart() { failed.result.is_none(), "faulted operation was not acknowledged" ); + // The fault is named for what it is, in one code for the faulting request and every change + // after it: `storage_full` for a full disk, `storage_failed` for any other storage error. + // Never `request_denied` with the operating system's own text (R-2). + let expected_code = match std::env::var("CREW_FAULT_ERRNO").as_deref() { + Ok("ENOSPC") => "storage_full", + _ => "storage_failed", + }; + let failed = failed.error.expect("faulted operation returned no error"); + assert_eq!(failed.code, expected_code, "{}", failed.message); assert!( - failed.error.is_some(), - "faulted operation returned no error" + !failed.message.contains("os error"), + "the OS's text reached the member: {}", + failed.message ); let replayed = signed( &mut broker, @@ -234,7 +244,22 @@ fn journal_fault_preserves_prior_ack_after_restart() { .error .expect("poisoned broker rejects blob begin") .code, - "storage_failed" + expected_code + ); + // `hello` says the broker stopped saving, and the host's log says what happened. + let hello = broker.handle( + uid(), + &mut connection, + request("stopped-hello", "hello", json!({})), + ); + let hello = hello.result.expect("hello still answers"); + assert_eq!(hello["state"], "storage_failed"); + assert_eq!(hello["storage"]["code"], expected_code); + let log = fs::read_to_string(root.join("broker.log")).expect("broker.log was written"); + assert_eq!(log.lines().count(), 1, "{log}"); + assert!( + log.contains(&format!("{expected_code}: Crew stopped saving changes: ")), + "{log}" ); assert_eq!( blob_snapshot(&root), diff --git a/crates/biorouter-crew/tests/name_rules_contract.rs b/crates/biorouter-crew/tests/name_rules_contract.rs index c4312d562..5e92fbb44 100644 --- a/crates/biorouter-crew/tests/name_rules_contract.rs +++ b/crates/biorouter-crew/tests/name_rules_contract.rs @@ -2,7 +2,7 @@ //! device code and the workspace invitation codec (S3a), and the `hello` signing payloads. //! //! Pure functions only: no broker, socket or account lookup, so this runs on every platform. -//! See `docs/research/biorouter-crew/naming-design.md`. +//! See `docs/crew/design/naming-design.md`. use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; @@ -223,7 +223,7 @@ fn team_names_follow_the_team_rules() { validate_team_name(" Analysis Lab ").as_deref(), Ok("Analysis Lab") ); - let refused: [(&str, NameProblem); 14] = [ + let refused: [(&str, NameProblem); 15] = [ ("", NameProblem::Empty), ("Lab\u{FE0F}", NameProblem::InvisibleCharacter), ("Lab\u{202E}", NameProblem::InvisibleCharacter), @@ -234,7 +234,12 @@ fn team_names_follow_the_team_rules() { ("Lab \u{FF03}1", NameProblem::ReservedCharacter), ("Lab 🧬", NameProblem::DisallowedCharacter), ("Lab!", NameProblem::DisallowedCharacter), - ("\u{FF2C}\u{FF41}\u{FF42}", NameProblem::DisallowedCharacter), + // Fullwidth letters fold to ASCII, but a fullwidth symbol is still a symbol. + ("Lab \u{FF01}", NameProblem::DisallowedCharacter), + ( + "\u{FF2C}\u{FF41}\u{FF42} \u{0410}", + NameProblem::MixedScripts, + ), ("Laq\u{301}b", NameProblem::UnattachedMark), ("\u{0410}nalysis", NameProblem::MixedScripts), ("- _ .", NameProblem::NoLetterOrDigit), @@ -247,6 +252,30 @@ fn team_names_follow_the_team_rules() { Ok("Café"), "a combining accent that composes under NFC is stored composed" ); + // Team names fold fullwidth letters exactly as channel names do, and store the folded form + // (setup F9: a fullwidth team name was refused with a sentence saying letters are allowed). + for (typed, stored) in [ + ("\u{FF2C}\u{FF41}\u{FF42}", "Lab"), + ("\u{FF21}\u{FF4E}\u{FF41}\u{FF4C}\u{FF59}\u{FF53}\u{FF49}\u{FF53} \u{FF2C}\u{FF41}\u{FF42}", "Analysis Lab"), + ("Lab\u{3000}\u{FF12}", "Lab 2"), + ("\u{FF08}R\u{FF06}D\u{FF09} Lab", "(R&D) Lab"), + ] { + assert_eq!(validate_team_name(typed).as_deref(), Ok(stored), "{typed:?}"); + assert_eq!( + canonical_channel_name(typed).map(|_| ()), + canonical_channel_name(stored).map(|_| ()), + "a team and a channel treat {typed:?} alike" + ); + } + assert_eq!( + name_key("\u{FF2C}\u{FF41}\u{FF42}"), + name_key(&validate_team_name("\u{FF2C}\u{FF41}\u{FF42}").unwrap()), + "the folded name keeps the key it collides by" + ); + // A name accepted before the fold is stored exactly as before. + for unchanged in ["Analysis Lab", "Équipe Génomique", "日本語 ラボ", "Lab_2.0"] { + assert_eq!(validate_team_name(unchanged).as_deref(), Ok(unchanged)); + } assert_eq!( problem(validate_team_name(&"a".repeat(65))), NameProblem::TooLong @@ -785,6 +814,144 @@ fn garbage_oversize_and_unknown_versions_are_refused() { assert_eq!(invitation::parse(&two), Err(InvitationError::Malformed)); } +/// `line` with its token (the text after `brcrew1:`) cut every `width` characters and the +/// pieces joined by `separator`. +fn wrap_token(line: &str, width: usize, separator: &str) -> String { + let body = line.strip_prefix(invitation::PREFIX).unwrap(); + let pieces: Vec = body + .as_bytes() + .chunks(width) + .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap()) + .collect(); + format!("{}{}", invitation::PREFIX, pieces.join(separator)) +} + +/// Plain-text mail and ticket tools wrap long lines, so a token the host sent on one line +/// often arrives on several. Whitespace inside the token is not part of it, and the prose +/// after the token is not swallowed into it (setup-chen F1). +#[test] +fn a_wrapped_invitation_token_still_parses() { + let original = sample_invitation(); + let line = invitation::encode(&original).unwrap(); + assert!(line.len() > 160, "the sample must be long enough to wrap"); + let intro = "Join lab on Crew.\nIn Biorouter, open Crew, choose Join a workspace, and paste \ + this whole message.\n"; + + // A mail client wraps the whole line, `brcrew1:` included, at a fixed column. + let hard = |width: usize, separator: &str| { + line.as_bytes() + .chunks(width) + .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap()) + .collect::>() + .join(separator) + }; + let wrapped = vec![ + ("hard wrap at 64", hard(64, "\n")), + ("hard wrap at 72", hard(72, "\n")), + ("hard wrap at 76", hard(76, "\n")), + ("indented continuation lines", hard(72, "\n ")), + ("tab-indented continuation lines", hard(72, "\n\t")), + ("soft wrap with a space", wrap_token(&line, 70, " ")), + ("CRLF wrap", hard(76, "\r\n")), + ( + "brcrew1: alone on its line", + format!( + "{}\n{}", + invitation::PREFIX, + line.get(invitation::PREFIX.len()..).unwrap() + ), + ), + ( + "brcrew1: alone, then a wrapped token", + format!( + "{}\r\n{}", + invitation::PREFIX, + wrap_token(&line, 76, "\r\n") + .strip_prefix(invitation::PREFIX) + .unwrap() + ), + ), + ( + "a zero-width space at each break", + wrap_token(&line, 60, "\u{200B}"), + ), + ( + "quoted-printable soft line breaks", + wrap_token(&line, 75, "=\r\n"), + ), + ( + "a quoted reply that was re-wrapped", + format!("> {}", hard(72, "\n> ")), + ), + ]; + for (case, token) in wrapped { + for pasted in [ + token.clone(), + format!("{intro}{token}"), + format!("{intro}{token}\n"), + // Prose right after the token, on the same line and on the next one with no blank + // line between, is not part of the token. + format!("{intro}{token} IT says the server is up\nThanks Alice"), + format!("{intro}{token}\nIT says the server is up\n\nAlice"), + format!("{intro}{token}\r\nIT says hi\r\n"), + format!("{intro}{token}\n\nbrcrew1 is the prefix"), + ] { + let parsed = invitation::parse(&pasted); + assert_eq!( + parsed.map(|parsed| parsed.invitation), + Ok(original.clone()), + "{case}: {pasted:?}" + ); + } + } + + // A wrapped token that lost a piece is still refused as damaged, not guessed at. + let body = line.strip_prefix(invitation::PREFIX).unwrap(); + let damaged = format!( + "{}{}\n{}", + invitation::PREFIX, + body.get(..64).unwrap(), + body.get(128..).unwrap() + ); + assert_eq!(invitation::parse(&damaged), Err(InvitationError::Malformed)); + // A blank line ends the token: the rest after it is not joined in. + let split_by_blank = format!( + "{}{}\n\n{}", + invitation::PREFIX, + body.get(..64).unwrap(), + body.get(64..).unwrap() + ); + assert_eq!( + invitation::parse(&split_by_blank), + Err(InvitationError::Malformed) + ); + // So does punctuation: only whitespace separates the pieces of one token. + let split_by_comma = format!( + "{}{}, {}", + invitation::PREFIX, + body.get(..64).unwrap(), + body.get(64..).unwrap() + ); + assert_eq!( + invitation::parse(&split_by_comma), + Err(InvitationError::Malformed) + ); + // A wrapped token keeps its own refusals: a newer version is still named as one. + let newer = token_for(&json!({"v": 2, "anything": "x".repeat(200)})); + assert_eq!( + invitation::parse(&wrap_token(&newer, 64, "\n")), + Err(InvitationError::UnsupportedVersion) + ); + // And a wrapped token with a bad field names the field. + let mut bad = serde_json::to_value(sample_invitation()).unwrap(); + bad["v"] = json!(1); + bad["owner_uid"] = json!(0); + assert_eq!( + invitation::parse(&wrap_token(&token_for(&bad), 64, "\n")), + Err(InvitationError::InvalidField(InvitationField::OwnerUid)) + ); +} + /// One invalid value per rule, each with the field the refusal must name. fn invalid_field_cases() -> Vec<(&'static str, Value, InvitationField)> { use InvitationField as F; diff --git a/crates/biorouter-crew/tests/naming_contract.rs b/crates/biorouter-crew/tests/naming_contract.rs index 56238e055..4afcdf9fd 100644 --- a/crates/biorouter-crew/tests/naming_contract.rs +++ b/crates/biorouter-crew/tests/naming_contract.rs @@ -1,5 +1,5 @@ //! Broker contract for human-readable names, slices S1a and S2a of -//! `docs/research/biorouter-crew/naming-design.md` ("Tests", "Broker"). +//! `docs/crew/design/naming-design.md` ("Tests", "Broker"). //! //! S1a: former principals, invitation enrichment, the `people` and `channel_names` maps, one //! active principal per username (D3), display-name rules, `profile.suggest`, @@ -666,6 +666,9 @@ fn team_names_are_unique_whatever_the_spelling() { "ANALYSIS_LAB", "Analysis.Lab", "AnaIysis Lab", + // Fullwidth letters fold to ASCII, as they do in a channel name, so a fullwidth + // spelling is the same name. + "\u{FF2C}\u{FF41}\u{FF42}", ] { assert_eq!( refused(ws.host_call("team.create", json!({"name": taken}))), @@ -673,15 +676,19 @@ fn team_names_are_unique_whatever_the_spelling() { "{taken}" ); } - // Width and invisible variants never reach the uniqueness check: they are not valid team - // names at all. - for invalid in ["\u{FF2C}\u{FF41}\u{FF42}", "Lab\u{FE0F}"] { - assert_eq!( - refused(ws.host_call("team.create", json!({"name": invalid}))).0, - "name_invalid", - "{invalid:?}" - ); - } + // An invisible variant never reaches the uniqueness check: it is not a valid team name. + assert_eq!( + refused(ws.host_call("team.create", json!({"name": "Lab\u{FE0F}"}))).0, + "name_invalid" + ); + let folded = ws.host_ok( + "team.create", + json!({"name": "\u{FF27}\u{FF45}\u{FF4E}\u{FF4F}\u{FF4D}\u{FF49}\u{FF43}\u{FF53}"}), + ); + assert_eq!( + folded["team"]["name"], "Genomics", + "a fullwidth team name is stored folded" + ); let cleaned = ws.host_ok("team.create", json!({"name": " Data Team "})); assert_eq!(cleaned["team"]["name"], "Data Team"); let renamed = ws.host_ok("team.rename", json!({"team_id": lab, "name": "LAB"})); diff --git a/crates/biorouter-crew/tests/presence_contract.rs b/crates/biorouter-crew/tests/presence_contract.rs new file mode 100644 index 000000000..d37a92e0f --- /dev/null +++ b/crates/biorouter-crew/tests/presence_contract.rs @@ -0,0 +1,176 @@ +//! Presence (M18): who is online, as a snapshot's `online_principal_ids`. +//! +//! A person is online while a connection their signed device spoke on is open (a member's +//! bridge holds one for as long as their computer is connected), and for a window after their +//! last signed request. Presence lives in the broker's memory only: it is never journaled, +//! costs the state nothing, and no authorization reads it. An agent's grant is not a person +//! being there. +#![cfg(unix)] + +mod support; + +use biorouter_crew::Connection; +use serde_json::{json, Value}; +use std::time::Duration; +use support::*; + +const BOB: u32 = 73_001; +const CAROL: u32 = 73_002; + +/// A run's parameters, as `quota_contract.rs` builds them. +fn run_params(ws: &Workspace, channel: &str) -> Value { + json!({ + "channel_id": channel, + "source_channels": [channel], + "provider_policy_id": "private", + "personal_mode": "private", + "public_provider": false, + "expires_in": 600, + "expected_workspace_policy_epoch": ws.broker.workspace().policy_epoch, + "expected_protected_context": true, + "workspace_institution_id": "ucsf", + "connection_institution_id": "ucsf", + "provider_affiliation": {"kind": "local"}, + }) +} + +fn online(snapshot: &Value) -> Vec { + let mut ids: Vec = snapshot["online_principal_ids"] + .as_array() + .expect("the snapshot lists who is online") + .iter() + .map(|id| id.as_str().unwrap().to_owned()) + .collect(); + ids.sort(); + ids +} + +fn sorted(ids: &[&str]) -> Vec { + let mut ids: Vec = ids.iter().map(|id| (*id).to_owned()).collect(); + ids.sort(); + ids +} + +#[test] +fn a_person_is_online_while_connected_and_for_a_window_after_their_last_request() { + let mut ws = Workspace::new("presence"); + ws.broker.set_presence_window(Duration::from_millis(300)); + let mut bob = ws.enroll(BOB, "bob", 41); + let mut carol = ws.enroll(CAROL, "carol", 42); + let host = ws.host.principal_id.clone(); + + // Bob and Carol have both spoken on their connections: both are online, and so is the + // host reading the snapshot. + ws.snapshot(&mut bob); + let snapshot = ws.snapshot(&mut carol); + assert_eq!( + online(&snapshot), + sorted(&[&host, &bob.principal_id, &carol.principal_id]) + ); + + // Carol's connection closes (her computer slept, her bridge ended) and she stays quiet + // past the window: she is offline. Bob's connection is still open, so he stays online + // however long he is idle, as a member whose app sits connected in the background. + ws.broker.connection_closed(&mut carol.connection); + std::thread::sleep(Duration::from_millis(450)); + let snapshot = ws.snapshot(&mut bob); + assert_eq!(online(&snapshot), sorted(&[&host, &bob.principal_id])); + + // Within the window after a request, a person is online even with no connection open. + ws.snapshot(&mut carol); + ws.broker.connection_closed(&mut carol.connection); + let snapshot = ws.host_snapshot(); + assert!(online(&snapshot).contains(&carol.principal_id)); + std::thread::sleep(Duration::from_millis(450)); + let snapshot = ws.host_snapshot(); + assert!(!online(&snapshot).contains(&carol.principal_id)); + + // A connection closing twice, or one that never signed, changes nothing. + ws.broker.connection_closed(&mut carol.connection); + ws.broker.connection_closed(&mut Connection::new()); + let snapshot = ws.snapshot(&mut bob); + assert_eq!(online(&snapshot), sorted(&[&host, &bob.principal_id])); +} + +#[test] +fn presence_is_never_journaled_and_a_removed_member_is_never_online() { + let mut ws = Workspace::new("presence-journal"); + let mut bob = ws.enroll(BOB, "bob", 41); + let journal = ws.journal_bytes(); + let state = ws.broker.state_json(); + for _ in 0..3 { + ws.snapshot(&mut bob); + } + assert_eq!(ws.journal_bytes(), journal, "a read wrote the journal"); + assert_eq!(ws.broker.state_json(), state, "presence reached the state"); + assert!(!serde_json::to_string(&state).unwrap().contains("online")); + + // Bob's connection is still open, but once he is removed he is not listed as online. + ws.offboard(&bob.principal_id); + let snapshot = ws.host_snapshot(); + assert!(!online(&snapshot).contains(&bob.principal_id)); + + // A restart forgets presence: nobody is online until they speak again. + let mut ws = ws.reopen(); + let host = ws.host.principal_id.clone(); + let snapshot = ws.host_snapshot(); + assert_eq!(online(&snapshot), vec![host]); +} + +#[test] +fn an_agents_grant_does_not_make_its_owner_online() { + let mut ws = Workspace::new("presence-agent"); + ws.broker.set_presence_window(Duration::from_millis(200)); + let mut bob = ws.enroll(BOB, "bob", 41); + let (team, general) = ws.host_team("lab"); + ws.host_adds_to_team(&mut bob, &team); + let params = run_params(&ws, &general); + let credential = ws.call_ok(&mut bob, "run.create", params)["credential"] + .as_str() + .unwrap() + .to_owned(); + ws.broker.connection_closed(&mut bob.connection); + std::thread::sleep(Duration::from_millis(300)); + // Bob's agent keeps reading on its own connection while Bob is away. + let mut worker = Connection::new(); + let read = ws.broker.handle( + BOB, + &mut worker, + biorouter_crew::Request { + version: 1, + id: "worker".into(), + method: "context.manifest".into(), + params: json!({}), + auth: None, + credential: Some(credential), + }, + ); + assert!(read.error.is_none(), "{:?}", read.error); + let snapshot = ws.host_snapshot(); + assert!(!online(&snapshot).contains(&bob.principal_id)); +} + +/// A client learns from `hello` that this broker reports presence; an older broker sends no +/// `online_principal_ids`, which a client must read as unknown rather than as nobody online. +/// `hello` needs the node identity, so this runs on Linux. +#[cfg(target_os = "linux")] +#[test] +fn hello_advertises_presence() { + let mut ws = Workspace::new("presence-hello"); + let hello = ok(ws.broker.handle( + host_uid(), + &mut Connection::new(), + biorouter_crew::Request { + version: 1, + id: "hello".into(), + method: "hello".into(), + params: json!({}), + auth: None, + credential: None, + }, + )); + assert!(hello["capabilities"] + .as_array() + .unwrap() + .contains(&json!("presence_v1"))); +} diff --git a/crates/biorouter-crew/tests/quota_contract.rs b/crates/biorouter-crew/tests/quota_contract.rs new file mode 100644 index 000000000..160a9a31e --- /dev/null +++ b/crates/biorouter-crew/tests/quota_contract.rs @@ -0,0 +1,1864 @@ +//! Shared workspace budgets: no single member can exhaust them, what ages out is removed, the +//! host keeps headroom for administrative operations, and responses other members can grow stay +//! under the frame limit. +//! +//! - BROKER-1: per-member shares of the state and journal, bounded free-text fields (escaped +//! size included), an idempotency cache that ages out and is bounded per member, and headroom +//! for the host's administrative operations. +//! - BROKER-2: snapshot sections other members can grow (references, invitations, runs, teams +//! and channels) and the worker's context manifest and message pages stay within the frame +//! limit, while what a snapshot leaves out still counts in `name_conflict` and +//! `former_principals`; a repeat invitation renews rather than adds; a member leaves the +//! channels someone else added them to, and the host manages the channels of someone who left +//! the workspace. +//! - BROKER-4: per-member shares of attachments, teams, channels and references, and +//! unfinished uploads that expire. +#![cfg(unix)] + +mod support; + +use biorouter_crew::names::{name_key, skeleton_key}; +use biorouter_crew::{Quotas, Request, MAX_FRAME}; +use serde_json::{json, Value}; +use support::*; + +const MALLORY: u32 = 71_001; +const VICTOR: u32 = 71_002; + +/// The broker's cache key for a human request by `principal` with `key`. +fn dedupe_key(principal: &str, key: &str) -> String { + format!("{principal}:human:{key}") +} + +/// The serialized size of a response as `serve` would frame it. +fn frame_len(response: &biorouter_crew::Response) -> usize { + serde_json::to_vec(response).unwrap().len() +} + +/// `run.create` parameters for a private run over `channel` in a workspace labelled `ucsf`. +fn run_params(ws: &Workspace, channel: &str) -> Value { + json!({ + "channel_id": channel, + "source_channels": [channel], + "provider_policy_id": "private", + "personal_mode": "private", + "public_provider": false, + "expires_in": 600, + "expected_workspace_policy_epoch": ws.broker.workspace().policy_epoch, + "expected_protected_context": true, + "workspace_institution_id": "ucsf", + "connection_institution_id": "ucsf", + "provider_affiliation": {"kind": "local"}, + }) +} + +fn worker(ws: &mut Workspace, uid: u32, credential: &str, method: &str, params: Value) -> Value { + ok(worker_call(ws, uid, credential, method, params)) +} + +fn worker_call( + ws: &mut Workspace, + uid: u32, + credential: &str, + method: &str, + params: Value, +) -> biorouter_crew::Response { + let mut connection = biorouter_crew::Connection::new(); + ws.broker.handle( + uid, + &mut connection, + Request { + version: 1, + id: format!("worker-{method}"), + method: method.into(), + params, + auth: None, + credential: Some(credential.into()), + }, + ) +} + +/// What `principal` holds against their share of the state, counted as the broker counts it +/// for the records these tests make: their messages, and their runs with a grant each. +fn share_used(ws: &Workspace, principal: &str) -> usize { + let state = ws.broker.state_json(); + let size = |value: &Value| serde_json::to_vec(value).unwrap().len(); + let messages: usize = state["messages"] + .as_array() + .unwrap() + .iter() + .filter(|message| message["actor_id"] == principal) + .map(size) + .sum(); + let runs: usize = state["runs"] + .as_object() + .unwrap() + .values() + .filter(|run| run["owner_id"] == principal) + .map(|run| size(run) + 110) + .sum(); + messages + runs +} + +// --------------------------------------------------------------------------------------------- +// BROKER-1: state and journal shares, free text, the idempotency cache, host headroom +// --------------------------------------------------------------------------------------------- + +#[test] +fn one_member_cannot_fill_the_workspace_state() { + let mut ws = Workspace::new("member-state-share"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut victor = ws.enroll(VICTOR, "victor", 22); + let (_, mallory_general) = ws.create_team(&mut mallory, "mallory-team"); + let (_, victor_general) = ws.create_team(&mut victor, "victor-team"); + let body = "x".repeat(65_000); + let mut refusal = None; + for index in 0..200 { + let response = ws.call( + &mut mallory, + "message.post", + json!({"channel_id": mallory_general, "body": body}), + ); + if let Some(error) = response.error { + refusal = Some((index, error)); + break; + } + } + let (index, error) = refusal.expect("a member's share runs out"); + assert_eq!(error.code, "quota_exceeded"); + assert!(error.message.contains("your share"), "{}", error.message); + // A quarter of the 16 MiB state, not all of it. + assert!( + (55..70).contains(&index), + "refused after {index} posts of 65 KB" + ); + // Everyone else still writes. + ws.call_ok( + &mut victor, + "message.post", + json!({"channel_id": victor_general, "body": "still here"}), + ); + ws.host_ok("team.create", json!({"name": "host-team"})); + // Mallory can still do what adds no content, and read. + ws.call_ok(&mut mallory, "profile.update", json!({"nickname": null})); + ws.call_ok( + &mut mallory, + "messages.history", + json!({"channel_id": mallory_general, "latest": true, "limit": 1}), + ); +} + +#[test] +fn a_message_body_is_bounded_after_escaping() { + let mut ws = Workspace::new("escaped-body"); + let (_, general) = ws.host_team("escape"); + // 65,536 bytes that JSON escapes six-fold: 384 KiB stored, twice (history and the cache). + let (code, _) = refused(ws.host_call( + "message.post", + json!({"channel_id": general, "body": "\u{1}".repeat(65_536)}), + )); + assert_eq!(code, "invalid_params"); + let (code, _) = refused(ws.host_call( + "message.post", + json!({"channel_id": general, "body": "\u{1b}[0m".repeat(16_000)}), + )); + assert_eq!(code, "invalid_params"); + // Quotes and backslashes double at most, so any ordinary body up to the limit still fits. + ws.host_ok( + "message.post", + json!({"channel_id": general, "body": "\"".repeat(65_536)}), + ); + // Colored terminal output of an ordinary density still posts. + ws.host_ok( + "message.post", + json!({"channel_id": general, "body": "\u{1b}[31merror\u{1b}[0m: failed\n".repeat(2_000)}), + ); +} + +#[test] +fn free_text_fields_are_bounded_and_printable() { + let mut ws = Workspace::new("free-text"); + let (_, general) = ws.host_team("text"); + let sha = "0".repeat(64); + let begin = |media_type: &str, name: &str| json!({"channel_id": general, "name": name, "media_type": media_type, "size": 1, "sha256": sha}); + for (media_type, name) in [ + ("a".repeat(1_040_000), "x".to_owned()), + ("a".repeat(256), "x".to_owned()), + ("text/plain\u{1}".to_owned(), "x".to_owned()), + ("téxt/plain".to_owned(), "x".to_owned()), + ("text/plain".to_owned(), "report\u{202e}fdp.exe".to_owned()), + ("text/plain".to_owned(), "a\u{200b}b".to_owned()), + ("text/plain".to_owned(), "x".repeat(256)), + ] { + let (code, _) = refused(ws.host_call("blob.begin", begin(&media_type, &name))); + assert_eq!(code, "invalid_params", "{name:?}"); + } + // T3-BE-11: blank-looking characters that are not "format" characters hide a file's real + // extension just as well: Hangul fillers (letters that render as nothing), the braille + // blank, and every other default-ignorable character. + for filler in [ + '\u{3164}', '\u{115F}', '\u{1160}', '\u{FFA0}', '\u{2800}', '\u{FE0F}', '\u{00AD}', + ] { + let name = format!("q3-report.pdf{}.command", filler.to_string().repeat(60)); + let (code, message) = refused(ws.host_call("blob.begin", begin("text/plain", &name))); + assert_eq!(code, "invalid_params", "{filler:?}"); + // T3-BE-10: the refusal reads as a sentence a person can act on. + assert_eq!( + message, + "invalid_params: This file's name has an invisible or formatting character. Rename the file, then share it again.", + "{filler:?}" + ); + } + let (_, message) = refused(ws.host_call("blob.begin", begin("text/plain", &"x".repeat(256)))); + assert_eq!( + message, + "invalid_params: This file's name is longer than 255 bytes. Rename the file, then share it again." + ); + ws.host_ok( + "blob.begin", + begin("text/csv; charset=utf-8", "results.csv"), + ); + // Names in any script, with spaces and punctuation, are shown as themselves. + ws.host_ok( + "blob.begin", + begin("text/plain", "résumé 日本語 (final) – v2 📁.txt"), + ); + + let mut run = run_params(&ws, &general); + run["provider_policy_id"] = json!("p".repeat(2_000)); + assert_eq!( + refused(ws.host_call("run.create", run.clone())).0, + "invalid_params" + ); + run["provider_policy_id"] = json!("private\u{1}"); + assert_eq!( + refused(ws.host_call("run.create", run.clone())).0, + "invalid_params" + ); + run["provider_policy_id"] = json!("private"); + run["remote_root"] = json!(format!("/{}", "r".repeat(5_000))); + assert_eq!(refused(ws.host_call("run.create", run)).0, "invalid_params"); + + for (path, label) in [ + (format!("/{}", "\u{1}".repeat(4_095)), "data"), + ("/data/\u{202e}vsc.exe".to_owned(), "data"), + (format!("/{}", "p".repeat(4_096)), "data"), + ("/data/set".to_owned(), "da\u{200b}ta"), + ("/data/set".to_owned(), "data\u{2028}"), + ("/data/set".to_owned(), "data\u{3164}\u{3164}.sh"), + ("/data/set".to_owned(), "data\u{2800}"), + ] { + let (code, _) = refused(ws.host_call( + "reference.create", + json!({"channel_id": general, "path": path, "label": label}), + )); + assert_eq!(code, "invalid_params", "{path:?} {label:?}"); + } + let (_, message) = refused(ws.host_call( + "reference.create", + json!({"channel_id": general, "path": "/data/set", "label": "set\u{115F}"}), + )); + assert_eq!( + message, + "invalid_params: This reference's label has an invisible or formatting character. Choose another label." + ); + ws.host_ok( + "reference.create", + json!({"channel_id": general, "path": "/data/set 1/\"quoted\" 📁", "label": "Set 1"}), + ); +} + +#[test] +fn the_idempotency_cache_is_bounded_per_member() { + let mut ws = Workspace::new("dedupe-per-member"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + ok(signed_raw( + &mut ws.broker, + &mut mallory, + "profile.update", + json!({"nickname": "First", "idempotency_key": "first"}), + )); + for index in 0..600 { + ok(signed_raw( + &mut ws.broker, + &mut mallory, + "profile.update", + json!({"nickname": null, "idempotency_key": format!("fill-{index}")}), + )); + } + let state = ws.broker.state_json(); + let prefix = format!("{}:", mallory.principal_id); + let own = state["dedupe"] + .as_object() + .unwrap() + .keys() + .filter(|key| key.starts_with(&prefix)) + .count(); + assert!( + own <= Quotas::STANDARD.dedupe_actor_entries, + "{own} cached results for one member" + ); + // The oldest result aged out of the member's cache: its key is free again rather than a + // conflict. + ok(signed_raw( + &mut ws.broker, + &mut mallory, + "profile.update", + json!({"nickname": "Changed", "idempotency_key": "first"}), + )); + // A retry of a recent request still replays. + let replay = ok(signed_raw( + &mut ws.broker, + &mut mallory, + "profile.update", + json!({"nickname": null, "idempotency_key": "fill-599"}), + )); + assert_eq!(replay["nickname"], "mallory"); +} + +#[test] +fn an_agent_burst_never_evicts_the_persons_own_recent_result() { + let mut ws = Workspace::new("dedupe-agent-burst"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let (team, general) = ws.host_team("burst"); + ws.host_adds_to_team(&mut mallory, &team); + let params = run_params(&ws, &general); + let credential = ws.call_ok(&mut mallory, "run.create", params)["credential"] + .as_str() + .unwrap() + .to_owned(); + let post = json!({"channel_id": general, "body": "only once", "idempotency_key": "keep"}); + ok(signed_raw( + &mut ws.broker, + &mut mallory, + "message.post", + post.clone(), + )); + for index in 0..600 { + worker( + &mut ws, + MALLORY, + &credential, + "run.project", + json!({"body": "working", "status": "progress", "idempotency_key": format!("p{index}")}), + ); + } + // The composer retries its unanswered post: it replays rather than posting again. + ok(signed_raw( + &mut ws.broker, + &mut mallory, + "message.post", + post, + )); + let posted = ws.broker.state_json()["messages"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["body"] == "only once") + .count(); + assert_eq!(posted, 1); +} + +#[test] +fn a_member_at_their_share_can_still_finish_an_agent_task() { + let mut ws = Workspace::new("terminal-at-share"); + ws.broker.set_quotas(Quotas { + member_state_bytes: 256 * 1024, + ..Quotas::STANDARD + }); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let (team, general) = ws.host_team("finish"); + ws.host_adds_to_team(&mut mallory, &team); + let params = run_params(&ws, &general); + let credential = ws.call_ok(&mut mallory, "run.create", params)["credential"] + .as_str() + .unwrap() + .to_owned(); + // Fill the share to the last few bytes. + for size in [60_000, 2_000, 100, 1] { + let mut full = false; + for _ in 0..100 { + if ws + .call( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "x".repeat(size)}), + ) + .error + .is_some() + { + full = true; + break; + } + } + assert!(full, "the share fills with {size}-byte posts"); + } + worker( + &mut ws, + MALLORY, + &credential, + "run.project", + json!({"body": "done", "status": "completed", "idempotency_key": "done"}), + ); +} + +#[test] +fn terminal_projections_take_a_member_at_most_one_message_past_their_share() { + let mut ws = Workspace::new("terminal-share-bound"); + // The standard limits scaled down, so the attack fits in a test: runs made while under the + // share, then one maximum-size result per run. + let share = 256 * 1024; + ws.broker.set_quotas(Quotas { + state_bytes: 2 * 1024 * 1024, + state_admin_headroom: 128 * 1024, + member_state_bytes: share, + ..Quotas::STANDARD + }); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut victor = ws.enroll(VICTOR, "victor", 22); + let (team, general) = ws.host_team("results"); + ws.host_adds_to_team(&mut mallory, &team); + ws.host_adds_to_team(&mut victor, &team); + let params = run_params(&ws, &general); + let runs: Vec<(String, String)> = (0..40) + .map(|_| { + let run = ws.call_ok(&mut mallory, "run.create", params.clone()); + ( + run["run"]["id"].as_str().unwrap().to_owned(), + run["credential"].as_str().unwrap().to_owned(), + ) + }) + .collect(); + assert!(share_used(&ws, &mallory.principal_id) < share); + // Quotes escape to twice their size: the largest body a message may have. + let body = "\"".repeat(65_536); + let mut posted = 0; + let mut stopped = Vec::new(); + for (index, (run, credential)) in runs.iter().enumerate() { + let response = worker_call( + &mut ws, + MALLORY, + credential, + "run.project", + json!({"body": body, "status": "completed", "idempotency_key": format!("done-{index}")}), + ); + match response.error { + None => posted += 1, + Some(error) => { + assert_eq!(error.code, "quota_exceeded", "{}", error.message); + assert!(error.message.contains("your share"), "{}", error.message); + stopped.push(run.clone()); + } + } + } + assert!( + posted > 0, + "results post while the member is within the share" + ); + assert!(!stopped.is_empty(), "and stop once they are past it"); + // Never more than one message past the share. + let largest = ws.broker.state_json()["messages"] + .as_array() + .unwrap() + .iter() + .filter(|message| message["actor_id"] == json!(mallory.principal_id)) + .map(|message| serde_json::to_vec(message).unwrap().len()) + .max() + .unwrap(); + let used = share_used(&ws, &mallory.principal_id); + assert!( + used <= share + largest, + "{used} bytes held against a {share}-byte share" + ); + // Everyone else keeps working. + ws.call_ok( + &mut victor, + "message.post", + json!({"channel_id": general, "body": "still here"}), + ); + // A refused result leaves its run live, and its owner still ends it: removing access is + // never refused for space. + for run in &stopped { + let revoked = ws.call_ok(&mut mallory, "run.revoke", json!({"run_id": run})); + assert_eq!(revoked["revoked"], true); + } +} + +#[test] +fn the_idempotency_cache_ages_out() { + let mut ws = Workspace::new("dedupe-ttl"); + let host = ws.host.principal_id.clone(); + for key in ["old", "legacy", "fresh"] { + ok(signed_raw( + &mut ws.broker, + &mut ws.host, + "profile.update", + json!({"nickname": "Before", "idempotency_key": key}), + )); + } + // One result cached two days ago, and one from a release that recorded no time. + let ws = ws.edit_journal(|journal| { + journal.append( + "system", + "test.age", + vec![ + set( + &["dedupe", &dedupe_key(&host, "old"), "at"], + json!(now() - 2 * 24 * 60 * 60), + ), + remove(&["dedupe", &dedupe_key(&host, "legacy"), "at"]), + ], + ); + }); + let mut ws = ws; + // Any mutation prunes what aged out. + ws.host_ok("profile.update", json!({"nickname": null})); + let state = ws.broker.state_json(); + let dedupe = state["dedupe"].as_object().unwrap(); + assert!(!dedupe.contains_key(&dedupe_key(&host, "old"))); + assert!(!dedupe.contains_key(&dedupe_key(&host, "legacy"))); + assert!(dedupe.contains_key(&dedupe_key(&host, "fresh"))); + for key in ["old", "legacy"] { + ok(signed_raw( + &mut ws.broker, + &mut ws.host, + "profile.update", + json!({"nickname": "After", "idempotency_key": key}), + )); + } + let (code, _) = refused(signed_raw( + &mut ws.broker, + &mut ws.host, + "profile.update", + json!({"nickname": "After", "idempotency_key": "fresh"}), + )); + assert_eq!(code, "conflict", "a fresh result still guards its key"); +} + +#[test] +fn a_workspace_filled_by_results_that_never_aged_recovers() { + let mut ws = Workspace::new("legacy-cache-full"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let (team, general) = ws.host_team("legacy"); + ws.host_adds_to_team(&mut mallory, &team); + // 1,500 cached results from a release that kept them forever and recorded no time: more + // than one mutation removes at once. + let host = ws.host.principal_id.clone(); + let ws = ws.edit_journal(|journal| { + let patches = (0..1_500) + .map(|index| { + set( + &["dedupe", &dedupe_key(&host, &format!("legacy-{index}"))], + json!({"digest": "0".repeat(64), "result": {"filler": "f".repeat(1_000)}}), + ) + }) + .collect(); + journal.append("system", "test.legacy", patches); + }); + let mut ws = ws; + // Ordinary changes stop 1.3 MB below the state as it stands, so every change is over the + // limit. One that removes more than it adds is still allowed: each mutation removes up to + // 1,024 aged results, so the workspace sheds them and recovers. + let size = serde_json::to_vec(&ws.broker.state_json()).unwrap().len(); + ws.broker.set_quotas(Quotas { + state_bytes: size - 1_300_000 + 64 * 1024, + state_admin_headroom: 64 * 1024, + ..Quotas::STANDARD + }); + let legacy = |ws: &Workspace| { + ws.broker.state_json()["dedupe"] + .as_object() + .unwrap() + .keys() + .filter(|key| key.contains(":legacy-")) + .count() + }; + ws.call_ok( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "hello"}), + ); + assert_eq!(legacy(&ws), 1_500 - 1_024); + ws.call_ok( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "again"}), + ); + assert_eq!(legacy(&ws), 0); + // Now under the limit, ordinary changes carry on. + ws.call_ok( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "and again"}), + ); +} + +#[test] +fn the_workspace_wide_cache_limit_evicts_instead_of_refusing() { + let mut ws = Workspace::new("dedupe-total"); + ws.broker.set_quotas(Quotas { + dedupe_entries: 40, + ..Quotas::STANDARD + }); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + for _ in 0..30 { + ws.host_ok("profile.update", json!({"nickname": null})); + ws.call_ok(&mut mallory, "profile.update", json!({"nickname": null})); + } + assert!(ws.broker.state_json()["dedupe"].as_object().unwrap().len() <= 40); +} + +#[test] +fn host_administrative_operations_keep_headroom() { + let mut ws = Workspace::new("admin-headroom"); + // The standard limits scaled down eightfold, so the state fills in seconds; the rule is the + // same at any size. + ws.broker.set_quotas(Quotas { + state_bytes: 2 * 1024 * 1024, + state_admin_headroom: 128 * 1024, + member_state_bytes: 512 * 1024, + ..Quotas::STANDARD + }); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let (team, general) = ws.host_team("full"); + ws.host_adds_to_team(&mut mallory, &team); + let params = run_params(&ws, &general); + let run = ws.call_ok(&mut mallory, "run.create", params)["run"]["id"].clone(); + // Fill the state to the last few hundred bytes the host's ordinary changes may use. + for size in [65_000, 4_000, 200, 1] { + let body = "x".repeat(size); + let mut filled = false; + for _ in 0..400 { + if let Some(error) = ws + .host_call("message.post", json!({"channel_id": general, "body": body})) + .error + { + assert_eq!(error.code, "quota_exceeded", "{}", error.message); + filled = true; + break; + } + } + assert!(filled, "the state fills with {size}-byte posts"); + } + let (code, _) = refused(ws.call( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "hello"}), + )); + assert_eq!(code, "quota_exceeded"); + let (code, _) = refused(ws.host_call( + "message.post", + json!({"channel_id": general, "body": "hello"}), + )); + assert_eq!(code, "quota_exceeded"); + // Taking access away never waits for storage: a member stops their agent, and the channel + // owner removes someone. + ws.call_ok(&mut mallory, "run.revoke", json!({"run_id": run})); + ws.host_ok( + "membership.revoke", + json!({"channel_id": general, "principal_id": mallory.principal_id}), + ); + // The host can still remove the member and change policy. + ws.host_ok( + "enrollment.revoke", + json!({"principal_id": mallory.principal_id, "expected_username": "mallory"}), + ); + // A real change: re-sending the current policy is a no-op that proves nothing here. + let epoch = ws.broker.workspace().policy_epoch; + let changed = ws.host_ok("policy.set", json!({"mode": "public"})); + assert_eq!(changed["mode"], "public"); + assert_eq!(changed["policy_epoch"], json!(epoch + 1)); + ws.host_ok("workspace.rename", json!({"name": "archive"})); + // Reading still works. + ws.host_snapshot(); +} + +#[test] +fn a_run_removed_after_it_expired_is_still_revoked_for_its_owner() { + let mut ws = Workspace::new("removed-run-revoke"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut victor = ws.enroll(VICTOR, "victor", 22); + let (team, general) = ws.host_team("revoke"); + ws.host_adds_to_team(&mut mallory, &team); + ws.host_adds_to_team(&mut victor, &team); + let params = run_params(&ws, &general); + let run_id = |run: Value| run["run"]["id"].as_str().unwrap().to_owned(); + let earlier = run_id(ws.call_ok(&mut mallory, "run.create", params.clone())); + let later = run_id(ws.call_ok(&mut mallory, "run.create", params)); + let expired_two_days_ago = |ws: Workspace, run: &str| { + ws.edit_journal(|journal| { + journal.append( + "system", + "test.age", + vec![set( + &["runs", run, "expires_at"], + json!(now() - 2 * 24 * 60 * 60), + )], + ); + }) + }; + let held = |ws: &Workspace, run: &str| ws.broker.state_json()["runs"].get(run).is_some(); + + // Removed by an unrelated change, a day after it expired, then revoked by its owner: a + // daemon that stopped the grant while the workspace was out of reach asks this whenever it + // reconnects. + let mut ws = expired_two_days_ago(ws, &earlier); + ws.host_ok("profile.update", json!({"nickname": null})); + assert!(!held(&ws, &earlier), "retention removed the run"); + let revoked = ws.call_ok(&mut mallory, "run.revoke", json!({"run_id": earlier})); + assert_eq!(revoked["id"], json!(earlier)); + assert_eq!(revoked["revoked"], true); + + // Removed by the very revoke that asks about it. + let mut ws = expired_two_days_ago(ws, &later); + assert!(held(&ws, &later)); + let revoked = ws.call_ok(&mut mallory, "run.revoke", json!({"run_id": later})); + assert_eq!(revoked["revoked"], true); + assert!(!held(&ws, &later)); + + // Anyone else is answered as for a run that never existed. + let (code, message) = refused(ws.call(&mut victor, "run.revoke", json!({"run_id": earlier}))); + let (unknown_code, unknown_message) = + refused(ws.call(&mut victor, "run.revoke", json!({"run_id": uuid()}))); + assert_eq!( + (code.as_str(), message.as_str()), + ("forbidden", unknown_message.as_str()) + ); + assert_eq!(unknown_code, "forbidden"); + let (code, _) = refused(ws.call(&mut mallory, "run.revoke", json!({"run_id": uuid()}))); + assert_eq!(code, "forbidden"); + + // The removals are journaled, so a restarted broker still answers the owner. + let mut ws = ws.reopen(); + for run in [&earlier, &later] { + let revoked = ws.call_ok(&mut mallory, "run.revoke", json!({"run_id": run})); + assert_eq!(revoked["revoked"], true); + } + let (code, _) = refused(ws.call(&mut victor, "run.revoke", json!({"run_id": later}))); + assert_eq!(code, "forbidden"); +} + +#[test] +fn repeated_removals_cannot_use_up_the_journal_headroom() { + let mut ws = Workspace::new("journal-headroom"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let (team, general) = ws.host_team("journal"); + ws.host_adds_to_team(&mut mallory, &team); + let params = run_params(&ws, &general); + let run = ws.call_ok(&mut mallory, "run.create", params)["run"]["id"].clone(); + // A journal 256 KiB from full, half of it kept for the host. + let used = std::fs::metadata(ws.journal_path()).unwrap().len(); + ws.broker.set_quotas(Quotas { + journal_bytes: used + 256 * 1024, + journal_admin_headroom: 128 * 1024, + ..Quotas::STANDARD + }); + let mut refusal = None; + for _ in 0..2_000 { + if let Some(error) = ws + .call(&mut mallory, "run.revoke", json!({"run_id": run})) + .error + { + refusal = Some(error); + break; + } + } + let error = refusal.expect("repeated removals reach the ordinary journal limit"); + assert_eq!(error.code, "quota_exceeded"); + // What is left is the host's. + ws.host_ok( + "enrollment.revoke", + json!({"principal_id": mallory.principal_id}), + ); +} + +#[test] +fn one_member_cannot_fill_the_journal() { + let mut ws = Workspace::new("member-journal-share"); + ws.broker.set_quotas(Quotas { + member_journal_bytes: 256 * 1024, + ..Quotas::STANDARD + }); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut refusal = None; + for index in 0..2_000 { + if let Some(error) = ws + .call(&mut mallory, "profile.update", json!({"nickname": null})) + .error + { + refusal = Some((index, error)); + break; + } + } + let (_, error) = refusal.expect("a member's journal share runs out"); + assert_eq!(error.code, "quota_exceeded"); + assert!(error.message.contains("audit journal"), "{}", error.message); + // The share is counted from the journal itself, so a restart does not reset it. + let quotas = Quotas { + member_journal_bytes: 256 * 1024, + ..Quotas::STANDARD + }; + let mut ws = ws.reopen(); + ws.broker.set_quotas(quotas); + let (code, _) = refused(ws.call(&mut mallory, "profile.update", json!({"nickname": null}))); + assert_eq!(code, "quota_exceeded"); + ws.host_ok("profile.update", json!({"nickname": null})); +} + +// --------------------------------------------------------------------------------------------- +// BROKER-4: attachment, team, channel and reference shares; unfinished uploads expire +// --------------------------------------------------------------------------------------------- + +#[test] +fn one_member_cannot_reserve_the_workspace_attachment_space() { + let mut ws = Workspace::new("attachment-share"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut victor = ws.enroll(VICTOR, "victor", 22); + let (_, mallory_general) = ws.create_team(&mut mallory, "mallory-team"); + let (_, victor_general) = ws.create_team(&mut victor, "victor-team"); + let sha = "0".repeat(64); + let gib = 1024u64 * 1024 * 1024; + let begin = |channel: &str, size: u64| json!({"channel_id": channel, "name": "big.bin", "media_type": "application/octet-stream", "size": size, "sha256": sha}); + let mut begun = Vec::new(); + for _ in 0..10 { + match ws.call(&mut mallory, "blob.begin", begin(&mallory_general, gib)) { + response if response.error.is_none() => begun.push(ok(response)["id"].clone()), + response => { + let (code, message) = refused(response); + assert_eq!(code, "quota_exceeded"); + assert!(message.contains("your share"), "{message}"); + break; + } + } + } + assert_eq!( + begun.len(), + 2, + "a quarter of 10 GiB holds two 1 GiB uploads" + ); + // Another member can still attach files. + ws.call_ok(&mut victor, "blob.begin", begin(&victor_general, 1_000)); + + // Nothing touched Mallory's uploads for two days: they lapse, their files go, and her + // space is back. + let blob_ids: Vec = begun + .iter() + .map(|id| id.as_str().unwrap().to_owned()) + .collect(); + let root = ws.root.path().to_path_buf(); + for id in &blob_ids { + assert!(root.join("blobs").join(id).exists()); + } + let ws = ws.edit_journal(|journal| { + journal.append( + "system", + "test.age", + blob_ids + .iter() + .map(|id| { + set( + &["blobs", id, "touched_at"], + json!(now() - 2 * 24 * 60 * 60), + ) + }) + .collect(), + ); + }); + let mut ws = ws; + ws.call_ok(&mut mallory, "blob.begin", begin(&mallory_general, gib)); + let state = ws.broker.state_json(); + for id in &blob_ids { + assert!( + state["blobs"].get(id).is_none(), + "the lapsed upload is gone" + ); + assert!(!root.join("blobs").join(id).exists(), "and so is its file"); + } +} + +#[test] +fn progress_renews_an_upload_and_a_finished_attachment_never_lapses() { + let mut ws = Workspace::new("upload-progress"); + let (_, general) = ws.host_team("upload"); + let data = b"hello"; + let sha = hex::encode(::digest(data)); + let blob = ws.host_ok( + "blob.begin", + json!({"channel_id": general, "name": "a.txt", "media_type": "text/plain", "size": 5, "sha256": sha}), + ); + let id = blob["id"].as_str().unwrap().to_owned(); + let begun = blob["touched_at"] + .as_u64() + .expect("an upload records its activity"); + let chunk = ws.host_ok( + "blob.chunk", + json!({"blob_id": id, "offset": 0, "data_hex": hex::encode(data)}), + ); + assert!( + chunk["touched_at"].as_u64().unwrap() >= begun, + "a chunk renews it" + ); + ws.host_ok("blob.finish", json!({"blob_id": id})); + // A finished attachment is history: it never lapses. + let ws = ws.edit_journal(|journal| { + journal.append( + "system", + "test.age", + vec![set(&["blobs", &id, "touched_at"], json!(0))], + ); + }); + let mut ws = ws; + ws.host_ok("profile.update", json!({"nickname": null})); + assert!(ws.broker.state_json()["blobs"].get(&id).is_some()); +} + +#[test] +fn one_member_cannot_take_every_team_channel_or_reference() { + let mut ws = Workspace::new("create-shares"); + ws.broker.set_quotas(Quotas { + member_references: 5, + ..Quotas::STANDARD + }); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut victor = ws.enroll(VICTOR, "victor", 22); + let mut teams = Vec::new(); + for index in 0..11 { + let response = ws.call( + &mut mallory, + "team.create", + json!({"name": format!("m{index}")}), + ); + if index < Quotas::STANDARD.member_teams { + teams.push(ok(response)["team"]["id"].as_str().unwrap().to_owned()); + } else { + let (code, message) = refused(response); + assert_eq!(code, "quota_exceeded"); + assert!(message.contains("teams"), "{message}"); + } + } + // Ten teams brought ten #general channels; ninety more reach the channel share. + let mut created = 10; + let mut refused_channel = false; + for index in 0..200 { + let team = &teams[index % teams.len()]; + let response = ws.call( + &mut mallory, + "channel.create", + json!({"team_id": team, "name": format!("c{index}")}), + ); + if response.error.is_some() { + let (code, message) = refused(response); + assert_eq!(code, "quota_exceeded"); + assert!(message.contains("channels"), "{message}"); + refused_channel = true; + break; + } + created += 1; + } + assert!(refused_channel); + assert_eq!(created, Quotas::STANDARD.member_channels); + // Others still create teams and channels; the host has no share. + let (victor_team, victor_general) = ws.create_team(&mut victor, "victor-team"); + ws.call_ok( + &mut victor, + "channel.create", + json!({"team_id": victor_team, "name": "notes"}), + ); + for index in 0..12 { + ws.host_ok("team.create", json!({"name": format!("h{index}")})); + } + + let general = ws.snapshot(&mut mallory)["teams"][0]["general_channel_id"] + .as_str() + .unwrap() + .to_owned(); + for index in 0..6 { + let response = ws.call( + &mut mallory, + "reference.create", + json!({"channel_id": general, "path": format!("/data/{index}"), "label": "d"}), + ); + if index < 5 { + ok(response); + } else { + assert_eq!(refused(response).0, "quota_exceeded"); + } + } + ws.call_ok( + &mut victor, + "reference.create", + json!({"channel_id": victor_general, "path": "/data/v", "label": "v"}), + ); +} + +// --------------------------------------------------------------------------------------------- +// BROKER-2: what other members can grow never pushes a response past the frame limit +// --------------------------------------------------------------------------------------------- + +#[test] +fn a_snapshot_stays_under_the_frame_limit_whatever_another_member_adds() { + let mut ws = Workspace::new("snapshot-bound"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut victor = ws.enroll(VICTOR, "victor", 22); + let (team, general) = ws.create_team(&mut mallory, "shared"); + ws.add_to_team(&mut mallory, &mut victor, &team); + // References with the longest path a reference may have, in a channel Victor reads. + let path = format!("/{}", "p".repeat(4_095)); + for _ in 0..300 { + ws.call_ok( + &mut mallory, + "reference.create", + json!({"channel_id": general, "path": path, "label": "l".repeat(255)}), + ); + } + // Invitations aimed at the host, again and again. + for _ in 0..50 { + ws.call_ok( + &mut mallory, + "invitation.create", + json!({"kind": "team", "target_id": team, "principal_id": ws.host.principal_id}), + ); + } + let response = ws.call(&mut victor, "workspace.snapshot", json!({})); + assert!(frame_len(&response) < MAX_FRAME, "{}", frame_len(&response)); + let snapshot = ok(response); + assert_eq!(snapshot["totals"]["references"], 300); + assert!(snapshot["references"].as_array().unwrap().len() < 300); + + let response = ws.host_call("workspace.snapshot", json!({})); + assert!(frame_len(&response) < MAX_FRAME); + let snapshot = ok(response); + let from_mallory: Vec<&Value> = snapshot["invitations"] + .as_array() + .unwrap() + .iter() + .filter(|i| i["inviter_id"] == json!(mallory.principal_id)) + .collect(); + assert_eq!( + from_mallory.len(), + 1, + "a repeat invitation renews the first" + ); + assert_eq!(snapshot["totals"]["invitations"], 1); +} + +#[test] +fn one_inviter_cannot_crowd_the_others_out_of_a_snapshot() { + let mut ws = Workspace::new("invitation-turns"); + ws.broker.set_quotas(Quotas { + member_live_invitations: 1_000, + member_channels: 1_000, + ..Quotas::STANDARD + }); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut victor = ws.enroll(VICTOR, "victor", 22); + // The host's invitation is the oldest Victor has. + let (host_team, _) = ws.host_team("host-team"); + let from_host = ws.host_ok( + "invitation.create", + json!({"kind": "team", "target_id": host_team, "principal_id": victor.principal_id}), + )["id"] + .clone(); + std::thread::sleep(std::time::Duration::from_millis(1_100)); + // Then Mallory sends him more than a snapshot section holds. + let (team, _) = ws.create_team(&mut mallory, "crowd"); + ws.call_ok( + &mut mallory, + "team.add_member", + json!({"team_id": team, "principal_id": victor.principal_id, "expected_username": "victor"}), + ); + for index in 0..250 { + let channel = ws.call_ok( + &mut mallory, + "channel.create", + json!({"team_id": team, "name": format!("c{index}")}), + )["id"] + .clone(); + ws.call_ok( + &mut mallory, + "invitation.create", + json!({"kind": "channel", "target_id": channel, "principal_id": victor.principal_id}), + ); + } + let snapshot = ws.snapshot(&mut victor); + let listed = snapshot["invitations"].as_array().unwrap(); + assert!(listed.len() < 251, "the section is bounded"); + assert_eq!(snapshot["totals"]["invitations"], 251); + assert!( + listed + .iter() + .any(|invitation| invitation["id"] == from_host), + "every inviter's newest invitation gets a turn" + ); +} + +#[test] +fn a_member_has_a_bounded_number_of_invitations_outstanding() { + let mut ws = Workspace::new("invitation-share"); + ws.broker.set_quotas(Quotas { + member_live_invitations: 3, + ..Quotas::STANDARD + }); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let targets: Vec = (0..4) + .map(|index| ws.create_team(&mut mallory, &format!("t{index}")).0) + .collect(); + for (index, team) in targets.iter().enumerate() { + let response = ws.call( + &mut mallory, + "invitation.create", + json!({"kind": "team", "target_id": team, "principal_id": ws.host.principal_id}), + ); + if index < 3 { + ok(response); + } else { + assert_eq!(refused(response).0, "quota_exceeded"); + } + } + // Renewing one already outstanding is not another. + ws.call_ok( + &mut mallory, + "invitation.create", + json!({"kind": "team", "target_id": targets[0], "principal_id": ws.host.principal_id}), + ); +} + +#[test] +fn the_context_manifest_stays_under_the_frame_limit() { + let mut ws = Workspace::new("manifest-bound"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let (team, general) = ws.host_team("agents"); + ws.host_adds_to_team(&mut mallory, &team); + for _ in 0..20 { + ws.call_ok( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "m".repeat(65_000)}), + ); + } + ws.host_ok( + "message.post", + json!({"channel_id": general, "body": "newest"}), + ); + let params = run_params(&ws, &general); + let run = ws.host_ok("run.create", params); + let credential = run["credential"].as_str().unwrap().to_owned(); + let manifest = worker( + &mut ws, + host_uid(), + &credential, + "context.manifest", + json!({}), + ); + let size = serde_json::to_vec(&manifest).unwrap().len(); + assert!(size < MAX_FRAME, "{size}"); + let messages = manifest["messages"].as_array().unwrap(); + assert_eq!( + messages[0]["body"], "newest", + "the newest messages are kept" + ); + assert!(messages.len() < 21); + assert_eq!(manifest["restricted"], true); +} + +#[test] +fn another_members_largest_posts_never_stop_an_agent_reading_its_channel() { + let mut ws = Workspace::new("worker-history-bound"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let (team, general) = ws.host_team("agents"); + ws.host_adds_to_team(&mut mallory, &team); + // Twenty of the largest bodies a message may have (quotes escape to twice their size), + // well within Mallory's share: 2.5 MiB, where one frame is 1 MiB. + for _ in 0..20 { + ws.call_ok( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "\"".repeat(65_536)}), + ); + } + let newest = ws.host_ok( + "message.post", + json!({"channel_id": general, "body": "newest"}), + )["id"] + .as_str() + .unwrap() + .to_owned(); + let params = run_params(&ws, &general); + let credential = ws.host_ok("run.create", params)["credential"] + .as_str() + .unwrap() + .to_owned(); + let read = |ws: &mut Workspace, method: &str, params: Value| { + let response = worker_call(ws, host_uid(), &credential, method, params); + assert!(frame_len(&response) < MAX_FRAME, "{}", frame_len(&response)); + ok(response) + }; + let ids = |page: &Value| -> Vec { + page["messages"] + .as_array() + .unwrap() + .iter() + .map(|message| message["id"].as_str().unwrap().to_owned()) + .collect() + }; + + // What every agent task reads first: its destination's newest 50. + let page = read( + &mut ws, + "messages.history", + json!({"channel_id": general, "limit": 50, "latest": true}), + ); + let mut seen = ids(&page); + assert_eq!(seen.last(), Some(&newest), "the newest messages are kept"); + assert!(seen.len() < 21); + assert_eq!(page["truncated"], true); + assert_eq!(page["cursor"], json!(newest)); + // The rest is still there, before the first message of each page. + loop { + let page = read( + &mut ws, + "messages.history", + json!({"channel_id": general, "limit": 50, "latest": true, "before": seen[0]}), + ); + let older = ids(&page); + if older.is_empty() { + break; + } + seen.splice(0..0, older); + } + assert_eq!(seen.len(), 21); + + // Paging forward from the start reaches the same messages in the same order. + let mut forward: Vec = Vec::new(); + let mut cursor = Value::Null; + loop { + let mut params = json!({"channel_id": general, "limit": 50}); + if !cursor.is_null() { + params["after"] = cursor.clone(); + } + let page = read(&mut ws, "messages.history", params); + let next = ids(&page); + if next.is_empty() { + break; + } + forward.extend(next); + cursor = page["cursor"].clone(); + } + assert_eq!(forward, seen); + + // Search pages are bounded the same way. + let page = read( + &mut ws, + "messages.search", + json!({"channel_id": general, "query": "\"", "limit": 50, "latest": true}), + ); + assert_eq!(page["truncated"], true); + + // A person's page is not cut short: a client offers an older page when the one it has is + // full, so it is refused as too large at the frame and asked again for fewer. + let page = ws.host_ok( + "messages.history", + json!({"channel_id": general, "limit": 50, "latest": true}), + ); + assert_eq!(page["messages"].as_array().unwrap().len(), 21); + assert!(page.get("truncated").is_none()); +} + +#[test] +fn a_revoked_or_expired_run_leaves_the_snapshot() { + let mut ws = Workspace::new("snapshot-runs"); + let (_, general) = ws.host_team("runs"); + let params = run_params(&ws, &general); + let live = ws.host_ok("run.create", params.clone())["run"]["id"].clone(); + let revoked = ws.host_ok("run.create", params)["run"]["id"].clone(); + ws.host_ok("run.revoke", json!({"run_id": revoked})); + let snapshot = ws.host_snapshot(); + let runs: Vec<&Value> = snapshot["runs"] + .as_array() + .unwrap() + .iter() + .map(|run| &run["id"]) + .collect(); + assert_eq!(runs, vec![&live]); + assert_eq!(snapshot["totals"]["runs"], 1); +} + +// --------------------------------------------------------------------------------------------- +// BROKER-2: one member's teams and channels, and how the people they added get out of them +// --------------------------------------------------------------------------------------------- + +const OLIVIA: u32 = 71_003; +/// Everyone else in the crowded workspace: enough that a team or channel listing them all +/// takes about 12 KB, so one member's full share of teams and channels is more than a frame. +const CROWD: u32 = 300; + +/// A workspace where Mallory, a member and not the host, has created her whole share of teams +/// and channels and added everyone to every one of them, as direct add lets any team owner do +/// without asking. Victor is also in the host's team, a team of his own and Olivia's. +struct Crowded { + ws: Workspace, + mallory: Member, + victor: Member, + olivia: Member, + mallory_teams: Vec, + /// Each of Mallory's teams' `#general`, then its nine other channels. + mallory_channels: Vec, + /// The teams Victor is in that are not Mallory's: the host's, his own and Olivia's. + other_teams: Vec, + /// Their `#general` channels, in the same order. + other_channels: Vec, +} + +fn crowded(label: &str) -> Crowded { + let mut ws = Workspace::new(label); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let mut victor = ws.enroll(VICTOR, "victor", 22); + let mut olivia = ws.enroll(OLIVIA, "olivia", 23); + let (host_team, host_general) = ws.host_team("lab"); + ws.host_ok( + "team.add_member", + json!({"team_id": host_team, "principal_id": victor.principal_id, "expected_username": "victor"}), + ); + let (victor_team, victor_general) = ws.create_team(&mut victor, "victor-notes"); + let (olivia_team, olivia_general) = ws.create_team(&mut olivia, "olivia-team"); + ws.call_ok( + &mut olivia, + "team.add_member", + json!({"team_id": olivia_team, "principal_id": victor.principal_id, "expected_username": "victor"}), + ); + let mut mallory_teams = Vec::new(); + let mut mallory_channels = Vec::new(); + for team_index in 0..Quotas::STANDARD.member_teams { + let (team, general) = ws.create_team(&mut mallory, &format!("crowd-{team_index}")); + mallory_channels.push(general); + for channel_index in 0..9 { + let channel = ws.call_ok( + &mut mallory, + "channel.create", + json!({"team_id": team, "name": format!("c{team_index}-{channel_index}")}), + ); + mallory_channels.push(channel["id"].as_str().unwrap().to_owned()); + } + mallory_teams.push(team); + } + assert_eq!(mallory_channels.len(), Quotas::STANDARD.member_channels); + // What direct add would record for 300 more people, written straight into the journal: + // thousands of real adds would take minutes, and direct_add_contract covers the adds. + let crowd: Vec<(String, u32)> = (0..CROWD).map(|index| (uuid(), 80_000 + index)).collect(); + let mut everyone: Vec = crowd.iter().map(|(id, _)| id.clone()).collect(); + everyone.extend([ + mallory.principal_id.clone(), + victor.principal_id.clone(), + olivia.principal_id.clone(), + ]); + everyone.sort(); + let actor = mallory.principal_id.clone(); + let ws = ws.edit_journal(|journal| { + journal.append( + "system", + "test.crowd", + crowd + .iter() + .map(|(id, uid)| { + let name = format!("crowd{uid}"); + set( + &["principals", id], + legacy_principal(id, *uid, &name, &name, true), + ) + }) + .collect(), + ); + let places = mallory_teams + .iter() + .map(|team| ("teams", team)) + .chain(mallory_channels.iter().map(|channel| ("channels", channel))); + journal.append( + &actor, + "team.add_member", + places + .map(|(section, id)| set(&[section, id, "members"], json!(everyone))) + .collect(), + ); + }); + Crowded { + ws, + mallory, + victor, + olivia, + mallory_teams, + mallory_channels, + other_teams: vec![host_team, victor_team, olivia_team], + other_channels: vec![host_general, victor_general, olivia_general], + } +} + +/// The `id`s a snapshot section lists. +fn listed(snapshot: &Value, section: &str) -> Vec { + snapshot[section] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["id"].as_str().unwrap().to_owned()) + .collect() +} + +#[test] +fn one_members_teams_and_channels_never_push_a_snapshot_past_the_frame_limit() { + let Crowded { + mut ws, + mut victor, + mallory_teams, + mallory_channels, + other_teams, + other_channels, + .. + } = crowded("places-bound"); + let state = ws.broker.state_json(); + let size = |value: &Value| serde_json::to_vec(value).unwrap().len(); + let mallorys: usize = mallory_teams + .iter() + .map(|team| size(&state["teams"][team])) + .chain( + mallory_channels + .iter() + .map(|channel| size(&state["channels"][channel])), + ) + .sum(); + assert!( + mallorys > MAX_FRAME, + "unbounded, Mallory's teams and channels alone list {mallorys} bytes" + ); + + let response = ws.call(&mut victor, "workspace.snapshot", json!({})); + assert!(frame_len(&response) < MAX_FRAME, "{}", frame_len(&response)); + let snapshot = ok(response); + let (teams, channels) = (listed(&snapshot, "teams"), listed(&snapshot, "channels")); + assert_eq!( + snapshot["totals"]["teams"], + mallory_teams.len() + other_teams.len() + ); + assert_eq!( + snapshot["totals"]["channels"], + mallory_channels.len() + other_channels.len() + ); + assert!( + channels.len() < mallory_channels.len(), + "some of Mallory's channels are left out" + ); + // The host's, Victor's own and Olivia's are all there: the host and Victor himself come + // first, and Olivia takes turns with Mallory. + for id in &other_teams { + assert!(teams.contains(id), "team {id}"); + } + for id in &other_channels { + assert!(channels.contains(id), "channel {id}"); + } + assert!( + mallory_channels.iter().any(|id| channels.contains(id)), + "Mallory takes her turns too" + ); + // Nothing listed is cut short, and a channel comes with its team. + for team in snapshot["teams"].as_array().unwrap() { + let id = team["id"].as_str().unwrap(); + assert_eq!(team["members"], state["teams"][id]["members"], "{id}"); + } + for channel in snapshot["channels"].as_array().unwrap() { + let id = channel["id"].as_str().unwrap(); + assert_eq!(channel["members"], state["channels"][id]["members"], "{id}"); + let team = channel["team_id"].as_str().unwrap().to_owned(); + assert!(teams.contains(&team), "{id} without its team"); + } + // What hangs off a channel is there exactly for the channels listed. + let mut expected = channels.clone(); + expected.sort(); + for section in ["read_positions", "unread"] { + let mut keys: Vec = snapshot[section] + .as_object() + .unwrap() + .keys() + .cloned() + .collect(); + keys.sort(); + assert_eq!(keys, expected, "{section}"); + } + let protected: Vec = snapshot["protected_channel_ids"] + .as_array() + .unwrap() + .iter() + .map(|id| id.as_str().unwrap().to_owned()) + .collect(); + assert!(!protected.is_empty()); + assert!(protected.iter().all(|id| channels.contains(id))); +} + +/// What a snapshot leaves out still counts in what the daemon's name resolver reads beside the +/// listing: each listed channel's `name_conflict` and the `former_principals` it matches +/// `former @name` against. Were either computed over the listed channels only, a name whose +/// namesake was left out would resolve to the listed one, and a person could post to, or remove, +/// someone other than the one they named. +#[test] +fn a_snapshot_that_leaves_channels_out_still_counts_them_in_names_and_former_members() { + let Crowded { + ws, + mut victor, + mallory, + olivia, + mallory_channels, + .. + } = crowded("places-names"); + let state = ws.broker.state_json(); + let named: [&str; 3] = [ + &mallory.principal_id, + &victor.principal_id, + &olivia.principal_id, + ]; + // Each of Mallory's channels names one former member no other place names, in place of + // someone from the crowd. And in each of her teams, eight of the nine channels besides + // `#general` pair up under one name, as a journal written before the name rules can hold. + let formers: Vec = mallory_channels.iter().map(|_| uuid()).collect(); + let mut patches = Vec::new(); + for (position, (channel, former)) in mallory_channels.iter().zip(&formers).enumerate() { + let uid = 90_000 + u32::try_from(position).unwrap(); + let username = format!("gone{uid}"); + patches.push(set( + &["principals", former], + legacy_principal(former, uid, &username, &username, false), + )); + let mut members: Vec = state["channels"][channel]["members"] + .as_array() + .unwrap() + .clone(); + let crowd: Vec = (0..members.len()) + .filter(|index| !named.contains(&members[*index].as_str().unwrap())) + .collect(); + members[crowd[position]] = json!(former); + patches.push(set(&["channels", channel, "members"], json!(members))); + let within_team = position % 10; + if (1..=8).contains(&within_team) && within_team % 2 == 0 { + let namesake = state["channels"][&mallory_channels[position - 1]]["name"].clone(); + patches.push(set(&["channels", channel, "name"], namesake)); + } + } + let actor = mallory.principal_id.clone(); + let mut ws = ws.edit_journal(|journal| journal.append(&actor, "test.namesakes", patches)); + + let response = ws.call(&mut victor, "workspace.snapshot", json!({})); + assert!(frame_len(&response) < MAX_FRAME, "{}", frame_len(&response)); + let snapshot = ok(response); + let state = ws.broker.state_json(); + let channels = listed(&snapshot, "channels"); + assert!( + mallory_channels.iter().any(|id| !channels.contains(id)), + "some of Mallory's channels are left out" + ); + let victors: Vec<(&String, &Value)> = state["channels"] + .as_object() + .unwrap() + .iter() + .filter(|(_, channel)| { + channel["members"] + .as_array() + .unwrap() + .contains(&json!(victor.principal_id)) + }) + .collect(); + let keys = |name: &Value| { + let name = name.as_str().unwrap(); + (name_key(name), skeleton_key(name)) + }; + let mut only_namesake_left_out = 0; + for channel in snapshot["channels"].as_array().unwrap() { + let id = channel["id"].as_str().unwrap(); + let (key, skeleton) = keys(&channel["name"]); + let namesakes: Vec<&String> = victors + .iter() + .filter(|(other, peer)| { + let (other_key, other_skeleton) = keys(&peer["name"]); + other.as_str() != id + && peer["team_id"] == channel["team_id"] + && (other_key == key || other_skeleton == skeleton) + }) + .map(|(other, _)| *other) + .collect(); + assert_eq!( + channel["name_conflict"], + json!(!namesakes.is_empty()), + "{id}" + ); + if !namesakes.is_empty() && namesakes.iter().all(|other| !channels.contains(other)) { + only_namesake_left_out += 1; + } + } + assert!( + only_namesake_left_out > 0, + "a listed channel whose only namesake is left out" + ); + let former_principals = listed(&snapshot, "former_principals"); + for former in &formers { + assert!(former_principals.contains(former), "former member {former}"); + } +} + +#[test] +fn a_member_leaves_the_channels_someone_else_added_them_to() { + let Crowded { + mut ws, + mut mallory, + mut victor, + mut olivia, + mallory_teams, + mallory_channels, + other_teams, + other_channels, + .. + } = crowded("places-leave"); + let (victor_id, olivia_id) = (victor.principal_id.clone(), olivia.principal_id.clone()); + // An archived channel too. + ws.call_ok( + &mut mallory, + "channel.archive", + json!({"channel_id": mallory_channels[1]}), + ); + let leave = json!({ + "channel_id": mallory_channels[0], + "principal_id": victor_id, + "expected_username": "victor", + "idempotency_key": "leave-general", + }); + let left = ws.call_ok(&mut victor, "membership.revoke", leave.clone()); + // A retry of the leave returns what the leave returned, though he is no longer in it. + assert_eq!(ws.call_ok(&mut victor, "membership.revoke", leave), left); + for channel in &mallory_channels[1..] { + ws.call_ok( + &mut victor, + "membership.revoke", + json!({"channel_id": channel, "principal_id": victor_id}), + ); + } + let snapshot = ws.snapshot(&mut victor); + assert_eq!(snapshot["totals"]["channels"], other_channels.len()); + assert_eq!(listed(&snapshot, "channels").len(), other_channels.len()); + // Leaving the channels leaves him in Mallory's teams, which now fit. + assert_eq!(snapshot["totals"]["teams"], mallory_teams.len() + 3); + assert_eq!(listed(&snapshot, "teams").len(), mallory_teams.len() + 3); + let (code, _) = refused(ws.call( + &mut victor, + "messages.history", + json!({"channel_id": mallory_channels[2]}), + )); + assert_eq!(code, "forbidden"); + + // Leaving is only ever oneself, and an owner transfers before leaving. + let olivia_general = &other_channels[2]; + assert_eq!( + refused(ws.call( + &mut victor, + "membership.revoke", + json!({"channel_id": olivia_general, "principal_id": olivia_id}), + )) + .1, + "forbidden: current owner required" + ); + assert_eq!( + refused(ws.call( + &mut olivia, + "membership.revoke", + json!({"channel_id": olivia_general, "principal_id": olivia_id}), + )) + .1, + "forbidden: transfer before owner removal" + ); + + // Leaving takes back only the leaver's own invitations to the channel. + let olivia_team = &other_teams[2]; + ws.call_ok( + &mut olivia, + "team.add_member", + json!({"team_id": olivia_team, "principal_id": mallory.principal_id, "expected_username": "mallory"}), + ); + let notes = ws.call_ok( + &mut olivia, + "channel.create", + json!({"team_id": olivia_team, "name": "notes"}), + )["id"] + .clone(); + ws.call_ok( + &mut olivia, + "channel.add_member", + json!({"channel_id": notes, "principal_id": victor_id, "expected_username": "victor"}), + ); + let invitation = ws.call_ok( + &mut olivia, + "invitation.create", + json!({"kind": "channel", "target_id": notes, "principal_id": mallory.principal_id}), + )["id"] + .clone(); + ws.call_ok( + &mut victor, + "membership.revoke", + json!({"channel_id": notes, "principal_id": victor_id}), + ); + ws.call_ok( + &mut mallory, + "invitation.accept", + json!({"invitation_id": invitation}), + ); + let members = &ws.broker.state_json()["channels"][notes.as_str().unwrap()]["members"]; + assert!(members + .as_array() + .unwrap() + .contains(&json!(mallory.principal_id))); +} + +#[test] +fn the_host_manages_the_channels_of_someone_who_left_the_workspace() { + let Crowded { + mut ws, + mallory, + mut victor, + mallory_channels, + other_channels, + .. + } = crowded("places-steward"); + let (archive, remove) = (&mallory_channels[1], &mallory_channels[2]); + let remove_victor = json!({ + "channel_id": remove, + "principal_id": victor.principal_id, + "expected_username": "victor", + "idempotency_key": "host-removes-victor", + }); + // While Mallory is a member her channels are hers, and the host is not in them. + assert_eq!( + refused(ws.host_call("channel.archive", json!({"channel_id": archive}))).1, + "forbidden: channel unavailable" + ); + assert_eq!( + refused(ws.host_call("membership.revoke", remove_victor.clone())).1, + "forbidden: channel unavailable" + ); + + // Once she has left the workspace nobody else could ever manage them, so the host does. + ws.offboard(&mallory.principal_id); + assert_eq!( + ws.host_ok("channel.archive", json!({"channel_id": archive}))["archived"], + true + ); + let removed = ws.host_ok("membership.revoke", remove_victor.clone()); + assert_eq!(ws.host_ok("membership.revoke", remove_victor), removed); + assert_eq!( + ws.snapshot(&mut victor)["totals"]["channels"], + mallory_channels.len() - 1 + other_channels.len() + ); + // Never her own membership, never a transfer, nothing more on an archived channel, and no + // other member steps in. + assert_eq!( + refused(ws.host_call( + "membership.revoke", + json!({"channel_id": remove, "principal_id": mallory.principal_id}), + )) + .1, + "forbidden: transfer before owner removal" + ); + assert_eq!( + refused(ws.host_call( + "channel.transfer", + json!({"channel_id": remove, "successor_id": victor.principal_id}), + )) + .1, + "forbidden: channel unavailable" + ); + assert_eq!( + refused(ws.host_call( + "membership.revoke", + json!({"channel_id": archive, "principal_id": victor.principal_id}), + )) + .0, + "channel_archived" + ); + let crowd = ws.broker.state_json()["channels"][&mallory_channels[3]]["members"] + .as_array() + .unwrap() + .iter() + .find(|id| *id != &json!(victor.principal_id) && *id != &json!(mallory.principal_id)) + .unwrap() + .clone(); + assert_eq!( + refused(ws.call( + &mut victor, + "membership.revoke", + json!({"channel_id": mallory_channels[3], "principal_id": crowd}), + )) + .1, + "forbidden: current owner required" + ); +} + +/// M1: the state and journal budgets are finite and only the host can act on them, so the +/// host's snapshot says how full they are, well before writes stop. Nobody else's does. +#[test] +fn the_hosts_snapshot_says_how_full_the_workspace_is_and_a_members_does_not() { + let mut ws = Workspace::new("usage"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + let (team, general) = ws.host_team("usage"); + ws.host_adds_to_team(&mut mallory, &team); + let state_size = |ws: &Workspace| serde_json::to_vec(&ws.broker.state_json()).unwrap().len(); + + let usage = ws.host_snapshot()["usage"].clone(); + assert_eq!(usage["state_bytes"], json!(state_size(&ws))); + assert_eq!(usage["state_limit"], json!(Quotas::STANDARD.state_bytes)); + assert_eq!( + usage["state_admin_headroom"], + json!(Quotas::STANDARD.state_admin_headroom) + ); + assert_eq!(usage["journal_bytes"], json!(ws.journal_bytes().len())); + assert_eq!( + usage["journal_limit"], + json!(Quotas::STANDARD.journal_bytes) + ); + assert_eq!( + usage["journal_admin_headroom"], + json!(Quotas::STANDARD.journal_admin_headroom) + ); + assert_eq!(usage["attachment_bytes"], json!(0)); + assert_eq!(usage["attachment_limit"], json!(10u64 * 1024 * 1024 * 1024)); + assert_eq!(usage["attachments"], json!(0)); + assert_eq!(usage["attachments_limit"], json!(10_000)); + + // It follows every change: a post grows the state and the journal, and an upload's + // declared size counts as soon as it begins. + ws.call_ok( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "x".repeat(10_000)}), + ); + ws.call_ok( + &mut mallory, + "blob.begin", + json!({ + "channel_id": general, + "size": 4096, + "sha256": digest(b"four kibibytes"), + "name": "data.csv", + "media_type": "text/csv", + }), + ); + let grown = ws.host_snapshot()["usage"].clone(); + assert_eq!(grown["state_bytes"], json!(state_size(&ws))); + assert!(grown["state_bytes"].as_u64() > usage["state_bytes"].as_u64()); + assert_eq!(grown["journal_bytes"], json!(ws.journal_bytes().len())); + assert_eq!(grown["attachment_bytes"], json!(4096)); + assert_eq!(grown["attachments"], json!(1)); + + // A restart measures the state it replayed. + let mut ws = ws.reopen(); + let reopened = ws.host_snapshot()["usage"].clone(); + assert_eq!(reopened["state_bytes"], json!(state_size(&ws))); + + // A member is told nothing new: only the host can act on it. + let snapshot = ws.snapshot(&mut mallory); + assert!( + snapshot.get("usage").is_none(), + "{:?}", + snapshot.get("usage") + ); +} diff --git a/crates/biorouter-crew/tests/runtime_contract.rs b/crates/biorouter-crew/tests/runtime_contract.rs new file mode 100644 index 000000000..16dccbe3f --- /dev/null +++ b/crates/biorouter-crew/tests/runtime_contract.rs @@ -0,0 +1,416 @@ +//! The broker's runtime directory and its lifecycle commands: `status` and `stop` never create +//! state, an empty descriptor left by an older release does not block a start, a runtime path +//! another account took after `/tmp` was cleaned moves to a fresh one instead of refusing every +//! start (and one only this account could ever have written into keeps its path), and decoy +//! directories cannot hide a running sibling from the workspace-name check. The bridge finding a +//! workspace that moved is tested beside it in `broker.rs`, since it needs the broker's +//! private helpers. +//! +//! Everything here runs on any Unix except what drives a real broker process (Linux only). +#![cfg(unix)] + +mod support; + +use serde_json::json; +use std::fs; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use support::*; + +const NODE: &str = "test-node"; + +/// A private file, as the broker writes its own. +fn private_write(path: &Path, bytes: &[u8]) { + fs::write(path, bytes).unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); +} + +/// The runtime descriptor `serve` writes after it binds `socket`. +fn write_descriptor(ws: &Workspace, socket: &Path) { + let info = json!({ + "pid": std::process::id(), + "socket": socket, + "workspace_id": ws.broker.workspace().id, + "host_uid": host_uid(), + "protocol": 1, + "node_id": NODE, + }); + private_write( + &ws.root.path().join("runtime.json"), + &serde_json::to_vec(&info).unwrap(), + ); +} + +// --------------------------------------------------------------------------------------------- +// BROKER-6: `status` and `stop` never create an empty runtime.json +// --------------------------------------------------------------------------------------------- + +#[test] +fn status_without_a_descriptor_reports_not_running_and_creates_nothing() { + let state = TempRoot::new("status-missing"); + let error = biorouter_crew::lifecycle("status", state.path(), "", None) + .expect_err("nothing is running"); + assert!(error.to_string().starts_with("not_running:"), "{error}"); + assert!( + !state.path().join("runtime.json").exists(), + "status must not leave an empty descriptor behind" + ); + + let missing = state.path().join("never-created"); + let error = biorouter_crew::lifecycle("status", &missing, "", None).expect_err("no state"); + assert!(error.to_string().starts_with("not_running:"), "{error}"); + assert!( + !missing.exists(), + "status must not create the state directory" + ); +} + +#[test] +fn status_reads_an_empty_descriptor_from_an_older_release_as_not_running() { + let state = TempRoot::new("status-empty"); + private_write(&state.path().join("runtime.json"), b""); + let error = biorouter_crew::lifecycle("status", state.path(), "", None) + .expect_err("nothing is running"); + assert!(error.to_string().starts_with("not_running:"), "{error}"); +} + +#[cfg(target_os = "linux")] +#[test] +fn stop_without_a_descriptor_reports_not_running_and_creates_nothing() { + let state = TempRoot::new("stop-missing"); + let error = + biorouter_crew::lifecycle("stop", state.path(), "", None).expect_err("nothing is running"); + assert!(error.to_string().starts_with("not_running:"), "{error}"); + assert!(!state.path().join("runtime.json").exists()); + + let missing = state.path().join("never-created"); + let error = biorouter_crew::lifecycle("stop", &missing, "", None).expect_err("no state"); + assert!(error.to_string().starts_with("not_running:"), "{error}"); + assert!( + !missing.exists(), + "stop must not create the state directory" + ); +} + +/// A descriptor naming `socket` and `pid`, as a broker that has since stopped left it. +fn write_stale_descriptor(state: &Path, socket: &Path, pid: u32) { + let info = json!({ + "pid": pid, + "socket": socket, + "workspace_id": "00000000-0000-4000-8000-000000000000", + "host_uid": host_uid(), + "protocol": 1, + "node_id": NODE, + }); + private_write( + &state.join("runtime.json"), + &serde_json::to_vec(&info).unwrap(), + ); +} + +/// The pid of a process that has exited and been reaped. +fn exited_pid() -> u32 { + let mut child = std::process::Command::new("true").spawn().unwrap(); + let pid = child.id(); + child.wait().unwrap(); + pid +} + +/// `status`, and `stop` where it is supported, for `state`: each must say `not_running` and +/// leave the descriptor where it is, so the next `start` reclaims the same runtime path. +fn assert_not_running(state: &Path) { + let before = fs::read(state.join("runtime.json")).unwrap(); + let mut commands = vec!["status"]; + if cfg!(target_os = "linux") { + commands.push("stop"); + } + for command in commands { + let error = + biorouter_crew::lifecycle(command, state, "", None).expect_err("nothing is running"); + assert_eq!( + error.to_string(), + "not_running: no broker is running from this state directory; start it with biorouter-crew start", + "{command}" + ); + } + assert_eq!(fs::read(state.join("runtime.json")).unwrap(), before); +} + +/// T3-BE-2: a broker that stopped cleanly, was killed, or whose server rebooted leaves its +/// runtime descriptor behind, and often its socket file. `status` and a second `stop` say +/// `not_running` for every way that looks: the process it names is gone, the socket file is +/// missing, or nothing listens on it. They used to print "Connection refused (os error 111)". +#[test] +fn status_and_stop_after_a_broker_stopped_report_not_running() { + let state = TempRoot::new("status-stopped"); + let runtime = TempRoot::short(); + fs::set_permissions(runtime.path(), fs::Permissions::from_mode(0o711)).unwrap(); + let socket = runtime.path().join("broker.sock"); + + // The process it names has exited. + write_stale_descriptor(state.path(), &socket, exited_pid()); + assert_not_running(state.path()); + + // The process is there (a pid the broker's could have been reused for), the socket is not. + write_stale_descriptor(state.path(), &socket, std::process::id()); + assert_not_running(state.path()); + + // The socket file is left, and nothing listens on it: connecting is refused. + drop(std::os::unix::net::UnixListener::bind(&socket).unwrap()); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(socket.exists(), "a closed listener leaves its socket file"); + assert_not_running(state.path()); +} + +#[test] +fn an_empty_descriptor_does_not_block_the_next_start() { + let mut ws = Workspace::new("empty-descriptor"); + let runtime = TempRoot::short(); + ws.broker.set_runtime_root(runtime.path()); + // What `status` or `stop` from an older release left behind when runtime.json was missing. + private_write(&ws.root.path().join("runtime.json"), b""); + let socket = ws + .broker + .prepare_runtime(NODE) + .expect("an empty descriptor records nothing and must not read as corrupt"); + assert_eq!(socket.file_name().unwrap(), "broker.sock"); + assert_eq!(socket.parent().unwrap().parent().unwrap(), runtime.path()); +} + +/// The directory a runtime socket lives in, and its basename. +fn runtime_dir(socket: &Path) -> (PathBuf, String) { + let directory = socket.parent().unwrap().to_path_buf(); + let basename = directory.file_name().unwrap().to_str().unwrap().to_owned(); + (directory, basename) +} + +#[test] +fn the_runtime_directory_is_created_private_and_reused_across_restarts() { + let mut ws = Workspace::new("runtime-reuse"); + let runtime = TempRoot::short(); + ws.broker.set_runtime_root(runtime.path()); + let socket = ws.broker.prepare_runtime(NODE).unwrap(); + let (directory, basename) = runtime_dir(&socket); + assert!(basename.starts_with(&format!("crew-{}-", host_uid()))); + let metadata = fs::symlink_metadata(&directory).unwrap(); + assert!(metadata.is_dir()); + assert_eq!(metadata.uid(), host_uid()); + assert_eq!(metadata.mode() & 0o7777, 0o711); + write_descriptor(&ws, &socket); + + // `/tmp` cleaned, nobody else took the name: the same path comes back. + fs::remove_dir(&directory).unwrap(); + let mut ws = ws.reopen(); + ws.broker.set_runtime_root(runtime.path()); + assert_eq!(ws.broker.prepare_runtime(NODE).unwrap(), socket); +} + +// --------------------------------------------------------------------------------------------- +// BROKER-3: a runtime path another account took after /tmp was cleaned moves, it never blocks +// --------------------------------------------------------------------------------------------- + +/// The shapes an entry at the recorded runtime name can take when this account did not leave +/// it there as a usable runtime directory. Another account's directory is the case that +/// matters and cannot be made without root here; it takes the same path as these (and the +/// broker's own unit test drives it with a foreign owner). +fn squat(directory: &Path, shape: usize) { + match shape { + // A symbolic link, to a directory this account could otherwise use. + 0 => std::os::unix::fs::symlink("/", directory).unwrap(), + // A plain file. + 1 => fs::write(directory, b"").unwrap(), + // A directory anyone can write into. + 2 => { + fs::create_dir(directory).unwrap(); + fs::set_permissions(directory, fs::Permissions::from_mode(0o777)).unwrap(); + } + // The right directory, holding something that is not the broker's socket. + _ => { + fs::create_dir(directory).unwrap(); + fs::set_permissions(directory, fs::Permissions::from_mode(0o711)).unwrap(); + fs::write(directory.join("planted"), b"").unwrap(); + } + } +} + +#[test] +fn a_runtime_path_taken_after_tmp_was_cleaned_moves_to_a_fresh_private_one() { + for shape in 0..4 { + let mut ws = Workspace::new(&format!("runtime-squat-{shape}")); + let runtime = TempRoot::short(); + ws.broker.set_runtime_root(runtime.path()); + let first = ws.broker.prepare_runtime(NODE).unwrap(); + write_descriptor(&ws, &first); + let (directory, basename) = runtime_dir(&first); + + // /tmp is cleaned, and the recorded name is taken before the broker starts again. + fs::remove_dir(&directory).unwrap(); + squat(&directory, shape); + let mut ws = ws.reopen(); + ws.broker.set_runtime_root(runtime.path()); + let moved = ws + .broker + .prepare_runtime(NODE) + .unwrap_or_else(|error| panic!("shape {shape}: the start must recover: {error}")); + let (new_directory, new_basename) = runtime_dir(&moved); + assert_ne!(new_basename, basename, "shape {shape}"); + assert!(new_basename.starts_with(&format!("crew-{}-", host_uid()))); + assert_eq!(new_directory.parent().unwrap(), runtime.path()); + let metadata = fs::symlink_metadata(&new_directory).unwrap(); + assert!(metadata.is_dir()); + assert_eq!(metadata.uid(), host_uid()); + assert_eq!(metadata.mode() & 0o7777, 0o711); + assert!( + fs::read_dir(&new_directory).unwrap().next().is_none(), + "a fresh directory" + ); + assert!( + !ws.root.path().join("runtime.json").exists(), + "the descriptor naming the old path is retired" + ); + // The squatted entry is left alone. + assert!(fs::symlink_metadata(&directory).is_ok()); + + // The move is journaled: the next start comes back to the new path. + let mut ws = ws.reopen(); + ws.broker.set_runtime_root(runtime.path()); + assert_eq!( + ws.broker.prepare_runtime(NODE).unwrap(), + moved, + "shape {shape}" + ); + let _ = fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)); + } +} + +#[test] +fn a_recorded_directory_only_this_account_could_write_into_keeps_its_path() { + // Only this account and root can change the mode of this account's own directory, so with + // any mode that never let another account write into it, nothing in it can be another + // account's: the mode is put back and members keep the path they were given. + for mode in [0o700, 0o755, 0o751] { + let mut ws = Workspace::new(&format!("runtime-repair-{mode:o}")); + let runtime = TempRoot::short(); + ws.broker.set_runtime_root(runtime.path()); + let socket = ws.broker.prepare_runtime(NODE).unwrap(); + write_descriptor(&ws, &socket); + let (directory, _) = runtime_dir(&socket); + fs::set_permissions(&directory, fs::Permissions::from_mode(mode)).unwrap(); + let mut ws = ws.reopen(); + ws.broker.set_runtime_root(runtime.path()); + assert_eq!( + ws.broker.prepare_runtime(NODE).unwrap(), + socket, + "mode {mode:o}" + ); + assert_eq!( + fs::symlink_metadata(&directory).unwrap().mode() & 0o7777, + 0o711 + ); + assert!( + ws.root.path().join("runtime.json").exists(), + "nothing moved, so the descriptor stands" + ); + } + // A directory its group could write into is still moved, as one anyone can write into is. + let mut ws = Workspace::new("runtime-repair-group"); + let runtime = TempRoot::short(); + ws.broker.set_runtime_root(runtime.path()); + let socket = ws.broker.prepare_runtime(NODE).unwrap(); + let (directory, _) = runtime_dir(&socket); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o771)).unwrap(); + let mut ws = ws.reopen(); + ws.broker.set_runtime_root(runtime.path()); + assert_ne!(ws.broker.prepare_runtime(NODE).unwrap(), socket); +} + +#[test] +fn a_live_listener_on_the_recorded_socket_is_still_refused() { + let mut ws = Workspace::new("runtime-live"); + let runtime = TempRoot::short(); + ws.broker.set_runtime_root(runtime.path()); + let socket = ws.broker.prepare_runtime(NODE).unwrap(); + let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o666)).unwrap(); + let error = ws + .broker + .prepare_runtime(NODE) + .expect_err("a live listener"); + assert!(error.to_string().starts_with("runtime_in_use:"), "{error}"); +} + +// --------------------------------------------------------------------------------------------- +// BROKER-5: decoy directories in /tmp cannot hide a running sibling from the name check +// --------------------------------------------------------------------------------------------- + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::UnixListener; + + /// A fake sibling broker at `runtime_root/basename`: answers every `hello` for + /// `workspace_id` named `name`. + fn fake_sibling(runtime_root: &Path, basename: &str, workspace_id: &str, name: &str) { + let directory = runtime_root.join(basename); + fs::create_dir(&directory).unwrap(); + let listener = UnixListener::bind(directory.join("broker.sock")).unwrap(); + let answer = + json!({"id": "name-probe", "result": {"workspace_id": workspace_id, "name": name}}); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + let mut line = String::new(); + if BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .is_ok() + { + let _ = stream.write_all(format!("{answer}\n").as_bytes()); + } + } + }); + } + + #[test] + fn decoys_that_sort_first_cannot_hide_a_running_sibling() { + let mut ws = Workspace::new("sibling-decoys"); + let runtime = TempRoot::short(); + ws.broker.set_runtime_root(runtime.path()); + // The real sibling sorts last of everything named for this account. + fake_sibling( + runtime.path(), + &format!("crew-{}-{}", host_uid(), "f".repeat(32)), + &uuid(), + "lab", + ); + // Forty correctly named decoys that sort before it, of every shape another account + // can leave in /tmp: an empty directory, a plain file, a symbolic link to the real + // sibling's directory, and a directory whose broker.sock is not a socket. + let real = runtime + .path() + .join(format!("crew-{}-{}", host_uid(), "f".repeat(32))); + for index in 0..40u32 { + let decoy = runtime + .path() + .join(format!("crew-{}-{index:032x}", host_uid())); + match index % 4 { + 0 => fs::create_dir(&decoy).unwrap(), + 1 => fs::write(&decoy, b"").unwrap(), + 2 => std::os::unix::fs::symlink(&real, &decoy).unwrap(), + _ => { + fs::create_dir(&decoy).unwrap(); + fs::write(decoy.join("broker.sock"), b"").unwrap(); + } + } + } + let (code, _) = refused(ws.host_call("workspace.rename", json!({"name": "lab"}))); + assert_eq!( + code, "name_taken", + "the running sibling must be probed whatever sorts before it" + ); + assert_eq!( + ws.host_ok("workspace.rename", json!({"name": "lab-two"}))["name"], + "lab-two" + ); + } +} diff --git a/crates/biorouter-crew/tests/storage_fault_contract.rs b/crates/biorouter-crew/tests/storage_fault_contract.rs new file mode 100644 index 000000000..a746728b6 --- /dev/null +++ b/crates/biorouter-crew/tests/storage_fault_contract.rs @@ -0,0 +1,267 @@ +//! A disk that fills up, or fails, under a running broker (R-2). +//! +//! The broker stops saving changes the moment a journal write or sync fails (fail-stop: it can +//! no longer know what reached the disk), and a restart repairs the journal. What this pins is +//! that nobody is left guessing: the request that hit the fault and every change after it are +//! refused with one code, `storage_full` for a full disk or quota and `storage_failed` for any +//! other storage error, in a sentence rather than the operating system's text; `hello` reports +//! the stop; the host's `broker.log` says what happened and what to run; and an attachment whose +//! file cannot be written is refused the same way, without stopping the broker. +//! +//! The failure is injected at the journal call (`Broker::inject_journal_fault`, `test-seams`). +//! `journal_fault_contract.rs` drives the same path with a real failing syscall under an +//! `LD_PRELOAD` interposer. Linux only, as `hello` needs the node identity. +#![cfg(target_os = "linux")] + +mod support; + +use biorouter_crew::{Connection, JournalCall, Request}; +use serde_json::{json, Value}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use support::*; + +const MALLORY: u32 = 72_001; + +const FULL_NOT_SAVED: &str = "The workspace server is out of disk space, so this change was not saved. Reading still works. Ask the host to free space on the server and restart Crew."; +const FULL_STOPPED: &str = "The workspace server ran out of disk space and has stopped saving changes. Reading still works. Ask the host to free space on the server and restart Crew."; +const FAILED_UNCERTAIN: &str = "The workspace server could not confirm this change was saved to disk, so it may not have been saved. Reading still works. Ask the host to check the server's storage and restart Crew."; +const FAILED_STOPPED: &str = "The workspace server could not save a change to disk and has stopped saving changes. Reading still works. Ask the host to check the server's storage and restart Crew."; + +fn hello(ws: &mut Workspace) -> Value { + let mut connection = Connection::new(); + let response = ws.broker.handle( + host_uid(), + &mut connection, + Request { + version: 1, + id: "hello".into(), + method: "hello".into(), + params: json!({}), + auth: None, + credential: None, + }, + ); + ok(response) +} + +fn history(ws: &mut Workspace, channel: &str) -> Vec { + ws.host_ok("messages.history", json!({"channel_id": channel}))["messages"] + .as_array() + .unwrap() + .iter() + .map(|message| message["body"].as_str().unwrap().to_owned()) + .collect() +} + +fn log_lines(ws: &Workspace) -> Vec { + fs::read_to_string(ws.root.path().join("broker.log")) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() +} + +fn begin(channel: &str) -> Value { + json!({ + "channel_id": channel, + "size": 3, + "sha256": digest(b"abc"), + "name": "notes.txt", + "media_type": "text/plain", + }) +} + +fn blob_files(ws: &Workspace) -> usize { + fs::read_dir(ws.root.path().join("blobs")).unwrap().count() +} + +#[test] +fn a_full_disk_stops_saving_and_every_refusal_says_so_in_one_code() { + let mut ws = Workspace::new("storage-full"); + let mut mallory = ws.enroll(MALLORY, "mallory", 31); + let (team, general) = ws.host_team("lab"); + ws.host_adds_to_team(&mut mallory, &team); + ws.host_ok( + "message.post", + json!({"channel_id": general, "body": "saved", "idempotency_key": "saved"}), + ); + assert_eq!(hello(&mut ws)["state"], "running"); + + ws.broker + .inject_journal_fault(JournalCall::Write, libc::ENOSPC); + // The request that hit the fault: its record was never written, and it says so. + let (code, message) = refused(ws.host_call( + "message.post", + json!({"channel_id": general, "body": "lost"}), + )); + assert_eq!(code, "storage_full"); + assert_eq!(message, format!("storage_full: {FULL_NOT_SAVED}")); + assert!(!message.contains("os error"), "{message}"); + + // Every change after it, from anyone, is refused with the same code, before anything is + // touched: an attachment leaves no file behind. + let files = blob_files(&ws); + for response in [ + ws.call( + &mut mallory, + "message.post", + json!({"channel_id": general, "body": "later"}), + ), + ws.call(&mut mallory, "blob.begin", begin(&general)), + ws.host_call("policy.set", json!({"mode": "public"})), + ] { + assert_eq!( + refused(response), + ( + "storage_full".into(), + format!("storage_full: {FULL_STOPPED}") + ) + ); + } + assert_eq!(blob_files(&ws), files, "a refused upload left a file"); + + // Reading still works, and a change acknowledged before the fault still replays. + assert_eq!(history(&mut ws, &general), vec!["saved"]); + ws.snapshot(&mut mallory); + ws.host_ok( + "message.post", + json!({"channel_id": general, "body": "saved", "idempotency_key": "saved"}), + ); + + // Anyone who asks the broker learns it has stopped, in words a member can act on. + let answer = hello(&mut ws); + assert_eq!(answer["state"], "storage_failed"); + assert_eq!(answer["storage"]["code"], "storage_full"); + assert_eq!(answer["storage"]["message"], FULL_STOPPED); + assert!(answer["storage"]["since"].as_u64().unwrap() > 0); + + // The host's log has one line: when, the OS's error, how much of the journal is kept, + // and the two commands that bring the workspace back. + let lines = log_lines(&ws); + assert_eq!(lines.len(), 1, "{lines:#?}"); + let line = &lines[0]; + let root = fs::canonicalize(ws.root.path()).unwrap(); + for expected in [ + "storage_full: Crew stopped saving changes: ".to_owned(), + "(os error 28)".to_owned(), + "journal.jsonl held ".to_owned(), + format!("biorouter-crew stop --state-dir {}", root.display()), + format!("biorouter-crew start --state-dir {}", root.display()), + "Free space on this server".to_owned(), + ] { + assert!(line.contains(&expected), "{expected:?} missing from {line}"); + } + assert!( + line.starts_with("20"), + "the line starts with its UTC time: {line}" + ); + + // A restart repairs the journal and saves again; what was refused was never saved. + let mut ws = ws.reopen(); + assert_eq!(hello(&mut ws)["state"], "running"); + ws.host_ok( + "message.post", + json!({"channel_id": general, "body": "after restart"}), + ); + assert_eq!(history(&mut ws, &general), vec!["saved", "after restart"]); +} + +#[test] +fn a_failed_sync_says_the_change_may_not_have_been_saved_and_a_retry_never_duplicates_it() { + let mut ws = Workspace::new("storage-sync"); + let (_, general) = ws.host_team("lab"); + ws.broker.inject_journal_fault(JournalCall::Sync, libc::EIO); + let params = json!({"channel_id": general, "body": "uncertain", "idempotency_key": "once"}); + assert_eq!( + refused(ws.host_call("message.post", params.clone())), + ( + "storage_failed".into(), + format!("storage_failed: {FAILED_UNCERTAIN}") + ) + ); + assert_eq!( + refused(ws.host_call("team.create", json!({"name": "other"}))), + ( + "storage_failed".into(), + format!("storage_failed: {FAILED_STOPPED}") + ) + ); + let answer = hello(&mut ws); + assert_eq!(answer["state"], "storage_failed"); + assert_eq!(answer["storage"]["code"], "storage_failed"); + let lines = log_lines(&ws); + assert_eq!(lines.len(), 1, "{lines:#?}"); + assert!(lines[0].contains("storage_failed: Crew stopped saving changes: ")); + assert!(lines[0].contains("Check this server's storage")); + + // The record did reach the file here, so the restart keeps it; sending the same request + // again is answered from it, never posted twice. + let mut ws = ws.reopen(); + ws.host_ok("message.post", params); + assert_eq!(history(&mut ws, &general), vec!["uncertain"]); +} + +#[test] +fn a_log_line_the_full_disk_refused_is_written_once_there_is_space() { + let mut ws = Workspace::new("storage-log-retry"); + let (_, general) = ws.host_team("lab"); + // broker.log cannot be created while the state directory refuses new files, as a full + // disk refuses them. + let root = ws.root.path().to_path_buf(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o500)).unwrap(); + ws.broker + .inject_journal_fault(JournalCall::Write, libc::EDQUOT); + let (code, _) = refused(ws.host_call( + "message.post", + json!({"channel_id": general, "body": "lost"}), + )); + assert_eq!(code, "storage_full", "a full quota is a full disk"); + let writable = fs::File::create(root.join("probe")).is_ok(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + if writable { + // Running as root: permissions refuse nothing, so there was nothing to retry. + return; + } + assert!(log_lines(&ws).is_empty()); + // The next request, of any kind, tries again, and the line lands once. + hello(&mut ws); + hello(&mut ws); + let lines = log_lines(&ws); + assert_eq!(lines.len(), 1, "{lines:#?}"); + assert!(lines[0].contains("storage_full: Crew stopped saving changes: ")); +} + +#[test] +fn an_attachment_that_cannot_be_written_is_refused_in_words_and_the_broker_keeps_saving() { + let mut ws = Workspace::new("storage-blob"); + let mut mallory = ws.enroll(MALLORY, "mallory", 31); + let (team, general) = ws.host_team("lab"); + ws.host_adds_to_team(&mut mallory, &team); + let blob = ws.call_ok(&mut mallory, "blob.begin", begin(&general)); + let blob_id = blob["id"].as_str().unwrap().to_owned(); + let file = ws.root.path().join("blobs").join(&blob_id); + // The attachment's file refuses the write, as a full or failing disk would. + fs::set_permissions(&file, fs::Permissions::from_mode(0o400)).unwrap(); + let chunk = json!({"blob_id": blob_id, "offset": 0, "data_hex": hex::encode(b"abc")}); + let response = ws.call(&mut mallory, "blob.chunk", chunk.clone()); + fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap(); + if response.error.is_none() { + // Running as root: permissions refuse nothing. + return; + } + let (code, message) = refused(response); + assert_eq!(code, "storage_failed"); + assert_eq!( + message, + "storage_failed: The workspace server could not read or write its storage. Ask the host to check the server's storage, then try again." + ); + // Nothing was recorded, so the broker is still saving and the same chunk goes through. + assert_eq!(hello(&mut ws)["state"], "running"); + ws.call_ok(&mut mallory, "blob.chunk", chunk); + ws.call_ok(&mut mallory, "blob.finish", json!({"blob_id": blob_id})); + ws.host_ok( + "message.post", + json!({"channel_id": general, "body": "still saving"}), + ); +} diff --git a/crates/biorouter-mcp/src/agent_drafter/mod.rs b/crates/biorouter-mcp/src/agent_drafter/mod.rs index bedba968c..9be6b8f80 100644 --- a/crates/biorouter-mcp/src/agent_drafter/mod.rs +++ b/crates/biorouter-mcp/src/agent_drafter/mod.rs @@ -2698,8 +2698,8 @@ impl AgentDrafterServer { let mut content = vec![Content::resource_link(link).with_audience(vec![Role::User])]; content.push( Content::text(format!( - "App '{}' is ready. Open it in your browser: {}\n(The desktop GUI shows a click-only preview link; in the CLI open the URL above with a running biorouterd.)", - manifest.id, browser_url + "App '{}' is ready at {}\n(The desktop GUI shows a click-only preview link that opens it. The address alone answers \"Open this app from Biorouter\" in a browser that has not opened it that way; in a terminal, run `biorouter apps open {}`.)", + manifest.id, browser_url, manifest.id )) .with_audience(vec![Role::Assistant]), ); diff --git a/crates/biorouter-mcp/src/agent_drafter/render.rs b/crates/biorouter-mcp/src/agent_drafter/render.rs index c10a1b9a7..406f9ee79 100644 --- a/crates/biorouter-mcp/src/agent_drafter/render.rs +++ b/crates/biorouter-mcp/src/agent_drafter/render.rs @@ -265,7 +265,9 @@ fn package_json(manifest: &Manifest) -> String { /// store, and bring a daemon up on a known port. Sourced by `run.sh` so the two /// launch paths can't drift. /// -/// Defines `BIOROUTERD`, `PORT`, and `BASE` (e.g. `http://127.0.0.1:3000`). +/// Defines `BIOROUTERD`, `PORT`, `SECRET` (the daemon's, never printed and never +/// on a command line) and, after `launch_url`, `LAUNCH_URL`: the app's one-time +/// launch link (W2-HRD-1). #[expect( clippy::too_many_lines, reason = "the generated launcher is one contiguous shell program; splitting its literal would obscure control flow" @@ -331,41 +333,141 @@ install_app() {{ port_alive() {{ curl -sf -o /dev/null --max-time 1 "http://127.0.0.1:$1/status" 2>/dev/null; }} -# ── 3. Reuse a running daemon, else start one ───────────────────────────── -# Any biorouterd on this machine reads the same store, so once the app is -# installed, whichever daemon is up can serve it. +# ── 3. A daemon whose secret this launcher knows ────────────────────────── +# A daemon serves an app's page only to a browser holding that app's access +# cookie, and the one-time link that sets the cookie is handed only to a caller +# holding the daemon's secret. So this launcher uses a daemon it started (its +# port and secret are kept, readable by you alone, in $LAUNCHER_STATE) or one you +# name with BIOROUTERD_PORT and BIOROUTER_SERVER__SECRET_KEY. It cannot open the +# app on any other daemon, whatever answers /status. +LAUNCHER_STATE="${{XDG_CONFIG_HOME:-$HOME/.config}}/biorouter/app-launcher/daemon" +SECRET="" +LOG="" + +new_secret() {{ od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; }} + +# Every request that carries the secret hands it to curl on stdin, never on a +# command line, where other accounts on this machine can read it. +secret_ok() {{ + printf 'X-Secret-Key: %s\n' "$2" \ + | curl -sf -o /dev/null --max-time 2 -H @- "http://127.0.0.1:$1/apps" 2>/dev/null +}} + +# The path of this app's one-time launch link, from the daemon on :$1. +launch_path() {{ + printf 'X-Secret-Key: %s\n' "$2" \ + | curl -sf --max-time 5 -X POST -H @- "http://127.0.0.1:$1/apps/$APP_ID/launch" 2>/dev/null \ + | sed -n 's|.*"path":"\(/apps/[A-Za-z0-9_-]*/?t=[0-9a-f]*\)".*|\1|p' +}} + +save_daemon() {{ + ( umask 077 + mkdir -p "$(dirname "$LAUNCHER_STATE")" + printf '%s %s\n' "$PORT" "$SECRET" > "$LAUNCHER_STATE.$$" && mv -f "$LAUNCHER_STATE.$$" "$LAUNCHER_STATE" + ) || echo " (could not remember this daemon; the next run starts another)" >&2 +}} + start_daemon() {{ - for p in "${{BIOROUTERD_PORT:-3000}}" 3000 3001 3002 3003; do - if port_alive "$p"; then PORT="$p"; echo "Using the Biorouter daemon already on :$PORT"; return 0; fi - done + if [ -n "${{BIOROUTER_SERVER__SECRET_KEY:-}}" ] && [ -n "${{BIOROUTERD_PORT:-}}" ] \ + && secret_ok "$BIOROUTERD_PORT" "$BIOROUTER_SERVER__SECRET_KEY"; then + PORT="$BIOROUTERD_PORT"; SECRET="$BIOROUTER_SERVER__SECRET_KEY" + echo "Using the Biorouter daemon on :$PORT" + return 0 + fi + if [ -f "$LAUNCHER_STATE" ]; then + saved_port=""; saved_secret="" + read -r saved_port saved_secret < "$LAUNCHER_STATE" || true + if [ -n "$saved_port" ] && [ -n "$saved_secret" ] && secret_ok "$saved_port" "$saved_secret"; then + PORT="$saved_port"; SECRET="$saved_secret" + echo "Using the Biorouter daemon this launcher started on :$PORT" + return 0 + fi + fi BIOROUTERD="$(find_biorouterd)" || die "biorouterd not found. Install Biorouter, or set BIOROUTERD_BIN=/path/to/biorouterd" - for p in "${{BIOROUTERD_PORT:-3000}}" 3001 3002 3003; do + for p in "${{BIOROUTERD_PORT:-3000}}" 3001 3002 3003 3004 3005; do + # A daemon already here is not one whose secret this launcher knows. + if port_alive "$p"; then continue; fi + # A new secret for every attempt: until the daemon answers with it, whatever + # listens on the port is asked with it, and that must never be the secret of a + # daemon that goes on to start somewhere else. + SECRET="$(new_secret)" + [ "${{#SECRET}}" -eq 64 ] || die "could not make a secret for the daemon (this needs od and /dev/urandom)" LOG="${{TMPDIR:-/tmp}}/biorouterd-$APP_ID-$p.log" echo "Starting $BIOROUTERD on :$p (using your configured Biorouter provider)..." - BIOROUTER_PORT="$p" "$BIOROUTERD" agent >"$LOG" 2>&1 & + BIOROUTER_SERVER__SECRET_KEY="$SECRET" BIOROUTER_PORT="$p" "$BIOROUTERD" agent "$LOG" 2>&1 & for _ in $(seq 1 40); do - port_alive "$p" && {{ PORT="$p"; return 0; }} + if secret_ok "$p" "$SECRET"; then PORT="$p"; save_daemon; return 0; fi kill -0 "$!" 2>/dev/null || break # it died; try the next port sleep 0.5 done echo " :$p did not come up (see $LOG)" >&2 done - die "could not start biorouterd. Last log: $LOG" + die "could not start biorouterd.${{LOG:+ Last log: $LOG}}" }} -# ── 4. Verify the daemon can actually serve this app ────────────────────── -verify_app() {{ - code="$(curl -s -o /dev/null -w '%{{http_code}}' "$BASE/apps/$APP_ID/" 2>/dev/null || echo 000)" - [ "$code" = "200" ] || die "the daemon on :$PORT does not serve '$APP_ID' (HTTP $code). Is the store at $STORE?" +# ── 4. The one-time address that opens this app ─────────────────────────── +# Opening it sets the app's access cookie in your browser and lands on the app. +# It works once and for a few minutes; run the launcher again for a new one. +launch_url() {{ + path="$(launch_path "$PORT" "$SECRET")" + [ -n "$path" ] || die "the daemon on :$PORT does not serve '$APP_ID'. Is the store at $STORE?" + LAUNCH_URL="http://127.0.0.1:$PORT$path" +}} + +# The link opens the app for whoever uses it FIRST, and a command line is +# readable by every account on this machine (`ps`, /proc//cmdline) while +# `open` or `xdg-open` runs. So the link never goes on one: it is written into a +# page only you can read, which sends the browser on to it, and the opener is +# handed that page. Pages older than ten minutes hold expired links and are +# removed here. +LAUNCH_PAGES="${{XDG_CONFIG_HOME:-$HOME/.config}}/biorouter/app-launcher/open" + +launch_page() {{ + ( umask 077 + set -C + mkdir -p "$LAUNCH_PAGES" || exit 1 + [ -d "$LAUNCH_PAGES" ] && [ ! -L "$LAUNCH_PAGES" ] && [ -O "$LAUNCH_PAGES" ] || exit 1 + chmod 700 "$LAUNCH_PAGES" || exit 1 + find "$LAUNCH_PAGES" -type f -name 'launch-*.html' -mmin +10 -exec rm -f {{}} + 2>/dev/null || true + name="$(new_secret)" + [ "${{#name}}" -eq 64 ] || exit 1 + page="$LAUNCH_PAGES/launch-${{name:0:32}}.html" + printf '%s%s%s%s%s\n' \ + 'Opening Biorouter app

Opening the app. If nothing happens, open it here. The address works once.

' > "$page" || exit 1 + printf '%s\n' "$page" + ) }} open_url() {{ - echo "Opening $1" - if command -v open >/dev/null 2>&1; then open "$1" - elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$1" - else echo "Open this URL in your browser: $1"; fi + echo "Opening $APP_ID at http://127.0.0.1:$PORT/apps/$APP_ID/" + token="${{1#"http://127.0.0.1:$PORT/apps/$APP_ID/?t="}}" + # The digits and letters are listed, not written as ranges: macOS's bash 3.2 + # reads [0-9a-f] by the locale's collation under UTF-8, where it takes + # upper-case letters too. + case "$token" in + "$1" | *[!0123456789abcdef]*) die "not a launch link for $APP_ID; refusing to open it" ;; + esac + [ "${{#token}}" -eq 64 ] || die "not a launch link for $APP_ID; refusing to open it" + page="$(launch_page "$1")" || page="" + opened=0 + if [ -n "$page" ]; then + case "$(uname -s)" in + Darwin) open "$page" && opened=1 ;; + *) if command -v xdg-open >/dev/null 2>&1; then xdg-open "$page" && opened=1; fi ;; + esac + fi + # This terminal is yours alone, so the link can be shown here: for a browser + # that cannot read the page (a snap-packaged one cannot read hidden folders), + # or for when none opened. + if [ "$opened" = 1 ]; then + echo "If the app does not appear, open this address instead (it works once): $1" + else + echo "Open this address in your browser (it works once): $1" + fi }} # ── 5. First-run payload install (full-mode exports only) ───────────────── @@ -454,9 +556,8 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" install_app install_payload # no-op for launcher-mode exports (no payload/ dir) start_daemon -BASE="http://127.0.0.1:$PORT" -verify_app -open_url "$BASE/apps/$APP_ID/" +launch_url +open_url "$LAUNCH_URL" "# .to_string() } @@ -588,19 +689,67 @@ function Test-Port([int]$Port) { } catch { return $false } } -# Reuse a running daemon, else spawn one headlessly and wait for /status. +# A daemon serves an app's page only to a browser holding that app's access +# cookie, and the one-time link that sets it is handed only to a caller holding +# the daemon's secret. So this launcher uses a daemon it started (its port and +# secret are kept in $StateFile, under your profile) or one you name with +# BIOROUTERD_PORT and BIOROUTER_SERVER__SECRET_KEY, and no other. +$StateFile = Join-Path $Cfg "app-launcher\daemon" + +function New-Secret { + $bytes = New-Object byte[] 32 + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + return (($bytes | ForEach-Object { $_.ToString("x2") }) -join "") +} + +function Test-Secret([int]$Port, [string]$Secret) { + try { + $r = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/apps" -Headers @{ "X-Secret-Key" = $Secret } -TimeoutSec 2 -UseBasicParsing + return ($r.StatusCode -eq 200) + } catch { return $false } +} + +function Save-Daemon([int]$Port, [string]$Secret) { + try { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $StateFile) | Out-Null + Set-Content -Path $StateFile -Value "$Port $Secret" -NoNewline + } catch { Write-Host " (could not remember this daemon; the next run starts another)" } +} + +# Use a daemon whose secret is known, else start one headlessly with a new secret. function Start-Daemon { - $preferred = if ($env:BIOROUTERD_PORT) { [int]$env:BIOROUTERD_PORT } else { 3000 } - foreach ($p in @($preferred, 3000, 3001, 3002, 3003)) { - if (Test-Port $p) { Write-Host "Using the Biorouter daemon already on :$p"; return $p } + if ($env:BIOROUTER_SERVER__SECRET_KEY -and $env:BIOROUTERD_PORT -and + (Test-Secret ([int]$env:BIOROUTERD_PORT) $env:BIOROUTER_SERVER__SECRET_KEY)) { + Write-Host "Using the Biorouter daemon on :$env:BIOROUTERD_PORT" + return @([int]$env:BIOROUTERD_PORT, [string]$env:BIOROUTER_SERVER__SECRET_KEY) + } + if (Test-Path $StateFile) { + try { + $saved = ((Get-Content $StateFile -Raw) -as [string]).Trim() -split "\s+" + if ($saved.Count -eq 2 -and (Test-Secret ([int]$saved[0]) $saved[1])) { + Write-Host "Using the Biorouter daemon this launcher started on :$($saved[0])" + return @([int]$saved[0], [string]$saved[1]) + } + } catch { } # an unreadable record: start a daemon of our own } $bin = Find-Biorouterd - foreach ($p in @($preferred, 3001, 3002, 3003)) { + $preferred = if ($env:BIOROUTERD_PORT) { [int]$env:BIOROUTERD_PORT } else { 3000 } + foreach ($p in @($preferred, 3001, 3002, 3003, 3004, 3005)) { + # A daemon already here is not one whose secret this launcher knows. + if (Test-Port $p) { continue } + # A new secret for every attempt, so one asked of whatever held the port + # never becomes the secret of a daemon started elsewhere. + $secret = New-Secret Write-Host "Starting $bin on :$p ..." $env:BIOROUTER_PORT = "$p" + $env:BIOROUTER_SERVER__SECRET_KEY = $secret Start-Process -FilePath $bin -ArgumentList "agent" -WindowStyle Hidden | Out-Null + # Only the daemon needs it: the browser started below inherits this + # process's environment, so the secret must not stay in it. + Remove-Item Env:BIOROUTER_SERVER__SECRET_KEY -ErrorAction SilentlyContinue for ($i = 0; $i -lt 40; $i++) { - if (Test-Port $p) { return $p } + if (Test-Secret $p $secret) { Save-Daemon $p $secret; return @($p, $secret) } Start-Sleep -Milliseconds 500 } } @@ -609,13 +758,42 @@ function Start-Daemon { Install-App Install-Payload -$Port = Start-Daemon +$Daemon = Start-Daemon +$Port = [int]$Daemon[0] +$Secret = [string]$Daemon[1] $Base = "http://127.0.0.1:$Port" -$code = 0 -try { $code = (Invoke-WebRequest -Uri "$Base/apps/$AppId/" -TimeoutSec 5 -UseBasicParsing).StatusCode } catch { $code = 0 } -if ($code -ne 200) { throw "the daemon on :$Port does not serve '$AppId' (HTTP $code)." } -Write-Host "Opening $Base/apps/$AppId/" -Start-Process "$Base/apps/$AppId/" +# The app's one-time launch link: opening it sets the app's access cookie in the +# browser and lands on the app. It works once and for a few minutes. +$LaunchPath = $null +try { + $LaunchPath = [string](Invoke-RestMethod -Method Post -Uri "$Base/apps/$AppId/launch" -Headers @{ "X-Secret-Key" = $Secret } -TimeoutSec 5).path +} catch { $LaunchPath = $null } +if (-not $LaunchPath -or $LaunchPath -notmatch '^/apps/[A-Za-z0-9_-]+/\?t=[0-9a-f]{64}$') { + throw "the daemon on :$Port does not serve '$AppId'." +} +Write-Host "Opening $AppId at $Base/apps/$AppId/" +# The link opens the app for whoever uses it first, so it never goes on a +# command line: it is written into a page under your profile, which only you can +# read, and the page sends the browser on to it. Pages older than ten minutes +# hold expired links and are removed here. +$Link = "$Base$LaunchPath" +$Pages = Join-Path $Cfg "app-launcher\open" +New-Item -ItemType Directory -Force -Path $Pages | Out-Null +Get-ChildItem -Path $Pages -Filter "launch-*.html" -File -ErrorAction SilentlyContinue | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } | + Remove-Item -Force -ErrorAction SilentlyContinue +$Page = Join-Path $Pages ("launch-" + (New-Secret).Substring(0, 32) + ".html") +$Html = '' + + 'Opening Biorouter app' + + '

Opening the app. If nothing happens, open it here. The address works once.

' +try { + Set-Content -LiteralPath $Page -Value $Html -Encoding UTF8 + Invoke-Item -LiteralPath $Page + # This window is yours alone, so the link can be shown here too. + Write-Host "If the app does not appear, open this address instead (it works once): $Link" +} catch { + Write-Host "Open this address in your browser (it works once): $Link" +} "#; /// The thin `run.bat` wrapper: double-clickable on Windows, it just runs @@ -651,7 +829,8 @@ fn serve_mjs(id: &str, default_port: u16) -> String { // the page and its agent share an origin and no port is ever hard-coded. import {{ createServer, request as httpRequest }} from "node:http"; import {{ spawn }} from "node:child_process"; -import {{ readFile, mkdir, cp, rm, access }} from "node:fs/promises"; +import {{ readFile, writeFile, rename, mkdir, cp, rm, access }} from "node:fs/promises"; +import {{ randomBytes }} from "node:crypto"; import {{ constants }} from "node:fs"; import {{ extname, normalize, resolve, join, dirname }} from "node:path"; import {{ fileURLToPath }} from "node:url"; @@ -662,6 +841,13 @@ const PORT = Number(process.env.PORT || {default_port}); const ROOT = dirname(fileURLToPath(import.meta.url)); const STORE = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "biorouter", "agent_drafter", APP_ID); +// A daemon serves an app's page only to a browser holding that app's access +// cookie, and the one-time link that sets it is handed only to a caller holding +// the daemon's secret. So this server uses a daemon it (or run.sh) started, whose +// port and secret are kept here readable by you alone, or one you name with +// BIOROUTERD_PORT and BIOROUTER_SERVER__SECRET_KEY, and no other. +const LAUNCHER_STATE = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), + "biorouter", "app-launcher", "daemon"); const MIME = {{ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", @@ -716,18 +902,64 @@ function probe(port) {{ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -/** Reuse a daemon if one answers; otherwise spawn one and wait for /status. */ +/** One request to the daemon on `port` carrying its secret; resolves to {{ status, body }}. */ +function withSecret(port, secret, method, path) {{ + return new Promise((res) => {{ + const r = httpRequest( + {{ host: "127.0.0.1", port, path, method, timeout: 5000, headers: {{ "X-Secret-Key": secret }} }}, + (resp) => {{ + let body = ""; + resp.setEncoding("utf8"); + resp.on("data", (chunk) => {{ body += chunk; }}); + resp.on("end", () => res({{ status: resp.statusCode, body }})); + }} + ); + r.on("error", () => res({{ status: 0, body: "" }})); + r.on("timeout", () => {{ r.destroy(); res({{ status: 0, body: "" }}); }}); + r.end(); + }}); +}} + +const secretWorks = async (port, secret) => + (await withSecret(port, secret, "GET", "/apps")).status === 200; + +async function rememberDaemon(port, secret) {{ + try {{ + await mkdir(dirname(LAUNCHER_STATE), {{ recursive: true, mode: 0o700 }}); + const staged = `${{LAUNCHER_STATE}}.${{process.pid}}`; + await writeFile(staged, `${{port}} ${{secret}}\n`, {{ mode: 0o600 }}); + await rename(staged, LAUNCHER_STATE); + }} catch {{ + console.warn("(could not remember this daemon; the next run starts another)"); + }} +}} + +/** A daemon whose secret is known: named, remembered, or started here. */ async function ensureDaemon() {{ + const named = process.env.BIOROUTER_SERVER__SECRET_KEY; const preferred = Number(process.env.BIOROUTERD_PORT || 3000); - for (const p of [preferred, 3000, 3001, 3002, 3003]) {{ - if (await probe(p)) {{ console.log(`Using the Biorouter daemon already on :${{p}}`); return p; }} + if (named && process.env.BIOROUTERD_PORT && (await secretWorks(preferred, named))) {{ + console.log(`Using the Biorouter daemon on :${{preferred}}`); + return {{ port: preferred, secret: named }}; }} + try {{ + const [port, secret] = (await readFile(LAUNCHER_STATE, "utf8")).trim().split(/\s+/); + if (port && secret && (await secretWorks(Number(port), secret))) {{ + console.log(`Using the Biorouter daemon started for your apps on :${{port}}`); + return {{ port: Number(port), secret }}; + }} + }} catch {{ /* no remembered daemon */ }} for (const bin of DAEMON_PATHS) {{ - for (const p of [preferred, 3001, 3002, 3003]) {{ + for (const p of [preferred, 3001, 3002, 3003, 3004, 3005]) {{ + // A daemon already here is not one whose secret this server knows. + if (await probe(p)) continue; + // A new secret for every attempt, so one asked of whatever held the port + // never becomes the secret of a daemon started elsewhere. + const secret = randomBytes(32).toString("hex"); let child; try {{ child = spawn(bin, ["agent"], {{ - env: {{ ...process.env, BIOROUTER_PORT: String(p) }}, + env: {{ ...process.env, BIOROUTER_PORT: String(p), BIOROUTER_SERVER__SECRET_KEY: secret }}, stdio: "ignore", detached: true, }}); }} catch {{ break; }} // this binary doesn't exist; try the next one @@ -735,7 +967,11 @@ async function ensureDaemon() {{ child.on("error", () => {{ spawnFailed = true; }}); child.unref(); for (let i = 0; i < 40 && !spawnFailed; i++) {{ - if (await probe(p)) {{ console.log(`Started ${{bin}} on :${{p}}`); return p; }} + if (await secretWorks(p, secret)) {{ + console.log(`Started ${{bin}} on :${{p}}`); + await rememberDaemon(p, secret); + return {{ port: p, secret }}; + }} await sleep(500); }} if (spawnFailed) break; @@ -746,12 +982,26 @@ async function ensureDaemon() {{ ); }} +/** The path of this app's one-time launch link: opening it sets the app's access cookie. */ +async function launchPath(port, secret) {{ + const {{ status, body }} = await withSecret(port, secret, "POST", `/apps/${{APP_ID}}/launch`); + let path; + try {{ path = JSON.parse(body).path; }} catch {{ /* not JSON */ }} + if (status !== 200 || typeof path !== "string" || !/^\/apps\/[A-Za-z0-9_-]+\/\?t=[0-9a-f]{{64}}$/.test(path)) {{ + throw new Error(`The daemon on :${{port}} does not serve '${{APP_ID}}'. Is the store at ${{STORE}}?`); + }} + return path; +}} + // Install the app, then bring up a daemon. On failure, print the actionable // message (not an unhandled-rejection stack trace) and exit cleanly. let daemonPort; +let launch; try {{ await installApp(); - daemonPort = await ensureDaemon(); + const daemon = await ensureDaemon(); + daemonPort = daemon.port; + launch = await launchPath(daemon.port, daemon.secret); }} catch (e) {{ console.error("\n" + (e && e.message ? e.message : e) + "\n"); process.exit(1); @@ -812,10 +1062,10 @@ server.on("upgrade", (req, socket, head) => {{ up.end(); }}); -// Bind loopback ONLY. This server proxies straight through to the daemon's -// `/apps/**` routes, which are deliberately exempt from the secret-key check -// (a browser tab can't send the header). Listening on 0.0.0.0 would hand the -// whole LAN an unauthenticated agent. +// Bind loopback ONLY. This server proxies the daemon's `/apps/**` routes and +// never adds the secret to what it forwards: a browser gets the app only with the +// access cookie its one-time launch link sets. Listening on 0.0.0.0 would still +// put the app's page one stolen link away from the whole LAN. function listen(port, attempt = 0) {{ server.once("error", (e) => {{ if (e.code === "EADDRINUSE" && attempt < 10) {{ @@ -828,6 +1078,7 @@ function listen(port, attempt = 0) {{ }}); server.listen(port, "127.0.0.1", () => {{ console.log(`App running at http://127.0.0.1:${{port}} (agent -> :${{daemonPort}})`); + console.log(`Open it here (this address works once): http://127.0.0.1:${{port}}${{launch}}`); }}); }} listen(PORT); @@ -858,9 +1109,28 @@ That's the whole thing. The launcher: 1. installs the app into your local Biorouter store (`manifest.json` and all), 2. installs any first-run payload this export carries (`payload/`: knowledge bases and skills, with your consent; see `payload/export.json`), -3. reuses a running `biorouterd`, or starts one on the first free port, -4. checks the daemon can actually serve the app, and -5. opens it in your browser. +3. reuses the `biorouterd` it started last time, or starts one on the first + free port, +4. asks that daemon for a one-time link to the app, and +5. opens the link in your browser, and prints it in the terminal too. + +A daemon serves an app only to a browser that opened such a link, and hands one +out only to a caller that knows the daemon's secret, so another account on the +same computer cannot open your app. That is why the launcher starts a daemon of +its own rather than using one that happens to be running: it keeps that +daemon's port and secret, readable by you alone, in +`~/.config/biorouter/app-launcher/daemon`. To use a daemon you run yourself, set +`BIOROUTERD_PORT` and `BIOROUTER_SERVER__SECRET_KEY` to its port and secret. The +link works once and for a few minutes; after that the browser keeps the app +open until the daemon restarts. Run the launcher again for a new link. + +The link opens the app for whoever uses it first, so the launcher never puts it +on a command line, which other accounts on the computer can read. It writes the +link into a small page only you can read, in +`~/.config/biorouter/app-launcher/open`, and opens that page, which sends the +browser on to the app. A browser installed as a snap package cannot read that +folder; if yours shows an error instead of the app, open the address the +terminal printed. No Node, no `npm install`, no build step. You need Biorouter installed with a provider configured (`biorouter configure`), unless this is a **fat** export, @@ -884,9 +1154,10 @@ your rebuilt bundle shows up on refresh: npm run build npm start # http://localhost:8787 -`serve.mjs` starts (or reuses) a daemon for you and proxies `/apps/**` to it, -including the agent WebSocket. Because the page and the agent then share an -origin, nothing hard-codes a port. +`serve.mjs` starts (or reuses) a daemon for you, the same way the launcher does, +and proxies `/apps/**` to it, including the agent WebSocket. Because the page +and the agent then share an origin, nothing hard-codes a port. It prints a +one-time address under `/apps/{id}/`: open that to reach the live agent. ## Notes @@ -1422,12 +1693,22 @@ mod tests { serve.contains("path: req.url"), "proxy must preserve query strings (the ws token rides the query)" ); - // It fronts the daemon's auth-exempt /apps routes, so it must never - // leave loopback — binding 0.0.0.0 would expose an unauthenticated agent. + // It fronts the daemon's /apps routes, so it must never leave loopback. assert!( serve.contains(r#"server.listen(port, "127.0.0.1""#), "the dev server must bind loopback only" ); + // W2-HRD-1: the proxy forwards the browser's own headers and never adds + // the daemon secret, or this port would open the app to every local + // account. The secret is used only to mint the one-time launch link. + let (_, proxy) = serve.split_once("function proxyHttp").unwrap(); + assert!( + !proxy.contains("X-Secret-Key"), + "the proxy must not add the secret" + ); + assert!(serve.contains("`/apps/${APP_ID}/launch`")); + assert!(serve.contains("BIOROUTER_SERVER__SECRET_KEY: secret")); + assert!(serve.contains("works once")); } /// The OS-agnostic launcher set: `run.ps1` (PowerShell) + `run.bat` (thin @@ -1459,6 +1740,40 @@ mod tests { "prefers bundled daemon" ); assert!(ps1.contains("/apps/$AppId/"), "opens the daemon origin"); + // W2-HRD-1: through the one-time launch link, minted with the secret of + // a daemon the launcher started or was told about. + assert!(ps1.contains("\"$Base/apps/$AppId/launch\"")); + assert!(ps1.contains("$env:BIOROUTER_SERVER__SECRET_KEY = $secret")); + // The link opens the app for whoever uses it first, so it never reaches + // a command line: the browser is handed a page that forwards to it. + assert!(ps1.contains("$Link = \"$Base$LaunchPath\"")); + assert!(ps1.contains("Invoke-Item -LiteralPath $Page")); + for line in ps1.lines() { + let opens = [ + "Start-Process", + "Invoke-Item", + "Invoke-Expression", + "cmd", + "explorer", + ] + .iter() + .any(|opener| line.contains(opener)); + let names_the_link = ["$Link", "$LaunchPath", "?t="] + .iter() + .any(|link| line.contains(link)); + assert!( + !(opens && names_the_link), + "an opener is handed the link: {line}" + ); + } + // ...and the secret leaves the launcher's environment before the browser starts. + let (_, after_spawn) = ps1 + .split_once("$env:BIOROUTER_SERVER__SECRET_KEY = $secret") + .unwrap(); + let (spawn, _) = after_spawn + .split_once("Invoke-Item -LiteralPath $Page") + .unwrap(); + assert!(spawn.contains("Remove-Item Env:BIOROUTER_SERVER__SECRET_KEY")); // The app id is embedded, not a leftover placeholder. assert!(ps1.contains("$AppId = \"demo\"")); assert!(!ps1.contains("__APP_ID__")); @@ -1508,10 +1823,126 @@ mod tests { "must set the daemon port env var biorouterd actually reads" ); assert!(lib.contains("die \"biorouterd not found")); - // And the runner opens the DAEMON's own origin, not a static server's. + // And the runner opens the DAEMON's own origin, not a static server's, + // through the app's one-time launch link (W2-HRD-1). let run = file(&files, "run.sh"); - assert!(run.contains("open_url \"$BASE/apps/$APP_ID/\"")); - assert!(run.contains("verify_app")); + assert!(run.contains("launch_url\nopen_url \"$LAUNCH_URL\"")); + assert!(lib.contains(r#"LAUNCH_URL="http://127.0.0.1:$PORT$path""#)); + assert!(lib.contains(r#""http://127.0.0.1:$1/apps/$APP_ID/launch""#)); + // It starts its daemon with a secret it keeps, owner-only. + assert!(lib.contains( + r#"BIOROUTER_SERVER__SECRET_KEY="$SECRET" BIOROUTER_PORT="$p" "$BIOROUTERD" agent"# + )); + assert!(lib.contains("umask 077")); + // The secret never rides a command line other accounts can read: every + // curl that carries it reads the header from stdin. + assert_eq!(lib.matches("-H @-").count(), 2); + assert!(!lib.contains("-H \"X-Secret-Key")); + } + + /// W2-HRD-1: the launcher never puts the one-time link on a command line, + /// where every account on the machine can read it and redeem it before the + /// browser does. Runs the real `open_url` with stub `open` and `xdg-open` + /// that record their arguments, and checks they were handed a page only this + /// account can read, which forwards to the link, and never the link itself. + #[cfg(unix)] + #[test] + fn the_launcher_hands_its_opener_a_private_page_and_never_the_link() { + use std::os::unix::fs::PermissionsExt; + use std::process::Command; + + let dir = tempfile::tempdir().unwrap(); + // Every Unix this ships to has bash; a missing one must fail here, not + // pass without checking anything. + let bash = find_usable_bash(dir.path()).expect("a usable bash"); + let m = manifest(ArtifactKind::Agentic); + let files = export(&m, None); + std::fs::write( + dir.path().join("biorouter-launch.sh"), + file(&files, "biorouter-launch.sh"), + ) + .unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let log = dir.path().join("opened.log"); + for opener in ["open", "xdg-open"] { + let stub = bin.join(opener); + std::fs::write( + &stub, + format!("#!/bin/sh\nprintf '%s\\n' \"$@\" >> '{}'\n", log.display()), + ) + .unwrap(); + std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let config = dir.path().join("config"); + let token = "cd".repeat(32); + let run = |url: &str| { + Command::new(&bash) + .arg("-c") + .arg(format!( + "set -euo pipefail\n. ./biorouter-launch.sh\nPORT=4321\nopen_url '{url}'" + )) + .current_dir(dir.path()) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .env("XDG_CONFIG_HOME", &config) + .env("HOME", dir.path()) + .output() + .unwrap() + }; + + let launch = format!("http://127.0.0.1:4321/apps/{}/?t={token}", m.id); + let out = run(&launch); + assert!( + out.status.success(), + "open_url failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + // The terminal is the user's alone, so the link is shown there for a + // browser that cannot read the page. + assert!(String::from_utf8_lossy(&out.stdout).contains(&format!( + "open this address instead (it works once): {launch}" + ))); + let opened = std::fs::read_to_string(&log).expect("an opener ran"); + let handed: Vec<&str> = opened.lines().collect(); + assert_eq!(handed.len(), 1, "one argument, the page: {opened}"); + assert!( + !opened.contains("?t=") && !opened.contains(&token), + "{opened}" + ); + let page = std::path::Path::new(handed[0]); + assert_eq!( + page.parent(), + Some(config.join("biorouter/app-launcher/open").as_path()) + ); + let html = std::fs::read_to_string(page).unwrap(); + assert!( + html.contains(&format!(r#"content="0;url={launch}""#)), + "{html}" + ); + assert!(html.contains(&format!(r#"href="{launch}""#)), "{html}"); + let mode = + |path: &std::path::Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(page), 0o600, "the page is this account's alone"); + assert_eq!(mode(page.parent().unwrap()), 0o700, "and so is its folder"); + + // Anything but this app's launch link on this daemon is refused, and no + // opener runs. + std::fs::remove_file(&log).unwrap(); + for other in [ + format!("http://127.0.0.1:4321/apps/other/?t={token}"), + format!("http://127.0.0.1:9999/apps/{}/?t={token}", m.id), + format!("http://127.0.0.1:4321/apps/{}/?t={}", m.id, "cd".repeat(31)), + format!("http://127.0.0.1:4321/apps/{}/?t={token}\">