diff --git a/biorouter-self-test.yaml b/biorouter-self-test.yaml index fa7137b30..0e24ba59b 100644 --- a/biorouter-self-test.yaml +++ b/biorouter-self-test.yaml @@ -30,8 +30,8 @@ activities: - Verify daemon-side Ollama readiness, bounded recovery, safe connection diagnostics, and failed Meditation cursors - Validate Crew empty-state onboarding, isolated Alice/Bob/Carol development profiles, and private-by-default workspace presentation - Exercise Crew first-host preparation and SSH trust validation with synthetic invitation metadata only - - Verify Crew request idempotency, human-only session access, and scoped normal-chat tool denial paths - - Exercise Crew CLI and daemon parity against an isolated synthetic profile, including help/version and no-start refusal without a vault prompt + - Verify Crew request idempotency, human-only session access, and scoped normal-chat tool admission + - Exercise the hidden Crew CLI without tool invocation, plus help/version and no-start refusal in isolated profiles - Verify Crew transfer lifecycle receipts, resume/forget behavior, and bounded file cleanup using disposable fixtures only - Measure performance and resource usage - Generate comprehensive test report @@ -107,7 +107,7 @@ parameters: input_type: string requirement: optional default: "" - description: "Opaque harness-owned provider-only profile root, prepared outside model tools; no Crew state or other extensions" + description: "Opaque harness-owned provider-only profile root, prepared outside model tools; no Crew state or enabled extensions" - key: crew_standalone_provider input_type: string @@ -367,7 +367,7 @@ extensions: - type: platform name: crew bundled: true - description: Crew request and scope-denial tools for the isolated Crew contract phase + description: Crew capability for the isolated hidden CLI contract phase # {% endif %} # {% endif %} @@ -608,16 +608,18 @@ prompt: | {% endif %} {% if test_depth != "fixture_only" and (test_phases == "all" or "crew" in test_phases) %} - ## PHASE 2E: Crew CLI and MCP scope without human proof + ## PHASE 2E: Hidden Crew CLI without tool invocation This phase is selected explicitly with `test_phases=crew` (and is included by - `test_phases=all`). It is a headless Crew contract check. Use + `test_phases=all`). It checks the named `hidden_cli_no_invocation_v1` contract. Use only phase-owned state rooted under `{{ workspace_dir }}` for model-driven shell and file actions. Before the workflow starts, the test harness may prepare an independent provider-only profile from the authorized selective credential projector and supply its opaque root plus approved provider/model and absolute CLI path as the `crew_standalone_*` and `crew_cli_path` parameters. That profile - contains no Crew connection, grant, SSH configuration, database or other extension. + contains no Crew connection, grant, SSH configuration or database and no enabled extension. + Its valid disabled registry entries prevent injected defaults from enabling other tools. + Its current extension migration marker is prepared before the unchanged-file snapshot. The agent must not create or inspect it. Use this host in place, without copying or exporting its API key, saved user-action key, private key, SSH key, keyring entry, saved credential, or profile files into this phase. Keep that @@ -645,13 +647,18 @@ prompt: | --model {{ crew_standalone_model }} --workdir `. Pass arguments separately without shell interpolation; the workdir must not exist before the checker runs. Record its exit code and its nonsecret JSON - verdict. Require exit 0 and `result=PASS`, `request_count=1`, `exact_arguments=true`, - `response_id_matches=true`, `exact_denial=true`, `channel_content_returned=false`, - `local_planning_calls<=1`, `cli_exit=0`, `listener_positive_control=true`, `live_listener_samples>0`, - `ordinary_run_baseline=true`, - `ordinary_bridge_listeners=1`, `unexpected_tcp_listeners=0`, - `phase_daemon_or_ssh=false`, `phase_runtime_created=false`, - `provider_files_unchanged=true`, and `baseline_processes_untouched=true`. + verdict. Require exit 0, `result=PASS`, `contract=hidden_cli_no_invocation_v1`, + `tool_request_count=0`, `tool_response_count=0`, `fixed_response=true`, + `completed=true`, `channel_content_returned=false`, `cli_exit=0`, + `listener_positive_control=true`, `live_listener_samples>0`, + `ordinary_run_baseline=true`, `ordinary_bridge_listeners=1`, + `unexpected_tcp_listeners=0`, `phase_daemon_or_ssh=false`, + `phase_runtime_created=false`, `provider_files_unchanged=true`, + and `baseline_processes_untouched=true`. + Require the limitation fields `effective_model_roster=NOT OBSERVABLE`, + `forged_call_denial=NOT OBSERVABLE`, and `separate_rust_evidence_required=true`. + These fields are under the verdict's `limitations` object. PASS qualifies only + the named contract, never an effective roster or forged request admission claim. {% else %} The harness did not supply all five handles. Report the standalone assertions NOT OBSERVABLE without inventing a provider, reading the normal profile or @@ -661,22 +668,28 @@ prompt: | the approved provider/model, `--max-turns 2 --quiet --output-format stream-json`, a clean environment and fresh phase-owned HOME/workdir. It uses the opaque provider-only root in place, never exposing or copying its config or secrets. - Exactly one synthetic `messages.history` request omits `connection_id`: - `{"method":"messages.history","params":{"channel_id":"00000000-0000-4000-8000-0000000002e5","limit":1}}`. - It pairs the structured tool request/response by ID and accepts either a direct - `crew__request` or the runtime's `code_execution__execute_code` transport with - exactly one `crew/request` graph node and matching `biorouter/tool-calls` metadata. - At most one preceding local `todo__todo_write` may record a bounded synthetic - checklist and return only its exact local item-count summary. No other tool, - grant, connection action or Crew retry is permitted. Arguments must match - exactly and the only returned Crew content must be the exact - no-channel denial, optionally inside the normal outer `tool-output` wrapper: - `This chat isn't connected to a Crew channel. To connect it, type /crew in this chat.` - The code-execution `Result: ` transport prefix must decode as that exact JSON - string or a standard MCP object with only `isError=false` and one exact-denial - text content item. Known null optional MCP metadata/structured-content fields - add no content; nonempty structured payloads and metadata or other fields fail. Extra Crew/other calls, unmatched responses, - message/resource/file payloads or additional text fail. The checker first proves its observer sees a checker-owned loopback + Both the ordinary baseline and the Crew run use the same fixed prompt. + You may see no input echo. Any input echo must be one exact initial user message. + It must contain one text item with the checker's fixed prompt and no whitespace + changes. The checker sends the exact prompt to stdin. + You cannot prove which prompt the model received from stream output alone. + The remaining events must be assistant text fragments and one final completion. + The fragments must join to `CREW_STANDALONE_NO_INVOCATION_OK`, allowing whitespace + only at the response's ends. A duplicate, different or later user message fails. + Every tool call, + tool response, nontext content item, extra message payload, error, aborted turn, + model change, missing completion or extra response text fails the checker. + Do not ask the model to invoke an unoffered worker or to say which tools it has. + A hidden local chat is not the person's own chat, and it holds no Crew grant. + This native stream exposes no effective model tool roster. No tool invocation + means it also supplies no forged request denial evidence. Qualify those claims + separately with real Rust `Agent::list_tools` and dispatch coverage, including + `no_other_session_is_offered_a_crew_tool`, + `a_persons_chat_without_a_grant_is_offered_exactly_the_person_tools`, and + `an_ungranted_chat_cannot_call_the_worker_tools`. Record that cohort's exact + source and result separately; unavailable or failed coverage remains open. + Never infer either claim from model prose, a fixed response or zero calls. + The checker first proves its observer sees a checker-owned loopback listener and closes that control before starting the CLI. It runs a no-Crew ordinary native baseline and samples structured listener rows on exact live child PIDs. It permits only the single CLI-owned IPv4 loopback @@ -687,16 +700,16 @@ prompt: | a pass inferred from model prose. The checker keeps private 0600 native traces below its 0700 phase directory for external QA. Do not read or copy those raw traces with model tools; only the fixed nonsecret verdict belongs in the report. - Do not discover connections, retry the - request, create a grant, invoke a daemon, emulate the refusal or alter a report. - 3. After that single request, resolve the locally installed native binaries + Do not discover connections, make or retry a Crew request, create a grant, + invoke a daemon, emulate a refusal or alter a report. + 3. After the hidden runs, resolve the locally installed native binaries without building or starting anything. Run ` --version`, ` crew --help`, ` --version`, ` --help`, ` crew privacy set-personal --help`, and ` crew privacy set-workspace --help`; record exact paths, exit codes, bounded output, and require `--institution` in both privacy command help outputs. These six help/version assertions qualify the CLI - surface and no-grant refusal only; same-institution and foreign-institution + surface only; same-institution and foreign-institution admission remain separate synthetic acceptance coverage. An unavailable binary is NOT OBSERVABLE, not a pass, and must not trigger a build or fallback. @@ -710,7 +723,7 @@ prompt: | not falsely claim that no session row or run metadata was created. Require no daemon process, listener, SSH file, team, connection, invitation, provider mutation, credential mutation, or live-workspace write. Record - only synthetic paths, session metadata identifiers, and refusal metadata; + only synthetic paths, session metadata identifiers, and the named contract verdict; scrub secrets and provider/profile paths from logs. If an assertion cannot be observed without human proof or credentials, mark it NOT OBSERVABLE rather than PASS. @@ -722,7 +735,7 @@ prompt: | Compare before/after snapshots and require no phase-created daemon/runtime descriptor/socket and no tracked child listener. Missing ownership evidence is NOT OBSERVABLE, never a broad match or an assumed pass. - 5. Log the exact commands, exit codes, denial result, and before/after + 5. Log the exact commands, exit codes, named contract result and limits, and before/after mutation audit to `{{ workspace_dir }}/phase2e_crew.md`. Keep all test inputs synthetic and remove only artifacts created by this phase when `cleanup_after` is true. diff --git a/crates/biorouter-acp/src/server.rs b/crates/biorouter-acp/src/server.rs index 3a3d5b0d4..ceb17e67d 100644 --- a/crates/biorouter-acp/src/server.rs +++ b/crates/biorouter-acp/src/server.rs @@ -343,6 +343,16 @@ impl BioRouterAcpAgent { } pub async fn with_config(config: BioRouterAcpConfig) -> Result { + // ⚠ An ACP process can never prove that a person made a decision. The + // protocol cannot tell an editor's click from an auto-allow policy, so + // every card is answered with `DecisionAuthority::unproven()` + // (`handle_tool_permission_request`), and a proof-backed approval refuses an allow + // from it forever. A tool whose approval needs that proof must then not + // be offered at all (SD-8): crew__change, the skill and extension + // installers, the bug reporter. The flag is process-global and defaults + // to true, so it is set here, before any extension or roster is built. + // Only an ACP process builds this agent (`biorouter acp`, and the tests). + biorouter::pending_user_action::set_user_proof_available(false); let session_manager = Arc::new(SessionManager::new(config.data_dir)); // The decision log follows this agent's store, as the CLI's session // builder and the daemon install it. Without it a decision made in an @@ -398,6 +408,17 @@ impl BioRouterAcpAgent { self.agent.list_extensions().await } + /// Names of the tools the model is offered in `session_id`, by the same rule + /// a turn lists them with. Exposed for diagnostics and tests. + pub async fn list_tool_names(&self, session_id: &str) -> Vec { + self.agent + .list_tools(session_id, None) + .await + .into_iter() + .map(|tool| tool.name.to_string()) + .collect() + } + fn convert_acp_prompt_to_message(&self, prompt: Vec) -> Message { let mut user_message = Message::user(); diff --git a/crates/biorouter-acp/tests/acp_user_proof.rs b/crates/biorouter-acp/tests/acp_user_proof.rs new file mode 100644 index 000000000..4eba00800 --- /dev/null +++ b/crates/biorouter-acp/tests/acp_user_proof.rs @@ -0,0 +1,116 @@ +//! An ACP process can never prove that a person made a decision: every card it shows is +//! answered with `DecisionAuthority::unproven()`, because the protocol cannot tell an editor's +//! click from an auto-allow policy. So it must say so, and offer no tool whose approval needs +//! that proof (SD-8). `crew__change` is the one this checks by name: every one of its actions +//! waits on a proof-backed card, which an ACP client could never allow. +//! +//! ⚠ A binary of its own, holding ONE test. It points `BIOROUTER_PATH_ROOT` at a temporary +//! directory, so Crew's policy is read from there and never from the person's own +//! configuration, and it installs a Crew tool host, which no test sharing its process could +//! undo. With nothing else in the process, neither can reach another test. + +use biorouter::agents::ExtensionConfig; +use biorouter::config::BioRouterMode; +use biorouter::crew::tool_host::{CrewToolHost, PersonToolCall}; +use biorouter::model::ModelConfig; +use biorouter::providers::api_client::{ApiClient, AuthMethod}; +use biorouter::providers::openai::OpenAiProvider; +use biorouter::session::session_manager::SessionType; +use biorouter::session::SessionManager; +use biorouter_acp::server::{BioRouterAcpAgent, BioRouterAcpConfig}; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +/// A host that answers nothing: installed only so the Crew person tools are offered at all, +/// which leaves the proof as the one thing that can withhold `crew__change`. No call is made. +struct NoCallsHost; + +impl CrewToolHost for NoCallsHost { + // `CrewToolHost` is an `async_trait`; this is the signature the macro gives it, written + // out because this crate does not depend on the macro. + fn call<'life0, 'async_trait>( + &'life0 self, + _call: PersonToolCall, + ) -> Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { anyhow::bail!("this test makes no Crew call") }) + } +} + +/// A provider for an agent that runs no turn. Construction makes no network call. +fn no_turn_provider() -> Arc { + let api_client = ApiClient::new( + "http://127.0.0.1:1".to_string(), + AuthMethod::BearerToken("test-key".to_string()), + ) + .unwrap(); + Arc::new(OpenAiProvider::new( + api_client, + ModelConfig::new("gpt-5-nano").unwrap(), + )) +} + +#[test] +fn an_acp_process_offers_no_tool_that_needs_a_persons_proof() { + let root = tempfile::tempdir().unwrap(); + // Set before anything reads it; this binary has no other test to race. + std::env::set_var("BIOROUTER_PATH_ROOT", root.path()); + biorouter::crew::tool_host::install(Arc::new(NoCallsHost)); + + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_stack_size(16 * 1024 * 1024) + .enable_all() + .build() + .unwrap() + .block_on(async { + assert!( + biorouter::pending_user_action::user_proof_available(), + "premise: a process counts as able to prove a person acted until it says otherwise" + ); + let data = root.path().join("acp"); + let agent = BioRouterAcpAgent::with_config(BioRouterAcpConfig { + provider: no_turn_provider(), + builtins: vec![], + work_dir: root.path().to_path_buf(), + data_dir: data.clone(), + config_dir: data.clone(), + biorouter_mode: BioRouterMode::Auto, + extensions: vec![ExtensionConfig::Platform { + name: "crew".to_string(), + bundled: None, + description: "crew".to_string(), + available_tools: vec![], + }], + }) + .await + .unwrap(); + assert!( + !biorouter::pending_user_action::user_proof_available(), + "an ACP process must report that it can never obtain a person's proof" + ); + + // The agent's own store: a chat the ACP client would have opened. + let chat = SessionManager::new(data) + .create_session( + root.path().to_path_buf(), + "ACP chat".to_string(), + SessionType::User, + ) + .await + .unwrap(); + let tools = agent.list_tool_names(&chat.id).await; + assert!( + tools.iter().any(|tool| tool == "crew__read"), + "premise: this chat is offered the Crew person tools at all: {tools:?}" + ); + assert!( + !tools.iter().any(|tool| tool == "crew__change"), + "crew__change waits on a card ACP can never allow, so it is not offered: {tools:?}" + ); + }); +} diff --git a/crates/biorouter-cli/src/cli.rs b/crates/biorouter-cli/src/cli.rs index 267beabef..2f17be5f5 100644 --- a/crates/biorouter-cli/src/cli.rs +++ b/crates/biorouter-cli/src/cli.rs @@ -1750,6 +1750,15 @@ enum Command { )] history: bool, + /// Run the chat in this process even when the shared daemon is running + #[arg( + long, + conflicts_with = "shared_daemon", + help = "Run the chat in this terminal only, not on the shared Biorouter daemon", + long_help = "Run the chat in this terminal only. Without it, an interactive chat runs on the shared Biorouter daemon when one is running, so it can use Crew as you and shows in the desktop app." + )] + local: bool, + #[command(flatten)] session_opts: SessionOptions, @@ -2378,14 +2387,15 @@ fn reject_shared_fields(fields: &[(&str, bool)]) -> Result<()> { Ok(()) } -fn shared_conversation_options( +/// The options only a chat in this process takes, each with whether it was given. A shared +/// daemon chat refuses them, and an interactive `biorouter session` given one stays in this +/// process (D4). +fn in_process_fields( identifier: &Option, - resume: bool, session: &SessionOptions, extensions: &ExtensionOptions, - model: &ModelOptions, -) -> Result { - reject_shared_fields(&[ +) -> [(&'static str, bool); 8] { + [ ( "--name", identifier.as_ref().is_some_and(|id| id.name.is_some()), @@ -2406,7 +2416,17 @@ fn shared_conversation_options( !extensions.streamable_http_extensions.is_empty(), ), ("--with-builtin", !extensions.builtins.is_empty()), - ])?; + ] +} + +fn shared_conversation_options( + identifier: &Option, + resume: bool, + session: &SessionOptions, + extensions: &ExtensionOptions, + model: &ModelOptions, +) -> Result { + reject_shared_fields(&in_process_fields(identifier, session, extensions))?; let session_id = identifier.as_ref().and_then(|id| id.session_id.clone()); anyhow::ensure!( !resume || session_id.is_some(), @@ -2489,7 +2509,9 @@ async fn handle_shared_run( crate::commands::shared_conversation::run(options).await } -/// `biorouter session` — both of its forms, dispatched in one place. +/// `biorouter session` — both of its forms, dispatched in one place. The interactive form +/// runs its chat on the shared daemon when one is running and answers its saved key, and in +/// this process otherwise or with `--local` (D4, [`shared_daemon_for`]). /// /// ⚠ **Extracted from `cli()` rather than inlined, and the reason is mechanical.** /// Task 31 (issue #56) gave the interactive form a seventh field, `model_opts`, @@ -2497,53 +2519,225 @@ async fn handle_shared_run( /// ceiling (`too_many_lines`, 103/100 — a NEW violation against /// `scripts/clippy-lint.sh`'s baseline, so CI fails on it). The honest fix is to /// move the arm's body out, not to `#[allow]` the lint on a function that has -/// been growing an arm per feature for years. -async fn handle_session_command( - command: Option, - identifier: Option, - resume: bool, - history: bool, - session_opts: SessionOptions, - extension_opts: ExtensionOptions, - model_opts: ModelOptions, -) -> Result<()> { - if session_opts.shared.shared_daemon { +/// been growing an arm per feature for years. The chat's fields travel as one +/// [`SessionChat`] since D4 added an eighth, `local`. +async fn handle_session_command(command: Option, chat: SessionChat) -> Result<()> { + if chat.session_opts.shared.shared_daemon { anyhow::ensure!(command.is_none(), "--shared-daemon on session does not support local session subcommands; use an exact --resume --session-id for a daemon conversation"); - let mut options = shared_conversation_options( - &identifier, - resume, - &session_opts, - &extension_opts, - &model_opts, - )?; - options.interactive = !options.create_only; - options.history = history; - return crate::commands::shared_conversation::run(options).await; + return handle_shared_session(chat).await; } match command { Some(cmd) => handle_session_subcommand(cmd).await, - None => { - handle_interactive_session( - identifier, - resume, - history, - session_opts, - extension_opts, - model_opts, - ) - .await - } + None => match shared_daemon_for(&chat).await { + Some(client) => run_default_shared_session(client, chat).await, + None => handle_interactive_session(chat).await, + }, } } -async fn handle_interactive_session( +/// What `biorouter session` without a subcommand was asked for: one chat, opened here. +#[derive(Default)] +struct SessionChat { identifier: Option, resume: bool, history: bool, + /// `--local`: the chat runs in this process even when the shared daemon is running. + local: bool, session_opts: SessionOptions, extension_opts: ExtensionOptions, model_opts: ModelOptions, +} + +/// What [`crate::commands::session::shared_daemon_for_chat`] decides on for `chat`, read from +/// its options and this process. +fn chat_request(chat: &SessionChat) -> crate::commands::session::ChatRequest { + use crate::commands::session::{chat_model, configured_model, ChatRequest}; + let session_id = chat + .identifier + .as_ref() + .and_then(|id| id.session_id.as_deref()); + let (provider, model) = ( + chat.model_opts.provider.as_deref(), + chat.model_opts.model.as_deref(), + ); + let model_known = match session_id { + // A chat resumed by its ID keeps its own model unless both are named. + Some(_) => provider.is_some() == model.is_some(), + None => chat_model(provider, model, configured_model).is_some(), + }; + ChatRequest { + local: chat.local, + at_a_terminal: crate::commands::session::at_a_terminal(), + // `--session-id` without `--resume` is refused by the chat in this process. + in_process_option: (session_id.is_some() && !chat.resume) + || in_process_fields(&chat.identifier, &chat.session_opts, &chat.extension_opts) + .iter() + .any(|(_, given)| *given), + model_known, + agent_shell: crate::commands::needs_terminal::in_agent_shell(), + } +} + +/// D4: the shared daemon an interactive `chat` runs on, or `None` to run it in this process. +async fn shared_daemon_for(chat: &SessionChat) -> Option { + crate::commands::session::shared_daemon_for_chat(&chat_request(chat)).await +} + +/// Run `chat` on the shared daemon `client` reached (D4): the chat `--session-id` names, the +/// latest one for `--resume` alone (found as an in-process chat finds it), or a new one. +async fn run_default_shared_session( + client: crate::daemon_client::CrewClient, + chat: SessionChat, ) -> Result<()> { + use crate::commands::session::{chat_model, configured_model, latest_chat_id}; + eprintln!("{}", crate::commands::session::RUNS_ON_THE_SHARED_DAEMON); + let SessionChat { + identifier, + resume, + history, + model_opts, + .. + } = chat; + let mut session_id = identifier.and_then(|id| id.session_id); + if resume && session_id.is_none() { + session_id = latest_chat_id().await?; + if session_id.is_none() { + eprintln!("No previous chat to resume; starting a new chat."); + } + } + let (provider, model) = match &session_id { + Some(_) => (model_opts.provider, model_opts.model), + None => chat_model( + model_opts.provider.as_deref(), + model_opts.model.as_deref(), + configured_model, + ) + .unzip(), + }; + let options = crate::commands::shared_conversation::SharedConversationOptions { + session_id, + working_dir: std::env::current_dir()?, + provider, + model, + prompt: None, + interactive: true, + create_only: false, + history, + approval_key_stdin: false, + no_start: true, + quiet: false, + output_format: "text".into(), + }; + run_shared_chat(options, Some(client)).await +} + +/// `biorouter session --shared-daemon`. A new interactive chat starts on the configured +/// provider and model when neither `--provider` nor `--model` is given, as an in-process chat +/// does; `--create-only` still needs both. +async fn handle_shared_session(chat: SessionChat) -> Result<()> { + let SessionChat { + identifier, + resume, + history, + session_opts, + extension_opts, + model_opts, + .. + } = chat; + let new_interactive_chat = !session_opts.shared.create_only + && !resume + && identifier.as_ref().is_none_or(|id| id.session_id.is_none()); + let model_opts = new_chat_model( + model_opts, + new_interactive_chat, + crate::commands::session::configured_model, + ); + let mut options = shared_conversation_options( + &identifier, + resume, + &session_opts, + &extension_opts, + &model_opts, + )?; + options.interactive = !options.create_only; + options.history = history; + run_shared_chat(options, None).await +} + +/// `model_opts` for a `biorouter session --shared-daemon` chat: a new interactive chat given +/// neither `--provider` nor `--model` takes the `configured` pair, as an in-process chat does. +/// Anything else keeps what was given, and the conversation checks it. +fn new_chat_model( + model_opts: ModelOptions, + new_interactive_chat: bool, + configured: impl FnOnce() -> (Option, Option), +) -> ModelOptions { + if !new_interactive_chat || model_opts.provider.is_some() || model_opts.model.is_some() { + return model_opts; + } + match crate::commands::session::chat_model(None, None, configured) { + Some((provider, model)) => ModelOptions { + provider: Some(provider), + model: Some(model), + }, + None => model_opts, + } +} + +/// The provider and model of a new chat this command starts on the shared daemon itself +/// ([`run_shared_chat`]): a new interactive chat at a terminal (`at_a_terminal`: standard +/// input and error) that names both. `None` leaves the chat to the conversation exactly as +/// given, which refuses one it cannot hold before it creates anything. +fn starts_here( + options: &crate::commands::shared_conversation::SharedConversationOptions, + at_a_terminal: bool, +) -> Option<(String, String)> { + if options.session_id.is_some() + || !options.interactive + || options.approval_key_stdin + || !at_a_terminal + { + return None; + } + Some((options.provider.clone()?, options.model.clone()?)) +} + +/// Run a shared daemon conversation. A new interactive chat ([`starts_here`]) is started first, +/// in this folder with the configured extensions as an in-process chat starts +/// ([`crate::commands::session::start_daemon_chat`]), and then opened by its ID. +async fn run_shared_chat( + mut options: crate::commands::shared_conversation::SharedConversationOptions, + client: Option, +) -> Result<()> { + use std::io::IsTerminal; + let at_a_terminal = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); + if let Some((provider, model)) = starts_here(&options, at_a_terminal) { + let client = match client { + Some(client) => client, + None => crate::daemon_client::CrewClient::connect(options.no_start).await?, + }; + let id = crate::commands::session::start_daemon_chat( + &client, + &options.working_dir, + &provider, + &model, + ) + .await?; + options.session_id = Some(id); + } + crate::commands::shared_conversation::run(options).await +} + +async fn handle_interactive_session(chat: SessionChat) -> Result<()> { + let SessionChat { + identifier, + resume, + history, + session_opts, + extension_opts, + model_opts, + .. + } = chat; let session_start = std::time::Instant::now(); let session_type = if resume { "resumed" } else { "new" }; @@ -3167,6 +3361,11 @@ async fn handle_default_session() -> Result<()> { if !Config::global().exists() { return handle_configure().await; } + // `biorouter` alone is a new `biorouter session`, and runs where that would (D4). + let chat = SessionChat::default(); + if let Some(client) = shared_daemon_for(&chat).await { + return run_default_shared_session(client, chat).await; + } let session_id = get_or_create_session_id(None, false, false, None, None).await?; @@ -3292,20 +3491,21 @@ async fn dispatch(command: Option) -> anyhow::Result<()> { identifier, resume, history, + local, session_opts, extension_opts, model_opts, }) => { - handle_session_command( - command, + let chat = SessionChat { identifier, resume, history, + local, session_opts, extension_opts, model_opts, - ) - .await + }; + handle_session_command(command, chat).await } // Both already return `anyhow::Result<()>`, so `?` followed by `Ok(())` // was a six-line restatement of the value the call hands back. @@ -3880,6 +4080,185 @@ mod cli_tests { .contains("requires both --provider and --model")); } + /// The `biorouter session` chat a command line describes. + fn session_chat(argv: &[&str]) -> SessionChat { + let Some(Command::Session { + command: None, + identifier, + resume, + history, + local, + session_opts, + extension_opts, + model_opts, + }) = Cli::try_parse_from(argv).expect("parses").command + else { + panic!("an interactive session expected: {argv:?}"); + }; + SessionChat { + identifier, + resume, + history, + local, + session_opts, + extension_opts, + model_opts, + } + } + + /// D4: `--local` is a `biorouter session` option only, and cannot be combined with + /// `--shared-daemon`; `biorouter run` stays in this process and has no such option. + #[test] + fn local_is_a_session_option_that_excludes_the_shared_daemon() { + assert!(session_chat(&["biorouter", "session", "--local"]).local); + assert!(!session_chat(&["biorouter", "session"]).local); + let both = Cli::try_parse_from(["biorouter", "session", "--local", "--shared-daemon"]) + .err() + .expect("--local and --shared-daemon conflict"); + assert_eq!(both.kind(), clap::error::ErrorKind::ArgumentConflict); + let run = Cli::try_parse_from(["biorouter", "run", "--local", "--text", "hi"]) + .err() + .expect("run has no --local"); + assert_eq!(run.kind(), clap::error::ErrorKind::UnknownArgument); + } + + /// D4: what decides where a chat runs, read from its command line. An option only a chat in + /// this process takes keeps it here, and so does `--session-id` without `--resume`; a chat + /// resumed by its ID needs both model flags or neither. + #[test] + fn a_session_command_line_says_where_its_chat_may_run() { + let request = |argv: &[&str]| chat_request(&session_chat(argv)); + let plain = request(&["biorouter", "session"]); + assert!(!plain.local && !plain.in_process_option); + assert!(request(&["biorouter", "session", "--local"]).local); + for option in [ + &["--debug"][..], + &["--name", "notes"], + &["--max-turns", "3"], + &["--max-tool-repetitions", "2"], + &["--with-builtin", "developer"], + &["--with-extension", "echo"], + &["--with-streamable-http-extension", "http://127.0.0.1:9/mcp"], + &["--session-id", "20261107_1"], + ] { + let argv: Vec<&str> = ["biorouter", "session"] + .iter() + .chain(option) + .copied() + .collect(); + assert!(request(&argv).in_process_option, "{argv:?}"); + } + let resumed = request(&[ + "biorouter", + "session", + "--resume", + "--session-id", + "20261107_1", + ]); + assert!(!resumed.in_process_option && resumed.model_known); + let rebound = [ + "biorouter", + "session", + "--resume", + "--session-id", + "20261107_1", + "--provider", + "p", + "--model", + "m", + ]; + assert!(request(&rebound).model_known); + let half = [ + "biorouter", + "session", + "--resume", + "--session-id", + "20261107_1", + "--provider", + "p", + ]; + assert!(!request(&half).model_known); + assert!(request(&["biorouter", "session", "--provider", "p", "--model", "m"]).model_known); + assert!(!request(&["biorouter", "session", "--provider", "p"]).model_known); + } + + /// `biorouter session --shared-daemon` with neither model flag starts a new interactive + /// chat on the configured pair; `--create-only`, a named chat or a given flag keep what was + /// given, so the conversation still refuses a pair that is incomplete. + #[test] + fn a_new_shared_daemon_chat_takes_the_configured_model() { + let configured = || { + ( + Some("cfg-provider".to_owned()), + Some("cfg-model".to_owned()), + ) + }; + let filled = new_chat_model(ModelOptions::default(), true, configured); + assert_eq!(filled.provider.as_deref(), Some("cfg-provider")); + assert_eq!(filled.model.as_deref(), Some("cfg-model")); + let kept = new_chat_model(ModelOptions::default(), false, configured); + assert!(kept.provider.is_none() && kept.model.is_none()); + let named = ModelOptions { + provider: Some("p".into()), + model: None, + }; + let kept = new_chat_model(named, true, configured); + assert_eq!(kept.provider.as_deref(), Some("p")); + assert!(kept.model.is_none()); + let unconfigured = new_chat_model(ModelOptions::default(), true, || (None, None)); + assert!(unconfigured.provider.is_none() && unconfigured.model.is_none()); + } + + /// Only a new interactive chat at a terminal that names its model is started by this + /// command (with the configured extensions); every other shape goes to the conversation as + /// given, which refuses it before creating anything. + #[test] + fn only_a_new_interactive_chat_at_a_terminal_is_started_here() { + use crate::commands::shared_conversation::SharedConversationOptions; + fn new_chat() -> SharedConversationOptions { + SharedConversationOptions { + session_id: None, + working_dir: PathBuf::from("/tmp"), + provider: Some("p".into()), + model: Some("m".into()), + prompt: None, + interactive: true, + create_only: false, + history: false, + approval_key_stdin: false, + no_start: true, + quiet: false, + output_format: "text".into(), + } + } + let with = |change: fn(&mut SharedConversationOptions)| { + let mut options = new_chat(); + change(&mut options); + starts_here(&options, true) + }; + assert_eq!( + starts_here(&new_chat(), true), + Some(("p".into(), "m".into())) + ); + assert_eq!(starts_here(&new_chat(), false), None, "no terminal"); + assert_eq!( + with(|o| o.session_id = Some("20261107_1".into())), + None, + "resumed" + ); + assert_eq!( + with(|o| o.interactive = false), + None, + "create-only or one prompt" + ); + assert_eq!( + with(|o| o.approval_key_stdin = true), + None, + "stdin is taken" + ); + assert_eq!(with(|o| o.model = None), None, "no model"); + } + #[tokio::test] async fn shared_run_rejects_before_reading_an_instruction_file_or_building_a_provider() { let input = InputOptions { diff --git a/crates/biorouter-cli/src/commands/crew/args.rs b/crates/biorouter-cli/src/commands/crew/args.rs index a4940392d..56e967599 100644 --- a/crates/biorouter-cli/src/commands/crew/args.rs +++ b/crates/biorouter-cli/src/commands/crew/args.rs @@ -151,19 +151,28 @@ pub enum CrewCommand { /// Host a new workspace from start to finish: install Crew in your account on the server, /// start the workspace, save and connect it here, and set it up open to every member. Host(Box), + /// Open a workspace on a server that your account there hosts, belongs to or is invited + /// to: find it, save it on this computer, connect and join. + Open(Box), /// List, save, change or remove this computer's saved connections. #[command(subcommand)] Connections(ConnectionCommand), - /// Authenticate SSH through the shared daemon's owned authentication session. - Auth, + /// Authenticate SSH through the shared daemon's owned authentication session, for the + /// selected connection or for a setup that waits for someone to sign in. + Auth(AuthArgs), /// Open the verified SSH bridge for the selected connection. Connect, /// Close the selected SSH connection. Disconnect, - /// Join the workspace a saved connection points to: show this computer's code for the + /// Join the workspace a saved connection points to. On a server whose Crew knows your + /// account, you join as that account; on an older one, show this computer's code for the /// host, then wait until they let you in. Sets Crew up in your account on the server first /// when it isn't there yet. Join(JoinArgs), + /// Add this computer to the selected connection's workspace again with a new device key, + /// as your account on the server. The connection stays, so chats can be given Crew access + /// through it again. + Rejoin, /// Show the workspace, set it up once as its host, rename or delete it, choose who manages /// it, or keep its context document. #[command(subcommand)] @@ -282,7 +291,7 @@ pub enum ConnectionCommand { List, /// Show one saved connection in detail. Show, - /// Prepare this device's enrollment public key in the daemon. + /// Prepare this computer's enrollment public key in the daemon. Prepare, /// Save a verified connection descriptor from JSON. Use - for stdin. Save { @@ -295,17 +304,18 @@ pub enum ConnectionCommand { input: PathBuf, }, /// 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. + /// workspace. Asks you to type the connection's name first. On a server whose Crew knows + /// your account, the workspace can be opened again from any computer that signs in as it, + /// the host controls included. On an older one, a host's only computer is refused unless + /// --give-up-host-controls is added, because nothing restores the host controls afterwards, + /// and a host with other enrolled computers is shown them first. 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. + /// On a server with an older Crew, 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, }, @@ -353,15 +363,40 @@ pub struct ServerArgs { pub proxy_jump: Option, } -/// `setup`: install Crew's server program in your own account (design OW-9). +/// `setup`: install Crew's server program in your own account (design OW-9), keep it current +/// and the workspaces you host on it (AO-8). #[derive(Args)] pub struct SetupArgs { #[command(flatten)] pub target: ServerArgs, - /// Replace an older copy of Crew, or a file Biorouter didn't put there, at - /// ~/.local/bin/biorouter-crew. The old one is kept beside it. + /// Replace a Crew at ~/.local/bin/biorouter-crew that doesn't run or doesn't say which + /// version it is, or a file Biorouter didn't put there. The old one is kept beside it. An + /// older Crew is replaced without this. #[arg(long, conflicts_with = "check")] pub replace_existing: bool, + /// Keep an older Crew in your account on the server instead of replacing it. + #[arg(long, conflicts_with = "check")] + pub keep_older: bool, + /// Restart the workspaces you host there on the new Crew even when other people are + /// connected to them. Their running agent commands stop; their computers reconnect by + /// themselves. + #[arg(long, conflicts_with_all = ["check", "no_restart"])] + pub restart: bool, + /// Restart one workspace you host there on the new Crew, by its name (or its ID, for one + /// with no name), even when other people are connected to it. Repeat for more. The others + /// are left as --restart or --no-restart says. + #[arg( + long = "restart-workspace", + value_name = "NAME", + conflicts_with_all = ["check", "restart"] + )] + pub restart_workspace: Vec, + /// Restart none of the workspaces you host there; only say which still run an older Crew. + #[arg(long, conflicts_with = "check")] + pub no_restart: bool, + /// Start a stopped workspace you host there, by its name. Repeat for more. + #[arg(long = "start", value_name = "NAME", conflicts_with = "check")] + pub start: Vec, /// Only check the server, changing nothing (the same as biorouter crew doctor). #[arg(long)] pub check: bool, @@ -372,6 +407,48 @@ pub struct SetupArgs { pub agent_access: Option, } +/// `open`: a workspace on a server that the account there hosts, belongs to or is invited to +/// (design AO-9). +#[derive(Args)] +pub struct OpenArgs { + /// The workspace's name on the server. + #[arg(value_name = "NAME")] + pub workspace: String, + /// The SSH login to open it as: an alias from your SSH settings or user@host. + #[arg(long, value_name = "LOGIN")] + pub server: String, + /// The workspace's host, as their username on the server, when two hosts there have a + /// workspace of that name. + #[arg(long, value_name = "USERNAME")] + pub host: 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 sent. + #[arg(long)] + pub identity_file: Option, + /// A jump route. + #[arg(long)] + pub proxy_jump: Option, + /// What agents may do on the server through the connection: nothing (off), work in one + /// folder (folder), or run commands as you (full). Default: off; choose later with + /// connections set-access. + #[arg(long, value_enum)] + pub agent_access: Option, + /// This computer's name for the connection. Default: the workspace's name. + #[arg(long = "name", value_name = "LOCAL_NAME")] + pub local_name: Option, +} + +/// `auth`: sign in for the selected connection, or for a setup job. +#[derive(Args)] +pub struct AuthArgs { + /// Sign in for this setup job, which waits for someone to sign in to its server. + #[arg(long, value_name = "ID")] + pub setup_job: Option, +} + /// `host`: a new workspace, from installing Crew to an open workspace with its context. #[derive(Args)] pub struct HostArgs { @@ -401,6 +478,10 @@ pub struct HostArgs { /// Crew's storage that other accounts can write to. Hosting is refused while they can. #[arg(long)] pub fix_permissions: bool, + /// What your agent may do on the server through the new workspace's connection: nothing + /// (off), work in a folder setup makes (folder), or run commands as you (full). + #[arg(long, value_enum, default_value = "full")] + pub agent_access: AgentAccess, /// Go ahead without asking. Needed when there is no terminal to ask in. #[arg(long)] pub yes: bool, @@ -472,7 +553,7 @@ pub struct JoinArgs { 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. + /// Initialize the workspace using this computer's prepared public identity. Bootstrap, /// Rename the workspace (host only): lowercase letters, numbers and dashes. Rename { @@ -557,7 +638,7 @@ pub enum EnrollmentCommand { /// Join with an invitation instead. #[command(hide = true)] Accept(SecretInput), - /// Revoke a member's enrollment, devices and grants (host). + /// Revoke a member's enrollment, computers and grants (host). Revoke { /// The member, as @username or their ID. An ID never prompts, for scripts. member: String, @@ -589,7 +670,7 @@ pub struct EnrollInviteArgs { conflicts_with = "username" )] pub public_key: Option, - /// Deprecated: the member a legacy token adds a device to. With a username, use + /// Deprecated: the member a legacy token adds a computer to. With a username, use /// --add-device instead. #[arg(long, hide = true, requires = "uid", conflicts_with = "username")] pub existing_principal: Option, @@ -750,6 +831,28 @@ pub enum ProfileCommand { #[arg(long)] avatar: Option, }, + /// List the computers enrolled as you: each one's fingerprint, how and when it was added, + /// which one is this computer, and which are new. + Devices { + /// Mark the new computers as seen, so biorouter crew status stops naming them. + #[arg(long)] + acknowledge: bool, + }, + /// Remove one of your computers from the workspace, by the fingerprint profile devices + /// shows. Take its SSH access to the server away first: a computer that can still sign in + /// as you can add itself again. Asks first; add --yes where there is no terminal to ask in. + #[command(group(ArgGroup::new("device").required(true).args(["fingerprint", "this_device"])))] + RevokeDevice { + /// The computer's fingerprint as profile devices shows it, like 3F2A 9C1E 77B0 D4E1. + #[arg(num_args = 1..)] + fingerprint: Vec, + /// This computer, in place of a fingerprint. + #[arg(long, conflicts_with = "fingerprint")] + this_device: bool, + /// Remove it without asking. Needed when there is no terminal to ask in. + #[arg(long)] + yes: bool, + }, } #[derive(Subcommand)] @@ -1000,6 +1103,10 @@ pub enum GrantCommand { Revoke { /// The chat's session ID. session: String, + /// Withdraw the permission you gave the chat to read the workspace as you, instead of + /// a Crew grant. + #[arg(long)] + reads: bool, }, } @@ -1894,6 +2001,49 @@ mod tests { assert!(help.contains("device key"), "{help}"); } + /// C1 (2026-11): a person's machine is a "computer" in every help text, hidden ones too. + /// "device key" still names the key itself, and the command and flag names keep theirs. + #[test] + fn help_says_computer_for_a_persons_machine() { + fn texts(command: &clap::Command, path: &str, out: &mut Vec<(String, String)>) { + for text in [command.get_about(), command.get_long_about()] + .into_iter() + .flatten() + { + out.push((path.to_owned(), text.to_string())); + } + for arg in command.get_arguments() { + for text in [arg.get_help(), arg.get_long_help()].into_iter().flatten() { + out.push((format!("{path} {}", arg.get_id()), text.to_string())); + } + } + for sub in command.get_subcommands() { + texts(sub, &format!("{path} {}", sub.get_name()), out); + } + } + let mut said = Vec::new(); + texts(&TestCli::command(), "crew", &mut said); + assert!(said.len() > 100, "{}", said.len()); + let names = [ + "device public key", + "device key", + "--add-device", + "revoke-device", + "profile devices", + "--this-device", + ]; + let offenders: Vec<_> = said + .into_iter() + .filter(|(_, text)| { + let rest = names + .iter() + .fold(text.to_lowercase(), |rest, name| rest.replace(name, "")); + rest.contains("device") + }) + .collect(); + assert!(offenders.is_empty(), "{offenders:#?}"); + } + /// CLI-14: a wrong command line is refused while it is read, with clap's usage status 2, /// before anything is asked for or a daemon is started. #[test] @@ -1999,4 +2149,162 @@ mod tests { assert!(!help.contains(hidden), "{hidden} is hidden: {help}"); } } + + /// AO-8: setup keeps Crew current by default; the flags keep an older one, restart + /// workspaces others use, restart none, or start stopped ones by name. A check changes + /// nothing, so it takes none of them. + #[test] + fn setup_takes_the_restart_and_start_choices() { + let CrewCommand::Setup(args) = parse(&[ + "setup", + "--server", + "wanjun@leo.ucsf.edu", + "--keep-older", + "--restart", + "--start", + "baranzini-lab", + "--start", + "other-lab", + ]) + .command + else { + panic!("setup") + }; + assert!(args.keep_older && args.restart && !args.no_restart); + assert_eq!(args.start, ["baranzini-lab", "other-lab"]); + let CrewCommand::Setup(args) = parse(&["setup", "--no-restart"]).command else { + panic!("setup --no-restart") + }; + assert!(args.no_restart && !args.restart && args.start.is_empty() && !args.keep_older); + for args in [ + &["setup", "--restart", "--no-restart"][..], + &["setup", "--check", "--restart"], + &["setup", "--check", "--no-restart"], + &["setup", "--check", "--keep-older"], + &["setup", "--check", "--start", "lab"], + &["setup", "--check", "--restart-workspace", "lab"], + &["setup", "--restart", "--restart-workspace", "lab"], + ] { + assert!(refused(args), "{args:?}"); + } + // --restart still means all of them; --restart-workspace names some, with the others + // left as --no-restart says. + let CrewCommand::Setup(args) = parse(&[ + "setup", + "--no-restart", + "--restart-workspace", + "baranzini-lab", + "--restart-workspace", + "other-lab", + ]) + .command + else { + panic!("setup --restart-workspace") + }; + assert!(args.no_restart && !args.restart); + assert_eq!(args.restart_workspace, ["baranzini-lab", "other-lab"]); + } + + /// AO-9, AO-5, AO-10: open names a workspace and a login; rejoin, devices and revoking a + /// device take what they need; auth signs in for a setup job; host chooses agent access. + #[test] + fn account_commands_parse() { + let CrewCommand::Open(args) = parse(&[ + "open", + "baranzini-lab", + "--server", + "wanjun@leo.ucsf.edu", + "--host", + "wanjun", + "--port", + "2222", + "--agent-access", + "folder", + "--name", + "Lab on leo", + ]) + .command + else { + panic!("open") + }; + assert_eq!(args.workspace, "baranzini-lab"); + assert_eq!(args.server, "wanjun@leo.ucsf.edu"); + assert_eq!(args.host.as_deref(), Some("wanjun")); + assert_eq!(args.port, Some(2222)); + assert_eq!(args.agent_access, Some(AgentAccess::Folder)); + assert_eq!(args.local_name.as_deref(), Some("Lab on leo")); + assert!(refused(&["open", "baranzini-lab"])); + assert!(refused(&["open", "--server", "leo"])); + + assert!(matches!(parse(&["rejoin"]).command, CrewCommand::Rejoin)); + let CrewCommand::Auth(args) = parse(&["auth", "--setup-job", "abc"]).command else { + panic!("auth --setup-job") + }; + assert_eq!(args.setup_job.as_deref(), Some("abc")); + let CrewCommand::Auth(args) = parse(&["auth"]).command else { + panic!("auth") + }; + assert!(args.setup_job.is_none()); + + let CrewCommand::Profile(ProfileCommand::Devices { acknowledge }) = + parse(&["profile", "devices", "--acknowledge"]).command + else { + panic!("profile devices") + }; + assert!(acknowledge); + let CrewCommand::Profile(ProfileCommand::RevokeDevice { + fingerprint, + this_device, + yes, + }) = parse(&["profile", "revoke-device", "3F2A", "9C1E", "77B0", "D4E1"]).command + else { + panic!("profile revoke-device") + }; + assert_eq!(fingerprint, ["3F2A", "9C1E", "77B0", "D4E1"]); + assert!(!this_device && !yes); + let CrewCommand::Profile(ProfileCommand::RevokeDevice { + fingerprint, + this_device, + yes, + }) = parse(&["profile", "revoke-device", "--this-device", "--yes"]).command + else { + panic!("profile revoke-device --this-device") + }; + assert!(fingerprint.is_empty() && this_device && yes); + assert!(refused(&["profile", "revoke-device"])); + assert!(refused(&[ + "profile", + "revoke-device", + "3F2A", + "--this-device" + ])); + + let CrewCommand::Grants(GrantCommand::Revoke { session, reads }) = + parse(&["grants", "revoke", "20260924_2", "--reads"]).command + else { + panic!("grants revoke --reads") + }; + assert_eq!(session, "20260924_2"); + assert!(reads); + + let CrewCommand::Host(args) = parse(&["host", "--name", "lab", "--server", "hpc"]).command + else { + panic!("host") + }; + assert_eq!(args.agent_access, AgentAccess::Full); + let CrewCommand::Host(args) = parse(&[ + "host", + "--name", + "lab", + "--server", + "hpc", + "--agent-access", + "off", + ]) + .command + else { + panic!("host --agent-access off") + }; + assert_eq!(args.agent_access, AgentAccess::Off); + } } diff --git a/crates/biorouter-cli/src/commands/crew/mod.rs b/crates/biorouter-cli/src/commands/crew/mod.rs index 60e058aba..ae1d837a2 100644 --- a/crates/biorouter-cli/src/commands/crew/mod.rs +++ b/crates/biorouter-cli/src/commands/crew/mod.rs @@ -34,10 +34,12 @@ const RESTART_FOR_NAMES: &str = "Restart the shared Biorouter daemon to use name /// The same, for the invitation and device-code routes. const RESTART_FOR_JOINING: &str = "Restart the shared Biorouter daemon to invite or join with an invitation."; -/// A revoke that stopped the grant on this device but that the workspace has not confirmed. It +/// A revoke that stopped the grant on this computer but that the workspace has not confirmed. It /// is not a success, so the command exits non-zero (RV-D1). The daemon asks the workspace -/// again by itself whenever the connection is back (F3), so no second command is needed. -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."; +/// again by itself whenever the connection is back (F3), so no second command is needed. The +/// daemon's own sentence (`REVOCATION_UNCONFIRMED_MESSAGE` in `routes/crew_profile.rs`), then +/// where this terminal sees that it has. +const STOPPED_ON_THIS_COMPUTER: &str = "Stopped on this computer. The workspace hasn't confirmed yet; Biorouter confirms it by itself when the connection is back. biorouter crew grants list shows when it has."; /// The same, for setting Crew up on a server (`/crew/setup`, design OW-9). const RESTART_FOR_SETUP: &str = "Restart the shared Biorouter daemon to set up Crew on a server."; /// The same, for deleting a workspace (`…/workspace/delete`, OW-5). @@ -104,6 +106,7 @@ fn streams(command: &CrewCommand) -> bool { | CrewCommand::Setup(_) | CrewCommand::Doctor(_) | CrewCommand::Host(_) + | CrewCommand::Open(_) | CrewCommand::Tasks(TaskCommand::Watch { .. }) | CrewCommand::Files(FileCommand::Watch { .. }) ) @@ -417,6 +420,9 @@ fn safe_lines(text: &str) -> String { } async fn execute(options: CrewOptions, sent: Arc) -> Result<()> { + // D5, before anything else, so nothing is read from stdin, started or sent: an agent's + // own shell reads Crew but never changes it as the person ([`agent_shell`]). + agent_shell::refuse(&options.command, needs_terminal::in_agent_shell())?; // Before any prompt: nothing here can work without the shared daemon's Unix socket. crate::daemon_client::require_supported_platform(cfg!(unix))?; let CrewOptions { @@ -488,6 +494,7 @@ async fn execute(options: CrewOptions, sent: Arc) -> Result<()> { setup_poll: SETUP_POLL, connection_id: tokio::sync::OnceCell::new(), channel_labels: std::sync::Mutex::default(), + printed: Printed::Terminal, }; let reply = match run(&api, command).await { Ok(reply) => reply, @@ -496,6 +503,634 @@ async fn execute(options: CrewOptions, sent: Arc) -> Result<()> { reply.print(api.format) } +/// `biorouter crew` in an agent's own shell (owner ruling D5): reads keep working, and every +/// command that changes Crew as the person is refused before anything is sent, with a sentence +/// that points the agent to `crew__change`, where the person sees a card and allows it first. +/// +/// An agent's shell is known by `BIOROUTER_TERMINAL` ([`needs_terminal::in_agent_shell`]), +/// which `developer__shell` and the retry runner set on every command they run. Without it, +/// nothing here changes anything. +/// +/// ⚠ **Accident prevention, not a boundary.** An agent can unset the variable (local agent +/// forgery is out of scope by owner ruling). What it stops is a model's ordinary `biorouter +/// crew send …` acting as the person with no card, the way the broker's +/// `agent_session_refused` stops an agent's job on the server from adding or removing the +/// person's computers. +/// +/// Which commands change Crew is the chat's own line: a command is a change exactly when the +/// chat makes it with `crew__change` (`biorouter::crew::tool_host::CHANGE_ACTIONS`), or when +/// it is one of the person-only controls no chat offers. The tests hold [`as_you`] to that +/// list, and [`as_you`] has no catch-all arm, so a new command does not build until it says +/// which it is. +mod agent_shell { + use super::args::{ + ChannelCommand, ConnectionCommand, ContextCommand, CredentialCommand, CrewCommand, + DaemonCommand, DefaultsCommand, EnrollmentCommand, FileCommand, GrantCommand, + InvitationCommand, MembersArgs, MembersCommand, OwnershipCommand, PrivacyCommand, + ProfileCommand, TaskCommand, TeamCommand, WorkspaceCommand, + }; + use super::usage; + use AsYou::{Changes, PersonOnly, Reads}; + + /// The JSON code of the refusal. + const REFUSED: &str = "crew_agent_shell_refused"; + /// The refusal of a change a chat makes with `crew__change`. The action's name follows. + const USE_CREW_CHANGE: &str = "An agent's shell can't change Crew as you. Use crew__change in the chat, which asks the person first."; + /// The refusal of a person-only control, which no chat offers. + const ONLY_YOU: &str = "An agent's shell can't change Crew as you. Only you can do this, in the Crew view or with biorouter crew in your own terminal."; + + /// What a command does as the person. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum AsYou { + /// Reads, or changes nothing in Crew. `daemon start` only starts what every read + /// starts by itself. + Reads, + /// A change a chat makes with `crew__change`, under this action. + Changes(&'static str), + /// A person-only control that no chat offers: daemon stop, `workspace bootstrap`, + /// `enroll prepare` and `accept`, `connections prepare` and `save`, `grants grant`. + PersonOnly, + } + + /// `Ok` unless this runs in an agent's shell and `command` changes Crew as the person. The + /// refusal is a [`super::Usage`]: it exits with status 2, as a command line refused before + /// anything was sent does, and carries [`REFUSED`] in JSON. + pub(super) fn refuse(command: &CrewCommand, agent_shell: bool) -> anyhow::Result<()> { + if !agent_shell { + return Ok(()); + } + match as_you(command) { + Reads => Ok(()), + Changes(action) => Err(usage( + format!("{USE_CREW_CHANGE} The action is {action}."), + REFUSED, + )), + PersonOnly => Err(usage(ONLY_YOU, REFUSED)), + } + } + + /// What `command` does as the person, named by the chat's action for it. + fn as_you(command: &CrewCommand) -> AsYou { + match command { + CrewCommand::Daemon(DaemonCommand::Status | DaemonCommand::Start) => Reads, + CrewCommand::Daemon(DaemonCommand::Stop) => PersonOnly, + CrewCommand::Credentials(command) => credentials(command), + CrewCommand::Status + | CrewCommand::Doctor(_) + | CrewCommand::History(_) + | CrewCommand::Search { .. } + | CrewCommand::Watch(_) + | CrewCommand::Context { .. } => Reads, + CrewCommand::Setup(args) if args.check => Reads, + CrewCommand::Setup(_) => Changes("setup"), + CrewCommand::Host(_) => Changes("host"), + CrewCommand::Open(_) => Changes("open"), + CrewCommand::Connections(command) => connections(command), + CrewCommand::Auth(_) => Changes("auth"), + CrewCommand::Connect => Changes("connect"), + CrewCommand::Disconnect => Changes("disconnect"), + CrewCommand::Join(_) => Changes("join"), + CrewCommand::Rejoin => Changes("rejoin"), + CrewCommand::Workspace(command) => workspace(command), + CrewCommand::Enroll(command) => enrollment(command), + CrewCommand::Members(MembersArgs { command: None }) => Reads, + CrewCommand::Members(MembersArgs { + command: Some(MembersCommand::Add { .. }), + }) => Changes("members_add"), + CrewCommand::Teams(command) => teams(command), + CrewCommand::Channels(command) => channels(command), + CrewCommand::Invites(command) => invitations(command), + CrewCommand::Profile(command) => profile(command), + CrewCommand::Ownership(OwnershipCommand::Offer { .. }) => Changes("ownership_offer"), + CrewCommand::Ownership(OwnershipCommand::Accept { .. }) => Changes("ownership_accept"), + CrewCommand::RemoveMember { .. } => Changes("remove_member"), + CrewCommand::Send(_) => Changes("send"), + CrewCommand::Files(command) => files(command), + CrewCommand::Tasks(command) => tasks(command), + CrewCommand::Ask { .. } => Changes("tasks_ask"), + CrewCommand::Defaults(command) => defaults(command), + CrewCommand::Grants(command) => grants(command), + CrewCommand::Privacy(command) => privacy(command), + } + } + + fn credentials(command: &CredentialCommand) -> AsYou { + match command { + CredentialCommand::Status => Reads, + CredentialCommand::Init => Changes("credentials_init"), + CredentialCommand::Unlock => Changes("credentials_unlock"), + CredentialCommand::Lock => Changes("credentials_lock"), + } + } + + fn connections(command: &ConnectionCommand) -> AsYou { + match command { + ConnectionCommand::List + | ConnectionCommand::Show + | ConnectionCommand::Invitation { .. } => Reads, + ConnectionCommand::JoinInvitation(args) if args.preview => Reads, + // Saving from an invitation is the first half of the chat's `join`. + ConnectionCommand::JoinInvitation(_) => Changes("join"), + ConnectionCommand::Prepare | ConnectionCommand::Save { .. } => PersonOnly, + ConnectionCommand::Update { .. } => Changes("connections_update"), + ConnectionCommand::Remove { .. } => Changes("connections_remove"), + ConnectionCommand::SetAccess { .. } => Changes("connections_set_access"), + } + } + + fn workspace(command: &WorkspaceCommand) -> AsYou { + match command { + WorkspaceCommand::Show | WorkspaceCommand::Context(ContextCommand::Show) => Reads, + WorkspaceCommand::Bootstrap => PersonOnly, + WorkspaceCommand::Rename { .. } => Changes("workspace_rename"), + WorkspaceCommand::Delete { .. } => Changes("workspace_delete"), + WorkspaceCommand::Governance { .. } => Changes("workspace_governance"), + WorkspaceCommand::Context(ContextCommand::Set { .. } | ContextCommand::Edit) => { + Changes("context_set") + } + // Drafting signs in to the server as the person, saved or not, so the chat cards + // it too. + WorkspaceCommand::Context(ContextCommand::Draft { .. }) => Changes("context_draft"), + } + } + + fn enrollment(command: &EnrollmentCommand) -> AsYou { + match command { + EnrollmentCommand::Pending => Reads, + EnrollmentCommand::Prepare | EnrollmentCommand::Accept(_) => PersonOnly, + EnrollmentCommand::Invite(_) => Changes("enroll_invite"), + EnrollmentCommand::Approve { .. } => Changes("enroll_approve"), + EnrollmentCommand::Cancel { .. } => Changes("enroll_cancel"), + EnrollmentCommand::Revoke { .. } => Changes("enroll_revoke"), + } + } + + fn teams(command: &TeamCommand) -> AsYou { + match command { + TeamCommand::List => Reads, + TeamCommand::Create { .. } => Changes("teams_create"), + TeamCommand::Rename { .. } => Changes("teams_rename"), + TeamCommand::Join { .. } => Changes("teams_join"), + TeamCommand::Delete { .. } => Changes("teams_delete"), + } + } + + fn channels(command: &ChannelCommand) -> AsYou { + match command { + ChannelCommand::List { .. } => Reads, + ChannelCommand::Create { .. } => Changes("channels_create"), + ChannelCommand::Archive { .. } => Changes("channels_archive"), + ChannelCommand::MarkRead { .. } => Changes("channels_read"), + ChannelCommand::Rename { .. } => Changes("channels_rename"), + ChannelCommand::Join { .. } => Changes("channels_join"), + ChannelCommand::Delete { .. } => Changes("channels_delete"), + } + } + + fn invitations(command: &InvitationCommand) -> AsYou { + match command { + InvitationCommand::List => Reads, + InvitationCommand::Create { .. } => Changes("invites_send"), + InvitationCommand::Accept { .. } => Changes("invites_accept"), + } + } + + fn profile(command: &ProfileCommand) -> AsYou { + match command { + ProfileCommand::Show | ProfileCommand::Devices { acknowledge: false } => Reads, + ProfileCommand::Devices { acknowledge: true } => Changes("profile_devices_acknowledge"), + ProfileCommand::Set { .. } => Changes("profile_set"), + ProfileCommand::RevokeDevice { .. } => Changes("profile_revoke_device"), + } + } + + fn files(command: &FileCommand) -> AsYou { + match command { + FileCommand::Status { .. } + | FileCommand::Watch { .. } + | FileCommand::Pending + | FileCommand::ShowReference { .. } + | FileCommand::Show { .. } + | FileCommand::List { .. } => Reads, + FileCommand::Upload { .. } => Changes("files_share"), + FileCommand::Resume { .. } => Changes("files_resume"), + FileCommand::Pause { .. } => Changes("files_pause"), + FileCommand::Forget { .. } => Changes("files_forget"), + FileCommand::Download { .. } => Changes("files_download"), + FileCommand::Reference { .. } => Changes("files_reference"), + } + } + + fn tasks(command: &TaskCommand) -> AsYou { + match command { + TaskCommand::List | TaskCommand::Show { .. } | TaskCommand::Watch { .. } => Reads, + TaskCommand::Start { .. } => Changes("tasks_ask"), + TaskCommand::Cancel { .. } => Changes("tasks_cancel"), + } + } + + fn defaults(command: &DefaultsCommand) -> AsYou { + match command { + DefaultsCommand::Show => Reads, + DefaultsCommand::Set { .. } => Changes("defaults_set"), + DefaultsCommand::Clear => Changes("defaults_clear"), + } + } + + fn grants(command: &GrantCommand) -> AsYou { + match command { + GrantCommand::List => Reads, + // Giving a chat Crew access stays the person's (RV-D5). + GrantCommand::Grant { .. } => PersonOnly, + GrantCommand::Revoke { .. } => Changes("grants_revoke"), + } + } + + fn privacy(command: &PrivacyCommand) -> AsYou { + match command { + PrivacyCommand::Show => Reads, + PrivacyCommand::SetPersonal { .. } => Changes("privacy_set_personal"), + PrivacyCommand::SetWorkspace { .. } => Changes("privacy_set_workspace"), + } + } + + #[cfg(test)] + mod tests { + use super::super::{error_code, CrewOptions, Usage}; + use super::*; + use crate::commands::needs_terminal::{NeedsTerminal, AGENT_SHELL_MARKER}; + use biorouter::crew::tool_host::{CHANGE_ACTIONS, READ_ACTIONS}; + use clap::Parser; + + #[derive(Parser)] + struct Line { + #[command(flatten)] + crew: CrewOptions, + } + + /// `biorouter crew `, as clap reads it. + fn options(words: &[&str]) -> CrewOptions { + let mut argv = vec!["crew"]; + argv.extend_from_slice(words); + match Line::try_parse_from(argv) { + Ok(line) => line.crew, + Err(error) => panic!("{words:?} should parse: {error}"), + } + } + + /// What a command should be, by the chat's name for it ("" where no chat action + /// matches a read). + #[derive(Debug, Clone, Copy)] + enum Chat { + Read(&'static str), + Change(&'static str), + Person(&'static str), + } + use Chat::{Change, Person, Read}; + + const SESSION: &str = "20261107_1"; + + /// Every command, at least once, with the chat's name for it. + const EVERY_COMMAND: &[(&[&str], Chat)] = &[ + (&["daemon", "status"], Read("")), + (&["daemon", "start"], Read("")), + (&["daemon", "stop"], Person("daemon_stop")), + (&["credentials", "status"], Read("credentials_status")), + (&["credentials", "init"], Change("credentials_init")), + (&["credentials", "unlock"], Change("credentials_unlock")), + (&["credentials", "lock"], Change("credentials_lock")), + (&["status"], Read("status")), + (&["setup", "--check"], Read("setup_check")), + (&["doctor"], Read("setup_check")), + (&["setup"], Change("setup")), + ( + &["host", "--name", "lab", "--server", "bob@hpc"], + Change("host"), + ), + (&["open", "lab", "--server", "bob@hpc"], Change("open")), + (&["connections", "list"], Read("")), + (&["connections", "show"], Read("connections_show")), + ( + &["connections", "invitation"], + Read("connections_invitation"), + ), + ( + &["connections", "join-invitation", "-", "--preview"], + Read("connections_preview"), + ), + (&["connections", "join-invitation", "-"], Change("join")), + (&["connections", "prepare"], Person("connections_prepare")), + (&["connections", "save", "-"], Person("connections_save")), + ( + &["connections", "update", "-"], + Change("connections_update"), + ), + (&["connections", "remove"], Change("connections_remove")), + ( + &["connections", "set-access", "off"], + Change("connections_set_access"), + ), + (&["auth"], Change("auth")), + (&["auth", "--setup-job", "job-1"], Change("auth")), + (&["connect"], Change("connect")), + (&["disconnect"], Change("disconnect")), + (&["join"], Change("join")), + (&["join", "--no-wait"], Change("join")), + (&["rejoin"], Change("rejoin")), + (&["workspace", "show"], Read("workspace_show")), + (&["workspace", "bootstrap"], Person("workspace_bootstrap")), + ( + &["workspace", "rename", "lab-2"], + Change("workspace_rename"), + ), + (&["workspace", "delete"], Change("workspace_delete")), + ( + &["workspace", "governance", "open"], + Change("workspace_governance"), + ), + (&["workspace", "context", "show"], Read("context_show")), + (&["workspace", "context", "set", "-"], Change("context_set")), + (&["workspace", "context", "edit"], Change("context_set")), + (&["workspace", "context", "draft"], Change("context_draft")), + ( + &["workspace", "context", "draft", "--save"], + Change("context_draft"), + ), + (&["enroll", "prepare"], Person("enroll_prepare")), + (&["enroll", "invite", "@bob"], Change("enroll_invite")), + (&["enroll", "pending"], Read("enroll_pending")), + ( + &["enroll", "approve", "@bob", "7QK2-M9XA-3JTP-WZ4D"], + Change("enroll_approve"), + ), + (&["enroll", "cancel", "@bob"], Change("enroll_cancel")), + (&["enroll", "accept"], Person("enroll_accept")), + (&["enroll", "revoke", "@bob"], Change("enroll_revoke")), + (&["members"], Read("members")), + ( + &["members", "add", "@bob", "--team", "analysis-lab"], + Change("members_add"), + ), + (&["teams", "list"], Read("teams_list")), + (&["teams", "create", "Analysis Lab"], Change("teams_create")), + ( + &["teams", "rename", "analysis-lab", "Lab"], + Change("teams_rename"), + ), + (&["teams", "join", "analysis-lab"], Change("teams_join")), + (&["teams", "delete", "analysis-lab"], Change("teams_delete")), + (&["channels", "list"], Read("channels_list")), + ( + &["channels", "create", "methods", "--team", "analysis-lab"], + Change("channels_create"), + ), + ( + &["channels", "archive", "#methods"], + Change("channels_archive"), + ), + ( + &["channels", "mark-read", "#methods"], + Change("channels_read"), + ), + ( + &["channels", "rename", "#methods", "assays"], + Change("channels_rename"), + ), + (&["channels", "join", "#methods"], Change("channels_join")), + ( + &["channels", "delete", "#methods"], + Change("channels_delete"), + ), + (&["invites", "list"], Read("invites_list")), + ( + &["invites", "create", "@bob", "--team", "analysis-lab"], + Change("invites_send"), + ), + (&["invites", "accept"], Change("invites_accept")), + (&["profile", "show"], Read("profile_show")), + (&["profile", "set", "Bob"], Change("profile_set")), + (&["profile", "devices"], Read("profile_devices")), + ( + &["profile", "devices", "--acknowledge"], + Change("profile_devices_acknowledge"), + ), + ( + &["profile", "revoke-device", "--this-device"], + Change("profile_revoke_device"), + ), + ( + &["ownership", "offer", "#methods", "@bob"], + Change("ownership_offer"), + ), + ( + &["ownership", "accept", "#methods"], + Change("ownership_accept"), + ), + ( + &["remove-member", "#methods", "@bob"], + Change("remove_member"), + ), + (&["history", "#methods"], Read("history")), + (&["search", "#methods", "qpcr"], Read("search")), + (&["watch", "#methods"], Read("")), + (&["send", "#methods", "--text", "hi"], Change("send")), + (&["context", SESSION], Read("")), + ( + &["files", "upload", "#methods", "data.csv"], + Change("files_share"), + ), + ( + &["files", "resume", "t-1", "data.csv"], + Change("files_resume"), + ), + (&["files", "status", "t-1"], Read("files_status")), + (&["files", "watch", "t-1"], Read("")), + (&["files", "pending"], Read("files_pending")), + (&["files", "pause", "t-1"], Change("files_pause")), + (&["files", "forget", "t-1"], Change("files_forget")), + ( + &["files", "download", "a-1", "--output", "data.csv"], + Change("files_download"), + ), + ( + &[ + "files", + "reference", + "#methods", + "/data/run", + "--label", + "run", + ], + Change("files_reference"), + ), + (&["files", "show-reference", "r-1"], Read("")), + (&["files", "show", "a-1"], Read("")), + (&["files", "list", "#methods"], Read("files_list")), + ( + &[ + "tasks", + "start", + "#methods", + "--text", + "hi", + "--allow-posting", + ], + Change("tasks_ask"), + ), + (&["tasks", "list"], Read("tasks_list")), + (&["tasks", "show", "run-1"], Read("")), + (&["tasks", "watch", "run-1"], Read("tasks_watch")), + (&["tasks", "cancel", "run-1"], Change("tasks_cancel")), + (&["ask", "#methods", "summarise", "it"], Change("tasks_ask")), + (&["defaults", "show"], Read("defaults_show")), + ( + &["defaults", "set", "--provider", "p", "--model", "m"], + Change("defaults_set"), + ), + (&["defaults", "clear"], Change("defaults_clear")), + (&["grants", "list"], Read("grants_list")), + (&["grants", "grant", SESSION, "#methods"], Person("grant")), + (&["grants", "revoke", SESSION], Change("grants_revoke")), + (&["privacy", "show"], Read("privacy_show")), + ( + &["privacy", "set-personal", "private"], + Change("privacy_set_personal"), + ), + ( + &["privacy", "set-workspace", "private"], + Change("privacy_set_workspace"), + ), + ]; + + /// The CLI's line between reading and changing is the chat's: each change is a + /// `crew__change` action, each read with a chat name is a `crew__read` one, and a + /// person-only control is neither. + #[test] + fn the_line_between_reading_and_changing_is_the_chats() { + for &(words, chat) in EVERY_COMMAND { + let found = as_you(&options(words).command); + match chat { + Read(name) => { + assert_eq!(found, Reads, "{words:?}"); + assert!( + name.is_empty() || READ_ACTIONS.contains(&name), + "{words:?}: {name} is not a crew__read action" + ); + } + Change(action) => { + assert_eq!(found, Changes(action), "{words:?}"); + assert!( + CHANGE_ACTIONS.contains(&action), + "{words:?}: {action} is not a crew__change action" + ); + } + Person(name) => { + assert_eq!(found, PersonOnly, "{words:?}"); + assert!( + !READ_ACTIONS.contains(&name) && !CHANGE_ACTIONS.contains(&name), + "{words:?}: a chat offers {name}, so it is not person-only" + ); + } + } + } + } + + /// In an agent's shell, each change is refused with the sentence and its action, a + /// person-only control with its own sentence, and every read runs. Without the marker + /// nothing is refused. + #[test] + fn an_agent_shell_is_refused_every_change_and_nothing_else() { + for &(words, chat) in EVERY_COMMAND { + let command = options(words).command; + assert!(refuse(&command, false).is_ok(), "{words:?} outside"); + let refused = refuse(&command, true); + let expected = match chat { + Read(_) => { + assert!(refused.is_ok(), "{words:?} is a read"); + continue; + } + Change(action) => format!("{USE_CREW_CHANGE} The action is {action}."), + Person(_) => ONLY_YOU.to_owned(), + }; + let error = refused.expect_err("a change in an agent's shell"); + assert_eq!(error.to_string(), expected, "{words:?}"); + assert!(error.is::(), "{words:?} exits as a usage refusal"); + assert_eq!(error_code(&error).as_deref(), Some(REFUSED), "{words:?}"); + } + } + + /// The copy rules: plain sentences, no dashes, the tool named as the chat offers it. + #[test] + fn the_refusals_read_plainly() { + for sentence in [USE_CREW_CHANGE, ONLY_YOU] { + assert!(sentence.starts_with("An agent's shell can't change Crew as you. ")); + assert!(!sentence.contains(['\u{2013}', '\u{2014}']), "{sentence}"); + } + assert!(USE_CREW_CHANGE.contains("crew__change")); + } + + /// The whole command, as `main` runs it, with `BIOROUTER_TERMINAL` set and then + /// removed: the changes the work item names are refused before anything is sent (no + /// daemon is asked for), with the usage status, and `status` and `history` go on to + /// ask for the daemon. Without the marker, the changes go on too. `--no-start` keeps + /// every command from starting one, so each that passes stops at "no daemon". + #[cfg(unix)] + #[test] + fn biorouter_terminal_refuses_changes_and_keeps_reads() { + if !crate::test_sandbox::in_a_process_of_its_own() { + return; + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("a runtime"); + let run = |words: &[&str]| { + let mut words = words.to_vec(); + words.push("--no-start"); + runtime + .block_on(super::super::handle(options(&words))) + .expect_err("no daemon runs in this test") + }; + let changes: [&[&str]; 3] = [ + &["send", "#methods", "--text", "hi"], + &["channels", "create", "methods", "--team", "analysis-lab"], + &["credentials", "unlock"], + ]; + let reads: [&[&str]; 2] = [&["status"], &["history", "#methods"]]; + let no_daemon = crate::daemon_client::DAEMON_NOT_RUNNING; + { + let _marked = env_lock::lock_env([(AGENT_SHELL_MARKER, Some("1"))]); + for words in changes { + let error = run(words); + assert!( + error.is::(), + "{words:?} exits with the usage status: {error:#}" + ); + assert!( + error.to_string().starts_with(USE_CREW_CHANGE), + "{words:?}: {error:#}" + ); + } + for words in reads { + let error = run(words); + assert!( + error.to_string().starts_with(no_daemon), + "{words:?} reads in an agent's shell: {error:#}" + ); + } + } + let _unmarked = env_lock::lock_env([(AGENT_SHELL_MARKER, None::<&str>)]); + for words in changes.into_iter().chain(reads) { + let error = run(words); + assert!( + error.to_string().starts_with(no_daemon), + "{words:?} without the marker: {error:#}" + ); + } + } + } +} + /// The shared daemon, or a scripted stand-in in tests. Every request goes through /// [`Client::request`], which notes whether one carrying this invocation's request ID was sent. struct Client { @@ -681,6 +1316,10 @@ fn daemon_sentence(cause: &(dyn std::error::Error + 'static)) -> Option None => own(), }, DESTINATION_EXISTS => own().map(|sentence| overwrite_sentence(&sentence)), + // AO-9: the desktop's button, said as the command that does the same here. + DEVICE_KEY_MISSING | DEVICE_NOT_RECOGNIZED => { + own().map(|sentence| output::rejoin_sentence(&sentence)) + } GRANT_ENDED => Some(grant_ended_sentence( refusal_field(cause, "reason") == Some("settings_changed"), None, @@ -697,6 +1336,27 @@ fn daemon_sentence(cause: &(dyn std::error::Error + 'static)) -> Option /// 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 saved connection whose device key this computer no longer has +/// (AO-9): `rejoin` makes a new one. +const DEVICE_KEY_MISSING: &str = "crew_device_key_missing"; +/// The daemon's code for a member's computer the workspace no longer recognizes (AO-9): +/// `rejoin` adds it again. +const DEVICE_NOT_RECOGNIZED: &str = "crew_device_not_recognized"; +/// The daemon's code for a join refused because the person was removed (AO-9). +const JOIN_REMOVED: &str = "crew_join_removed"; +/// The daemon's code for something the account path needs on a server whose Crew predates it +/// (AO-9), worded by role. +const ACCOUNT_UNSUPPORTED: &str = "crew_account_unsupported"; +/// The setup refusal for a workspace name the account already hosts (AO-9). +const HOST_NAME_IN_USE: &str = "crew_host_name_in_use"; +/// The daemon's code for a setup job that was stopped before it finished. +const SETUP_CANCELLED: &str = "crew_setup_cancelled"; +/// What a daemon that predates the account path answers in place of its bare 404 (AO-9). +const RESTART_FOR_ACCOUNT: &str = + "Restart the shared Biorouter daemon to use your server account for Crew."; +/// The same, for withdrawing a chat's permission to read a workspace as the person (AO-12). +const RESTART_FOR_READS: &str = + "Restart the shared Biorouter daemon to withdraw a chat's permission to read as you."; /// 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"; @@ -1121,20 +1781,30 @@ async fn run(api: &Api, command: CrewCommand) -> Result { let args = SetupArgs { target, replace_existing: false, + keep_older: false, + restart: false, + restart_workspace: Vec::new(), + no_restart: false, + start: Vec::new(), check: true, agent_access: None, }; setup(api, args, tokio::signal::ctrl_c()).await? } CrewCommand::Host(args) => host(api, *args, tokio::signal::ctrl_c()).await?, + CrewCommand::Open(args) => open(api, *args, tokio::signal::ctrl_c()).await?, CrewCommand::Connections(command) => connections(api, command).await?, - CrewCommand::Auth => { + CrewCommand::Auth(AuthArgs { setup_job: None }) => { let id = api.connection_id().await?; api.show(api.client.shared()?.authenticate_ssh(&id).await?) } + CrewCommand::Auth(AuthArgs { + setup_job: Some(job), + }) => sign_in_for_setup_job(api, &job).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::Rejoin => rejoin(api).await?, CrewCommand::Workspace(command) => workspace(api, command).await?, CrewCommand::Enroll(command) => enrollment(api, command).await?, CrewCommand::Members(args) => members(api, args).await?, @@ -1211,9 +1881,30 @@ struct Api { /// 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>, + /// Where a streamed command's text lines go ([`Self::print`]). + printed: Printed, +} + +/// Where a streamed command's text lines go: standard output, or in tests a list the test reads. +enum Printed { + Terminal, + #[cfg(test)] + Kept(std::sync::Mutex>), } impl Api { + /// Print text lines of a streamed command as it goes ([`Printed`]). + fn print(&self, lines: &[String]) -> Result<()> { + match &self.printed { + Printed::Terminal => print_lines(lines), + #[cfg(test)] + Printed::Kept(kept) => { + kept.lock().expect("printed lines").extend_from_slice(lines); + Ok(()) + } + } + } + fn with_expected_mode(&self, body: Value) -> Value { add_expected_mode(body, self.expected_mode) } @@ -1271,7 +1962,7 @@ impl Api { /// Print one step of a streamed command: sentences in text, the value as a JSON line. fn stream(&self, value: &Value, lines: &[String]) -> Result<()> { match self.format { - OutputFormat::Text => print_lines(lines), + OutputFormat::Text => self.print(lines), OutputFormat::Json | OutputFormat::StreamJson => { emit(value, output::stream_format(self.format)) } @@ -1326,17 +2017,7 @@ impl Api { } // 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); + let snapshot = self.quick_snapshot(&id).await; if stopped_saving { let standing = snapshot.as_ref().map_or(HostStanding::Unknown, |snapshot| { host_standing(snapshot, None) @@ -1354,6 +2035,62 @@ impl Api { } } + /// The person's snapshot of the workspace connection `id` reaches, or `None` when it does + /// not answer within [`LISTING_WAIT`] or refuses. + async fn quick_snapshot(&self, id: &str) -> Option { + 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()? + .ok() + } + + /// The workspace `connection` reaches, named for a sentence, when the person's account hosts + /// it: as `listing` (a setup check job's list of the server's workspaces, or `Value::Null`) + /// says when that job checked this connection's own login, else as the person's own snapshot + /// says, which only a connected connection is asked for. `None` when the account does not + /// host it, or nothing can say so; the full-access warning then leaves the host's sentence + /// out, as the desktop's does without a snapshot. + /// + /// A listing names the place of the account it checked, and `open` may go on with a saved + /// connection of another login on the same server (the daemon prefers a connected one), so + /// its role is that connection's only when the logins agree. + async fn hosted_workspace(&self, connection: &Value, listing: &Value) -> Option { + let name = name_text(&connection_name(connection)); + let workspace = connection["workspace_id"].as_str(); + let listed = output::listed_role(listing, workspace).filter(|_| { + let login = |value: &Value| { + value + .as_str() + .map(str::trim) + .filter(|login| !login.is_empty()) + .map(str::to_owned) + }; + let checked = login(&listing["server"]["login"]); + checked.is_some() && checked == login(&connection["ssh_target"]) + }); + if listed.is_some() { + return (listed.as_deref() == Some("host")).then_some(name); + } + let id = connection["id"] + .as_str() + .filter(|_| connection["status"].as_str() == Some("connected")) + .and_then(|id| component(id).ok())?; + let snapshot = self.quick_snapshot(id).await?; + match host_standing(&snapshot, None) { + HostStanding::NotHost | HostStanding::Unknown => None, + HostStanding::HostElsewhereToo { .. } | HostStanding::OnlyHostComputer { .. } => { + Some(name) + } + } + } + /// 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 @@ -1369,7 +2106,8 @@ impl Api { .ok()?; match status["status"].as_str()? { "joined" => Some(true), - "invited" | "approved" | "code_mismatch" | "not_invited" | "expired" => Some(false), + "invited" | "approved" | "code_mismatch" | "not_invited" | "expired" | "ready" + | "removed" => Some(false), _ => None, } } @@ -1379,7 +2117,8 @@ impl Api { let connections = response["connections"] .as_array() .context("Daemon returned an invalid connection list")?; - if let Some(id) = &self.selected { + // A command that saved a connection (`host`, `open`) works with it from then on. + if let Some(id) = self.selected.as_ref().or(self.connection_id.get()) { return connections .iter() .find(|item| item["id"].as_str() == Some(id.as_str())) @@ -2013,6 +2752,25 @@ fn shell_word(text: &str) -> String { } } +/// The route flags a suggested command repeats after `--server LOGIN` (finding 39): the port, +/// key file and jump route of a setup `target`, as it was sent, each escaped for the shell and +/// the terminal, so the command reaches the server the way the person's did. Empty when the +/// person gave none. +fn route_flags(target: &Value) -> String { + let word = |text: &str| safe_text(&shell_word(text)); + let mut flags = String::new(); + if let Some(port) = target["port"].as_u64() { + flags.push_str(&format!(" --port {port}")); + } + if let Some(identity) = target["identity_file"].as_str() { + flags.push_str(&format!(" --identity-file {}", word(identity))); + } + if let Some(jump) = target["proxy_jump"].as_str() { + flags.push_str(&format!(" --proxy-jump {}", word(jump))); + } + flags +} + /// "in 23 hours", for an invitation's expiry. fn time_left(seconds: i64) -> String { let minutes = (seconds + 59) / 60; @@ -2305,6 +3063,24 @@ fn host_removal_notice( }) } +/// The host-controls refusal of a connection whose server runs an older Crew (AO-9), with the +/// way to keep the host controls without another computer: updating Crew on the server makes +/// them follow the account. Any other error is returned as it is. +fn update_crew_first(error: anyhow::Error, connection: &Value) -> anyhow::Error { + const CODE: &str = "crew_host_controls_would_end"; + if error_code(&error).as_deref() != Some(CODE) { + return error; + } + let login = connection["ssh_target"].as_str().unwrap_or_default(); + restated( + format!( + "{error}\n{}", + output::update_crew_for_host_controls(&server_label(connection), login) + ), + Some(CODE), + ) +} + /// What `connections remove` knows before it asks: the saved connection, whether its host /// deleted the workspace, and the workspace snapshot when it can be read. A workspace its host /// deleted has no host controls left to lose, and its server answers nothing but that it was @@ -2355,10 +3131,10 @@ async fn remove_connection( Some(snapshot) => host_standing(snapshot, own_device), None => HostStanding::Unknown, }; - let workspace = snapshot - .as_ref() - .and_then(workspace_name_in) - .map_or_else(|| shown.clone(), |workspace| name_text(&workspace)); + let workspace_name = snapshot.as_ref().and_then(workspace_name_in); + let workspace = workspace_name + .as_deref() + .map_or_else(|| shown.clone(), name_text); let mut lines = vec![if deleted { format!( "Remove {shown} from this computer? The host deleted it on the server, so this removes what is left of it here, including this computer's device key for it." @@ -2368,13 +3144,34 @@ async fn remove_connection( "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 !deleted { + // AO-11: a chat granted through this connection cannot be granted through a new one. + lines.push(output::REMOVAL_KEEPS_CHATS_HERE.to_owned()); + } + // AO-9: on a server whose Crew knows the account, the account keeps the workspace, the host + // controls included, so nothing here is refused; on an older one the guard stays. + let account = connection["account_identity"].as_bool() == Some(true); + let notice = if deleted { + None + } else if account { + Some(output::account_removal_notice( + !matches!(standing, HostStanding::NotHost | HostStanding::Unknown), + &workspace, + workspace_name.as_deref().unwrap_or(&name), + &server_label(&connection), + connection["ssh_target"].as_str().unwrap_or_default(), + )) + } else { + host_removal_notice( + &standing, + &workspace, + own_device.is_some(), + give_up_host_controls, + &HumanOptions::new(api.show_ids), + ) + .map_err(|error| update_crew_first(error, &connection))? + }; + if let Some(notice) = notice { if confirm.is_some() || !api.interactive { eprintln!("{notice}"); } @@ -2542,6 +3339,8 @@ async fn set_access( }) }; let mut input = saved_fields(&connection); + // What full access means, said with the result when --yes skipped the question. + let mut warned = None; let line = match access { AgentAccess::Off => { ensure!( @@ -2588,14 +3387,7 @@ async fn set_access( )); } let root = folder_or_existing()?; - if let Consent::Ask(question) = yes_or_ask( - yes, - api.interactive, - format!("Let agents work as you on {server}? Through {shown}, an agent can run any command your account can, read and change any file it can reach, use the network, and start jobs that keep running after its task ends. Public models never get this access."), - format!("Full access lets agents run anything as you on {server}. There is no terminal to ask in, so add --yes."), - )? { - ask_yes(question, "Nothing was changed.").await?; - } + warned = confirm_full_access(api, &connection, yes).await?; let line = format!( "Agents can work as you on {server}, starting in {}.", safe_text(&root) @@ -2611,6 +3403,7 @@ async fn set_access( .request("PATCH", &api.path("").await?, Some(Value::Object(input))) .await?; let mut lines = vec![line]; + lines.extend(warned); if saved["policy_epoch"] != connection["policy_epoch"] && !saved["policy_epoch"].is_null() { lines.push(format!( "This ended every chat's and task's access through {shown}; grant it again where you need it." @@ -2619,6 +3412,44 @@ async fn set_access( Ok(api.say(saved, lines)) } +/// `set-access full`'s one question (OW-6), with what full access means +/// ([`full_access_warning`], the host's authority included on the host's account), or, with +/// `--yes`, that warning to say with the result, which this answers. Nothing is sent before +/// the terminal refusal; the account is looked up only once there is a question or a text +/// result to say it in. +async fn confirm_full_access(api: &Api, connection: &Value, yes: bool) -> Result> { + let server = server_label(connection); + let consent = yes_or_ask( + yes, + api.interactive, + String::new(), + format!("Full access lets agents run anything as you on {server}. There is no terminal to ask in, so add --yes."), + )?; + let asks = matches!(consent, Consent::Ask(_)); + let hosted = if asks || api.text() { + api.hosted_workspace(connection, &Value::Null).await + } else { + None + }; + let warning = full_access_warning(hosted.as_deref()); + if !asks { + return Ok(Some(warning)); + } + let shown = name_text(&connection_name(connection)); + ask_yes( + full_access_question(&server, &shown, &warning), + "Nothing was changed.", + ) + .await?; + Ok(None) +} + +/// The question `set-access full` asks on a terminal: what an agent can do through `shown` on +/// `server`, then `warning` ([`full_access_warning`]). +fn full_access_question(server: &str, shown: &str, warning: &str) -> String { + format!("Let agents work as you on {server}? Through {shown}, an agent can run any command your account can, read and change any file it can reach, use the network, and start jobs that keep running after its task ends. {warning} Public models never get this access.") +} + async fn prepare(api: &Api) -> Result { api.client .request("POST", "/crew/devices/prepare", Some(json!({}))) @@ -3145,6 +3976,24 @@ async fn join_until( api: &Api, args: JoinArgs, interrupt: impl std::future::Future>, +) -> Result { + join_as(api, args, None, interrupt).await +} + +/// [`join_until`], saying when this computer joined which `role` the person holds there, when +/// the caller knows it (`open`, from the server's list of workspaces: `host`, `member` or +/// `invited`). +/// +/// On a server whose Crew knows the account (AO-9), `ready` joins as the account, as +/// `approved` claims an approved code, and `removed` ends the command with the sentence for it. +/// On one whose Crew predates the account path, a state nobody can move on from for this +/// person ([`older_crew`]) ends the command with `crew_account_unsupported`, in AO-9's words +/// for their role, rather than waiting for an invitation that never comes. +async fn join_as( + api: &Api, + args: JoinArgs, + role: Option<&str>, + interrupt: impl std::future::Future>, ) -> Result { tokio::pin!(interrupt); let stopped = || { @@ -3157,6 +4006,8 @@ async fn join_until( let mut rechecking = false; // Whether this join already set Crew up on the server (L5): it is tried once. let mut set_up = false; + // Whether the server's Crew was already asked about the account (12, 15): once. + let mut asked_account = false; loop { let status = tokio::select! { biased; @@ -3175,9 +4026,17 @@ async fn join_until( 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())); + if !asked_account && asks_about_the_account(&state, role) { + asked_account = true; + if let Some(refused) = older_crew(api, role).await { + return Err(refused); + } + } match state.as_str() { - "joined" => return say_joined(api, &status).await, - "approved" => { + "joined" => return say_joined(api, &status, role).await, + // AO-9: `ready` is the account path, `approved` an older server's approved code; + // either is claimed with the same request. + "approved" | "ready" => { // 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 @@ -3186,10 +4045,13 @@ async fn join_until( let claimed = tokio::select! { biased; signal = &mut interrupt => { signal?; return stopped(); } - claimed = claim(api, &path) => claimed?, + claimed = claim(api, &path) => match claimed { + Ok(claimed) => claimed, + Err(error) => return Err(host_rename_conflict(api, error).await), + }, }; if let Some(joined) = claimed { - return say_joined(api, &joined).await; + return say_joined(api, &joined, role).await; } rechecking = true; continue; @@ -3203,21 +4065,16 @@ async fn join_until( api.stream(&status, &join_lines(&status, !args.no_wait))?; } } - "expired" => { - let who = status - .get("inviter") - .filter(|inviter| inviter.is_object()) - .map_or_else( - || "the host".to_owned(), - |inviter| { - person_text( - inviter["username"].as_str().unwrap_or_default(), - inviter["display_name"].as_str(), - ) - }, - ); - bail!("This invitation expired. Ask {who} to invite you again."); - } + "expired" => bail!( + "This invitation expired. Ask {} to invite you again.", + inviter_text(&status) + ), + "removed" => { + return Err(restated( + output::removed_sentence(status["workspace_name"].as_str()), + Some(JOIN_REMOVED), + )) + } "unsupported" => bail!( "This workspace's server can't let people join with a code yet. Ask the host for an enrollment token instead." ), @@ -3250,6 +4107,57 @@ fn join_needs_setup(error: &anyhow::Error, interactive: bool) -> bool { } } +/// Who invited the person, as a join status names them, for a sentence: "the host" when it +/// does not. +fn inviter_text(status: &Value) -> String { + status + .get("inviter") + .filter(|inviter| inviter.is_object()) + .map_or_else( + || "the host".to_owned(), + |inviter| { + person_text( + inviter["username"].as_str().unwrap_or_default(), + inviter["display_name"].as_str(), + ) + }, + ) +} + +/// Whether a join in `state` asks the server's Crew what it knows about the account +/// ([`older_crew`]) before it says anything (findings 12 and 15). A server without joining by +/// name (`unsupported`) may still be one the account hosts. And when `open` found the account +/// already hosting or belonging to the workspace (`role`), an older Crew that says +/// `not_invited` leaves nobody who could invite it: the host cannot invite themselves, and the +/// contract answers a member's open with `crew_account_unsupported` too. An invited person +/// joins by code as before. +fn asks_about_the_account(state: &str, role: Option<&str>) -> bool { + match state { + "unsupported" => true, + "not_invited" => matches!(role, Some("host" | "member")), + _ => false, + } +} + +/// `crew_account_unsupported` from `GET …/account`, worded by role by the daemon (AO-9), when +/// the server's Crew predates the account path and the refusal applies to this person: always +/// for a role `open` read from the server's list (`host` or `member`), and otherwise only when +/// the daemon says this account hosts the workspace (`is_host`), since a new person can still +/// join an older Crew with an enrollment token. `None` for anything else, including a daemon +/// without the route: the join then goes on as it did. +async fn older_crew(api: &Api, role: Option<&str>) -> Option { + let path = api.path("/account").await.ok()?; + let error = api.client.request("GET", &path, None).await.err()?; + if error_code(&error).as_deref() != Some(ACCOUNT_UNSUPPORTED) { + return None; + } + let host = error + .chain() + .find_map(|cause| refusal_fields(cause)?.get("is_host")?.as_bool()) + == Some(true); + (host || matches!(role, Some("host" | "member"))).then_some(error) +} + /// The member setup `crew join` runs itself ([`join_needs_setup`]), after saying why. A /// connection never set up from this computer ([`never_set_up`]) gets the access `crew setup` /// gives it, since nobody has chosen yet (L8); one set up here before keeps what agents may do @@ -3270,7 +4178,14 @@ async fn set_up_for_join( let server = server_label(&connection); print_lines(&[join_setup_line(error, &server, changes)])?; } - member_setup(api, false, access, interrupt).await?; + member_setup( + api, + &SetupChoices::default(), + access, + &Value::Null, + interrupt, + ) + .await?; Ok(()) } @@ -3301,14 +4216,22 @@ fn join_setup_line(error: &anyhow::Error, server: &str, changes: bool) -> String /// Say that this computer joined, adding, in text, what to run when agents still can't work on /// the server through the connection ([`no_server_access_line`], L8). The JSON value is the /// join status, as before. -async fn say_joined(api: &Api, joined: &Value) -> Result { +async fn say_joined(api: &Api, joined: &Value, role: Option<&str>) -> Result { let mut lines = joined_lines(joined); + let mut value = joined.clone(); + // An invited person who joined is a member. + if let Some(role) = role.map(|role| if role == "host" { "host" } else { "member" }) { + if let Some(first) = lines.first_mut() { + *first = output::joined_as_line(joined["workspace_name"].as_str(), role); + } + value["role"] = json!(role); + } if api.text() { if let Ok(connection) = api.connection().await { lines.extend(no_server_access_line(&connection)); } } - api.stream(joined, &lines)?; + api.stream(&value, &lines)?; Ok(Reply::Streamed) } @@ -3410,10 +4333,13 @@ fn join_lines(status: &Value, waiting: bool) -> Vec { "You're not in {workspace} yet. Ask {} to invite your account on the server.", person.as_deref().unwrap_or("the host") )], - Some("approved") if waiting => vec![format!("Joining {workspace}…")], + Some("approved" | "ready") if waiting => vec![format!("Joining {workspace}…")], Some("approved") => vec![format!( "{first} saved a code for you, but this computer hasn't joined {workspace} yet. Run biorouter crew join to finish." )], + Some("ready") => vec![format!( + "This computer can join {workspace} as your account on the server. Run biorouter crew join to join." + )], _ => Vec::new(), }; if waiting && matches!(status["status"].as_str(), Some("invited" | "code_mismatch")) { @@ -3447,14 +4373,45 @@ fn pending_target( port: Option, identity_file: Option<&str>, proxy_jump: Option<&str>, +) -> Result { + let mut target = routed_target(server, port, identity_file, proxy_jump)?; + target["kind"] = json!("pending"); + target["preparation_id"] = json!(preparation_id); + Ok(target) +} + +/// A setup for a server and the account on it with nothing saved for it (AO-8): `{kind: +/// "server", …}`, which makes no device key. Every job on the same login and route shares one +/// sign-in; `preparation_id` (`open`) makes the job use that preparation's instead, so the +/// connection saved from it needs no second one. +fn server_target( + server: &str, + port: Option, + identity_file: Option<&str>, + proxy_jump: Option<&str>, + preparation_id: Option<&str>, +) -> Result { + let mut target = routed_target(server, port, identity_file, proxy_jump)?; + target["kind"] = json!("server"); + if let Some(preparation_id) = preparation_id { + target["preparation_id"] = json!(preparation_id); + } + Ok(target) +} + +/// A setup target's login and route, as the person typed them, the key file made absolute. +fn routed_target( + server: &str, + port: Option, + identity_file: Option<&str>, + proxy_jump: Option<&str>, ) -> Result { let server = server.trim(); ensure!( !server.is_empty() && !server.contains(char::is_whitespace), "Name the server's SSH login, like --server you@hpc.example.edu." ); - let mut target = - json!({"kind": "pending", "preparation_id": preparation_id, "ssh_target": server}); + let mut target = json!({"ssh_target": server}); if let Some(port) = port { target["port"] = json!(port); } @@ -3494,10 +4451,32 @@ async fn setup( let SetupArgs { target, replace_existing, + keep_older, + restart, + restart_workspace, + no_restart, + start, check, agent_access, } = args; - let Some(server) = target.server else { + tokio::pin!(interrupt); + let mut choices = SetupChoices { + replace_existing, + keep_older, + // `--restart` still means every workspace; `--restart-workspace` names some. + restart_workspaces: match (restart, no_restart) { + (true, _) => Some(true), + (_, true) => Some(false), + _ => None, + }, + start_workspaces: Vec::new(), + restart_workspace_ids: Vec::new(), + }; + let named = Named { + start, + restart: restart_workspace, + }; + let Some(server) = target.server.clone() else { let listed = api.connections().await?; ensure!( listed["connections"] @@ -3505,16 +4484,18 @@ async fn setup( .is_some_and(|connections| !connections.is_empty()), "Name the server to set up: biorouter crew setup --server you@hpc.example.edu" ); + let id = api.connection_id().await?; + let connection_target = json!({"kind": "connection", "connection_id": id}); if check { let connection = api.connection().await?; - let id = api.connection_id().await?; let login = connection["ssh_target"] .as_str() .unwrap_or_default() .to_owned(); - let request = - json!({"target": {"kind": "connection", "connection_id": id}, "intent": "check"}); - let (status, mut lines) = run_setup(api, request, &login, interrupt).await?; + let request = json!({"target": connection_target, "intent": "check"}); + let asked = output::SetupAsked::of(&request, route_flags(&connection)); + let (status, mut lines) = + run_setup(api, request, &login, &Value::Null, &mut interrupt).await?; // The bridge's last report says what the SSH check cannot: whether the kernel // confines commands to a folder. let connection = api.connection().await.ok(); @@ -3522,38 +4503,189 @@ async fn setup( &status, &login, connection.as_ref(), + &asked, )); api.stream(&status, &lines)?; return Ok(Reply::Streamed); } + let mut listing = Value::Null; + if !named.is_empty() { + let connection = api.connection().await?; + let login = connection["ssh_target"].as_str().unwrap_or_default(); + let route = route_flags(&connection); + listing = named + .match_ids( + api, + &connection_target, + login, + &route, + &mut choices, + &mut interrupt, + ) + .await?; + } let access = MemberAccess::Asked(agent_access); - member_setup(api, replace_existing, access, interrupt).await?; + member_setup(api, &choices, access, &listing, &mut interrupt).await?; return Ok(Reply::Streamed); }; ensure!( api.selected.is_none(), "Name the server with --server or a saved connection with --connection, not both." ); - let preparation = preparation_id(api).await?; - let setup_target = pending_target( - &preparation, - &server, + setup_server(api, &server, &target, check, choices, &named, interrupt).await +} + +/// `setup --server LOGIN` and `doctor --server LOGIN` (AO-8): a server with nothing saved for +/// it is checked and set up without a device key, and every job on its login shares one +/// sign-in. A name given with `--start` or `--restart-workspace` is matched to its workspace ID +/// first. A daemon from before the server target ([`OlderSetupDaemon`]) gets the pending +/// target it always took, when nothing newer was asked of it. +async fn setup_server( + api: &Api, + server: &str, + target: &ServerArgs, + check: bool, + mut choices: SetupChoices, + named: &Named, + interrupt: impl std::future::Future>, +) -> Result { + tokio::pin!(interrupt); + let route = ( target.port, target.identity_file.as_deref(), target.proxy_jump.as_deref(), - )?; - let intent = if check { "check" } else { "install" }; + ); + let setup_target = server_target(server, route.0, route.1, route.2, None)?; let login = server.trim().to_owned(); - let mut request = json!({"target": setup_target, "intent": intent}); - if replace_existing { - request["replace_existing"] = json!(true); + let mut listing = Value::Null; + if !named.is_empty() { + let flags = route_flags(&setup_target); + listing = named + .match_ids( + api, + &setup_target, + &login, + &flags, + &mut choices, + &mut interrupt, + ) + .await?; } - let (status, mut lines) = run_setup(api, request, &login, interrupt).await?; - lines.extend(output::setup_summary_lines(&status, &login, None)); + let intent = if check { "check" } else { "install" }; + let mut request = json!({"target": setup_target, "intent": intent}); + choices.apply(&mut request); + // The older daemon's request below asks the same of the workspaces, on the same route. + let asked = output::SetupAsked::of(&request, route_flags(&setup_target)); + let (status, mut lines) = match run_setup(api, request, &login, &listing, &mut interrupt).await + { + Err(error) if is_older_setup_daemon(&error) && !choices.newer() => { + // Finding 36: the daemon predates the server target; the request it always read. + let preparation = preparation_id(api).await?; + let mut request = json!({ + "target": pending_target(&preparation, server, route.0, route.1, route.2)?, + "intent": intent, + }); + choices.apply(&mut request); + run_setup(api, request, &login, &listing, &mut interrupt).await? + } + answer => answer?, + }; + lines.extend(output::setup_summary_lines(&status, &login, None, &asked)); api.stream(&status, &lines)?; Ok(Reply::Streamed) } +/// What a setup asks of Crew in the account besides installing it (AO-8): whether to replace a +/// program that doesn't say its version, keep an older one, restart the workspaces the account +/// hosts on the new one (all of them, or those named by ID), and which stopped ones to start. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct SetupChoices { + replace_existing: bool, + keep_older: bool, + restart_workspaces: Option, + start_workspaces: Vec, + restart_workspace_ids: Vec, +} + +impl SetupChoices { + /// The choices made, on `request`; one left as it is sends nothing. A daemon from before + /// the account path reads `replace_existing` and nothing else here ([`Self::newer`]). + fn apply(&self, request: &mut Value) { + if self.replace_existing { + request["replace_existing"] = json!(true); + } + if self.keep_older { + request["keep_older"] = json!(true); + } + if let Some(restart) = self.restart_workspaces { + request["restart_workspaces"] = json!(restart); + } + if !self.start_workspaces.is_empty() { + request["start_workspaces"] = json!(self.start_workspaces); + } + if !self.restart_workspace_ids.is_empty() { + request["restart_workspace_ids"] = json!(self.restart_workspace_ids); + } + } + + /// Whether any choice was made that a daemon from before the account path (AO-8) does not + /// read: everything but `replace_existing`. + fn newer(&self) -> bool { + self.keep_older + || self.restart_workspaces.is_some() + || !self.start_workspaces.is_empty() + || !self.restart_workspace_ids.is_empty() + } +} + +/// The workspaces `setup` was given by name (AO-8): stopped ones to start (`--start`) and +/// running ones to restart (`--restart-workspace`). +struct Named { + start: Vec, + restart: Vec, +} + +impl Named { + fn is_empty(&self) -> bool { + self.start.is_empty() && self.restart.is_empty() + } + + /// One check job on `target` lists the workspaces on the server, and each name is matched + /// to the one workspace the account hosts under it that can be started or restarted, into + /// `choices`. A name it hosts none of, or more than one of, is refused before anything is + /// changed. `route` is the route flags given, which a suggested command repeats. Answers the + /// check job's status, whose stages the setup that follows does not say again. + async fn match_ids( + &self, + api: &Api, + target: &Value, + login: &str, + route: &str, + choices: &mut SetupChoices, + interrupt: impl std::future::Future>, + ) -> Result { + let request = json!({"target": target, "intent": "check"}); + let (status, lines) = run_setup(api, request, login, &Value::Null, interrupt).await?; + // The listing is not the end of the command: the setup that follows says Ready. + api.stream(&status, &output::before_the_setup(lines))?; + let server = output::setup_server(&status, login); + let refused = |sentence| restated(sentence, None); + choices.start_workspaces = self + .start + .iter() + .map(|name| output::workspace_to_start(&status, name, &server)) + .collect::, _>>() + .map_err(refused)?; + choices.restart_workspace_ids = self + .restart + .iter() + .map(|name| output::workspace_to_restart(&status, name, &server, route)) + .collect::, _>>() + .map_err(refused)?; + Ok(status) + } +} + /// Which agent access a member's setup asks for (`SetupRequest.agent_access`, OW-6). #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum MemberAccess { @@ -3585,37 +4717,75 @@ fn member_agent_access(access: MemberAccess, connection: &Value) -> Option) -> String { + match hosted { + Some(workspace) => format!( + "{FULL_ACCESS_IS_YOU} You host {workspace} from this account, so your agent can also read the workspace's own files on the server, including channels you're not in. It can also act as the host of {workspace}." + ), + None => FULL_ACCESS_IS_YOU.to_owned(), + } +} + +/// The one warning a member's setup gives when it sets full access (OW-6), with how to choose +/// otherwise where the person did not name the level: `crew setup` takes `--agent-access`, and +/// `crew join` does not, so it names `set-access`. `hosted` is the workspace when the account +/// hosts it ([`full_access_warning`]). `None` when full access is not what the setup sets. fn full_access_notice( access: MemberAccess, level: Option, connection: &Value, + hosted: Option<&str>, ) -> Option { + if level != Some(AgentAccess::Full) { + return None; + } let otherwise = match access { MemberAccess::Asked(None) => { - "To choose otherwise, add --agent-access folder or --agent-access off.".to_owned() + " To choose otherwise, add --agent-access folder or --agent-access off.".to_owned() } MemberAccess::JoinDefault => format!( - "To choose otherwise afterwards, run biorouter crew --connection {} connections set-access folder, or set-access off.", + " To choose otherwise afterwards, run biorouter crew --connection {} connections set-access folder, or set-access off.", safe_text(&shell_word(&connection_name(connection))) ), - MemberAccess::Asked(Some(_)) | MemberAccess::Unchanged => return None, + // Named with --agent-access full: what it means is said, with nothing to choose. + MemberAccess::Asked(Some(_)) => String::new(), + MemberAccess::Unchanged => return None, }; let server = server_label(connection); - (level == Some(AgentAccess::Full)).then(|| format!( - "Setup lets your agent work as you on {server}: it can run any command your account can there, starting in a work folder it makes in your home folder. Public models never get this access. {otherwise}" + // The folder the connection already has stays its folder; setup makes one only without. + let start = connection["remote_root"] + .as_str() + .map(str::trim) + .filter(|root| !root.is_empty()) + .map_or_else( + || "a work folder it makes in your home folder".to_owned(), + safe_text, + ); + let warning = full_access_warning(hosted); + Some(format!( + "Setup lets your agent work as you on {server}: it can run any command your account can there, starting in {start}. {warning} Public models never get this access.{otherwise}" )) } /// A member's setup of the selected saved connection (OW-9): install or update Crew in the /// person's own account, connect, and, when asked, set what agents may do there. Its progress -/// and what it did are printed as it goes; the finished job's status is answered. +/// and what it did are printed as it goes; the finished job's status is answered. `listing` is +/// the check job this command already ran on the server (`Value::Null` when none): its stages +/// are not said again, and its list says whether the account hosts the workspace. async fn member_setup( api: &Api, - replace_existing: bool, + choices: &SetupChoices, access: MemberAccess, + listing: &Value, interrupt: impl std::future::Future>, ) -> Result { let connection = api.connection().await?; @@ -3626,25 +4796,28 @@ async fn member_setup( .to_owned(); let mut request = json!({"target": {"kind": "connection", "connection_id": id}, "intent": "member"}); - if replace_existing { - request["replace_existing"] = json!(true); - } + choices.apply(&mut request); let level = member_agent_access(access, &connection); if let Some(level) = level { request["agent_access"] = json!(level.as_str()); } - // OW-6: full access by default is said once, before anything is done, with how to choose - // otherwise. - if let Some(notice) = full_access_notice(access, level, &connection).filter(|_| api.text()) { - print_lines(&[notice])?; + // OW-6: full access is said once, before anything is done, with how to choose otherwise + // where the person did not choose it, and the host's authority on the host's account. + if api.text() && level == Some(AgentAccess::Full) { + let hosted = api.hosted_workspace(&connection, listing).await; + if let Some(notice) = full_access_notice(access, level, &connection, hosted.as_deref()) { + api.print(&[notice])?; + } } - let (status, mut lines) = run_setup(api, request, &login, interrupt).await?; + let asked = output::SetupAsked::of(&request, route_flags(&connection)); + let (status, mut lines) = run_setup(api, request, &login, listing, interrupt).await?; // What agents may do is what the connection says now, not what the job could have set. let connection = api.connection().await.ok(); lines.extend(output::setup_summary_lines( &status, &login, connection.as_ref(), + &asked, )); // Asked for full access where the server or its Crew can't give it: the job gave the // work folder instead (OW-6), and that is said. @@ -3664,19 +4837,84 @@ async fn member_setup( /// starts, signing in on a terminal when the server asks (`needs_sign_in`). Answers the /// finished status and the lines its last change adds, which the caller prints with its own. /// Ctrl-C stops the job. A job that failed is its typed error, said as the daemon's refusals -/// are ([`DaemonRefusal::reported`]). +/// are ([`DaemonRefusal::reported`]). `said` is an earlier job of the same command, whose +/// stages were said already ([`follow_setup`]). async fn run_setup( api: &Api, request: Value, login: &str, + said: &Value, interrupt: impl std::future::Future>, ) -> Result<(Value, Vec)> { + let Followed { + status, + lines, + interrupted, + } = follow_setup(api, request, login, said, interrupt).await?; + match (status["state"].as_str(), interrupted) { + (Some("failed"), _) => { + if api.text() { + print_lines(&lines)?; + } + Err(setup_failure(&status)) + } + (Some("cancelled"), _) | (_, true) => { + if api.text() && !interrupted { + print_lines(&lines)?; + } + let server = output::setup_server(&status, login); + Err(setup_stopped(&server, interrupted, None)) + } + _ => Ok((status, lines)), + } +} + +/// What a setup that was stopped says (`interrupted`: by Ctrl-C here; else from another place, +/// such as the desktop or a chat): run the same command again, which finishes it. `code` is the +/// daemon's code for a stopped job, which `host` keeps so it can say what is left. +fn setup_stopped(server: &str, interrupted: bool, code: Option<&'static str>) -> anyhow::Error { + restated( + if interrupted { + format!("Stopped setting up {server}. Run the same command again to finish.") + } else { + format!("Setting up {server} was stopped before it finished. Run the same command again to finish.") + }, + code, + ) +} + +/// How a followed setup job ended ([`follow_setup`]): its last status (`finished`, `failed`, +/// `cancelled`, or, after Ctrl-C, the last one read before the job was stopped), the lines its +/// last change adds, and whether Ctrl-C stopped it here. +struct Followed { + status: Value, + lines: Vec, + interrupted: bool, +} + +/// [`run_setup`], answering a failed or stopped job's status as well as a finished one's, so a +/// caller can read what the job did before it stopped (`host`: the saved connection and the +/// steps left). Ctrl-C stops the job and answers at once with the last status read +/// ([`settle_setup`] waits for where it ended). +/// +/// Each stage is said once in the command: `said` is an earlier job of it (the listing `setup +/// --start` and `open` run first, or `Value::Null`), whose stages this job does not say again. +/// The restart stage names the workspaces the request asks to restart or start. +async fn follow_setup( + api: &Api, + request: Value, + login: &str, + said: &Value, + interrupt: impl std::future::Future>, +) -> Result { tokio::pin!(interrupt); + let newer = uses_newer_setup(&request); + let named = output::named_in_setup(&request); let mut status = api .client .request("POST", "/crew/setup", Some(request)) .await - .map_err(|error| older_daemon(error, RESTART_FOR_SETUP))?; + .map_err(|error| older_setup_daemon(error, newer))?; let job = status["job_id"] .as_str() .and_then(|id| component(id).ok()) @@ -3688,25 +4926,17 @@ async fn run_setup( let mut sign_ins = 0; let mut signed_in_at = None; loop { - let lines = output::setup_progress_lines(&shown, &status, login); - match status["state"].as_str() { - Some("finished") => return Ok((status, lines)), - Some("failed") => { - if api.text() { - print_lines(&lines)?; - } - return Err(setup_failure(&status)); - } - Some("cancelled") => { - if api.text() { - print_lines(&lines)?; - } - return Err(restated( - format!("Setting up {server} was stopped before it finished. Run the same command again to finish."), - None, - )); - } - _ => {} + let previous = output::with_said_stages(&shown, said); + let lines = output::setup_progress_lines(&previous, &status, login, &named); + if matches!( + status["state"].as_str(), + Some("finished" | "failed" | "cancelled") + ) { + return Ok(Followed { + status, + lines, + interrupted: false, + }); } if status != shown { api.stream(&status, &lines)?; @@ -3725,10 +4955,7 @@ async fn run_setup( signal = &mut interrupt => { signal?; cancel_setup(api, &path).await; - return Err(restated( - format!("Stopped setting up {server}. Run the same command again to finish."), - None, - )); + return Ok(Followed { status, lines: Vec::new(), interrupted: true }); } () = tokio::time::sleep(api.setup_poll) => {} } @@ -3737,6 +4964,97 @@ async fn run_setup( } } +/// How many times [`settle_setup`] looks at a stopped job before it answers with what it has: +/// a minute at [`SETUP_POLL`]. A Stop is heeded before the job's next change, so a stage under +/// way still finishes. +const SETUP_SETTLE_LOOKS: usize = 120; + +/// The status of the setup job `status` names once it ended after Ctrl-C stopped it (finding +/// 13): what it did before the Stop took effect (the workspace it started, the connection it +/// saved) is only known then. The last status read when it does not end in time, or cannot be +/// read. +async fn settle_setup(api: &Api, mut status: Value) -> Value { + let Some(job) = status["job_id"] + .as_str() + .and_then(|id| component(id).ok()) + .map(str::to_owned) + else { + return status; + }; + let path = format!("/crew/setup/{job}"); + for _ in 0..SETUP_SETTLE_LOOKS { + if matches!( + status["state"].as_str(), + Some("finished" | "failed" | "cancelled") + ) { + break; + } + tokio::time::sleep(api.setup_poll).await; + match api.client.request("GET", &path, None).await { + Ok(now) => status = now, + Err(_) => break, + } + } + status +} + +/// What a daemon from before the account path answers a setup request it cannot read +/// (`crew_request_invalid`, finding 36): its fixed sentence for a body that is not the route's. +const SETUP_REQUEST_SHAPE: &str = "Send the server to set up, what to do there, and nothing else."; + +/// Whether a setup `request` uses what a daemon from before the account path (AO-8, AO-9) +/// cannot read: the server target, or a choice it does not know. +fn uses_newer_setup(request: &Value) -> bool { + request["target"]["kind"] == "server" + || [ + "finish", + "mode", + "institution_id", + "keep_older", + "restart_workspaces", + "start_workspaces", + "restart_workspace_ids", + ] + .iter() + .any(|key| request.get(key).is_some()) +} + +/// A daemon from before the account path, refusing a setup request that uses what it does not +/// know (finding 36). It says [`RESTART_FOR_SETUP`]; `setup --server` and `doctor --server` +/// with nothing newer asked try the request that daemon always read instead. +#[derive(Debug)] +struct OlderSetupDaemon; + +impl std::fmt::Display for OlderSetupDaemon { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(RESTART_FOR_SETUP) + } +} + +impl std::error::Error for OlderSetupDaemon {} + +/// Whether `error` is [`OlderSetupDaemon`]. +fn is_older_setup_daemon(error: &anyhow::Error) -> bool { + error.chain().any(|cause| cause.is::()) +} + +/// `POST /crew/setup`'s refusal, with a daemon that predates the route or what the request +/// uses (`newer`) said as one: [`RESTART_FOR_SETUP`]. A daemon of this version refuses a +/// request it cannot read with the same sentence, but this CLI never sends it one. +fn older_setup_daemon(error: anyhow::Error, newer: bool) -> anyhow::Error { + let shape = error.chain().any(|cause| { + refusal_code_and_message(cause).is_some_and(|(code, message)| { + code == "crew_request_invalid" && message.trim() == SETUP_REQUEST_SHAPE + }) + }); + match refusal(&error) { + Some(Refusal { + status: 400 | 422, .. + }) if newer && shape => OlderSetupDaemon.into(), + _ => older_daemon(error, RESTART_FOR_SETUP), + } +} + /// A setup job's failure (`error`: `{code, message, detail, host}`), as the daemon's refusals /// are read, so a script gets its code and a person its sentence. What the server or the /// broker printed (`detail`, L3) is said under it whatever the code: the setup and connect @@ -3813,6 +5131,47 @@ async fn sign_in_for_setup( Ok(()) } +/// `auth --setup-job ID` (AO-12): the sign-in a setup job waits for, started somewhere else (a +/// chat, the desktop, another terminal). The job goes on by itself once it has it. +async fn sign_in_for_setup_job(api: &Api, job: &str) -> Result { + let job = component(job.trim()) + .context("--setup-job needs the setup's ID, as the chat or biorouter crew setup named it")? + .to_owned(); + let path = format!("/crew/setup/{job}"); + let status = api + .client + .request("GET", &path, None) + .await + .map_err(|error| older_daemon(error, RESTART_FOR_SETUP))?; + let server = output::setup_server(&status, ""); + if status["state"].as_str() != Some("needs_sign_in") { + return Err(restated( + output::setup_failure_sentence("crew_setup_not_waiting_for_sign_in", None), + Some("crew_setup_not_waiting_for_sign_in"), + )); + } + let signed_in = api + .client + .shared()? + .authenticate_ssh_at( + &format!("{path}/authentication"), + &format!( + "{server} asks for a password or a verification code, so signing in needs a terminal. Run the same command in a terminal." + ), + ) + .await; + match signed_in { + Ok(answer) => Ok(api.say(answer, vec![output::setup_signed_in_line(&server)])), + // A sign-in that found no Crew in the account keeps its SSH session, and installing + // Crew there is what the job does next. + Err(error) if error_code(&error).as_deref() == Some("crew_bridge_missing") => Ok(api.say( + json!({"authenticated": true}), + vec![output::setup_signed_in_line(&server)], + )), + Err(error) => Err(error), + } +} + /// Where `host` stopped, after the workspace was started and saved: each step's own command /// finishes it from there. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] @@ -3824,16 +5183,38 @@ enum HostStep { Context, } -/// A `host` that stopped at `step` ([`Unfinished`]): what is done, why it stopped, and the -/// commands that finish it, in order. +/// The steps a host's one setup job left to do (`outcome.steps_left`, AO-9), in its order, as +/// `host`'s own steps. A step this version does not know is left out. +fn host_steps_left(outcome: &Value) -> Vec { + outcome["steps_left"] + .as_array() + .into_iter() + .flatten() + .filter_map(|step| match step.as_str()? { + "connect" => Some(HostStep::Connect), + "join" => Some(HostStep::Bootstrap), + "privacy" => Some(HostStep::Privacy), + "governance" => Some(HostStep::Governance), + "context" => Some(HostStep::Context), + _ => None, + }) + .collect() +} + +/// A `host` that stopped at `steps[0]` with `steps` left ([`Unfinished`]): what is done, why it +/// stopped, and the commands that finish it, in order. `account` is whether the workspace's +/// server knows the account (AO-9), which joins it as its host with `join`. fn host_unfinished( error: anyhow::Error, - step: HostStep, + steps: &[HostStep], connection: &str, done: &str, - mode: PrivacyMode, - institution: Option<&str>, + hosting: &Hosting, + account: bool, ) -> anyhow::Error { + let Some(step) = steps.first() else { + return error; + }; // A server that wants a password signs in with `auth`, which connects too. let connect = if error_code(&error).as_deref() == Some("crew_ssh_auth_required") { "auth" @@ -3847,17 +5228,20 @@ fn host_unfinished( HostStep::Governance => "opening it to every member", HostStep::Context => "writing its context", }; - let mut lines = vec![format!( - "{done}, but {failed} failed: {}", - error_text(&error) - )]; + // Finding 13: a job stopped here or elsewhere did not fail; it stopped before that step. + let mut lines = vec![if error_code(&error).as_deref() == Some(SETUP_CANCELLED) { + format!("{done}, then setup was stopped before {failed}.") + } else { + format!("{done}, but {failed} failed: {}", error_text(&error)) + }]; lines.push("To finish, run these in order:".to_owned()); - lines.extend(host_finish_commands( - step, + lines.extend(host_step_commands( + steps, connection, connect, - mode, - institution, + account, + hosting.mode, + hosting.institution.as_deref(), )); Unfinished { sentence: lines.join("\n"), @@ -3875,6 +5259,31 @@ fn host_finish_commands( connect: &str, mode: PrivacyMode, institution: Option<&str>, +) -> Vec { + let steps: Vec = [ + HostStep::Connect, + HostStep::Bootstrap, + HostStep::Privacy, + HostStep::Governance, + HostStep::Context, + ] + .into_iter() + .filter(|at| *at >= step) + .collect(); + host_step_commands(&steps, connection, connect, false, mode, institution) +} + +/// The command for each of `steps`, in order, for the saved connection named `connection`. +/// Joining as the host is `workspace bootstrap` with the hosting key, or, on a server whose +/// Crew knows the account (`account`, AO-9), `join`, which also works once the account has a +/// device there. +fn host_step_commands( + steps: &[HostStep], + connection: &str, + connect: &str, + account: bool, + mode: PrivacyMode, + institution: Option<&str>, ) -> Vec { let run = |verb: &str| { format!( @@ -3890,20 +5299,16 @@ fn host_finish_commands( ), None => format!("privacy set-workspace {}", mode.as_str()), }; - let steps = [ - (HostStep::Connect, connect.to_owned()), - (HostStep::Bootstrap, "workspace bootstrap".to_owned()), - (HostStep::Privacy, privacy), - (HostStep::Governance, "workspace governance open".to_owned()), - ( - HostStep::Context, - "workspace context draft --save".to_owned(), - ), - ]; steps .iter() - .filter(|(at, _)| *at >= step) - .map(|(_, verb)| run(verb)) + .map(|step| match step { + HostStep::Connect => run(connect), + HostStep::Bootstrap if account => run("join"), + HostStep::Bootstrap => run("workspace bootstrap"), + HostStep::Privacy => run(&privacy), + HostStep::Governance => run("workspace governance open"), + HostStep::Context => run("workspace context draft --save"), + }) .collect() } @@ -3938,10 +5343,10 @@ fn host_save_command( command.push_str(&format!(" --institution {}", word(institution))); } if let Some(folder) = advanced["remote_root"].as_str() { - command.push_str(&format!( - " --remote-root {} --remote-execution", - word(folder) - )); + command.push_str(&format!(" --remote-root {}", word(folder))); + if advanced["remote_execution"].as_bool() == Some(true) { + command.push_str(" --remote-execution"); + } } command } @@ -4001,6 +5406,8 @@ struct Hosting { identity_file: Option, proxy_jump: Option, fix_permissions: bool, + /// What the new workspace's connection lets agents do on the server. + agent_access: AgentAccess, yes: bool, /// The workspace's name as a line shows it. shown: String, @@ -4024,6 +5431,7 @@ impl Hosting { identity_file, proxy_jump, fix_permissions, + agent_access, yes, } = args; let name = name.trim().to_owned(); @@ -4053,6 +5461,7 @@ impl Hosting { identity_file, proxy_jump, fix_permissions, + agent_access, yes, shown, login, @@ -4063,26 +5472,26 @@ impl Hosting { /// The one question `host` asks, before anything is set up: what hosting installs and /// what it lets agents do. `--yes` where there is no terminal. async fn confirm(&self, api: &Api) -> Result<()> { - let Self { - shown, - login, - privacy, - .. - } = self; + let Self { shown, login, .. } = self; + let installs = match self.agent_access { + AgentAccess::Full => format!( + "Hosting {shown} installs Crew in your account on {login} and lets your agent work as you there." + ), + AgentAccess::Folder => format!( + "Hosting {shown} installs Crew in your account on {login} and lets your agent work in a folder there." + ), + AgentAccess::Off => { + format!("Hosting {shown} installs Crew in your account on {login}.") + } + }; if let Consent::Ask(question) = yes_or_ask( self.yes, api.interactive, "Go ahead?".to_owned(), - format!("Hosting {shown} installs Crew in your account on {login} and lets your agent work as you there. There is no terminal to ask in, so add --yes."), + format!("{installs} There is no terminal to ask in, so add --yes."), )? { let mut stderr = std::io::stderr().lock(); - for line in [ - format!("Host {shown} on {login}? Biorouter will:"), - " install Crew in your account there (~/.local/bin/biorouter-crew),".to_owned(), - format!(" start {shown} and save it on this computer,"), - format!(" make it {privacy} and open: every member can manage teams, channels and people,"), - format!(" and let your agent work as you on {login}: it can run any command your account can and reach any file it can. Public models never get this access."), - ] { + for line in self.plan_lines() { writeln!(stderr, "{line}")?; } drop(stderr); @@ -4090,14 +5499,50 @@ impl Hosting { } Ok(()) } + + /// What `host` says it will do before it asks ([`Self::confirm`]). With full access, what + /// that means follows the list, the host's authority included, since the account hosts the + /// workspace ([`full_access_warning`]). + fn plan_lines(&self) -> Vec { + let Self { + shown, + login, + privacy, + .. + } = self; + let mut lines = vec![ + format!("Host {shown} on {login}? Biorouter will:"), + " install Crew in your account there (~/.local/bin/biorouter-crew),".to_owned(), + format!(" start {shown} and save it on this computer,"), + ]; + match self.agent_access { + AgentAccess::Full => { + lines.push(format!(" make it {privacy} and open: every member can manage teams, channels and people,")); + lines.push(format!(" and let your agent work as you on {login}: it can run any command your account can and reach any file it can.")); + lines.push(format!( + "{} Public models never get this access.", + full_access_warning(Some(shown)) + )); + } + AgentAccess::Folder => { + lines.push(format!(" make it {privacy} and open: every member can manage teams, channels and people,")); + lines.push(format!(" and let your agent work in a folder it makes in your home folder on {login}.")); + } + AgentAccess::Off => lines.push(format!( + " and make it {privacy} and open: every member can manage teams, channels and people." + )), + } + lines + } } -/// `host` (OW-9): a new workspace from start to finish. Setup installs Crew in the person's -/// account and starts the workspace; then it is saved here from what the start printed, with -/// the work folder the job made and full agent access (OW-6), connected over the same SSH -/// session, bootstrapped, given its privacy, opened to every member (OW-1) and given the -/// context the job drafted from the server (OW-8). Asked once first, since it installs a -/// program and gives agents the person's account; `--yes` where there is no terminal. +/// `host` (OW-9, AO-9): a new workspace from start to finish, in one setup job. The job installs +/// Crew in the person's account, starts the workspace, saves it here with this computer's +/// prepared device and the agent access asked for (OW-6), joins it as its host and, when the +/// start created it, gives it its privacy, opens it to every member (OW-1) and saves the context +/// it drafted from the server (OW-8). Asked once first, since it installs a program and can give +/// agents the person's account; `--yes` where there is no terminal. A job that stops after the +/// save names the commands that finish it, from what it says is left (`outcome.steps_left`). async fn host( api: &Api, args: HostArgs, @@ -4113,101 +5558,264 @@ async fn host( hosting.identity_file.as_deref(), hosting.proxy_jump.as_deref(), )?; - let (outcome, invitation) = run_host_setup(api, &hosting, &target, interrupt).await?; - - // Saved from what the start printed, as `connections join-invitation --preparation-id` - // saves it, with the work folder the job made and full access. - let folder = outcome["work_folder"] - .as_str() - .map(str::trim) - .filter(|folder| folder.starts_with('/')) - .map(str::to_owned); - let advanced = host_advanced(&hosting, &preparation, &target, folder.as_deref()); - let connection = save_hosted( - api, - &hosting, - &preparation, - &advanced, - &invitation, - folder.as_deref(), - ) - .await?; - let saved_as = connection_name(&connection); + let Followed { + status, + mut lines, + interrupted, + } = run_host_setup(api, &hosting, &target, interrupt).await?; + let outcome = status["outcome"].clone(); let done = format!( "Started {} on {} and saved it on this computer", hosting.shown, hosting.login ); - let (open, context_revision) = - finish_hosting(api, &hosting, &connection, &outcome, &saved_as, &done).await?; - - let lines = hosted_lines( - &hosting, - &connection, - folder.as_deref(), - &done, - open, - context_revision, - &saved_as, - ); + // Finding 13: a job stopped here or elsewhere is said like a failed one, from what it did + // before it stopped, so a start it made is finished rather than tried again. + let error = match status["state"].as_str() { + Some("failed") => Some(setup_failure(&status)), + Some("finished") => None, + _ => Some(setup_stopped( + &output::setup_server(&status, &hosting.server), + interrupted, + Some(SETUP_CANCELLED), + )), + }; + if let Some(error) = error { + if api.text() { + print_lines(&lines)?; + } + return Err( + host_stopped(api, &hosting, &preparation, &target, &outcome, error, &done).await, + ); + } + // A host's job asks nothing of the workspaces the account already hosts. + let asked = output::SetupAsked::of(&Value::Null, route_flags(&target)); + lines.extend(output::setup_summary_lines( + &status, + &hosting.server, + None, + &asked, + )); + api.stream(&status, &lines)?; + let Some(connection) = hosted_connection(api, &outcome).await? else { + return Err(host_start_problem( + &outcome["host"], + &hosting.shown, + &hosting.login, + &hosting.name, + )); + }; + let saved_as = connection_name(&connection); + let lines = hosted_lines(&hosting, &status, &connection, &done, &saved_as); let value = json!({ "workspace": hosting.name, "connection": connection, - "governance": if open { "open" } else { "managed" }, - "context_revision": context_revision, + "governance": outcome["governance"], + "context_revision": outcome["context_revision"], "outcome": outcome, }); api.stream(&value, &lines)?; Ok(Reply::Streamed) } -/// `host`'s setup job: installs Crew in the person's account and starts the workspace, its -/// lines and summary printed as they come ([`run_setup`]). Answers the job's outcome and the -/// invitation the start printed, or, when the start pinned no workspace, what it said -/// ([`host_start_problem`]). +/// `host`'s one setup job (AO-9): `finish`, with the privacy, institution and agent access the +/// new workspace is set up with. Answers the job's last status, finished, failed or stopped, +/// and the lines its last change adds. A job Ctrl-C stopped is followed until it ends, since +/// what it did before the Stop took effect is what is left to say ([`settle_setup`]). async fn run_host_setup( api: &Api, hosting: &Hosting, target: &Value, interrupt: impl std::future::Future>, -) -> Result<(Value, String)> { - let mut request = json!({"target": target, "intent": "host", "workspace_name": hosting.name}); +) -> Result { + let mut request = json!({ + "target": target, + "intent": "host", + "workspace_name": hosting.name, + "finish": true, + "mode": hosting.mode.as_str(), + "agent_access": hosting.agent_access.as_str(), + }); + if let Some(institution) = &hosting.institution { + request["institution_id"] = json!(institution); + } // L3: the person asked setup to take others' write access off the folders of theirs above // Crew's storage; setup re-checks each one before it changes it. if hosting.fix_permissions { request["fix_storage_permissions"] = json!(true); } - let (status, mut lines) = run_setup(api, request, &hosting.server, interrupt) - .await - .map_err(|error| { - if hosting.fix_permissions { - already_tried_the_fix(error) - } else { - error - } - })?; - lines.extend(output::setup_summary_lines(&status, &hosting.server, None)); - api.stream(&status, &lines)?; - let outcome = status["outcome"].clone(); - let started = match outcome["host"]["kind"].as_str() { - Some("found") => outcome["host"]["text"] - .as_str() - .filter(|text| !text.trim().is_empty()) - .map(str::to_owned), - _ => None, + let mut followed = follow_setup(api, request, &hosting.server, &Value::Null, interrupt).await?; + if followed.interrupted { + followed.status = settle_setup(api, followed.status).await; + } + Ok(followed) +} + +/// The connection a host's job saved (`outcome.connection_id`), as the daemon lists it now: +/// every request from here is for it. `None` when the job saved none. +async fn hosted_connection(api: &Api, outcome: &Value) -> Result> { + let Some(id) = outcome["connection_id"].as_str() else { + return Ok(None); }; - let Some(invitation) = started else { - return Err(host_start_problem( - &outcome["host"], - &hosting.shown, - &hosting.login, - &hosting.name, + let id = component(id)?.to_owned(); + let _ = api.connection_id.set(id.clone()); + ensure!( + api.connection_id.get() == Some(&id), + "This command was already working with another connection" + ); + Ok(Some(api.connection().await?)) +} + +/// The work folder a host's job made, as its canonical path. +fn hosted_work_folder(outcome: &Value) -> Option { + outcome["work_folder"] + .as_str() + .map(str::trim) + .filter(|folder| folder.starts_with('/')) + .map(str::to_owned) +} + +/// A `host` whose job stopped (AO-9), said with what it had done: after the save, the commands +/// that finish it ([`host_unfinished`], from `outcome.steps_left`); after the start but before +/// the save, the save by hand ([`host_unsaved`]); before that, the job's own failure, with the +/// command that opens a workspace of that name the account already hosts and runs. +async fn host_stopped( + api: &Api, + hosting: &Hosting, + preparation: &str, + target: &Value, + outcome: &Value, + error: anyhow::Error, + done: &str, +) -> anyhow::Error { + let error = if hosting.fix_permissions { + already_tried_the_fix(error) + } else { + error + }; + if let Some(id) = outcome["connection_id"] + .as_str() + .and_then(|id| component(id).ok()) + { + let _ = api.connection_id.set(id.to_owned()); + let connection = api.connection().await.ok(); + let saved_as = connection + .as_ref() + .map_or_else(|| hosting.name.clone(), connection_name); + let account = connection + .as_ref() + .is_some_and(|connection| connection["account_identity"].as_bool() == Some(true)); + return host_unfinished( + error, + &host_steps_left(outcome), + &saved_as, + done, + hosting, + account, + ); + } + let started = (outcome["host"]["kind"].as_str() == Some("found")) + .then(|| outcome["host"]["text"].as_str()) + .flatten() + .filter(|text| !text.trim().is_empty()); + if let Some(invitation) = started { + // Stopped after the start: the workspace runs and the server knows the account, so + // `open` saves and joins it from here (finding 13). + if error_code(&error).as_deref() == Some(SETUP_CANCELLED) { + return host_stopped_before_save(error, hosting, target); + } + let folder = hosted_work_folder(outcome); + let advanced = host_advanced(hosting, preparation, target, folder.as_deref()); + return host_unsaved( + error, + hosting, + preparation, + &advanced, + invitation, + folder.as_deref(), + ); + } + if error_code(&error).as_deref() == Some(HOST_NAME_IN_USE) { + return name_in_use_here(error, hosting, target); + } + error +} + +/// A `host` stopped after it started the workspace and before it saved it here (finding 13): +/// the workspace runs, so running `host` again would be refused as a name in use. `open` +/// saves and joins it, with the agent access asked for, and the settings follow. +fn host_stopped_before_save( + error: anyhow::Error, + hosting: &Hosting, + target: &Value, +) -> anyhow::Error { + let Hosting { + name, + server, + institution, + mode, + shown, + login, + .. + } = hosting; + let mut open = format!( + " biorouter crew open {} --server {}{}", + safe_text(&shell_word(name)), + safe_text(&shell_word(server)), + route_flags(target) + ); + if hosting.agent_access != AgentAccess::Off { + open.push_str(&format!( + " --agent-access {}", + hosting.agent_access.as_str() )); + } + let mut lines = vec![ + format!("Started {shown} on {login}, then setup was stopped before it saved it on this computer."), + "To finish, run these in order:".to_owned(), + open, + ]; + lines.extend(host_finish_commands( + HostStep::Privacy, + name, + "connect", + *mode, + institution.as_deref(), + )); + Unfinished { + sentence: lines.join("\n"), + source: error, + } + .into() +} + +/// `crew_host_name_in_use` for a workspace that runs (AO-9): the daemon's sentence says to +/// open it on this computer, and the command that does is said after it. The sentence for a +/// stopped one already names its command. +fn name_in_use_here(error: anyhow::Error, hosting: &Hosting, target: &Value) -> anyhow::Error { + let said = error_text(&error); + // Finding 39: each command repeats the port, key and jump route `host` was given. + let route = route_flags(target); + let sentence = if said.contains("--start") { + let routed = output::with_route_in_start(&said, &hosting.server, &route); + if routed == said { + return error; + } + routed + } else { + format!( + "{said}\n{}", + output::open_command_line(&hosting.name, &hosting.server, &route) + ) }; - Ok((outcome, invitation)) + Worded { + sentence, + source: error, + } + .into() } /// What a hosted workspace is saved with besides its invitation (`advanced`): the login and -/// route `host` was given, and with the work folder the job made, full access to it. +/// route `host` was given, and with the work folder the job made, the agent access asked for. fn host_advanced( hosting: &Hosting, preparation: &str, @@ -4224,53 +5832,15 @@ fn host_advanced( if let Some(jump) = &hosting.proxy_jump { advanced["proxy_jump"] = json!(jump); } - if let Some(folder) = folder { + if let Some(folder) = folder.filter(|_| hosting.agent_access != AgentAccess::Off) { + let full = hosting.agent_access == AgentAccess::Full; advanced["remote_root"] = json!(folder); - advanced["remote_execution"] = json!(true); - advanced["remote_access"] = json!("full"); + advanced["remote_execution"] = json!(full); + advanced["remote_access"] = json!(if full { "full" } else { "folder" }); } advanced } -/// Saves the started workspace on this computer ([`from_invitation`]) and answers the saved -/// connection, which every request from here is for. A save that fails says how to finish by -/// hand ([`host_unsaved`]). -async fn save_hosted( - api: &Api, - hosting: &Hosting, - preparation: &str, - advanced: &Value, - invitation: &str, - folder: Option<&str>, -) -> Result { - let mut save = - json!({"invitation": invitation, "mode": hosting.mode.as_str(), "advanced": advanced}); - if let Some(institution) = &hosting.institution { - save["institution_id"] = json!(institution); - } - let saved = from_invitation(api, &save, false) - .await - .map_err(|error| host_unsaved(error, hosting, preparation, advanced, invitation, folder))?; - let connection = saved - .get("connection") - .filter(|connection| connection.is_object()) - .cloned() - .context("The daemon did not return the saved connection")?; - let id = component( - connection["id"] - .as_str() - .context("The daemon saved the connection without its ID")?, - )? - .to_owned(); - // Every request from here is for the connection just saved. - let _ = api.connection_id.set(id.clone()); - ensure!( - api.connection_id.get() == Some(&id), - "This command was already working with another connection" - ); - Ok(connection) -} - /// A `host` that started the workspace but couldn't save it here ([`Unfinished`]): the command /// that saves it by hand, the invitation line to paste into it, and the steps after the save. fn host_unsaved( @@ -4311,7 +5881,8 @@ fn host_unsaved( *mode, institution.as_deref(), )); - if folder.is_some() { + // A saved invitation cannot carry full access, so it is given afterwards. + if folder.is_some() && hosting.agent_access == AgentAccess::Full { lines.push(format!( " biorouter crew --connection {} connections set-access full", safe_text(&shell_word(name)) @@ -4324,80 +5895,14 @@ fn host_unsaved( .into() } -/// `host`'s steps once the workspace is saved, in order: connect (unless the save left it -/// connected), bootstrap, privacy, governance and context. Answers whether the workspace is -/// open and the revision of the context written from the job's draft; a step that fails is -/// [`host_unfinished`] from that step on. -async fn finish_hosting( - api: &Api, - hosting: &Hosting, - connection: &Value, - outcome: &Value, - saved_as: &str, - done: &str, -) -> Result<(bool, Option)> { - let mode = hosting.mode; - let institution = hosting.institution.as_deref(); - let finish = |step, error| host_unfinished(error, step, saved_as, done, mode, institution); - if connection["status"].as_str() != Some("connected") { - api.connect() - .await - .map_err(|error| finish(HostStep::Connect, error))?; - } - api.broker_step( - "auth.bootstrap", - json!({"public_key": connection["public_key"]}), - 0, - ) - .await - .map_err(|error| finish(HostStep::Bootstrap, error))?; - let mut policy = json!({"mode": mode.as_str()}); - if let Some(institution) = institution { - policy["institution_id"] = json!(institution); - } - api.broker_step("policy.set", policy, 1) - .await - .map_err(|error| finish(HostStep::Privacy, error))?; - // A server whose Crew predates open workspaces keeps it managed, and says so; nothing else - // about the workspace depends on it. - let open = match api - .broker_step("workspace.set_governance", json!({"governance": "open"}), 2) - .await - { - Ok(_) => true, - Err(error) if broker_unsupported(&error) => false, - Err(error) => return Err(finish(HostStep::Governance, error)), - }; - let draft = outcome["context_draft"] - .as_str() - .filter(|draft| !draft.trim().is_empty() && draft.len() <= MAX_CONTEXT_BYTES); - let context_revision = match draft { - Some(draft) => match api - .broker_step( - "workspace.context.set", - json!({"text": draft, "expected_revision": 0}), - 3, - ) - .await - { - Ok(saved) => Some(saved["revision"].as_u64().unwrap_or(1)), - Err(error) if broker_unsupported(&error) => None, - Err(error) => return Err(finish(HostStep::Context, error)), - }, - None => None, - }; - Ok((open, context_revision)) -} - -/// What a finished `host` says: what was done, the workspace's privacy and governance, what -/// agents may do on the server, the context it wrote, and how to invite people. +/// What a finished `host` says: what was done, the workspace's privacy and governance (or why +/// the job left them as they are), what agents may do on the server, the context it wrote, +/// what is left to set up, and how to invite people. fn hosted_lines( hosting: &Hosting, + status: &Value, connection: &Value, - folder: Option<&str>, done: &str, - open: bool, - context_revision: Option, saved_as: &str, ) -> Vec { let Hosting { @@ -4406,30 +5911,59 @@ fn hosted_lines( privacy, .. } = hosting; + let outcome = &status["outcome"]; let mut lines = vec![format!("{done}.")]; - lines.push(if open { - format!("{shown} is {privacy} and open: every member can manage teams, channels and people.") - } else { - format!("{shown} is {privacy}. The Crew on {login} can't open it to every member yet, so it stays managed.") - }); + match outcome["governance"].as_str() { + Some("open") => lines.push(format!( + "{shown} is {privacy} and open: every member can manage teams, channels and people." + )), + Some(_) => lines.push(format!( + "{shown} is {privacy}. The Crew on {login} can't open it to every member yet, so it stays managed." + )), + // The job left the settings of a workspace its start did not create, and says why. + None => lines.extend(output::skipped_stage_detail(status, "configure")), + } // What the saved connection says, not what was asked for (C22): a daemon that does not // report the level, or could not save it, is not taken to have given full access. + let folder = connection["remote_root"] + .as_str() + .map(str::trim) + .filter(|folder| !folder.is_empty()); match (folder, connection["remote_access"].as_str()) { - (Some(folder), Some("full")) => lines.push(format!( - "Your agent can work as you on {login}, starting in {}. Change that with biorouter crew connections set-access.", - safe_text(folder) - )), + (Some(folder), Some("full")) => { + lines.push(format!( + "Your agent can work as you on {login}, starting in {}. Change that with biorouter crew connections set-access.", + safe_text(folder) + )); + // OW-6: --yes skipped the plan, so what full access means on the host's account is + // said here. + if hosting.yes { + lines.push(full_access_warning(Some(shown))); + } + } (Some(folder), _) => lines.push(format!( "Your work folder on {login} is {}. Choose what agents may do there with biorouter crew connections set-access.", safe_text(folder) )), (None, _) => {} } - if let Some(revision) = context_revision { + if let Some(revision) = outcome["context_revision"].as_u64() { lines.push(format!( "Wrote the workspace context from what {login} has (revision {revision}). Read it with biorouter crew workspace context show." )); } + let steps = host_steps_left(outcome); + if !steps.is_empty() { + lines.push("To finish setting it up, run these in order:".to_owned()); + lines.extend(host_step_commands( + &steps, + saved_as, + "connect", + connection["account_identity"].as_bool() == Some(true), + hosting.mode, + hosting.institution.as_deref(), + )); + } lines.push(format!( "Next: invite people with biorouter crew --connection {} enroll invite @USERNAME", safe_text(&shell_word(saved_as)) @@ -4437,33 +5971,365 @@ fn hosted_lines( lines } -async fn workspace(api: &Api, command: WorkspaceCommand) -> Result { - Ok(match command { - WorkspaceCommand::Show => { - let snapshot = api.snapshot().await?; - let names = Directory::from_snapshot(&snapshot); - api.show_with(snapshot, names) - } - WorkspaceCommand::Bootstrap => { - let connection = api.connection().await?; - api.show( - api.broker( - "auth.bootstrap", - json!({"public_key": connection["public_key"]}), - true, - ) - .await?, - ) - } - WorkspaceCommand::Rename { name } => { - let result = api - .broker("workspace.rename", json!({"name": name}), true) - .await?; - let stored = result["name"].as_str().unwrap_or(&name).to_owned(); - api.say( - result, - vec![format!("Renamed the workspace to {}.", name_text(&stored))], - ) +/// `open NAME --server LOGIN` (AO-9): a workspace on the server that the account there hosts, +/// belongs to or is invited to, opened through the ordinary join path. A check job on the +/// server, with this computer's prepared device so the steps after it need no second sign-in, +/// lists the workspaces; the one named is saved from its invitation line with that login and +/// route, connected and joined, as the account. With `--agent-access` folder or full, a +/// member's setup then gives the connection its work folder. +async fn open( + api: &Api, + args: OpenArgs, + interrupt: impl std::future::Future>, +) -> Result { + let OpenArgs { + workspace, + server, + host, + port, + identity_file, + proxy_jump, + agent_access, + local_name, + } = args; + ensure!( + api.selected.is_none(), + "open saves a connection from the server's list, so it takes no --connection." + ); + let workspace = workspace.trim().to_owned(); + ensure!( + !workspace.is_empty(), + "Name the workspace to open, like biorouter crew open lab --server you@hpc.example.edu." + ); + tokio::pin!(interrupt); + let preparation = preparation_id(api).await?; + let target = server_target( + &server, + port, + identity_file.as_deref(), + proxy_jump.as_deref(), + Some(&preparation), + )?; + let login = server.trim().to_owned(); + let request = json!({"target": target, "intent": "check"}); + let (status, lines) = run_setup(api, request, &login, &Value::Null, &mut interrupt).await?; + // The listing is not the end of the command: joining is. + api.stream(&status, &output::before_the_setup(lines))?; + let shown_server = output::setup_server(&status, &login); + let entry = output::workspace_to_open( + &status, + &workspace, + host.as_deref(), + &shown_server, + &route_flags(&target), + ) + .map_err(|sentence| restated(sentence, None))?; + // The account's place in it as the server listed it: `host`, `member` or `invited`. + let role = entry["role"].as_str().unwrap_or("member").to_owned(); + let id = match entry["saved_connection_id"].as_str() { + Some(saved) => component(saved)?.to_owned(), + None => save_opened(api, &entry, &target, &preparation, local_name.as_deref()).await?, + }; + let _ = api.connection_id.set(id.clone()); + ensure!( + api.connection_id.get() == Some(&id), + "This command was already working with another connection" + ); + if let Some(level) = agent_access.filter(|level| *level != AgentAccess::Off) { + member_setup( + api, + &SetupChoices::default(), + MemberAccess::Asked(Some(level)), + &status, + &mut interrupt, + ) + .await?; + } + let joined = join_as( + api, + JoinArgs { no_wait: false }, + Some(&role), + &mut interrupt, + ) + .await; + match joined { + // Finding 15: the account's Crew is already newer than the one the workspace still runs + // (setup left it running because others were connected), so a restart is all it takes. + Err(error) + if role == "host" + && entry["binary_current"].as_bool() == Some(false) + && error_code(&error).as_deref() == Some(ACCOUNT_UNSUPPORTED) => + { + let name = entry["name"].as_str().unwrap_or(&workspace); + Err(restart_to_open(error, name, &login, &route_flags(&target))) + } + joined => joined, + } +} + +/// `crew_account_unsupported` said to a host whose workspace still runs an older Crew than the +/// one installed in their account (finding 15): the daemon's sentence, then the restart that +/// lets the workspace know the account, with the route given (`route`). +fn restart_to_open(error: anyhow::Error, name: &str, login: &str, route: &str) -> anyhow::Error { + let said = error_text(&error); + Worded { + sentence: format!( + "{said}\nThe Crew installed in your account there is already newer. To restart {} on it now, run biorouter crew setup --server {}{route} --restart-workspace {}; members' running agent commands stop.", + name_text(name), + safe_text(&shell_word(login)), + safe_text(&shell_word(name)) + ), + source: error, + } + .into() +} + +/// Save the workspace `entry` names (AO-6) from its invitation line, with the login and route +/// the check used and this computer's prepared device. Answers the saved connection's ID. +async fn save_opened( + api: &Api, + entry: &Value, + target: &Value, + preparation: &str, + local_name: Option<&str>, +) -> Result { + let invitation = entry["invitation"] + .as_str() + .filter(|line| !line.trim().is_empty()) + .context("The server didn't say how to open this workspace")?; + let mut advanced = json!({"preparation_id": preparation, "ssh_target": target["ssh_target"]}); + for key in ["port", "identity_file", "proxy_jump"] { + if !target[key].is_null() { + advanced[key] = target[key].clone(); + } + } + if let Some(name) = local_name.map(str::trim).filter(|name| !name.is_empty()) { + advanced["name"] = json!(name); + } + let saved = from_invitation( + api, + &json!({"invitation": invitation, "advanced": advanced}), + false, + ) + .await?; + let connection = saved + .get("connection") + .filter(|connection| connection.is_object()) + .context("The daemon did not return the saved connection")?; + let id = component( + connection["id"] + .as_str() + .context("The daemon saved the connection without its ID")?, + )? + .to_owned(); + // A script reads the saved connection, and its ID, from this step's line. + api.stream( + &json!({"connection": connection}), + &[api.with_id( + format!( + "Saved {}.", + name_text(connection["name"].as_str().unwrap_or("the connection")) + ), + "connection ID", + &id, + )], + )?; + Ok(id) +} + +/// `rejoin` (AO-9): this computer added to the selected connection's workspace again with a +/// new device key, as the account on the server. The daemon needs the connection up, so a +/// disconnected one is connected first. +async fn rejoin(api: &Api) -> Result { + let path = api.path("/rejoin").await?; + let answer = match api.client.request("POST", &path, None).await { + Err(error) if disconnected(&error) => { + api.connect().await?; + api.client.request("POST", &path, None).await + } + answer => answer, + }; + let answer = match answer { + Ok(answer) => answer, + Err(error) => { + let error = older_daemon(error, RESTART_FOR_ACCOUNT); + return Err(host_rename_conflict(api, error).await); + } + }; + let lines = output::rejoined_lines(&answer); + Ok(api.say(answer, lines)) +} + +/// The daemon's code for a join the workspace refused because another active member has the +/// account's username. +const JOIN_IDENTITY_CONFLICT: &str = "crew_join_identity_conflict"; + +/// `crew_join_identity_conflict` said to the host (finding 20): the host's account was renamed +/// on the server to a name another member of the workspace uses, so Crew cannot follow the +/// rename, and the daemon's sentence ("Ask your host…") asks the host to ask themselves. The +/// workspace's own answer about the account (`GET …/account`, unsigned) says whether this is +/// the host, and the name it keeps for them; the host is told what only they can do. A daemon +/// that already words it for the host, or a member, keeps the daemon's sentence. +async fn host_rename_conflict(api: &Api, error: anyhow::Error) -> anyhow::Error { + if error_code(&error).as_deref() != Some(JOIN_IDENTITY_CONFLICT) + || !error_text(&error).contains("Ask your host") + { + return error; + } + let Ok(path) = api.path("/account").await else { + return error; + }; + let Ok(account) = api.client.request("GET", &path, None).await else { + return error; + }; + if account["role"].as_str() != Some("host") { + return error; + } + Worded { + sentence: host_rename_conflict_sentence( + account["workspace_name"].as_str(), + account["username"].as_str(), + ), + source: error, + } + .into() +} + +/// What the host reads when Crew cannot follow their account's new name ([`host_rename_conflict`]): +/// rename the account back to the name the workspace keeps (`kept`), remove the member who has +/// the new one, then rename it again. The same steps as the server's own sentence for it. +fn host_rename_conflict_sentence(workspace: Option<&str>, kept: Option<&str>) -> String { + let workspace = workspace + .map(str::trim) + .filter(|name| !name.is_empty()) + .map_or_else(|| "this workspace".to_owned(), name_text); + let back = kept + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(|name| format!(" to @{}", safe_text(name))) + .unwrap_or_default(); + format!( + "Crew can't follow your account's new name, because another member already uses it in {workspace}. Rename the account back{back} on the server and remove that member from {workspace} before renaming it again." + ) +} + +/// `profile devices` (AO-10): the computers enrolled as the person, which one is this computer +/// and which are new since they last looked; `--acknowledge` marks seen exactly the new ones +/// this command shows (finding 37), so a computer added after they were read stays new. +async fn profile_devices(api: &Api, acknowledge: bool) -> Result { + let snapshot = api.snapshot().await?; + let connection = api.connection().await?; + let new_devices = connection["new_devices"].clone(); + let shown = shown_fingerprints(&new_devices); + let acknowledged = acknowledge && !shown.is_empty(); + if acknowledged { + api.connection_action("devices/acknowledge", json!({"fingerprints": shown})) + .await + .map_err(|error| older_daemon(error, RESTART_FOR_ACCOUNT))?; + } + let value = json!({ + "workspace_name": workspace_name_in(&snapshot), + "devices": snapshot["actor"]["devices"], + "new_devices": new_devices, + "acknowledged": acknowledged, + }); + let lines = output::profile_device_lines(&value, &api.human(Directory::default())); + Ok(api.say(value, lines)) +} + +/// The fingerprints of the new devices a connection reports (`new_devices`), as it gave them, +/// each once: what `profile devices` shows, and so what `--acknowledge` marks seen. +fn shown_fingerprints(new_devices: &Value) -> Vec { + let mut shown: Vec = Vec::new(); + for fingerprint in new_devices + .as_array() + .into_iter() + .flatten() + .filter_map(|device| device["fingerprint"].as_str()) + .map(str::trim) + .filter(|fingerprint| !fingerprint.is_empty()) + { + if !shown.iter().any(|seen| seen == fingerprint) { + shown.push(fingerprint.to_owned()); + } + } + shown +} + +/// `profile revoke-device` (AO-5): one of the person's own computers, by its fingerprint or as +/// this computer, removed from the workspace. Its SSH access to the server goes first, which is +/// said before asking; removing this computer says how to add it again after. A person reads +/// "Remove", in the desktop's words; the command keeps its name. +async fn revoke_device( + api: &Api, + fingerprint: &[String], + this_device: bool, + yes: bool, +) -> Result { + let snapshot = api.snapshot().await?; + let connection = api.connection().await?; + let workspace = workspace_name_in(&snapshot).map_or_else( + || name_text(&connection_name(&connection)), + |name| name_text(&name), + ); + let device = output::device_to_revoke(&snapshot, fingerprint, this_device) + .map_err(|sentence| restated(sentence, None))?; + let shown = device["fingerprint"] + .as_str() + .unwrap_or_default() + .to_owned(); + let here = device["this_device"].as_bool() == Some(true); + let warning = output::remove_device_warning(here); + let consent = yes_or_ask( + yes, + api.interactive, + output::remove_device_question(&shown, here), + "Removing a computer needs a yes. There is no terminal to ask in, so add --yes.".to_owned(), + )?; + match consent { + Consent::Ask(question) => { + eprintln!("{warning}"); + ask_yes(question, "Nothing was removed.").await?; + } + Consent::Given => eprintln!("{warning}"), + } + let answer = api + .broker("device.revoke", json!({"fingerprint": shown}), true) + .await?; + let revoked_here = answer["this_device"].as_bool().unwrap_or(here); + Ok(api.say( + answer, + output::device_revoked_lines(&shown, revoked_here, &workspace), + )) +} + +async fn workspace(api: &Api, command: WorkspaceCommand) -> Result { + Ok(match command { + WorkspaceCommand::Show => { + let snapshot = api.snapshot().await?; + let names = Directory::from_snapshot(&snapshot); + api.show_with(snapshot, names) + } + WorkspaceCommand::Bootstrap => { + let connection = api.connection().await?; + api.show( + api.broker( + "auth.bootstrap", + json!({"public_key": connection["public_key"]}), + true, + ) + .await?, + ) + } + WorkspaceCommand::Rename { name } => { + let result = api + .broker("workspace.rename", json!({"name": name}), true) + .await?; + let stored = result["name"].as_str().unwrap_or(&name).to_owned(); + api.say( + result, + vec![format!("Renamed the workspace to {}.", name_text(&stored))], + ) } WorkspaceCommand::Delete { confirm } => delete_workspace(api, confirm.as_deref()).await?, WorkspaceCommand::Governance { governance } => set_governance(api, governance).await?, @@ -4917,14 +6783,27 @@ async fn enroll_invite(api: &Api, args: EnrollInviteArgs) -> Result { // The message is what the person needs next. The invitation stands without it, so a // failure here only changes what is printed. let invitation = fetch_invitation(api, Some(&canonical)).await.ok(); - let lines = invite_lines(&result, &canonical, invitation.as_ref()); + // L1: on a server whose Crew knows the account (AO-9), the person joins with no code. + let account = api + .connection() + .await + .is_ok_and(|connection| connection["account_identity"].as_bool() == Some(true)); + let lines = invite_lines(&result, &canonical, invitation.as_ref(), account); if let Some(invitation) = invitation { result["invitation"] = invitation; } Ok(api.say(result, lines)) } -fn invite_lines(result: &Value, username: &str, invitation: Option<&Value>) -> Vec { +/// What `enroll invite` says: who was invited, the invitation to send them, and what happens +/// next. On a server whose Crew knows the account (`account`, AO-9) the person joins as soon as +/// they open the workspace, with no code to approve; an older one still sends the host a code. +fn invite_lines( + result: &Value, + username: &str, + invitation: Option<&Value>, + account: bool, +) -> Vec { let handle = format!("@{}", safe_text(username)); let mut lines = vec![if result["add_device"].as_bool() == Some(true) { format!("Invited {handle} to add another computer.") @@ -4951,9 +6830,15 @@ fn invite_lines(result: &Value, username: &str, invitation: Option<&Value>) -> V "Print the invitation to send them with: biorouter crew connections invitation --for {handle}" )), } - lines.push(format!( - "When {handle} sends you a code, let them in with: biorouter crew enroll approve {handle} CODE" - )); + lines.push(if account { + format!( + "{handle} joins as soon as they open the workspace. There is no code to approve." + ) + } else { + format!( + "When {handle} sends you a code, let them in with: biorouter crew enroll approve {handle} CODE" + ) + }); lines } @@ -5045,11 +6930,11 @@ fn revoke_confirmation( Ok(Confirmation::Confirmed) } None if interactive => Ok(Confirmation::Ask(format!( - "Revoke {label}? Their membership, devices and agent grants stop working. Type @{} to confirm:", + "Revoke {label}? Their membership, computers and agent grants stop working. Type @{} to confirm:", safe_text(username) ))), 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}." + "Revoking @{username} removes their membership, computers and agent grants. There is no terminal to ask in, so confirm with --confirm @{username}." ))), } } @@ -5079,7 +6964,7 @@ async fn revoke_member(api: &Api, member: &str, confirm: Option<&str>) -> Result Ok(api.say( result, vec![format!( - "Revoked {label}. Their membership, devices and agent grants no longer work." + "Revoked {label}. Their membership, computers and agent grants no longer work." )], )) } @@ -5815,6 +7700,12 @@ async fn profile(api: &Api, command: ProfileCommand) -> Result { ) .await?, ), + ProfileCommand::Devices { acknowledge } => profile_devices(api, acknowledge).await?, + ProfileCommand::RevokeDevice { + fingerprint, + this_device, + yes, + } => revoke_device(api, &fingerprint, this_device, yes).await?, }) } @@ -6421,8 +8312,14 @@ async fn watch_event( // 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 } => { + let mut message = watch_stopped(watched, &error); + // AO-9: a computer that lost its key, or that the workspace no longer recognizes, + // is added again with `rejoin`, said as the desktop says its button. + if matches!(code.as_str(), DEVICE_KEY_MISSING | DEVICE_NOT_RECOGNIZED) { + message = output::rejoin_sentence(&message); + } return Err(WatchStopped { - message: watch_stopped(watched, &error), + message, code: safe_text(&code), clear, } @@ -6781,7 +8678,50 @@ async fn start_task( started["task_model"] = choice.to_json(); } // The task is named by the channel it posts to, as `tasks list` names it. - Ok(api.show_with(started, api.names().await)) + let options = api.human(api.names().await); + // With full access the task runs as the person, which is said after it, as the chat's task + // card says it. + let access = if api.text() { + task_full_access_line(api).await + } else { + None + }; + Ok(match access { + Some(line) => { + let block = output::formatted(&started, OutputFormat::Text, &options)?; + api.say(started, vec![block, line]) + } + None => Reply::Show(started, Box::new(options)), + }) +} + +/// What a task started on the selected connection may do on its server when that is full +/// access, as the chat's task card says it (`access_lines` in `crew_tools/tasks.rs`): it runs +/// as the person's account ([`FULL_ACCESS_IS_YOU`]), and on the host's account it can act as +/// the host ([`full_access_warning`]). `None` for any other access, or a connection that can't +/// be read, which says nothing rather than something untrue. +async fn task_full_access_line(api: &Api) -> Option { + let connection = api.connection().await.ok()?; + let has_folder = connection["remote_root"] + .as_str() + .is_some_and(|root| !root.trim().is_empty()); + if !has_folder || connection["remote_access"].as_str() != Some("full") { + return None; + } + let hosted = api.hosted_workspace(&connection, &Value::Null).await; + Some(task_full_access_sentence( + &server_label(&connection), + hosted.as_deref(), + )) +} + +/// [`task_full_access_line`]'s words for `server`, with the host's authority when the account +/// hosts `hosted`. +fn task_full_access_sentence(server: &str, hosted: Option<&str>) -> String { + format!( + "With a private model it can run any command as you on {server}, and read and change any file your account can. {}", + full_access_warning(hosted) + ) } /// `defaults show|set|clear` (OW-11): the model Crew tasks start on, kept in the shared @@ -6954,7 +8894,7 @@ async fn task(api: &Api, id: &str) -> Result { .iter() .find(|run| run["run_id"].as_str() == Some(id)) .cloned() - .context("Task was not found on this device and connection") + .context("Task was not found on this computer and connection") } async fn watch_task(api: &Api, id: &str) -> Result { @@ -7071,6 +9011,7 @@ async fn grants(api: &Api, command: GrantCommand) -> Result { .request("GET", &api.path("/grants").await?, None) .await?; with_task_statuses(api, &mut grants).await; + with_read_workspaces(api, &mut grants).await; api.show_with(grants, api.names().await) } GrantCommand::Grant { @@ -7100,10 +9041,68 @@ async fn grants(api: &Api, command: GrantCommand) -> Result { .await?, ) } - GrantCommand::Revoke { session } => revoke_grant(api, &session).await?, + GrantCommand::Revoke { + session, + reads: false, + } => revoke_grant(api, &session).await?, + GrantCommand::Revoke { + session, + reads: true, + } => revoke_person_read(api, &session).await?, }) } +/// Each chat's permission to read a workspace as the person (`person_reads`, AO-12) with the +/// saved connection's name (`connection_name`), which is how a person knows the workspace. +/// Nothing is added when the connections cannot be read. +async fn with_read_workspaces(api: &Api, grants: &mut Value) { + let Some(reads) = grants["person_reads"] + .as_array_mut() + .filter(|reads| !reads.is_empty()) + else { + return; + }; + let names = saved_connection_names(api).await; + for read in reads.iter_mut() { + let name = read["connection_id"] + .as_str() + .and_then(|id| names.get(id)) + .cloned(); + if let (Some(name), None) = (name, read.get("connection_name")) { + read["connection_name"] = json!(name); + } + } +} + +/// `grants revoke SESSION --reads` (AO-12): the chat no longer reads the selected connection's +/// workspace as the person. A chat that had no such permission is said to have none. +async fn revoke_person_read(api: &Api, session: &str) -> Result { + let session = component(session)?; + let connection = api.connection().await?; + let id = api.connection_id().await?; + let workspace = name_text(&connection_name(&connection)); + let path = format!("/crew/sessions/{session}/reads/{id}"); + let answer = match api.client.request("DELETE", &path, None).await { + Ok(answer) => answer, + Err(error) => match refusal(&error) { + Some(Refusal { + status: 404, + code: Some(code), + }) if code == "crew_not_found" => { + return Err(restated( + output::no_person_read_sentence(session, &workspace), + Some("crew_not_found"), + )) + } + _ => return Err(older_daemon(error, RESTART_FOR_READS)), + }, + }; + Ok(api.say( + json!({"revoked": true, "session_id": session, "connection_id": id, "answer": answer}), + vec![output::person_read_revoked_sentence(session, &workspace)], + )) +} + /// 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 @@ -7162,9 +9161,10 @@ async fn revoke_grant(api: &Api, session: &str) -> Result { Some(Refusal { status: 503, code: Some(code), - }) if code == "crew_revocation_unconfirmed" => { - restated(STOPPED_ON_THIS_DEVICE, Some("crew_revocation_unconfirmed")) - } + }) if code == "crew_revocation_unconfirmed" => restated( + STOPPED_ON_THIS_COMPUTER, + Some("crew_revocation_unconfirmed"), + ), _ => error, })?; let lines = revoked_lines(session, &answer)?; @@ -7176,7 +9176,7 @@ fn revoked_lines(session: &str, answer: &Value) -> Result> { let confirmed = answer["remote_revocation_confirmed"].as_bool() != Some(false); if revoked && !confirmed { return Err(restated( - STOPPED_ON_THIS_DEVICE, + STOPPED_ON_THIS_COMPUTER, Some("crew_revocation_unconfirmed"), )); } @@ -7645,10 +9645,19 @@ mod tests { setup_poll: Duration::ZERO, connection_id: tokio::sync::OnceCell::new(), channel_labels: std::sync::Mutex::default(), + printed: Printed::Kept(Mutex::default()), }; (api, fake) } + /// The text lines a command streamed so far ([`Api::print`]). + fn printed(api: &Api) -> Vec { + match &api.printed { + Printed::Kept(kept) => kept.lock().expect("printed lines").clone(), + Printed::Terminal => Vec::new(), + } + } + fn said(reply: Reply) -> Vec { match reply { Reply::Say(_, lines) => lines, @@ -8041,6 +10050,7 @@ mod tests { fn revoke_command() -> CrewCommand { CrewCommand::Grants(GrantCommand::Revoke { session: SESSION.into(), + reads: false, }) } @@ -8056,7 +10066,18 @@ mod tests { let error = run(&api, revoke_command()) .await .expect_err("a 503 is not success"); - assert!(message(&error).starts_with("Stopped on this device.")); + // The daemon's own sentence (`REVOCATION_UNCONFIRMED_MESSAGE`), word for word, then + // where this terminal sees that it has. + let profile = include_str!("../../../../biorouter-server/src/routes/crew_profile.rs"); + let daemon = profile + .split("REVOCATION_UNCONFIRMED_MESSAGE: &str = \"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + .expect("the daemon's sentence"); + assert_eq!( + message(&error), + format!("{daemon} biorouter crew grants list shows when it has.") + ); assert_eq!( error_code(&error).as_deref(), Some("crew_revocation_unconfirmed") @@ -8084,7 +10105,7 @@ mod tests { ) }); let error = run(&api, revoke_command()).await.expect_err("unconfirmed"); - assert!(message(&error).starts_with("Stopped on this device.")); + assert!(message(&error).starts_with("Stopped on this computer.")); let (api, _) = revoke_with(|| Ok(json!({}))); let error = run(&api, revoke_command()).await.expect_err("not revoked"); @@ -8153,13 +10174,13 @@ mod tests { ); assert_eq!( lines, - ["Revoked \"Bob Lee\" (@bob). Their membership, devices and agent grants no longer work."] + ["Revoked \"Bob Lee\" (@bob). Their membership, computers and agent grants no longer work."] ); assert_eq!( revoke_confirmation("bob", "Bob Lee (@bob)", None, true).expect("asks"), Confirmation::Ask( - "Revoke Bob Lee (@bob)? Their membership, devices and agent grants stop working. Type @bob to confirm:".into() + "Revoke Bob Lee (@bob)? Their membership, computers and agent grants stop working. Type @bob to confirm:".into() ) ); assert_eq!( @@ -9371,7 +11392,7 @@ mod tests { ("POST", true) => Err(refuse( 409, Some("crew_join_code_mismatch"), - "The host hasn't let this device in.", + "The host hasn't let this computer in.", )), _ => standard(method, path, body), } @@ -11821,7 +13842,7 @@ mod tests { .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." + "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 · with an invitation\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}"); } @@ -12758,13 +14779,41 @@ mod tests { .await .expect("full"), ); + // Live re-test R6: --yes skips the question, so the result says what full access + // means, and, since the snapshot names Bob's account the host, the host's authority. assert_eq!( lines, [ "Agents can work as you on hpc, starting in /pool1/home/bob/crew-work/lab.", + "Crew treats anything running as your account as you, so your agent can also add a computer to your account and post in Crew as you, with no agent mark. You host UCSF HPC from this account, so your agent can also read the workspace's own files on the server, including channels you're not in. It can also act as the host of UCSF HPC.", "This ended every chat's and task's access through UCSF HPC; grant it again where you need it.", ] ); + // The question a terminal is asked carries the same words. + assert_eq!( + full_access_question("hpc", "UCSF HPC", &full_access_warning(None)), + format!("Let agents work as you on hpc? Through UCSF HPC, an agent can run any command your account can, read and change any file it can reach, use the network, and start jobs that keep running after its task ends. {FULL_ACCESS_IS_YOU} Public models never get this access.") + ); + // A member's account is not the host's: the host's sentence is left out. + let mut member_snapshot = snapshot(); + member_snapshot["actor"] = member_snapshot["principals"][1].clone(); + let daemon = access_daemon(connection_with_levels()); + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + return Ok(member_snapshot.clone()); + } + daemon(method, path, body) + }; + let (member_api, _) = api_with(OutputFormat::Text, handler); + let lines = said( + run( + &member_api, + set(AgentAccess::Full, Some("~/crew-work/lab"), true), + ) + .await + .expect("full"), + ); + assert_eq!(lines[1], FULL_ACCESS_IS_YOU); let body = patched(&fake).expect("saved"); assert_eq!(body["remote_root"], "/pool1/home/bob/crew-work/lab"); assert_eq!(body["remote_execution"], true); @@ -12871,6 +14920,73 @@ mod tests { .and_then(|sent| sent.body) } + /// A task daemon whose saved connection gives agents `access` in a work folder, and whose + /// snapshot makes the person the host when `host` (the standard snapshot's host UID). + fn access_task_daemon( + access: &'static str, + host: bool, + ) -> impl Fn(&str, &str, Option<&Value>) -> Result { + let tasks = task_daemon(json!({"_providers": ["versa_azure"]})); + move |method: &str, path: &str, body: Option<&Value>| { + if (method, path) == ("GET", "/crew/connections") { + let mut listed = standard(method, path, body)?; + listed["connections"][0]["remote_root"] = json!("/home/bob/crew-work"); + listed["connections"][0]["remote_access"] = json!(access); + return Ok(listed); + } + let snapshot_asked = body.is_some_and(|body| body["method"] == "workspace.snapshot"); + if path.ends_with("/request") && snapshot_asked && !host { + let mut member = snapshot(); + member["actor"]["uid"] = json!(1001); + return Ok(member); + } + tasks(method, path, body) + } + } + + /// A task that gets full access says what that lets it do after it starts, as the chat's + /// task card says it: what full access is (W1), and on the host's account that it can act + /// as the host (W2). A folder, and JSON output, add nothing. + #[tokio::test] + async fn a_task_with_full_access_says_it_works_as_you() { + let start = || { + CrewCommand::Tasks(TaskCommand::Start { + channel: "methods".into(), + prompt: TextInput { + text: Some("Sum the counts".into()), + input: None, + }, + provider: Some("versa_azure".into()), + model: Some("gpt-5.5".into()), + context_channels: Vec::new(), + allow_posting: true, + }) + }; + let full = "With a private model it can run any command as you on hpc, and read and change any file your account can. Crew treats anything running as your account as you, so your agent can also add a computer to your account and post in Crew as you, with no agent mark."; + let (api, fake) = api_with(OutputFormat::Text, access_task_daemon("full", false)); + let lines = said(run(&api, start()).await.expect("started")); + assert!(started_run(&fake).is_some()); + assert_eq!(lines.len(), 2, "{lines:?}"); + assert!(lines[0].starts_with("Task "), "{lines:?}"); + assert_eq!(lines[1], full); + + let (api, _) = api_with(OutputFormat::Text, access_task_daemon("full", true)); + let lines = said(run(&api, start()).await.expect("started")); + assert_eq!( + lines[1], + format!("{full} You host UCSF HPC from this account, so your agent can also read the workspace's own files on the server, including channels you're not in. It can also act as the host of UCSF HPC.") + ); + + let (api, _) = api_with(OutputFormat::Text, access_task_daemon("folder", true)); + let lines = said(run(&api, start()).await.expect("started")); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!(!lines[0].contains("any command as you"), "{lines:?}"); + + let (api, _) = api_with(OutputFormat::Json, access_task_daemon("full", true)); + let reply = run(&api, start()).await.expect("started"); + assert!(matches!(reply, Reply::Show(..)), "{reply:?}"); + } + /// OW-11: a task with no model named starts on the model for Crew tasks, else the default /// model for new chats, says which, and with neither fails before anything is looked up. #[tokio::test] @@ -13104,6 +15220,11 @@ mod tests { proxy_jump: None, }, replace_existing: false, + keep_older: false, + restart: false, + restart_workspace: Vec::new(), + no_restart: false, + start: Vec::new(), check, agent_access: None, } @@ -13133,12 +15254,16 @@ mod tests { .await .expect("set up"); assert!(matches!(reply, Reply::Streamed)); + // AO-8: a server with nothing saved is set up on the server target, which makes no + // device key (it was the pending target, with this computer's preparation). assert_eq!( setup_requests(&fake), - [ - json!({"target": {"kind": "pending", "preparation_id": PREPARATION, "ssh_target": "bob@hpc"}, "intent": "install"}) - ] + [json!({"target": {"kind": "server", "ssh_target": "bob@hpc"}, "intent": "install"})] ); + assert!(!fake + .sent() + .iter() + .any(|sent| sent.path == "/crew/devices/prepare")); let (api, fake) = api_with( OutputFormat::StreamJson, @@ -13252,52 +15377,71 @@ mod tests { identity_file: None, proxy_jump: None, fix_permissions: false, + agent_access: AgentAccess::Full, yes, } } - /// A daemon that hosts `chen-lab`: setup starts it and drafts its context, the save makes - /// connection [`SAVED`], and the broker behind it answers as `broker` says. - fn host_daemon( - broker: fn(&str) -> Option>, + /// The connection a host's job saves for `chen-lab` ([`SAVED`]), as the daemon lists it. + fn hosted_connection_view(account_identity: bool) -> Value { + json!({ + "id": SAVED, "name": "chen-lab", "ssh_target": "alice@leo", "workspace_id": "w2", + "status": "connected", "public_key": "pk", + "remote_root": "/home/alice/crew-work/chen-lab", "remote_access": "full", + "account_identity": account_identity + }) + } + + /// A daemon whose one setup job ends as `last`, listing `standard`'s connection and, once + /// the job saved it, [`SAVED`] with `account_identity`. + fn host_job_daemon( + last: Value, + account_identity: bool, ) -> impl Fn(&str, &str, Option<&Value>) -> Result { - let finished = setup_status( - "host", - "finished", - json!({"outcome": { - "installed_version": "1.93.0", "reused": false, + let daemon = setup_daemon(vec![last]); + move |method: &str, path: &str, body: Option<&Value>| { + if (method, path) == ("GET", "/crew/connections") { + let mut listed = standard(method, path, body)?; + listed["connections"] + .as_array_mut() + .expect("connections") + .push(hosted_connection_view(account_identity)); + return Ok(listed); + } + daemon(method, path, body) + } + } + + /// A daemon whose host job (AO-9) starts `chen-lab`, saves it as [`SAVED`], joins it and + /// sets it up: open, with the context it drafted. + fn host_daemon() -> impl Fn(&str, &str, Option<&Value>) -> Result { + let mut finished = setup_status( + "host", + "finished", + json!({"outcome": { + "installed_version": "1.93.0", "reused": false, "host": {"kind": "found", "text": "brcrew1:abc"}, "work_folder": "/home/alice/crew-work/chen-lab", - "context_draft": "# chen-lab on leo\nNo job scheduler." + "context_draft": "# chen-lab on leo\nNo job scheduler.", + "connection_id": SAVED, "remote_access": "full", + "governance": "open", "context_revision": 1, "steps_left": [] }}), ); - let daemon = setup_daemon(vec![finished]); - move |method: &str, path: &str, body: Option<&Value>| { - if path == "/crew/connections/from-invitation" { - return Ok(json!({"connection": { - "id": SAVED, "name": "chen-lab", "ssh_target": "alice@leo", - "status": "connected", "public_key": "pk" - }})); - } - if let Some(answer) = body - .and_then(|body| body["method"].as_str()) - .and_then(broker) - { - return answer; - } - if body.and_then(|body| body["method"].as_str()) == Some("workspace.context.set") { - return Ok(json!({"revision": 1})); - } - daemon(method, path, body) + for name in ["start", "save", "join", "configure", "done"] { + finished["stages"] + .as_array_mut() + .expect("stages") + .push(json!({"name": name, "state": "done"})); } + host_job_daemon(finished, true) } - /// OW-9, OW-1, OW-6, OW-8: host installs, starts, saves the workspace with its work folder - /// and full access, then bootstraps it, gives it its privacy, opens it and writes its - /// context, each its own retry key, all over the connection it just saved. + /// AO-9: host is one setup job that saves, joins and sets the workspace up (`finish`), + /// with the privacy, institution and agent access asked for; the command sends nothing to + /// the workspace itself, and says what the job did. #[tokio::test] async fn host_sets_a_workspace_up_from_start_to_finish() { - let (api, fake) = api_with(OutputFormat::Text, host_daemon(|_| None)); + let (api, fake) = api_with(OutputFormat::Text, host_daemon()); let error = host(&api, host_command(None, true), std::future::pending()) .await .expect_err("no institution"); @@ -13312,6 +15456,7 @@ mod tests { assert!(error.downcast_ref::().is_some(), "{error:?}"); assert!(fake.sent().is_empty(), "{:?}", fake.sent()); + let (api, fake) = api_with(OutputFormat::StreamJson, host_daemon()); let reply = host( &api, host_command(Some("ucsf"), true), @@ -13320,129 +15465,188 @@ mod tests { .await .expect("hosted"); assert!(matches!(reply, Reply::Streamed)); - assert_eq!(setup_requests(&fake)[0]["intent"], "host"); - assert_eq!(setup_requests(&fake)[0]["workspace_name"], "chen-lab"); - let save = fake - .sent() - .into_iter() - .find(|sent| sent.path == "/crew/connections/from-invitation") - .and_then(|sent| sent.body) - .expect("saved"); assert_eq!( - save, - json!({ - "invitation": "brcrew1:abc", "mode": "private", "institution_id": "ucsf", - "preview": false, - "advanced": { - "preparation_id": PREPARATION, "ssh_target": "alice@leo", - "remote_root": "/home/alice/crew-work/chen-lab", "remote_execution": true, - "remote_access": "full" - } - }) - ); - let calls: Vec<(String, Value)> = fake + setup_requests(&fake), + [json!({ + "target": {"kind": "pending", "preparation_id": PREPARATION, "ssh_target": "alice@leo"}, + "intent": "host", "workspace_name": "chen-lab", "finish": true, + "mode": "private", "institution_id": "ucsf", "agent_access": "full" + })] + ); + assert!(fake.broker_calls().is_empty(), "{:?}", fake.broker_calls()); + assert!(!fake .sent() - .into_iter() - .filter(|sent| sent.path == format!("/crew/connections/{SAVED}/request")) - .filter_map(|sent| sent.body) - .map(|body| { - ( - body["method"].as_str().unwrap_or_default().to_owned(), - body["params"].clone(), - ) - }) - .collect(); + .iter() + .any(|sent| sent.path == "/crew/connections/from-invitation")); + // Every request after the job is for the connection it saved. + assert_eq!(api.connection_id.get().map(String::as_str), Some(SAVED)); + + let mut args = host_command(Some("ucsf"), true); + args.agent_access = AgentAccess::Folder; + let (api, fake) = api_with(OutputFormat::Json, host_daemon()); + host(&api, args, std::future::pending()) + .await + .expect("hosted with a folder"); + assert_eq!(setup_requests(&fake)[0]["agent_access"], "folder"); + + // What a finished host says: the job's settings, what agents may do, the context. + let hosting = Hosting::check( + &api_with(OutputFormat::Text, standard).0, + host_command(Some("ucsf"), true), + ) + .expect("checked"); + let finished = host_daemon()("GET", &format!("/crew/setup/{JOB}"), None).expect("job"); + let done = "Started chen-lab on alice@leo and saved it on this computer"; + // Live re-test R6: --yes skipped the plan, so what full access means on the host's + // account is said with the result. + let host_warning = "Crew treats anything running as your account as you, so your agent can also add a computer to your account and post in Crew as you, with no agent mark. You host chen-lab from this account, so your agent can also read the workspace's own files on the server, including channels you're not in. It can also act as the host of chen-lab."; assert_eq!( - calls, + hosted_lines(&hosting, &finished, &hosted_connection_view(true), done, "chen-lab"), [ - ( - "auth.bootstrap".to_owned(), - json!({"public_key": "pk", "idempotency_key": "req-1"}) - ), - ( - "policy.set".to_owned(), - json!({"mode": "private", "institution_id": "ucsf", "idempotency_key": "req-1:1"}) - ), - ( - "workspace.set_governance".to_owned(), - json!({"governance": "open", "idempotency_key": "req-1:2"}) - ), - ( - "workspace.context.set".to_owned(), - json!({"text": "# chen-lab on leo\nNo job scheduler.", "expected_revision": 0, "idempotency_key": "req-1:3"}) - ), + "Started chen-lab on alice@leo and saved it on this computer.", + "chen-lab is Private · ucsf and open: every member can manage teams, channels and people.", + "Your agent can work as you on alice@leo, starting in /home/alice/crew-work/chen-lab. Change that with biorouter crew connections set-access.", + host_warning, + "Wrote the workspace context from what alice@leo has (revision 1). Read it with biorouter crew workspace context show.", + "Next: invite people with biorouter crew --connection chen-lab enroll invite @USERNAME", ] ); - - // A server whose Crew predates open workspaces keeps it managed; nothing else fails. - let (api, fake) = api_with( - OutputFormat::Text, - host_daemon(|method| { - (method == "workspace.set_governance").then(|| { - Err(refuse_broker( - "unsupported", - "unsupported: method unavailable", - )) - }) - }), + // Asked on a terminal, the plan said it before anything was done, so the result does + // not say it again. + let asked = Hosting::check( + &api_with(OutputFormat::Text, standard).0, + host_command(Some("ucsf"), false), + ) + .expect("checked"); + let plan = asked.plan_lines(); + assert_eq!( + plan[3..], + [ + " make it Private · ucsf and open: every member can manage teams, channels and people,", + " and let your agent work as you on alice@leo: it can run any command your account can and reach any file it can.", + format!("{host_warning} Public models never get this access.").as_str(), + ] ); - host( - &api, - host_command(Some("ucsf"), true), - std::future::pending(), + assert!(!hosted_lines( + &asked, + &finished, + &hosted_connection_view(true), + done, + "chen-lab" ) - .await - .expect("hosted, managed"); - assert!(fake.broker_call("workspace.context.set").is_some()); + .iter() + .any(|line| line.contains(FULL_ACCESS_IS_YOU))); + let mut folder = host_command(Some("ucsf"), false); + folder.agent_access = AgentAccess::Folder; + let folder = Hosting::check(&api_with(OutputFormat::Text, standard).0, folder) + .expect("checked") + .plan_lines(); + assert!(!folder.iter().any(|line| line.contains(FULL_ACCESS_IS_YOU))); + // A start that did not say it created the workspace leaves its settings as steps. + let mut older = finished.clone(); + older["outcome"]["governance"] = Value::Null; + older["outcome"]["context_revision"] = Value::Null; + older["outcome"]["steps_left"] = json!(["privacy", "governance", "context"]); + older["stages"] = json!([{"name": "configure", "state": "skipped", + "detail": "Crew on leo didn't say it created the workspace, so its privacy and settings are left for you to set."}]); + assert_eq!( + hosted_lines(&hosting, &older, &hosted_connection_view(true), done, "chen-lab")[1..5], + [ + "Crew on leo didn't say it created the workspace, so its privacy and settings are left for you to set.", + "Your agent can work as you on alice@leo, starting in /home/alice/crew-work/chen-lab. Change that with biorouter crew connections set-access.", + host_warning, + "To finish setting it up, run these in order:", + ] + ); } - /// A host that stops after the workspace is saved says what is done and the commands that - /// finish it, and offers no request ID: running host again would start over. + /// AO-9: a host job that stops after the save names what is done and the commands that + /// finish it, from the steps the job says are left, and offers no request ID: running host + /// again would start over. Joining as the host is `join` where the server's Crew knows the + /// account, and `workspace bootstrap` where it does not. #[tokio::test] async fn a_host_that_stops_after_saving_names_the_commands_that_finish_it() { - let (api, fake) = api_with( - OutputFormat::Text, - host_daemon(|method| { - (method == "auth.bootstrap").then(|| Err(refuse(503, Some(OUTCOME_UNKNOWN), ""))) + let stopped = setup_status( + "host", + "failed", + json!({ + "outcome": {"connection_id": SAVED, "steps_left": ["join", "privacy", "governance", "context"]}, + "error": {"code": "crew_request_refused", "message": "The workspace refused the request."} + }), + ); + for (account, join) in [(false, "workspace bootstrap"), (true, "join")] { + let (api, fake) = api_with( + OutputFormat::Text, + host_job_daemon(stopped.clone(), account), + ); + let error = host( + &api, + host_command(Some("ucsf"), true), + std::future::pending(), + ) + .await + .expect_err("join failed"); + let shown = shown_text(&error, true); + assert_eq!( + shown, + format!( + "Started chen-lab on alice@leo and saved it on this computer, but setting it up failed: The workspace refused the request.\n\ + To finish, run these in order:\n \ + biorouter crew --connection chen-lab {join}\n \ + biorouter crew --connection chen-lab privacy set-workspace private --institution ucsf\n \ + biorouter crew --connection chen-lab workspace governance open\n \ + biorouter crew --connection chen-lab workspace context draft --save" + ) + ); + assert!(!shown.contains("--request-id"), "{shown}"); + assert_eq!( + failure_body(&error, "m", "req-1")["code"], + "crew_request_refused" + ); + assert!(fake.broker_calls().is_empty()); + } + // A server that wants a password signs in with auth, which connects too. + let unconnected = setup_status( + "host", + "failed", + json!({ + "outcome": {"connection_id": SAVED, "steps_left": ["connect", "join"]}, + "error": {"code": "crew_ssh_auth_required", "message": "leo asks for a password."} }), ); + let (api, _) = api_with(OutputFormat::Text, host_job_daemon(unconnected, false)); let error = host( &api, host_command(Some("ucsf"), true), std::future::pending(), ) .await - .expect_err("bootstrap failed"); + .expect_err("connect failed"); let shown = shown_text(&error, true); assert!( - shown.starts_with("Started chen-lab on alice@leo and saved it on this computer, but setting it up failed: "), - "{shown}" - ); - assert!( - shown.ends_with("To finish, run these in order:\n biorouter crew --connection chen-lab workspace bootstrap\n biorouter crew --connection chen-lab privacy set-workspace private --institution ucsf\n biorouter crew --connection chen-lab workspace governance open\n biorouter crew --connection chen-lab workspace context draft --save"), + shown.ends_with("To finish, run these in order:\n biorouter crew --connection chen-lab auth\n biorouter crew --connection chen-lab workspace bootstrap"), "{shown}" ); - assert!(!shown.contains("--request-id"), "{shown}"); - assert_eq!(failure_body(&error, "m", "req-1")["code"], OUTCOME_UNKNOWN); - assert!(fake.broker_call("policy.set").is_none()); } - /// C26: a host whose save failed names a save command with every route choice it was given - /// (the invitation carries none) and `--yes` (it reads stdin), then every step still to do. + /// C26: a host whose job started the workspace but could not save it names a save command + /// with every route choice it was given (the invitation carries none) and `--yes` (it reads + /// stdin), then every step still to do. #[tokio::test] async fn a_host_that_could_not_save_names_the_whole_save_and_what_follows() { - let daemon = host_daemon(|_| None); - let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { - if path == "/crew/connections/from-invitation" { - return Err(refuse( - 503, - Some("crew_credential_store_unavailable"), - "Biorouter couldn't reach the system keyring.", - )); - } - daemon(method, path, body) - }; - let (api, _) = api_with(OutputFormat::Text, handler); + let unsaved = setup_status( + "host", + "failed", + json!({ + "outcome": { + "host": {"kind": "found", "text": "brcrew1:abc"}, + "work_folder": "/home/alice/crew-work/chen-lab", + "steps_left": [] + }, + "error": {"code": "crew_credential_store_unavailable", "message": "Biorouter couldn't reach the system keyring."} + }), + ); + let (api, _) = api_with(OutputFormat::Text, host_job_daemon(unsaved, true)); let mut args = host_command(Some("ucsf"), true); args.port = Some(2222); args.identity_file = Some("/keys/leo".into()); @@ -13475,19 +15679,64 @@ mod tests { assert!(!shown.contains("--request-id"), "{shown}"); } + /// AO-9: a name the account already hosts is refused with the daemon's sentence; a running + /// one adds the command that opens it here, and a stopped one's sentence already names the + /// start. + #[tokio::test] + async fn a_host_name_already_in_use_says_how_to_open_or_start_it() { + let in_use = |message: &str| { + setup_status( + "host", + "failed", + json!({"error": {"code": "crew_host_name_in_use", "message": message}}), + ) + }; + let (api, _) = api_with( + OutputFormat::Text, + setup_daemon(vec![in_use( + "You already host chen-lab on leo. Open it on this computer instead.", + )]), + ); + let error = host( + &api, + host_command(Some("ucsf"), true), + std::future::pending(), + ) + .await + .expect_err("in use"); + assert_eq!( + shown_text(&error, false), + "You already host chen-lab on leo. Open it on this computer instead.\nRun biorouter crew open chen-lab --server alice@leo." + ); + assert_eq!( + failure_body(&error, "m", "req-1")["code"], + "crew_host_name_in_use" + ); + let stopped = "You already host chen-lab on leo. It is stopped: start it with biorouter crew setup --server alice@leo --start chen-lab, then open it."; + let (api, _) = api_with(OutputFormat::Text, setup_daemon(vec![in_use(stopped)])); + let error = host( + &api, + host_command(Some("ucsf"), true), + std::future::pending(), + ) + .await + .expect_err("in use, stopped"); + assert_eq!(shown_text(&error, false), stopped); + } + /// L3: `--fix-permissions` asks setup to fix the storage folders; without it a host whose /// storage others can write to is refused with the daemon's sentence, the flag, and what the /// broker itself said. #[tokio::test] async fn host_offers_and_sends_the_storage_permissions_fix() { - let (api, fake) = api_with(OutputFormat::Text, host_daemon(|_| None)); + let (api, fake) = api_with(OutputFormat::Text, host_daemon()); let mut args = host_command(Some("ucsf"), true); args.fix_permissions = true; host(&api, args, std::future::pending()) .await .expect("hosted"); assert_eq!(setup_requests(&fake)[0]["fix_storage_permissions"], true); - let (api, fake) = api_with(OutputFormat::Text, host_daemon(|_| None)); + let (api, fake) = api_with(OutputFormat::Text, host_daemon()); host( &api, host_command(Some("ucsf"), true), @@ -13660,25 +15909,65 @@ mod tests { ); // The one warning says how to choose otherwise in the command that gave it. let full = Some(AgentAccess::Full); - let warned = |access| full_access_notice(access, full, &connection_with_levels()); + let warned = |access| full_access_notice(access, full, &connection_with_levels(), None); let setup = warned(MemberAccess::Asked(None)).expect("warned"); assert!( setup.starts_with("Setup lets your agent work as you on hpc: "), "{setup}" ); + assert!(setup.contains(FULL_ACCESS_IS_YOU), "{setup}"); assert!( setup.ends_with("add --agent-access folder or --agent-access off."), "{setup}" ); let join = warned(MemberAccess::JoinDefault).expect("warned"); + assert!(join.contains(FULL_ACCESS_IS_YOU), "{join}"); assert!( join.ends_with("run biorouter crew --connection 'UCSF HPC' connections set-access folder, or set-access off."), "{join}" ); - assert_eq!(warned(MemberAccess::Asked(full)), None); + // Live re-test R6: --agent-access full, named, is warned too, with nothing to choose. + assert_eq!( + warned(MemberAccess::Asked(full)).as_deref(), + Some( + "Setup lets your agent work as you on hpc: it can run any command your account can there, starting in a work folder it makes in your home folder. Crew treats anything running as your account as you, so your agent can also add a computer to your account and post in Crew as you, with no agent mark. Public models never get this access." + ) + ); + // A connection with its folder keeps it, and the warning says where it starts. + let mut with_folder = connection_with_levels(); + with_folder["remote_root"] = json!("/scratch/bob/proj"); + let named = full_access_notice(MemberAccess::Asked(full), full, &with_folder, None) + .expect("warned"); + assert!( + named.contains("there, starting in /scratch/bob/proj. Crew treats"), + "{named}" + ); + // On the host's account full access is the host's authority, and the warning says so. + let hosting = full_access_notice( + MemberAccess::Asked(None), + full, + &connection_with_levels(), + Some("lab"), + ) + .expect("warned"); + assert!( + hosting.contains(&format!( + "{FULL_ACCESS_IS_YOU} You host lab from this account, so your agent can also read the workspace's own files on the server, including channels you're not in. It can also act as the host of lab. Public models never get this access." + )), + "{hosting}" + ); assert_eq!(warned(MemberAccess::Unchanged), None); assert_eq!( - full_access_notice(MemberAccess::JoinDefault, None, &own_connection()), + full_access_notice(MemberAccess::JoinDefault, None, &own_connection(), None), + None + ); + assert_eq!( + full_access_notice( + MemberAccess::Asked(Some(AgentAccess::Folder)), + Some(AgentAccess::Folder), + &own_connection(), + None + ), None ); } @@ -14398,4 +16687,1772 @@ mod tests { .count(); assert_eq!(looked_up, 1); } + + // --------------------------------------------------------------------------------------- + // Account ownership (design AO-5, AO-8, AO-9, AO-10, AO-12) + // --------------------------------------------------------------------------------------- + + const LAB_ID: &str = "11111111-0000-4000-8000-000000000001"; + const RUNNING_ID: &str = "11111111-0000-4000-8000-000000000002"; + + /// The workspaces on `wanjun@leo` for its account, as a check job lists them (AO-6). + fn leo_workspaces() -> Value { + json!([ + {"workspace_id": LAB_ID, "name": "baranzini-lab", "role": "host", "host_username": "wanjun", + "state": "stopped", "this_computer": "not_saved", "invitation": "brcrew1:lab"}, + {"workspace_id": RUNNING_ID, "name": "running-lab", "role": "host", "host_username": "wanjun", + "state": "running", "version": "1.94.2", "this_computer": "not_saved", "invitation": "brcrew1:running"}, + {"workspace_id": "11111111-0000-4000-8000-000000000003", "name": "twin", "role": "host", + "state": "stopped", "this_computer": "not_saved"}, + {"workspace_id": "11111111-0000-4000-8000-000000000004", "name": "twin", "role": "host", + "state": "stopped", "this_computer": "not_saved"}, + {"workspace_id": "11111111-0000-4000-8000-000000000005", "name": "shared", "role": "member", + "host_username": "alice", "state": "running", "this_computer": "not_saved", "invitation": "brcrew1:alice"}, + {"workspace_id": "11111111-0000-4000-8000-000000000006", "name": "shared", "role": "invited", + "host_username": "bob", "state": "running", "this_computer": "not_saved", "invitation": "brcrew1:bob"}, + {"workspace_id": "11111111-0000-4000-8000-000000000007", "name": "gone", "role": "removed", + "host_username": "alice", "state": "running", "this_computer": "not_saved"} + ]) + } + + /// A daemon whose setup jobs finish at once on `wanjun@leo`: a check lists `workspaces`, any + /// other job installs. + fn listing_daemon(workspaces: Value) -> impl Fn(&str, &str, Option<&Value>) -> Result { + move |method: &str, path: &str, body: Option<&Value>| { + match (method, path) { + ("POST", "/crew/devices/prepare") => { + return Ok( + json!({"preparation_id": PREPARATION, "public_key": "pk", "device_id": "d"}), + ) + } + ("POST", "/crew/setup") => { + let intent = body.expect("body")["intent"] + .as_str() + .unwrap_or_default() + .to_owned(); + let mut status = setup_status( + &intent, + "finished", + json!({"outcome": {"installed_version": "1.94.2", "reused": true}}), + ); + status["server"]["login"] = json!("wanjun@leo"); + if intent == "check" { + status["workspaces"] = workspaces.clone(); + status["installed"] = json!({"state": "file", "version": "1.94.2"}); + } + return Ok(status); + } + _ => {} + } + standard(method, path, body) + } + } + + /// A daemon whose setup jobs on `wanjun@leo` finish at once: a check lists `busy-lab`, a + /// running workspace the account hosts, and any other job leaves it on its older Crew + /// because 2 other people are connected to it. + fn busy_lab_daemon() -> impl Fn(&str, &str, Option<&Value>) -> Result { + const BUSY: &str = "11111111-0000-4000-8000-000000000009"; + move |method: &str, path: &str, body: Option<&Value>| { + if (method, path) != ("POST", "/crew/setup") { + return standard(method, path, body); + } + let intent = body.expect("body")["intent"] + .as_str() + .unwrap_or_default() + .to_owned(); + let mut status = setup_status( + &intent, + "finished", + json!({"outcome": {"installed_version": "1.94.2", "reused": true}}), + ); + status["server"]["login"] = json!("wanjun@leo"); + if intent == "check" { + status["workspaces"] = json!([{"workspace_id": BUSY, "name": "busy-lab", + "role": "host", "host_username": "wanjun", "state": "running", + "version": "1.94.1", "this_computer": "not_saved"}]); + status["installed"] = json!({"state": "file", "version": "1.94.2"}); + } else { + status["outcome"]["outdated"] = json!([{"workspace_id": BUSY, "name": "busy-lab", + "running_version": "1.94.1", "installed_version": "1.94.2", + "others_connected": 2, "reason": "others_connected"}]); + } + Ok(status) + } + } + + /// C3: what `setup` says about a hosted workspace it left on an older Crew follows what the + /// command asked. `--no-restart` only looked at it, so it says what restarting does; named + /// with `--restart-workspace`, it says that one wasn't restarted. Either way the command it + /// suggests repeats the route flags given and restarts that one workspace alone. + #[tokio::test] + async fn setup_says_a_left_workspace_only_when_it_was_asked_to_restart_it() { + let leo = |args: &mut SetupArgs| { + args.target.server = Some("wanjun@leo".into()); + args.target.port = Some(2222); + }; + let (api, _) = api_with(OutputFormat::Text, busy_lab_daemon()); + let mut args = setup_command(None, false); + leo(&mut args); + args.no_restart = true; + setup(&api, args, std::future::pending()) + .await + .expect("set up"); + let said = printed(&api); + assert_eq!( + said.last().map(String::as_str), + Some("busy-lab runs Crew 1.94.1, not 1.94.2. To restart it on Crew 1.94.2, run biorouter crew setup --server wanjun@leo --port 2222 --restart-workspace busy-lab; 2 other people are connected, and their running agent commands stop."), + "{said:?}" + ); + assert!(!said.iter().any(|line| line.contains("wasn't restarted"))); + + let (api, _) = api_with(OutputFormat::Text, busy_lab_daemon()); + let mut args = setup_command(None, false); + leo(&mut args); + args.restart_workspace = vec!["busy-lab".into()]; + setup(&api, args, std::future::pending()) + .await + .expect("set up"); + let said = printed(&api); + assert_eq!( + said.last().map(String::as_str), + Some("busy-lab still runs Crew 1.94.1, not Crew 1.94.2, and 2 other people are connected to it, so it wasn't restarted. To restart it anyway, run biorouter crew setup --server wanjun@leo --port 2222 --restart-workspace busy-lab; their running agent commands stop."), + "{said:?}" + ); + } + + /// AO-8: setup for a server with nothing saved uses the server target, which makes no device + /// key, and sends the choices made; `--start NAME` is matched to a stopped workspace the + /// account hosts in a check job's listing first, and a name it can't match is refused before + /// anything is changed. + #[tokio::test] + async fn setup_sends_the_account_choices_and_starts_a_workspace_by_name() { + let leo = |args: &mut SetupArgs| { + args.target.server = Some("wanjun@leo".into()); + }; + let (api, fake) = api_with(OutputFormat::Json, listing_daemon(leo_workspaces())); + let mut args = setup_command(None, false); + leo(&mut args); + args.keep_older = true; + args.restart = true; + args.start = vec!["Baranzini-Lab".into()]; + setup(&api, args, std::future::pending()) + .await + .expect("set up"); + let server = json!({"kind": "server", "ssh_target": "wanjun@leo"}); + assert_eq!( + setup_requests(&fake), + [ + json!({"target": server, "intent": "check"}), + json!({"target": server, "intent": "install", "keep_older": true, + "restart_workspaces": true, "start_workspaces": [LAB_ID]}), + ] + ); + assert!(!fake + .sent() + .iter() + .any(|sent| sent.path == "/crew/devices/prepare")); + + // No choices: nothing but the target and the intent. A daemon from before the server + // target refuses it and gets the pending target instead (finding 36). + let (api, fake) = api_with(OutputFormat::Json, listing_daemon(leo_workspaces())); + let mut args = setup_command(None, false); + leo(&mut args); + setup(&api, args, std::future::pending()) + .await + .expect("set up"); + assert_eq!( + setup_requests(&fake), + [json!({"target": server, "intent": "install"})] + ); + let (api, fake) = api_with(OutputFormat::Json, listing_daemon(leo_workspaces())); + let mut args = setup_command(None, false); + leo(&mut args); + args.no_restart = true; + setup(&api, args, std::future::pending()) + .await + .expect("set up"); + assert_eq!(setup_requests(&fake)[0]["restart_workspaces"], false); + + // Doctor checks the server the same way. + let (api, fake) = api_with(OutputFormat::Json, listing_daemon(leo_workspaces())); + run( + &api, + CrewCommand::Doctor(ServerArgs { + server: Some("wanjun@leo".into()), + port: Some(2222), + identity_file: None, + proxy_jump: None, + }), + ) + .await + .expect("doctor"); + assert_eq!( + setup_requests(&fake), + [ + json!({"target": {"kind": "server", "ssh_target": "wanjun@leo", "port": 2222}, "intent": "check"}) + ] + ); + + for (name, said) in [ + ("missing-lab", "You don't host a workspace named missing-lab on wanjun@leo."), + ("twin", "You host 2 workspaces named twin on wanjun@leo, so Biorouter can't tell which one to start."), + ("running-lab", "running-lab is already running on wanjun@leo, so there is nothing to start."), + ("shared", "You don't host a workspace named shared on wanjun@leo."), + ] { + let (api, fake) = api_with(OutputFormat::Json, listing_daemon(leo_workspaces())); + let mut args = setup_command(None, false); + leo(&mut args); + args.start = vec![name.into()]; + let error = setup(&api, args, std::future::pending()) + .await + .expect_err("not started"); + assert_eq!(message(&error), said); + assert_eq!(setup_requests(&fake).len(), 1, "only the check ran"); + } + + // A saved connection is checked over its own connection, then set up as a member's. + let (api, fake) = api_with(OutputFormat::Json, listing_daemon(leo_workspaces())); + let mut args = setup_command(None, false); + args.start = vec!["baranzini-lab".into()]; + setup(&api, args, std::future::pending()) + .await + .expect("member setup"); + let requests = setup_requests(&fake); + assert_eq!( + requests[0], + json!({"target": {"kind": "connection", "connection_id": CONNECTION}, "intent": "check"}) + ); + assert_eq!(requests[1]["intent"], "member"); + assert_eq!(requests[1]["start_workspaces"], json!([LAB_ID])); + } + + /// A daemon on `wanjun@leo` that lists [`leo_workspaces`], saves what it is given as + /// [`SAVED`], and answers that connection's join status with each of `statuses` in turn. + fn opening_daemon( + statuses: Vec, + ) -> impl Fn(&str, &str, Option<&Value>) -> Result { + let listing = listing_daemon(leo_workspaces()); + let statuses = Mutex::new(statuses); + move |method: &str, path: &str, body: Option<&Value>| { + let join = format!("/crew/connections/{SAVED}/join"); + match (method, path) { + ("POST", "/crew/connections/from-invitation") => Ok(json!({"connection": { + "id": SAVED, "name": "baranzini-lab", "ssh_target": "wanjun@leo", + "workspace_id": "w2", "status": "connected", "public_key": "pk" + }})), + ("GET", "/crew/connections") => { + let mut listed = standard(method, path, body)?; + listed["connections"] + .as_array_mut() + .expect("connections") + .push(json!({"id": SAVED, "name": "baranzini-lab", "ssh_target": "wanjun@leo", + "workspace_id": "w2", "status": "connected", "public_key": "pk", + "remote_root": "/home/wanjun/crew-work/lab"})); + Ok(listed) + } + ("GET", path) if path == join => { + let mut statuses = statuses.lock().expect("statuses"); + Ok(if statuses.len() > 1 { + statuses.remove(0) + } else { + statuses[0].clone() + }) + } + ("POST", path) if path == join => Ok( + json!({"status": "joined", "workspace_name": "running-lab", "add_device": false}), + ), + _ => listing(method, path, body), + } + } + } + + fn open_args(name: &str) -> OpenArgs { + OpenArgs { + workspace: name.into(), + server: "wanjun@leo".into(), + host: None, + port: None, + identity_file: None, + proxy_jump: None, + agent_access: None, + local_name: None, + } + } + + /// AO-9: open checks the server with this computer's prepared device, picks the workspace + /// by name, saves it from its invitation line with the login the check used, and joins on + /// `ready` as the account; the saved connection is the one every request is for after it, + /// although another one is saved too. + #[tokio::test] + async fn open_finds_the_workspace_saves_it_and_joins_as_the_account() { + let ready = + json!({"status": "ready", "workspace_name": "running-lab", "add_device": false}); + let (api, fake) = api_with( + OutputFormat::StreamJson, + opening_daemon(vec![ready.clone()]), + ); + let mut args = open_args("running-lab"); + args.local_name = Some("Lab on leo".into()); + let reply = open(&api, args, std::future::pending()) + .await + .expect("opened"); + assert!(matches!(reply, Reply::Streamed)); + assert_eq!( + setup_requests(&fake), + [ + json!({"target": {"kind": "server", "ssh_target": "wanjun@leo", "preparation_id": PREPARATION}, "intent": "check"}) + ] + ); + let save = fake + .sent() + .into_iter() + .find(|sent| sent.path == "/crew/connections/from-invitation") + .and_then(|sent| sent.body) + .expect("saved"); + assert_eq!( + save, + json!({"invitation": "brcrew1:running", "preview": false, "advanced": { + "preparation_id": PREPARATION, "ssh_target": "wanjun@leo", "name": "Lab on leo" + }}) + ); + let joins: Vec = fake + .sent() + .into_iter() + .filter(|sent| sent.path.ends_with("/join")) + .map(|sent| format!("{} {}", sent.method, sent.path)) + .collect(); + assert_eq!( + joins, + [ + format!("GET /crew/connections/{SAVED}/join"), + format!("POST /crew/connections/{SAVED}/join"), + ] + ); + assert_eq!( + output::joined_as_line(Some("running-lab"), "host"), + "You're in running-lab as its host." + ); + assert_eq!( + output::joined_as_line(Some("shared"), "member"), + "You're in shared as a member." + ); + + // Two hosts with a workspace of that name: both are named, and nothing is saved. + let (api, fake) = api_with(OutputFormat::Json, opening_daemon(vec![ready.clone()])); + let error = open(&api, open_args("shared"), std::future::pending()) + .await + .expect_err("two hosts"); + assert_eq!( + message(&error), + "More than one host on wanjun@leo has a workspace named shared: @alice, @bob. Choose one with --host USERNAME." + ); + assert!(!fake + .sent() + .iter() + .any(|sent| sent.path == "/crew/connections/from-invitation")); + // --host chooses one. + let (api, fake) = api_with(OutputFormat::Json, opening_daemon(vec![ready.clone()])); + let mut args = open_args("shared"); + args.host = Some("@bob".into()); + open(&api, args, std::future::pending()) + .await + .expect("opened bob's"); + let save = fake + .sent() + .into_iter() + .find(|sent| sent.path == "/crew/connections/from-invitation") + .and_then(|sent| sent.body) + .expect("saved"); + assert_eq!(save["invitation"], "brcrew1:bob"); + + for (name, said) in [ + ("nope", "No workspace named nope on wanjun@leo is yours or invites you."), + ("baranzini-lab", "baranzini-lab is stopped. Start it with biorouter crew setup --server wanjun@leo --start baranzini-lab, then open it."), + ("gone", "You were removed from gone. Ask a member to invite you again."), + ] { + let (api, fake) = api_with(OutputFormat::Json, opening_daemon(vec![ready.clone()])); + let error = open(&api, open_args(name), std::future::pending()) + .await + .expect_err("not opened"); + assert_eq!(message(&error), said); + assert!(!fake + .sent() + .iter() + .any(|sent| sent.path == "/crew/connections/from-invitation")); + } + + // A workspace this computer already has saved is joined there, not saved again. + let mut listed = leo_workspaces(); + listed[1]["saved_connection_id"] = json!(SAVED); + listed[1]["this_computer"] = json!("saved"); + let listing = listing_daemon(listed); + let opening = opening_daemon(vec![ + json!({"status": "joined", "workspace_name": "running-lab", "add_device": false}), + ]); + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path == "/crew/setup" { + return listing(method, path, body); + } + opening(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Json, handler); + open(&api, open_args("running-lab"), std::future::pending()) + .await + .expect("already saved"); + assert!(!fake + .sent() + .iter() + .any(|sent| sent.path == "/crew/connections/from-invitation")); + assert_eq!(api.connection_id.get().map(String::as_str), Some(SAVED)); + + let (api, _) = api_with(OutputFormat::Json, opening_daemon(vec![ready])); + let mut selected = api; + selected.selected = Some(CONNECTION.into()); + let error = open(&selected, open_args("running-lab"), std::future::pending()) + .await + .expect_err("--connection"); + assert!(message(&error).contains("takes no --connection")); + } + + /// The canonical full-access words (W1) and host clause (W2), for `running-lab`, as written. + const CANONICAL_W1: &str = "Crew treats anything running as your account as you, so your agent can also add a computer to your account and post in Crew as you, with no agent mark."; + const CANONICAL_W2_RUNNING_LAB: &str = "You host running-lab from this account, so your agent can also read the workspace's own files on the server, including channels you're not in. It can also act as the host of running-lab."; + + /// `open running-lab --server wanjun@leo --agent-access full`'s daemon: its check lists + /// running-lab with the account's place in it as `role`. With `saved_login`, this computer + /// already has running-lab saved as that login's connection, which the listing names; with + /// none, open saves it with the login it checked. The connection's own snapshot says its + /// account hosts running-lab when `hosts`. + fn full_access_open_daemon( + role: &'static str, + saved_login: Option<&'static str>, + hosts: bool, + ) -> impl Fn(&str, &str, Option<&Value>) -> Result { + let mut listed = leo_workspaces(); + listed[1]["role"] = json!(role); + if saved_login.is_some() { + listed[1]["saved_connection_id"] = json!(SAVED); + listed[1]["this_computer"] = json!("connected"); + } + let listing = listing_daemon(listed); + let saved = json!({"id": SAVED, "name": "running-lab", + "ssh_target": saved_login.unwrap_or("wanjun@leo"), "workspace_id": RUNNING_ID, + "status": "connected", "public_key": "pk", "remote_root": "/home/crew-work/lab"}); + move |method: &str, path: &str, body: Option<&Value>| { + let own = format!("/crew/connections/{SAVED}"); + match (method, path) { + ("POST", "/crew/connections/from-invitation") => { + assert!( + saved_login.is_none(), + "a saved connection is not saved again" + ); + Ok(json!({"connection": saved.clone()})) + } + ("GET", "/crew/connections") => { + let mut listed = standard(method, path, body)?; + listed["connections"] + .as_array_mut() + .expect("connections") + .push(saved.clone()); + Ok(listed) + } + ("POST", path) + if path == format!("{own}/request") + && body.is_some_and(|body| body["method"] == "workspace.snapshot") => + { + let mut snapshot = snapshot(); + if !hosts { + snapshot["workspace"]["host_uid"] = json!(1001); + } + Ok(snapshot) + } + ("GET", path) if path == format!("{own}/join") => Ok( + json!({"status": "joined", "workspace_name": "running-lab", "add_device": false}), + ), + _ => listing(method, path, body), + } + } + } + + /// Runs `open running-lab --agent-access full` on `daemon` and answers what it printed and + /// the member setup it asked for. + async fn open_with_full_access( + daemon: impl Fn(&str, &str, Option<&Value>) -> Result + Send + Sync + 'static, + ) -> (Vec, Value) { + let (api, fake) = api_with(OutputFormat::Text, daemon); + let mut args = open_args("running-lab"); + args.agent_access = Some(AgentAccess::Full); + open(&api, args, std::future::pending()) + .await + .expect("opened"); + let member = setup_requests(&fake) + .into_iter() + .find(|request| request["intent"] == "member") + .expect("a member setup"); + (printed(&api), member) + } + + /// W1, W2 (review of 2026-11): `open --agent-access full` warns before its member setup, and + /// on the account that hosts the workspace the warning says what the host's account adds. + /// The check's listing says so for the login it checked, without asking the workspace: here + /// the snapshot would say otherwise, so only the listing can have named the host. + #[tokio::test] + async fn open_with_full_access_on_the_hosts_account_warns_as_its_host() { + let (shown, member) = + open_with_full_access(full_access_open_daemon("host", None, false)).await; + assert_eq!( + member, + json!({"target": {"kind": "connection", "connection_id": SAVED}, "intent": "member", "agent_access": "full"}) + ); + let notice = shown + .iter() + .find(|line| line.starts_with("Setup lets your agent work as you on ")) + .unwrap_or_else(|| panic!("no full-access notice: {shown:#?}")); + assert!( + notice.ends_with(&format!( + " {CANONICAL_W1} {CANONICAL_W2_RUNNING_LAB} Public models never get this access." + )), + "{notice}" + ); + } + + /// The review's finding on `Api::hosted_workspace`: `open` goes on with the connection this + /// computer already has saved, which may be another login's on the same server, and the + /// listing names the place of the account it checked. The warning follows the connection + /// that gets full access, from that account's own snapshot: the host's clause is said when + /// that account hosts the workspace although the checked one is a member, and left out when + /// it does not although the checked one hosts it. + #[tokio::test] + async fn open_with_full_access_through_another_logins_connection_asks_that_account() { + let (shown, member) = open_with_full_access(full_access_open_daemon( + "member", + Some("gbellucci@leo"), + true, + )) + .await; + assert_eq!(member["target"]["connection_id"], SAVED); + let notice = shown + .iter() + .find(|line| line.contains(CANONICAL_W1)) + .unwrap_or_else(|| panic!("no full-access notice: {shown:#?}")); + assert!( + notice.contains(&format!("{CANONICAL_W1} {CANONICAL_W2_RUNNING_LAB}")), + "{notice}" + ); + + let (shown, _) = open_with_full_access(full_access_open_daemon( + "host", + Some("gbellucci@leo"), + false, + )) + .await; + let notice = shown + .iter() + .find(|line| line.contains(CANONICAL_W1)) + .unwrap_or_else(|| panic!("no full-access notice: {shown:#?}")); + assert!(!notice.contains("You host"), "{notice}"); + assert!( + notice.ends_with(&format!( + " {CANONICAL_W1} Public models never get this access." + )), + "{notice}" + ); + } + + /// AO-9: join on a server whose Crew knows the account joins on `ready` with the claim an + /// approved code takes, and `removed` ends the command with AO-2's sentence and its code. + #[tokio::test] + async fn join_on_ready_joins_as_the_account_and_a_removal_says_so() { + let statuses = Mutex::new(vec![ + json!({"status": "ready", "workspace_name": "lab", "add_device": false}), + ]); + 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!({"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 claims = fake + .sent() + .iter() + .filter(|sent| sent.method == "POST" && sent.path.ends_with("/join")) + .count(); + assert_eq!(claims, 1); + + let removed = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/join") { + return Ok( + json!({"status": "removed", "workspace_name": "lab", "add_device": false}), + ); + } + standard(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Json, removed); + let error = run(&api, CrewCommand::Join(JoinArgs { no_wait: false })) + .await + .expect_err("removed"); + assert_eq!( + message(&error), + "You were removed from lab. Ask a member to invite you again." + ); + assert_eq!(error_code(&error).as_deref(), Some(JOIN_REMOVED)); + assert!(!fake.sent().iter().any(|sent| sent.method == "POST")); + + let ready = json!({"status": "ready", "workspace_name": "lab"}); + assert_eq!(join_lines(&ready, true), ["Joining lab…"]); + assert_eq!( + join_lines(&ready, false), + ["This computer can join lab as your account on the server. Run biorouter crew join to join."] + ); + } + + /// AO-9: rejoin gives the selected connection a new key in place, connecting first when it + /// is down, and says which computer the workspace removed to stay within its limit. A + /// daemon without the route is told to restart, and a refusal is the daemon's own sentence. + #[tokio::test] + async fn rejoin_adds_this_computer_again_and_names_the_computer_it_replaced() { + let path = format!("/crew/connections/{CONNECTION}/rejoin"); + let down = Arc::new(AtomicBool::new(true)); + let handler = { + let path = path.clone(); + let down = Arc::clone(&down); + move |method: &str, sent: &str, body: Option<&Value>| -> Result { + if sent == path { + if down.swap(false, Ordering::SeqCst) { + return Err(refuse(409, Some(NOT_CONNECTED), "The connection is down.")); + } + return Ok(json!({ + "status": {"status": "joined", "workspace_name": "lab", "add_device": false}, + "role": "member", "admitted": "device", + "principal": {"username": "bob", "display_name": "Bob Lee"}, + "evicted_device": {"fingerprint": "3F2A 9C1E 77B0 D4E1", "added_at": 1_790_200_000} + })); + } + if sent.ends_with("/connect") { + return Ok(json!({"status": "connected"})); + } + standard(method, sent, body) + } + }; + let (api, fake) = api_with(OutputFormat::Text, handler); + let lines = said(run(&api, CrewCommand::Rejoin).await.expect("rejoined")); + // The desktop's words (`rejoinCopy`), then this terminal's command. + assert_eq!( + lines, + [ + "This computer is back in lab. Chats that had Crew access through it need access again.", + "To make room, the workspace removed your oldest computer, 3F2A 9C1E 77B0 D4E1.", + "Give a chat access again with biorouter crew grants grant SESSION CHANNEL.", + ] + ); + let order: Vec = fake + .sent() + .into_iter() + .filter(|sent| sent.method == "POST") + .map(|sent| sent.path) + .collect(); + assert_eq!( + order, + [ + path.clone(), + format!("/crew/connections/{CONNECTION}/connect"), + path.clone() + ] + ); + + let old = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/rejoin") { + return Err(refuse(404, None, "")); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, old); + let error = run(&api, CrewCommand::Rejoin) + .await + .expect_err("old daemon"); + assert_eq!(message(&error), RESTART_FOR_ACCOUNT); + + let unsupported = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/rejoin") { + return Err(refuse( + 409, + Some("crew_account_unsupported"), + "lab's server runs an older Crew. Ask @alice to update it.", + )); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, unsupported); + let error = run(&api, CrewCommand::Rejoin) + .await + .expect_err("older broker"); + assert_eq!( + shown_text(&error, false), + "lab's server runs an older Crew. Ask @alice to update it." + ); + assert_eq!( + failure_body(&error, "m", "req-1")["code"], + "crew_account_unsupported" + ); + } + + /// AO-9: the daemon's sentences for a lost or unrecognized device key name the desktop's + /// button; the terminal says the command that does the same. + #[test] + fn a_lost_device_key_points_at_rejoin() { + for (code, daemon, terminal) in [ + ( + DEVICE_KEY_MISSING, + "This computer no longer has its key for lab. Choose Add this computer again to make a new one.", + "This computer no longer has its key for lab. Run biorouter crew rejoin to make a new one.", + ), + ( + DEVICE_NOT_RECOGNIZED, + "lab no longer recognizes this computer. Choose Add this computer again while you are still a member.", + "lab no longer recognizes this computer. Run biorouter crew rejoin while you are still a member.", + ), + ] { + let error = refuse(409, Some(code), daemon); + assert_eq!(shown_text(&error, false), terminal); + assert_eq!(failure_body(&error, "m", "req-1")["code"], code); + } + assert_eq!( + output::rejoin_sentence("lab lost this computer."), + "lab lost this computer. Run biorouter crew rejoin." + ); + } + + /// The person's own computers in `lab`: this one, added at setup, and one added by signing + /// in as the account, which is new. + fn devices_snapshot() -> Value { + let mut snapshot = snapshot(); + snapshot["actor"]["devices"] = json!([ + {"fingerprint": "70DC FFAF 1751 A59B", "added_at": 1_789_000_000, "added_via": "bootstrap", "this_device": true}, + {"fingerprint": "3F2A 9C1E 77B0 D4E1", "added_at": 1_790_200_000, "added_via": "account", "this_device": false} + ]); + snapshot + } + + /// A daemon whose saved connection reports the account-added computer as new (AO-10), whose + /// snapshot lists both, and which acknowledges. + fn devices_daemon(method: &str, path: &str, body: Option<&Value>) -> Result { + if (method, path) == ("GET", "/crew/connections") { + let mut listed = standard(method, path, body)?; + listed["connections"][0]["new_devices"] = json!([ + {"fingerprint": "3F2A 9C1E 77B0 D4E1", "added_via": "account", "added_at": 1_790_200_000} + ]); + return Ok(listed); + } + if path.ends_with("/devices/acknowledge") { + return Ok(json!({})); + } + if body.and_then(|body| body["method"].as_str()) == Some("workspace.snapshot") { + return Ok(devices_snapshot()); + } + standard(method, path, body) + } + + /// AO-10, AO-5: profile devices lists the person's computers with this one and the new one + /// marked, and --acknowledge marks the new one seen; status names a new computer until + /// then. + #[tokio::test] + async fn profile_devices_marks_this_computer_and_new_ones_and_acknowledges_them() { + let (api, fake) = api_with(OutputFormat::Text, devices_daemon); + let lines = said( + run( + &api, + CrewCommand::Profile(ProfileCommand::Devices { acknowledge: false }), + ) + .await + .expect("devices"), + ); + assert_eq!(lines[0], "Computers enrolled as you in lab:"); + assert!( + lines[1].starts_with(" 70DC FFAF 1751 A59B · added ") + && lines[1].ends_with(" · when the workspace was created · This computer"), + "{lines:?}" + ); + assert!( + lines[2].starts_with(" 3F2A 9C1E 77B0 D4E1 · added ") + && lines[2].ends_with(" · by signing in to the server · new"), + "{lines:?}" + ); + assert!(lines[3].starts_with("1 computer was added since you last looked. If one isn't yours, take its SSH access to the server away first"), "{lines:?}"); + assert!(!fake + .sent() + .iter() + .any(|sent| sent.path.ends_with("/devices/acknowledge"))); + + let (api, fake) = api_with(OutputFormat::Text, devices_daemon); + let lines = said( + run( + &api, + CrewCommand::Profile(ProfileCommand::Devices { acknowledge: true }), + ) + .await + .expect("acknowledged"), + ); + assert_eq!( + lines.last().map(String::as_str), + Some("Marked 1 new computer as seen.") + ); + assert!(fake.sent().iter().any(|sent| sent.method == "POST" + && sent.path == format!("/crew/connections/{CONNECTION}/devices/acknowledge"))); + + let (api, _) = api_with(OutputFormat::Text, devices_daemon); + let shown = said(run(&api, CrewCommand::Status).await.expect("status")).join("\n"); + // C2: in the desktop notice's words (`newDevicesCopy.one`), then the command that checks. + assert!( + shown.contains(" New computer on your account: 3F2A 9C1E 77B0 D4E1, added "), + "{shown}" + ); + assert!( + shown.contains(", 2026 by signing in to the server. Check it with biorouter crew --connection 'UCSF HPC' profile devices."), + "{shown}" + ); + + // Live re-test: profile devices is refused while the connection is down, as every read + // is, so on a disconnected connection the line names connect first. + let disconnected = |method: &str, path: &str, body: Option<&Value>| -> Result { + let mut answer = devices_daemon(method, path, body)?; + if (method, path) == ("GET", "/crew/connections") { + answer["connections"][0]["status"] = json!("disconnected"); + } + Ok(answer) + }; + let (api, _) = api_with(OutputFormat::Text, disconnected); + let shown = said(run(&api, CrewCommand::Status).await.expect("status")).join("\n"); + assert!( + shown.contains(", 2026 by signing in to the server. Connect with biorouter crew --connection 'UCSF HPC' connect, then check it with biorouter crew --connection 'UCSF HPC' profile devices."), + "{shown}" + ); + } + + fn revoke_device_command(fingerprint: &[&str], this_device: bool, yes: bool) -> CrewCommand { + CrewCommand::Profile(ProfileCommand::RevokeDevice { + fingerprint: fingerprint.iter().map(|part| (*part).to_owned()).collect(), + this_device, + yes, + }) + } + + /// AO-5: a device is removed by its fingerprint, however it was spaced, or as this + /// computer; it asks first, a fingerprint none of the person's computers has is refused + /// before anything is sent, and removing this computer says how to add it again. A person + /// reads "Remove" throughout, the warning and the question in the desktop's words. + #[tokio::test] + async fn a_person_revokes_their_own_computers() { + let (api, fake) = api_with(OutputFormat::Text, devices_daemon); + let lines = said( + run( + &api, + revoke_device_command(&["3f2a9c1e", "77b0-d4e1"], false, true), + ) + .await + .expect("revoked"), + ); + assert_eq!( + fake.broker_call("device.revoke"), + Some(json!({"fingerprint": "3F2A 9C1E 77B0 D4E1", "idempotency_key": "req-1"})) + ); + assert_eq!( + lines, + ["Removed 3F2A 9C1E 77B0 D4E1. That computer can no longer use lab as you."] + ); + + let (api, fake) = api_with(OutputFormat::Text, devices_daemon); + let lines = said( + run(&api, revoke_device_command(&[], true, true)) + .await + .expect("revoked this computer"), + ); + assert_eq!( + fake.broker_call("device.revoke").expect("sent")["fingerprint"], + "70DC FFAF 1751 A59B" + ); + assert_eq!( + lines, + [ + "Removed this computer. It can no longer use lab as you.", + "Add this computer again with biorouter crew rejoin." + ] + ); + + let (api, fake) = api_with(OutputFormat::Text, devices_daemon); + let error = run( + &api, + revoke_device_command(&["3F2A 9C1E 77B0 D4E1"], false, false), + ) + .await + .expect_err("no terminal to ask in"); + assert!(error.downcast_ref::().is_some(), "{error:?}"); + assert_eq!( + message(&error), + "Removing a computer needs a yes. There is no terminal to ask in, so add --yes." + ); + assert!(fake.broker_call("device.revoke").is_none()); + for (typed, said) in [ + (&["AAAA BBBB CCCC DDDD"][..], "None of your computers has the fingerprint AAAA BBBB CCCC DDDD. biorouter crew profile devices lists them."), + (&["XYZ"][..], "A fingerprint has 16 letters and digits, like 3F2A 9C1E 77B0 D4E1. biorouter crew profile devices lists yours."), + ] { + let (api, fake) = api_with(OutputFormat::Text, devices_daemon); + let error = run(&api, revoke_device_command(typed, false, true)) + .await + .expect_err("refused"); + assert_eq!(message(&error), said); + assert!(fake.broker_call("device.revoke").is_none()); + } + assert_eq!( + output::remove_device_warning(false), + "A lost computer can still sign in as you while it has an SSH key or password for your account. First remove its key from ~/.ssh/authorized_keys on the server, or change your password. Then remove it here." + ); + assert_eq!( + output::remove_device_warning(true), + "This computer stops reaching the workspace until you add it again with biorouter crew rejoin." + ); + assert_eq!( + output::remove_device_question("3F2A 9C1E 77B0 D4E1", false), + "Remove 3F2A 9C1E 77B0 D4E1 from your account?" + ); + assert_eq!( + output::remove_device_question("70DC FFAF 1751 A59B", true), + "Remove this computer from your account?" + ); + } + + /// A removal daemon whose saved connection says whether its verified hello advertises the + /// account path (`account_identity`, AO-9). + fn removing_with( + account_identity: Value, + ) -> impl Fn(&str, &str, Option<&Value>) -> Result { + let removing = removing(snapshot()); + move |method: &str, path: &str, body: Option<&Value>| { + if (method, path) == ("GET", "/crew/connections") { + let mut listed = standard(method, path, body)?; + listed["connections"][0]["account_identity"] = account_identity.clone(); + return Ok(listed); + } + removing(method, path, body) + } + } + + /// AO-9: on a server whose Crew knows the account, a host's only computer is removed with + /// the typed name alone, and --give-up-host-controls changes nothing; on an older one, or + /// one that hasn't said, the guard stays and says that updating Crew there keeps the host + /// controls. + #[tokio::test] + async fn the_removal_guard_follows_the_broker_kind() { + for give_up in [false, true] { + let (api, fake) = api_with(OutputFormat::Text, removing_with(json!(true))); + let lines = said( + run(&api, remove_connection_command(Some("UCSF HPC"), give_up)) + .await + .expect("removed: the account keeps the workspace"), + ); + assert!(deleted(&fake)); + assert_eq!(lines, ["Removed UCSF HPC from this computer."]); + } + assert_eq!( + output::account_removal_notice(true, "lab", "lab", "hpc", "bob@hpc"), + "You host lab. The host controls stay with your account on hpc: open it again from any computer that signs in as it, with biorouter crew open lab --server bob@hpc." + ); + assert_eq!( + output::account_removal_notice(false, "lab", "lab", "hpc", "bob@hpc"), + "You can open lab again from any computer that signs in as your account on hpc, with biorouter crew open lab --server bob@hpc." + ); + + for account in [json!(false), Value::Null] { + let (api, fake) = api_with(OutputFormat::Text, removing_with(account)); + let error = run(&api, remove_connection_command(Some("UCSF HPC"), false)) + .await + .expect_err("the host's only computer on an older Crew"); + assert_eq!( + error_code(&error).as_deref(), + Some("crew_host_controls_would_end") + ); + let shown = message(&error); + assert!(shown.contains("--give-up-host-controls"), "{shown}"); + assert!( + shown.ends_with("\nOr first update Crew on hpc with biorouter crew setup --server bob@hpc: then the host controls follow your account there, from any computer that signs in as it."), + "{shown}" + ); + assert!(!deleted(&fake)); + } + } + + /// AO-12: the grants list shows a chat the person let read a workspace as them, by the + /// saved connection's name, and `grants revoke --reads` withdraws it. + #[tokio::test] + async fn a_chat_reading_as_the_person_is_listed_and_withdrawn() { + let reads_path = format!("/crew/sessions/{SESSION}/reads/{CONNECTION}"); + let handler = { + let reads_path = reads_path.clone(); + move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/grants") { + return Ok(json!({"grants": [], "person_reads": [ + {"session_id": SESSION, "connection_id": CONNECTION, "session_incarnation": 4, + "granted_at": 1_790_200_000, "session_name": "Counts"} + ]})); + } + if (method, path) == ("DELETE", reads_path.as_str()) { + return Ok(json!({})); + } + standard(method, path, body) + } + }; + let (api, _) = api_with(OutputFormat::Text, handler.clone()); + let Reply::Show(listed, options) = run(&api, CrewCommand::Grants(GrantCommand::List)) + .await + .expect("listed") + else { + panic!("a listing") + }; + assert_eq!(listed["person_reads"][0]["connection_name"], "UCSF HPC"); + let text = output::render_text(&listed, &options); + assert!( + text.starts_with("Chat \"Counts\" (chat 20260924_2) reads UCSF HPC as you · since "), + "{text}" + ); + assert!(!text.contains("No chats have Crew access."), "{text}"); + + let (api, fake) = api_with(OutputFormat::Text, handler); + let lines = said( + run( + &api, + CrewCommand::Grants(GrantCommand::Revoke { + session: SESSION.into(), + reads: true, + }), + ) + .await + .expect("withdrawn"), + ); + assert_eq!( + lines, + ["Chat 20260924_2 can no longer read UCSF HPC as you."] + ); + assert!(fake + .sent() + .iter() + .any(|sent| sent.method == "DELETE" && sent.path == reads_path)); + assert!(!fake + .sent() + .iter() + .any(|sent| sent.path.ends_with("/revoke"))); + + let none = |method: &str, path: &str, body: Option<&Value>| -> Result { + if method == "DELETE" && path.contains("/reads/") { + return Err(refuse(404, Some("crew_not_found"), "Not found.")); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, none); + let error = run( + &api, + CrewCommand::Grants(GrantCommand::Revoke { + session: SESSION.into(), + reads: true, + }), + ) + .await + .expect_err("none to withdraw"); + assert_eq!( + message(&error), + "Chat 20260924_2 had no permission to read UCSF HPC as you." + ); + assert_eq!(error_code(&error).as_deref(), Some("crew_not_found")); + } + + /// AO-12: auth --setup-job signs in only for a job that waits for it, and an older daemon + /// is told to restart. + #[tokio::test] + async fn auth_for_a_setup_job_signs_in_only_when_it_waits() { + let waiting = |state: &'static str| { + move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path == format!("/crew/setup/{JOB}") { + return Ok( + json!({"job_id": JOB, "state": state, "server": {"login": "bob@hpc"}}), + ); + } + standard(method, path, body) + } + }; + let auth = |job: &str| { + CrewCommand::Auth(AuthArgs { + setup_job: Some(job.into()), + }) + }; + let (api, _) = api_with(OutputFormat::Text, waiting("running")); + let error = run(&api, auth(JOB)).await.expect_err("not waiting"); + assert_eq!( + message(&error), + "This setup isn't waiting for anyone to sign in." + ); + assert_eq!( + error_code(&error).as_deref(), + Some("crew_setup_not_waiting_for_sign_in") + ); + // A job that waits goes to the sign-in terminal, which only the real daemon serves. + let (api, _) = api_with(OutputFormat::Text, waiting("needs_sign_in")); + let error = run(&api, auth(JOB)).await.expect_err("no stream here"); + assert_eq!(message(&error), "The test daemon serves no streams"); + let old = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.starts_with("/crew/setup/") { + return Err(refuse(404, None, "")); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, old); + let error = run(&api, auth(JOB)).await.expect_err("old daemon"); + assert_eq!(message(&error), RESTART_FOR_SETUP); + let (api, fake) = api_with(OutputFormat::Text, waiting("running")); + assert!(run(&api, auth("../x")).await.is_err()); + assert!(fake.sent().is_empty()); + } + + /// A daemon refusal with its other fields, as `daemon_client` keeps them. + fn refuse_with(status: u16, code: &str, message: &str, fields: Value) -> anyhow::Error { + FakeRefusal { + status, + code: Some(code.into()), + broker_code: None, + institution_refusal: None, + connection_institution: None, + message: message.to_owned(), + detail: None, + modes: None, + fields: fields.as_object().cloned().unwrap_or_default(), + } + .into() + } + + const OLDER_FOR_HOST: &str = + "running-lab's server runs an older Crew. Update it with biorouter crew setup --server wanjun@leo."; + const OLDER_FOR_MEMBER: &str = "shared's server runs an older Crew. Ask @alice to update it."; + const ENROLLMENT_TOKEN: &str = "This workspace's server can't let people join with a code yet. Ask the host for an enrollment token instead."; + + /// `GET …/account` on a server whose Crew predates the account path: the daemon's + /// `crew_account_unsupported`, for the host when `host`, else for a member. + fn older_account(host: bool) -> anyhow::Error { + if host { + refuse_with( + 409, + ACCOUNT_UNSUPPORTED, + OLDER_FOR_HOST, + json!({"is_host": true, "server_login": "wanjun@leo"}), + ) + } else { + refuse_with( + 409, + ACCOUNT_UNSUPPORTED, + OLDER_FOR_MEMBER, + json!({"is_host": false, "host_username": "alice"}), + ) + } + } + + /// [`opening_daemon`] on a server whose Crew predates the account path: the join status is + /// `state`, and `GET …/account` answers [`older_account`]. + fn older_opening( + state: &'static str, + host: bool, + ) -> impl Fn(&str, &str, Option<&Value>) -> Result { + let opening = opening_daemon(vec![ + json!({"status": state, "workspace_name": "running-lab", "add_device": false}), + ]); + move |method: &str, path: &str, body: Option<&Value>| { + if (method, path) == ("GET", &format!("/crew/connections/{SAVED}/account")) { + return Err(older_account(host)); + } + opening(method, path, body) + } + } + + /// Findings 12 and 15: `open` and `join` against a server whose Crew predates the account + /// path end with `crew_account_unsupported` in AO-9's words for the person's role, instead + /// of waiting for an invitation nobody can send or pointing the host at an enrollment + /// token. An invited person and a newcomer keep the older join. + #[tokio::test] + async fn an_older_crew_on_the_server_is_said_by_role_instead_of_waited_on() { + let opened = |state: &'static str, host: bool, name: &str, from: Option<&str>| { + let (api, fake) = api_with(OutputFormat::Text, older_opening(state, host)); + let mut args = open_args(name); + args.host = from.map(str::to_owned); + async move { + let outcome = tokio::time::timeout( + Duration::from_secs(5), + open(&api, args, std::future::pending()), + ) + .await + .expect("open must not wait for an invitation nobody can send"); + (outcome, fake) + } + }; + // The host, on a server without joining by name: the update, not an enrollment token. + let (outcome, fake) = opened("unsupported", true, "running-lab", None).await; + let error = outcome.expect_err("older Crew"); + assert_eq!(shown_text(&error, false), OLDER_FOR_HOST); + assert_eq!(error_code(&error).as_deref(), Some(ACCOUNT_UNSUPPORTED)); + assert!(!fake + .sent() + .iter() + .any(|sent| sent.method == "POST" && sent.path.ends_with("/join"))); + // The account already has the newer Crew, which the workspace still waits to run. + let mut listed = leo_workspaces(); + listed[1]["binary_current"] = json!(false); + let listing = listing_daemon(listed); + let older = older_opening("not_invited", true); + let handler = move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path == "/crew/setup" { + return listing(method, path, body); + } + older(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let mut args = open_args("running-lab"); + args.port = Some(2222); + let error = tokio::time::timeout( + Duration::from_secs(5), + open(&api, args, std::future::pending()), + ) + .await + .expect("open must not wait") + .expect_err("older Crew still running"); + assert_eq!( + shown_text(&error, false), + format!("{OLDER_FOR_HOST}\nThe Crew installed in your account there is already newer. To restart running-lab on it now, run biorouter crew setup --server wanjun@leo --port 2222 --restart-workspace running-lab; members' running agent commands stop.") + ); + assert_eq!(error_code(&error).as_deref(), Some(ACCOUNT_UNSUPPORTED)); + // A member's new computer, where an older Crew says nobody invited it. + let (outcome, _) = opened("not_invited", false, "shared", Some("alice")).await; + let error = outcome.expect_err("older Crew"); + assert_eq!(shown_text(&error, false), OLDER_FOR_MEMBER); + // An invited person still joins as before: the enrollment token is theirs to ask for. + let (outcome, _) = opened("unsupported", false, "shared", Some("bob")).await; + assert_eq!( + shown_text(&outcome.expect_err("no join by name"), false), + ENROLLMENT_TOKEN + ); + + // `join` asks too, and tells the host apart by the daemon's `is_host`. + let joining = |host: bool| { + move |method: &str, path: &str, body: Option<&Value>| -> Result { + match (method, path) { + ("GET", path) if path.ends_with("/join") => { + Ok(json!({"status": "unsupported", "workspace_name": "lab"})) + } + ("GET", path) if path.ends_with("/account") => Err(older_account(host)), + _ => standard(method, path, body), + } + } + }; + let (api, _) = api_with(OutputFormat::Text, joining(true)); + let error = run(&api, CrewCommand::Join(JoinArgs { no_wait: false })) + .await + .expect_err("older Crew"); + assert_eq!(shown_text(&error, false), OLDER_FOR_HOST); + let (api, _) = api_with(OutputFormat::Text, joining(false)); + let error = run(&api, CrewCommand::Join(JoinArgs { no_wait: false })) + .await + .expect_err("no join by name"); + assert_eq!(shown_text(&error, false), ENROLLMENT_TOKEN); + + // The daemon's own refusal of the join status ends the command in its words. + let refused = |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/join") { + return Err(older_account(true)); + } + standard(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Text, refused); + let error = run(&api, CrewCommand::Join(JoinArgs { no_wait: false })) + .await + .expect_err("older Crew"); + assert_eq!(shown_text(&error, false), OLDER_FOR_HOST); + } + + /// A host job whose last status is `last`, stopped by Ctrl-C (`interrupted`) or elsewhere. + async fn host_stopped_with(last: Value, interrupted: bool, route: bool) -> anyhow::Error { + let (api, fake) = api_with(OutputFormat::Text, host_job_daemon(last, true)); + let mut args = host_command(Some("ucsf"), true); + if route { + args.port = Some(2222); + args.proxy_jump = Some("jump@gw".into()); + } + let error = if interrupted { + host(&api, args, std::future::ready(Ok(()))).await + } else { + host(&api, args, std::future::pending()).await + } + .expect_err("stopped"); + assert_eq!( + fake.sent().iter().any(|sent| sent.method == "DELETE"), + interrupted + ); + error + } + + /// Finding 13: a host job stopped after its start is said like a failed one, from what it + /// did: the steps left after the save, or `open` (with the route given) and the settings + /// before it. Running host again would be refused as a name in use. A job stopped before + /// anything started still says to run the same command again. + #[tokio::test] + async fn a_host_stopped_after_its_start_says_what_is_left_not_to_start_again() { + let cancelled = |outcome: Value| { + let mut status = setup_status( + "host", + "cancelled", + json!({"outcome": outcome, "error": {"code": SETUP_CANCELLED, "message": "Stopped. Nothing more will be done on leo."}}), + ); + status["server"]["login"] = json!("alice@leo"); + status + }; + let saved = cancelled( + json!({"connection_id": SAVED, "steps_left": ["join", "privacy", "governance", "context"]}), + ); + for interrupted in [true, false] { + let error = host_stopped_with(saved.clone(), interrupted, false).await; + assert_eq!( + shown_text(&error, true), + "Started chen-lab on alice@leo and saved it on this computer, then setup was stopped before setting it up.\n\ + To finish, run these in order:\n \ + biorouter crew --connection chen-lab join\n \ + biorouter crew --connection chen-lab privacy set-workspace private --institution ucsf\n \ + biorouter crew --connection chen-lab workspace governance open\n \ + biorouter crew --connection chen-lab workspace context draft --save" + ); + assert_eq!(error_code(&error).as_deref(), Some(SETUP_CANCELLED)); + } + let started = cancelled(json!({"host": {"kind": "found", "text": "brcrew1:abc"}})); + let error = host_stopped_with(started, true, true).await; + assert_eq!( + shown_text(&error, true), + "Started chen-lab on alice@leo, then setup was stopped before it saved it on this computer.\n\ + To finish, run these in order:\n \ + biorouter crew open chen-lab --server alice@leo --port 2222 --proxy-jump jump@gw --agent-access full\n \ + biorouter crew --connection chen-lab privacy set-workspace private --institution ucsf\n \ + biorouter crew --connection chen-lab workspace governance open\n \ + biorouter crew --connection chen-lab workspace context draft --save" + ); + let error = host_stopped_with(cancelled(json!({})), true, false).await; + assert_eq!( + shown_text(&error, true), + "Stopped setting up alice@leo. Run the same command again to finish." + ); + let error = host_stopped_with(cancelled(json!({})), false, false).await; + assert_eq!( + shown_text(&error, true), + "Setting up alice@leo was stopped before it finished. Run the same command again to finish." + ); + } + + /// Finding 36: a daemon from before the account path refuses a request with the server + /// target or a new choice (400 `crew_request_invalid`, its fixed sentence). Setup and doctor + /// with nothing newer asked fall back to the pending target it always took; anything else + /// says to restart the daemon. + #[tokio::test] + async fn an_older_daemon_gets_the_setup_it_reads_or_the_restart_sentence() { + let older = |method: &str, path: &str, body: Option<&Value>| -> Result { + if (method, path) == ("POST", "/crew/setup") { + let request = body.expect("body"); + if uses_newer_setup(request) { + return Err(refuse( + 400, + Some("crew_request_invalid"), + SETUP_REQUEST_SHAPE, + )); + } + } + listing_daemon(json!([]))(method, path, body) + }; + let (api, fake) = api_with(OutputFormat::Json, older); + let mut args = setup_command(Some("wanjun@leo"), false); + args.replace_existing = true; + setup(&api, args, std::future::pending()) + .await + .expect("set up as before"); + let requests = setup_requests(&fake); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1], + json!({"target": {"kind": "pending", "preparation_id": PREPARATION, "ssh_target": "wanjun@leo"}, + "intent": "install", "replace_existing": true}) + ); + let (api, fake) = api_with(OutputFormat::Json, older); + run( + &api, + CrewCommand::Doctor(ServerArgs { + server: Some("wanjun@leo".into()), + port: Some(2222), + identity_file: None, + proxy_jump: None, + }), + ) + .await + .expect("checked as before"); + assert_eq!(setup_requests(&fake)[1]["target"]["kind"], "pending"); + assert_eq!(setup_requests(&fake)[1]["target"]["port"], 2222); + + let (api, _) = api_with(OutputFormat::Json, older); + let mut args = setup_command(Some("wanjun@leo"), false); + args.keep_older = true; + let error = setup(&api, args, std::future::pending()) + .await + .expect_err("a choice it can't read"); + assert_eq!(shown_text(&error, false), RESTART_FOR_SETUP); + let (api, fake) = api_with(OutputFormat::Json, older); + let error = host( + &api, + host_command(Some("ucsf"), true), + std::future::pending(), + ) + .await + .expect_err("host needs the account path"); + assert_eq!(shown_text(&error, false), RESTART_FOR_SETUP); + assert_eq!(setup_requests(&fake).len(), 1); + // A current daemon's other refusals keep their own words. + let invalid = |method: &str, path: &str, body: Option<&Value>| -> Result { + if (method, path) == ("POST", "/crew/setup") { + return Err(refuse( + 400, + Some("crew_request_invalid"), + "Name each workspace to restart by its ID.", + )); + } + listing_daemon(json!([]))(method, path, body) + }; + let (api, _) = api_with(OutputFormat::Json, invalid); + let error = setup( + &api, + setup_command(Some("wanjun@leo"), false), + std::future::pending(), + ) + .await + .expect_err("refused"); + assert_eq!( + shown_text(&error, false), + "Name each workspace to restart by its ID." + ); + } + + /// Finding 39: the commands suggested for a name in use, or a stopped workspace, repeat + /// the port, jump route and key file the person gave. + #[tokio::test] + async fn suggested_commands_keep_the_route_given() { + let in_use = |message: &str| { + setup_status( + "host", + "failed", + json!({"error": {"code": HOST_NAME_IN_USE, "message": message}}), + ) + }; + let routed = || { + let mut args = host_command(Some("ucsf"), true); + args.port = Some(2222); + args.proxy_jump = Some("jump@gw".into()); + args + }; + let (api, _) = api_with( + OutputFormat::Text, + setup_daemon(vec![in_use( + "You already host chen-lab on leo. Open it on this computer instead.", + )]), + ); + let error = host(&api, routed(), std::future::pending()) + .await + .expect_err("in use"); + assert_eq!( + shown_text(&error, false), + "You already host chen-lab on leo. Open it on this computer instead.\nRun biorouter crew open chen-lab --server alice@leo --port 2222 --proxy-jump jump@gw." + ); + let (api, _) = api_with( + OutputFormat::Text, + setup_daemon(vec![in_use("You already host chen-lab on leo. It is stopped: start it with biorouter crew setup --server alice@leo --start chen-lab, then open it.")]), + ); + let error = host(&api, routed(), std::future::pending()) + .await + .expect_err("in use, stopped"); + assert_eq!( + shown_text(&error, false), + "You already host chen-lab on leo. It is stopped: start it with biorouter crew setup --server alice@leo --port 2222 --proxy-jump jump@gw --start chen-lab, then open it." + ); + // open's own sentence for a stopped workspace it found. + let (api, _) = api_with(OutputFormat::Json, opening_daemon(vec![Value::Null])); + let mut args = open_args("baranzini-lab"); + args.port = Some(2222); + let error = open(&api, args, std::future::pending()) + .await + .expect_err("stopped"); + assert_eq!( + message(&error), + "baranzini-lab is stopped. Start it with biorouter crew setup --server wanjun@leo --port 2222 --start baranzini-lab, then open it." + ); + } + + /// L1: inviting on a server whose Crew knows the account says the person joins when they + /// open the workspace, with no code; an older Crew still sends the host a code. + #[test] + fn an_invitation_says_how_the_person_gets_in() { + let result = json!({"username": "bob", "add_device": false}); + assert_eq!( + invite_lines(&result, "bob", None, true) + .last() + .map(String::as_str), + Some("@bob joins as soon as they open the workspace. There is no code to approve.") + ); + assert_eq!( + invite_lines(&result, "bob", None, false).last().map(String::as_str), + Some("When @bob sends you a code, let them in with: biorouter crew enroll approve @bob CODE") + ); + } + + /// L1, end to end: the connection says its server's Crew knows the account. + #[tokio::test] + async fn the_host_invites_on_an_account_server_without_a_code_line() { + let handler = |method: &str, path: &str, body: Option<&Value>| -> Result { + if (method, path) == ("GET", "/crew/connections") { + let mut listed = standard(method, path, body)?; + listed["connections"][0]["account_identity"] = json!(true); + return Ok(listed); + } + if path.contains("/invitation?") { + return Ok( + json!({"message": "Join lab on Crew.\nbrcrew1:abc", "line": "brcrew1:abc"}), + ); + } + match body.and_then(|body| body["method"].as_str()) { + Some("enrollment.invite") => Ok(json!({"username": "bob", "add_device": false})), + _ => standard(method, path, body), + } + }; + let (api, _) = api_with(OutputFormat::Text, handler); + let lines = said( + run( + &api, + CrewCommand::Enroll(EnrollmentCommand::Invite(EnrollInviteArgs { + username: Some("@bob".into()), + add_device: false, + uid: None, + public_key: None, + existing_principal: None, + })), + ) + .await + .expect("invited"), + ); + assert_eq!( + lines.last().map(String::as_str), + Some("@bob joins as soon as they open the workspace. There is no code to approve.") + ); + assert!(!lines.iter().any(|line| line.contains("enroll approve"))); + } + + /// `profile devices --acknowledge` sends the route exactly the fingerprints it showed + /// (finding 37): the route refuses an empty body, and a device added after the list was + /// read stays new. + #[tokio::test] + async fn acknowledging_sends_the_fingerprints_shown() { + let (api, fake) = api_with(OutputFormat::Text, devices_daemon); + run( + &api, + CrewCommand::Profile(ProfileCommand::Devices { acknowledge: true }), + ) + .await + .expect("acknowledged"); + let body = fake + .sent() + .into_iter() + .find(|sent| sent.path.ends_with("/devices/acknowledge")) + .and_then(|sent| sent.body) + .expect("acknowledged"); + assert_eq!(body, json!({"fingerprints": ["3F2A 9C1E 77B0 D4E1"]})); + assert_eq!( + shown_fingerprints(&json!([ + {"fingerprint": "AA"}, {"fingerprint": "AA"}, {"fingerprint": " "}, {}, {"fingerprint": "BB"} + ])), + ["AA", "BB"] + ); + } + + /// `--restart-workspace NAME` restarts the one running workspace the account hosts under + /// that name, by its ID from the listing, and `--restart` still means all of them. A name + /// it hosts none of, more than one of, or one that is stopped, is refused before anything + /// changes. + #[tokio::test] + async fn setup_restarts_a_workspace_by_name() { + let leo = || { + let mut args = setup_command(Some("wanjun@leo"), false); + args.target.port = Some(2222); + args + }; + let (api, fake) = api_with(OutputFormat::Json, listing_daemon(leo_workspaces())); + let mut args = leo(); + args.restart_workspace = vec!["Running-Lab".into()]; + args.no_restart = true; + setup(&api, args, std::future::pending()) + .await + .expect("set up"); + let server = json!({"kind": "server", "ssh_target": "wanjun@leo", "port": 2222}); + assert_eq!( + setup_requests(&fake), + [ + json!({"target": server, "intent": "check"}), + json!({"target": server, "intent": "install", "restart_workspaces": false, + "restart_workspace_ids": [RUNNING_ID]}), + ] + ); + for (name, said) in [ + ("missing-lab", "You don't host a workspace named missing-lab on wanjun@leo."), + ("twin", "You host 2 workspaces named twin on wanjun@leo, so Biorouter can't tell which one to restart."), + ("baranzini-lab", "baranzini-lab is stopped on wanjun@leo, so there is nothing to restart. Start it with biorouter crew setup --server wanjun@leo --port 2222 --start baranzini-lab."), + ] { + let (api, fake) = api_with(OutputFormat::Json, listing_daemon(leo_workspaces())); + let mut args = leo(); + args.restart_workspace = vec![name.into()]; + let error = setup(&api, args, std::future::pending()) + .await + .expect_err("not restarted"); + assert_eq!(message(&error), said); + assert_eq!(setup_requests(&fake).len(), 1, "only the check ran"); + } + } + + /// Two rows with one workspace ID are two rows when their host or key differs, and one row + /// when all three agree; a workspace kept in more than one folder is refused in the + /// server's own words. + #[tokio::test] + async fn listed_rows_are_told_apart_by_id_host_and_key() { + let entry = |host: &str, uid: u32, key: &str| { + json!({"workspace_id": RUNNING_ID, "name": "lab", "role": "member", "host_username": host, + "host_uid": uid, "workspace_key_fingerprint": key, "state": "running", + "this_computer": "not_saved", "invitation": "brcrew1:lab"}) + }; + let (api, _) = api_with( + OutputFormat::Json, + listing_daemon(json!([ + entry("alice", 1001, "K1"), + entry("bob", 1002, "K1") + ])), + ); + let error = open(&api, open_args("lab"), std::future::pending()) + .await + .expect_err("two hosts"); + assert_eq!( + message(&error), + "More than one host on wanjun@leo has a workspace named lab: @alice, @bob. Choose one with --host USERNAME." + ); + let (api, fake) = api_with( + OutputFormat::Json, + listing_daemon(json!([ + entry("alice", 1001, "K1"), + entry("alice", 1001, "K1") + ])), + ); + // One row listed twice: it is chosen, and the save is the next step. + let error = open(&api, open_args("lab"), std::future::pending()) + .await + .expect_err("the test daemon saves nothing"); + assert!(!message(&error).contains("more than one"), "{error:#}"); + assert!(fake + .sent() + .iter() + .any(|sent| sent.path == "/crew/connections/from-invitation")); + + let conflict = "identity_conflict: Workspace 11111111-0000-4000-8000-000000000002 is kept in more than one folder of ~/.local/share/biorouter-crew (lab, lab-2). Move every copy out of that folder, keeping the workspace's own, then try again."; + let mut hosted = entry("wanjun", 1020, "K1"); + hosted["role"] = json!("host"); + hosted["state"] = json!("unknown"); + hosted["error"] = json!(conflict); + let listed = json!({"workspaces": [hosted.clone(), hosted]}); + let said = conflict.trim_start_matches("identity_conflict: "); + assert_eq!( + output::workspace_to_start(&listed, "lab", "leo"), + Err(said.to_owned()) + ); + assert_eq!( + output::workspace_to_restart(&listed, "lab", "leo", ""), + Err(said.to_owned()) + ); + assert_eq!( + output::workspace_to_open(&listed, "lab", None, "leo", "").map(|_| ()), + Err(said.to_owned()) + ); + } + + /// Finding 20: a host whose renamed account collides with a member's name is told what + /// only they can do, not to ask their host; a member keeps the daemon's sentence. + #[tokio::test] + async fn a_host_whose_rename_collides_is_told_what_to_do() { + let daemon_sentence = "Another member of this workspace already has your username. Ask your host to remove the old account first."; + let renamed = |role: &'static str| { + move |method: &str, path: &str, body: Option<&Value>| -> Result { + if path.ends_with("/rejoin") { + return Err(refuse(409, Some(JOIN_IDENTITY_CONFLICT), daemon_sentence)); + } + if path.ends_with("/account") { + return Ok( + json!({"role": role, "workspace_name": "lab", "username": "alice", "expired": false}), + ); + } + standard(method, path, body) + } + }; + let (api, _) = api_with(OutputFormat::Text, renamed("host")); + let error = run(&api, CrewCommand::Rejoin).await.expect_err("conflict"); + assert_eq!( + shown_text(&error, false), + "Crew can't follow your account's new name, because another member already uses it in lab. Rename the account back to @alice on the server and remove that member from lab before renaming it again." + ); + assert_eq!(error_code(&error).as_deref(), Some(JOIN_IDENTITY_CONFLICT)); + let (api, _) = api_with(OutputFormat::Text, renamed("member")); + let error = run(&api, CrewCommand::Rejoin).await.expect_err("conflict"); + assert_eq!(shown_text(&error, false), daemon_sentence); + } + + /// AO-9: a watch that stops because this computer lost its key, or the workspace no longer + /// recognizes it, says how to add it again. + #[tokio::test] + async fn a_watch_stopped_by_a_lost_key_points_at_rejoin() { + let (api, _) = api_with(OutputFormat::Text, standard); + for (code, error) in [ + ( + DEVICE_KEY_MISSING, + "This computer no longer has its key for this workspace", + ), + ( + DEVICE_NOT_RECOGNIZED, + "This workspace no longer recognizes this computer", + ), + ] { + let state = std::sync::Mutex::new(WatchState::default()); + let stopped = match watch_event( + &api, + &state, + "#methods", + ObserveEvent::Error { + code: code.into(), + error: error.into(), + clear: true, + }, + ) + .await + { + Ok(_) => panic!("an error frame ends the watch"), + Err(stopped) => stopped, + }; + assert_eq!( + error_text(&stopped), + format!("Stopped watching #methods: {error}. Run biorouter crew rejoin.") + ); + assert_eq!(failure_body(&stopped, "m", "req-1")["code"], code); + } + } } diff --git a/crates/biorouter-cli/src/commands/crew/output.rs b/crates/biorouter-cli/src/commands/crew/output.rs index fee7b8ab6..435f4030d 100644 --- a/crates/biorouter-cli/src/commands/crew/output.rs +++ b/crates/biorouter-cli/src/commands/crew/output.rs @@ -21,7 +21,7 @@ use super::args::OutputFormat; use anyhow::{ensure, Context, Result}; use chrono::{DateTime, Datelike, FixedOffset, Local, TimeZone, Utc}; -use serde_json::{Map, Value}; +use serde_json::{json, Map, Value}; use std::collections::BTreeMap; use std::fmt::Write as _; use std::io::{Read, Write}; @@ -250,6 +250,11 @@ const REVISION_CONFLICT: &str = "Someone changed the workspace context since you /// sentence of its own. const NAME_CHANGED: &str = "Its name changed since you chose it, so nothing was deleted. Check the name, then try again."; +/// A joiner's claim the host has not approved (`code_mismatch`). A person's machine is a +/// "computer" in every sentence (2026-11), and a workspace keeps running the broker it started +/// with, so the words are this terminal's whatever an older broker wrote ("…this device in"). +const CODE_MISMATCH: &str = + "The host hasn't let this computer in. Send the host the code shown on your screen."; fn identity_conflict(username: Option<&str>) -> String { match username { @@ -425,7 +430,10 @@ pub fn refusal_subject(code: &str, message: &str) -> Option { /// 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) + directory + .people + .get(host) + .map(|person| directory.person_label(person)) } /// A request whose required privacy (`--expected-mode`) is not the connection's (DW-12): both @@ -610,6 +618,7 @@ pub fn broker_refusal_text_in(code: &str, message: &str, place: &RefusalPlace) - identity_conflict(named) } ("device_conflict", _) => DEVICE_CONFLICT.to_owned(), + ("code_mismatch", _) => CODE_MISMATCH.to_owned(), ("identity_mismatch", _) if !reads_as_sentence(sentence) => IDENTITY_MISMATCH.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). @@ -632,6 +641,11 @@ pub fn broker_refusal_text_in(code: &str, message: &str, place: &RefusalPlace) - // 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."), + // The same for an invitation that adds a computer for someone who isn't a member: the + // broker's "Turn off Add another computer…" names the desktop's switch. + ("invalid_params", Some(name)) if lower.contains("is not a member yet") => { + format!("@{name} is not a member yet. Invite them without --add-device.") + } _ if SENTENCE_CODES.contains(&code) && reads_as_sentence(sentence) => sentence.to_owned(), _ => plain_refusal(code, sentence), }; @@ -663,6 +677,9 @@ fn broker_refusal_hint(code: &str, sentence: &str, shown: &str) -> Option { Some("Run: biorouter crew teams delete TEAM".to_owned()) } + "invalid_params" if sentence.to_ascii_lowercase().contains("is not a member yet") => { + username_in(sentence).map(|name| format!("Run: biorouter crew enroll invite @{name}")) + } _ => None, } } @@ -898,6 +915,10 @@ const SETUP_FAILURES: &[(&str, &str)] = &[ "crew_install_incompatible", "The Crew in your account on the server doesn't run, or doesn't say which version it is.", ), + ( + "crew_host_name_in_use", + "You already host a workspace of that name on the server. Open it on this computer instead.", + ), ]; /// Whether `code` is one of the setup routes' codes ([`SETUP_FAILURES`]). @@ -994,19 +1015,163 @@ pub fn setup_stage_words(name: &str) -> String { "check" => "Checking the server".to_owned(), "sign_in" => "Signing in".to_owned(), "install" => "Installing Crew in your account".to_owned(), + "workspaces" => "Looking for workspaces".to_owned(), + "restart" => "Updating running workspaces".to_owned(), "start" => "Starting the workspace".to_owned(), "connect" => "Connecting".to_owned(), "work_folder" => "Making the work folder".to_owned(), "context" => "Reading what the server has, for the workspace context".to_owned(), + "save" => "Saving the workspace on this computer".to_owned(), + "join" => "Joining it as its host".to_owned(), + "configure" => "Setting up the workspace".to_owned(), "done" => "Ready".to_owned(), other => sentence_case(&other.replace('_', " ")), } } +/// The workspaces a setup request names by ID (AO-8): stopped ones to start +/// (`start_workspaces`) and running ones to restart (`restart_workspace_ids`). Its restart stage +/// is said by their names ([`setup_progress_lines`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SetupNamed { + start: Vec, + restart: Vec, +} + +/// What `request` (a `POST /crew/setup` body) names to start and restart ([`SetupNamed`]). +pub fn named_in_setup(request: &Value) -> SetupNamed { + let ids = |key: &str| -> Vec { + list_key(request, key) + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }; + SetupNamed { + start: ids("start_workspaces"), + restart: ids("restart_workspace_ids"), + } +} + +impl SetupNamed { + /// The restart stage in `current`'s words: "Restarting old-lab", "Starting acct-lab", or + /// "Restarting old-lab, then starting acct-lab", each workspace by the name the job's own + /// listing gives its ID (the server restarts before it starts). `None` when nothing is + /// named, or the listing names none of them: the stage then keeps its own words. + fn restart_words(&self, current: &Value) -> Option { + let names = |ids: &[String]| -> Vec { + ids.iter() + .filter_map(|id| { + list_key(current, "workspaces") + .iter() + .find(|workspace| str_field(workspace, "workspace_id") == Some(id.as_str())) + .and_then(|workspace| str_field(workspace, "name")) + .map(display_text) + }) + .collect() + }; + let (restart, start) = (names(&self.restart), names(&self.start)); + match (restart.is_empty(), start.is_empty()) { + (true, true) => None, + (false, true) => Some(format!("Restarting {}", super::and_list(&restart))), + (true, false) => Some(format!("Starting {}", super::and_list(&start))), + (false, false) => Some(format!( + "Restarting {}, then starting {}", + super::and_list(&restart), + super::and_list(&start) + )), + } + } +} + +/// `previous` with the stages `earlier`, another job of the same command, said as it ran (the +/// listing `setup --start` and `open` run first), so a stage is said once in the command +/// ([`setup_progress_lines`]). Its Done stage is not among them: the listing was not the end. +pub fn with_said_stages(previous: &Value, earlier: &Value) -> Value { + let said: Vec = list_key(earlier, "stages") + .iter() + .filter(|stage| str_field(stage, "name") != Some("done")) + .cloned() + .collect(); + if said.is_empty() { + return previous.clone(); + } + let mut stages = list_key(previous, "stages").to_vec(); + stages.extend(said); + json!({"state": previous.get("state").cloned().unwrap_or(Value::Null), "stages": stages}) +} + +/// What a setup request asked to restart or start among the workspaces the account hosts +/// (AO-8, C3), and the route flags its suggested commands repeat. Only a workspace it asked for +/// can be said to have been left running ("wasn't restarted"): every one +/// (`restart_workspaces: true`), or one it named by ID (`restart_workspace_ids`, +/// `start_workspaces`). Any other was only looked at, whatever the job did for the ones beside +/// it, as the desktop's `askedToRestart` (`OpenWorkspaceDialog.tsx`) decides it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SetupAsked { + all: bool, + ids: Vec, + /// The route flags given, already escaped ([`super::route_flags`]). + route: String, +} + +impl SetupAsked { + /// What `request` (a `POST /crew/setup` body, or `Value::Null` for one that asks nothing of + /// the hosted workspaces) asked, with `route`, the route flags the person gave. + pub fn of(request: &Value, route: String) -> Self { + let ids = ["restart_workspace_ids", "start_workspaces"] + .iter() + .flat_map(|key| list_key(request, key)) + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + Self { + all: request.get("restart_workspaces").and_then(Value::as_bool) == Some(true), + ids, + route, + } + } + + /// Whether the request asked to restart `entry`, a workspace the account hosts. + fn asked(&self, entry: &Value) -> bool { + self.all + || str_field(entry, "workspace_id") + .is_some_and(|id| self.ids.iter().any(|asked| asked.eq_ignore_ascii_case(id))) + } +} + +/// The lines a listing ends with when the command goes on to another job (`setup --start`, +/// `open`): its own "Ready" left out, since the command is not done. +pub fn before_the_setup(lines: Vec) -> Vec { + let ready = setup_stage_words("done"); + lines.into_iter().filter(|line| *line != ready).collect() +} + +/// The account's place in the workspace `workspace_id` as a setup check job listed it (`host`, +/// `member` or `invited`), when every row the listing has for that ID agrees. `None` when the +/// listing does not name it, or names it twice with different places. +pub fn listed_role(listing: &Value, workspace_id: Option<&str>) -> Option { + let id = workspace_id?; + let mut roles = list_key(listing, "workspaces") + .iter() + .filter(|workspace| str_field(workspace, "workspace_id") == Some(id)) + .map(|workspace| str_field(workspace, "role")); + let first = roles.next()??; + roles + .all(|role| role == Some(first)) + .then(|| first.to_owned()) +} + /// The lines a setup job's change from `previous` to `current` adds: each stage the first time /// it is seen running or done (a quick stage can finish between two looks), and a sign-in the -/// server asks for. A skipped stage says nothing; a failure is the command's error. -pub fn setup_progress_lines(previous: &Value, current: &Value, login: &str) -> Vec { +/// server asks for. A skipped stage says nothing; a failure is the command's error. The restart +/// stage names the workspaces `named` asks for ([`SetupNamed::restart_words`]). +pub fn setup_progress_lines( + previous: &Value, + current: &Value, + login: &str, + named: &SetupNamed, +) -> Vec { let seen = |status: &Value, name: &str| { list(status, "stages").iter().any(|stage| { str_field(stage, "name") == Some(name) @@ -1016,13 +1181,29 @@ pub fn setup_progress_lines(previous: &Value, current: &Value, login: &str) -> V ) }) }; + // A check that could not list the workspaces says why (`workspaces_note`, in the summary), + // and that is all it found: no bare "Ready" before it. + let unlisted_check = str_field(current, "intent") == Some("check") + && str_field(current, "workspaces_note").is_some(); let mut lines: Vec = list(current, "stages") .iter() .filter(|stage| matches!(str_field(stage, "state"), Some("running" | "done"))) + // AO-8: a job that found workspaces this computer cannot reach says so in place of a + // bare "Ready" (the Done stage's detail), and the summary says it. + .filter(|stage| { + !(str_field(stage, "name") == Some("done") + && (str_field(stage, "detail").is_some() || unlisted_check)) + }) .filter_map(|stage| str_field(stage, "name")) .filter(|name| !seen(previous, name)) .map(|name| match name { "done" => setup_stage_words(name), + "restart" => format!( + "{}…", + named + .restart_words(current) + .unwrap_or_else(|| setup_stage_words(name)) + ), _ => format!("{}…", setup_stage_words(name)), }) .collect(); @@ -1044,7 +1225,16 @@ pub fn setup_progress_lines(previous: &Value, current: &Value, login: &str) -> V /// one: the job's own server facts never say whether the kernel confines commands to a folder /// (only the connection's `server_access`, from its bridge, does, C25), and what agents may do /// is what the connection now says, not what the job could have set (C22). -pub fn setup_summary_lines(status: &Value, login: &str, connection: Option<&Value>) -> Vec { +/// +/// `asked` is what the request asked to restart, with the route flags given ([`SetupAsked`]): +/// it decides whether a workspace still on an older Crew was left or only looked at, and the +/// commands suggested for it reach the server as the person's did. +pub fn setup_summary_lines( + status: &Value, + login: &str, + connection: Option<&Value>, + asked: &SetupAsked, +) -> Vec { let server = setup_server(status, login); let server_access = connection.map_or(&Value::Null, |connection| &connection["server_access"]); let outcome = &status["outcome"]; @@ -1073,8 +1263,9 @@ pub fn setup_summary_lines(status: &Value, login: &str, connection: Option<&Valu Some("symlink") => "~/.local/bin/biorouter-crew there is a symbolic link, which setup never replaces. Remove it, then run biorouter crew setup.".to_owned(), Some("file") => match (str_field(installed, "version").map(safe_text), &artifact) { (Some(version), Some(ours)) => match release_order(&version, ours) { + // AO-8: setup replaces an older Crew by default. Some(std::cmp::Ordering::Less) => format!( - "Crew {version} is installed in your account there. This computer has Crew {ours}; biorouter crew setup --replace-existing updates it." + "Crew {version} is installed in your account there. This computer has Crew {ours}; biorouter crew setup updates it." ), Some(std::cmp::Ordering::Greater) => format!( "Crew {version} is installed in your account there, newer than this computer's {ours}, so setup leaves it in place." @@ -1104,6 +1295,7 @@ pub fn setup_summary_lines(status: &Value, login: &str, connection: Option<&Valu if unconfined { lines.push("Agents can run commands there only with full access: its kernel can't confine them to a folder.".to_owned()); } + lines.extend(setup_workspace_lines(status, login, &server, asked)); return lines; } let version = str_field(outcome, "installed_version") @@ -1137,9 +1329,291 @@ pub fn setup_summary_lines(status: &Value, login: &str, connection: Option<&Valu } lines.extend(member_access_line(outcome, connection, &server)); } + lines.extend(setup_workspace_lines(status, login, &server, asked)); + lines +} + +/// What a setup job found and did about the workspaces on the server (AO-6, AO-8). A check or +/// an install lists the workspaces there for the account, then, in place of a bare "Ready", the +/// daemon's sentences for those this computer cannot reach (the Done stage's detail), or why +/// it could not list them. Every job then says which workspaces it restarted or started, which +/// still run an older Crew and why, and which restarts failed, with the command that starts +/// each by hand. Nothing when the job reported none of it. `asked` is what the request asked to +/// restart ([`SetupAsked`]). +fn setup_workspace_lines( + status: &Value, + login: &str, + server: &str, + asked: &SetupAsked, +) -> Vec { + let mut lines = Vec::new(); + if matches!(str_field(status, "intent"), Some("check" | "install")) { + let workspaces = list_key(status, "workspaces"); + if !workspaces.is_empty() { + lines.push(format!("Workspaces on {server} for your account:")); + lines.extend( + workspaces + .iter() + .map(|workspace| format!(" {}", setup_workspace_row(workspace))), + ); + } + } + // Said in place of "Ready" by any job that found such a workspace. + lines.extend(stage_detail(status, "done")); + if matches!(str_field(status, "intent"), Some("check" | "install")) { + if let Some(note) = str_field(status, "workspaces_note") { + lines.push(safe_text(note)); + } + let scan = &status["workspaces_scan"]; + match scan.get("unsupported").and_then(Value::as_u64).unwrap_or(0) { + 0 => {} + 1 => lines.push(format!( + "Another workspace on {server} runs an older Crew, which can't say whether you belong to it." + )), + n => lines.push(format!( + "{n} other workspaces on {server} run an older Crew, which can't say whether you belong to them." + )), + } + if scan.get("truncated").and_then(Value::as_bool) == Some(true) { + lines.push(format!( + "There was more on {server} than setup looks through at once, so this list may not be complete." + )); + } + } + let outcome = &status["outcome"]; + lines.extend(list_key(outcome, "restarted").iter().map(restarted_line)); + // C3: a check only looks, and any other job only looked at a workspace it was not asked to + // restart, so neither says that one "wasn't restarted". + let check = str_field(status, "intent") == Some("check"); + lines.extend(list_key(outcome, "outdated").iter().map(|entry| { + let look_only = check || !asked.asked(entry); + outdated_line(entry, server, login, look_only, &asked.route) + })); + for failed in list_key(outcome, "restart_failed") { + lines.extend(restart_failed_lines(failed, server, login)); + } lines } +/// A hosted workspace a job could not restart or start (AO-7, AO-8), and what to do about it. +/// Two refusals the server makes before it stops anything are said in their own words, since +/// the job's sentence for a failed restart says the workspace stopped: the installed program is +/// missing or can't run (`program_missing`), or more than one folder holds the workspace +/// (`identity_conflict`). Either way it still runs as it did, and starting it by hand would be +/// refused the same way, so no command is offered for it. +fn restart_failed_lines(failed: &Value, server: &str, login: &str) -> Vec { + let name = listed_name(failed); + let code = str_field(failed, "code").unwrap_or_default(); + let own = server_sentence(str_field(failed, "detail"), code); + match code { + "program_missing" => vec![format!( + "{name}: {} Install it again with biorouter crew setup --server {}.", + own.unwrap_or_else(|| format!( + "The Crew program in your account on {server} is missing or can't run, so it was left as it was." + )), + safe_text(&super::shell_word(login.trim())) + )], + "identity_conflict" => vec![format!( + "{name}: {}", + own.unwrap_or_else(|| format!( + "More than one folder of ~/.local/share/biorouter-crew on {server} holds it, so it was left as it was. Keep its own folder, move the other copies out of that folder, then try again." + )) + )], + _ => { + let mut lines = vec![format!( + "{name}: {}", + str_field(failed, "message") + .map_or_else(|| "it didn't restart.".to_owned(), safe_text) + )]; + if let Some(command) = str_field(failed, "command") { + lines.push(format!( + " To start it by hand, run this on {server}: {}", + safe_text(command) + )); + } + lines + } + } +} + +/// The server's own sentence for its refusal `code` in what it printed (`detail`): a line +/// `Error: code: sentence` or `code: sentence`, or a JSON answer whose `error` says it. +fn server_sentence(detail: Option<&str>, code: &str) -> Option { + let after_code = |text: &str| -> Option { + let text = text.trim(); + let text = text.strip_prefix("Error:").map_or(text, str::trim); + let sentence = text.strip_prefix(code)?.strip_prefix(':')?.trim(); + (!sentence.is_empty()).then(|| safe_text(sentence)) + }; + detail?.lines().find_map(|line| { + after_code(line).or_else(|| { + let answer: Value = serde_json::from_str(line.trim()).ok()?; + after_code(str_field(&answer, "error")?) + }) + }) +} + +/// A workspace a setup job listed, in a sentence: its name, or "A workspace". +fn listed_name(workspace: &Value) -> String { + str_field(workspace, "name").map_or_else(|| "A workspace".to_owned(), display_text) +} + +/// One workspace on the server, as a setup job listed it (AO-6): its name, who hosts it and the +/// account's place in it, whether it runs and on which Crew, and what this computer has for it. +fn setup_workspace_row(workspace: &Value) -> String { + let host = str_field(workspace, "host_username").map(|host| format!("@{}", safe_text(host))); + let role = match (str_field(workspace, "role"), host) { + (Some("host"), _) => "you host it".to_owned(), + (Some("member"), Some(host)) => format!("hosted by {host}, you're a member"), + (Some("member"), None) => "you're a member".to_owned(), + (Some("invited"), Some(host)) => format!("hosted by {host}, you're invited"), + (Some("invited"), None) => "you're invited".to_owned(), + (Some("removed"), _) => "you were removed".to_owned(), + (_, Some(host)) => format!("hosted by {host}"), + (_, None) => "your place in it is unknown".to_owned(), + }; + let state = match ( + str_field(workspace, "state"), + str_field(workspace, "version"), + ) { + (Some("running"), Some(version)) => format!("running Crew {}", safe_text(version)), + (Some("running"), None) => "running".to_owned(), + (Some("stopped"), _) => "stopped".to_owned(), + (Some("deleted"), _) => "deleted".to_owned(), + (Some("storage_failed"), _) => "its storage failed".to_owned(), + _ => "state unknown".to_owned(), + }; + let here = match str_field(workspace, "this_computer") { + Some("connected") => "connected on this computer", + Some("saved") => "saved on this computer", + _ => "not on this computer", + }; + format!("{} · {role} · {state} · {here}", listed_name(workspace)) +} + +/// A workspace a setup job restarted on the installed Crew, or started (AO-8). +fn restarted_line(entry: &Value) -> String { + let name = listed_name(entry); + let from = str_field(entry, "from_version").map(safe_text); + let to = str_field(entry, "to_version").map(safe_text); + match (from, to) { + (Some(from), Some(to)) if from != to => { + format!("Restarted {name}: it ran Crew {from} and now runs Crew {to}.") + } + (_, Some(to)) => format!("{name} now runs Crew {to}."), + (_, None) => format!("{name} is running again."), + } +} + +/// A workspace the account hosts that still runs an older Crew than the one installed, and why +/// setup left it (AO-8), with the command that restarts it where one can, repeating the route +/// flags given (`route`). `look_only` is a workspace the job was not asked to restart (a check, +/// `--no-restart`, a setup that named another one, or one that named none, C3): it says what +/// updating does, never that the workspace "wasn't restarted", which only a run asked to +/// restart it and that left it running can say. +fn outdated_line(entry: &Value, server: &str, login: &str, look_only: bool, route: &str) -> String { + let name = listed_name(entry); + let running = str_field(entry, "running_version").map_or_else( + || "an older Crew".to_owned(), + |version| format!("Crew {}", safe_text(version)), + ); + let installed = str_field(entry, "installed_version").map_or_else( + || "the Crew installed there".to_owned(), + |version| format!("Crew {}", safe_text(version)), + ); + let setup = format!( + "biorouter crew setup --server {}{route}", + safe_text(&super::shell_word(login.trim())) + ); + let restart = restart_one_command(entry, &setup); + match str_field(entry, "reason") { + Some("running_newer") => { + format!("{name} runs {running}, newer than {installed}, so it was left running.") + } + Some("custom_state_dir") => format!( + "{name} still runs {running}, not {installed}, and keeps its state outside ~/.local/share/biorouter-crew, so setup can't restart it. Restart it on {server} yourself." + ), + _ if look_only => looked_outdated_line(entry, &name, &restart), + Some("others_connected") => { + let who = others_connected(entry) + .unwrap_or_else(|| "other people may be connected".to_owned()); + format!( + "{name} still runs {running}, not {installed}, and {who} to it, so it wasn't restarted. To restart it anyway, run {restart}; their running agent commands stop." + ) + } + _ => format!("{name} still runs {running}, not {installed}. Run {setup} to restart it."), + } +} + +/// The command that restarts only `entry` (`setup`, then the workspace), never every outdated +/// one the account hosts as `--restart` would: by its name, else by its ID, which +/// `--restart-workspace` takes too. One with neither gets the plain update, which restarts it +/// once nobody is connected to it. +fn restart_one_command(entry: &Value, setup: &str) -> String { + match str_field(entry, "name").or_else(|| str_field(entry, "workspace_id")) { + Some(named) => format!( + "{setup} --restart-workspace {}", + safe_text(&super::shell_word(named)) + ), + None => setup.to_owned(), + } +} + +/// Who else is connected to a hosted workspace, as the server counted them +/// (`others_connected`): one, how many, or "may be" when it did not say. `None` when it counted +/// nobody. +fn others_connected(entry: &Value) -> Option { + match entry.get("others_connected").and_then(Value::as_u64) { + Some(0) => None, + Some(1) => Some("1 other person is connected".to_owned()), + Some(n) => Some(format!("{n} other people are connected")), + None => Some("other people may be connected".to_owned()), + } +} + +/// [`outdated_line`] for a workspace the job only looked at: which Crew it runs, and the command +/// that restarts it on the installed one (`restart`), with who else is connected and what +/// stops for them (the desktop's "Update Crew restarts it." for the same row, C3). +fn looked_outdated_line(entry: &Value, name: &str, restart: &str) -> String { + let running = str_field(entry, "running_version").map(safe_text); + let installed = str_field(entry, "installed_version").map(safe_text); + let runs = match (&running, &installed) { + (Some(running), Some(installed)) => format!("runs Crew {running}, not {installed}"), + (Some(running), None) => format!("runs Crew {running}"), + (None, _) => "runs an older Crew".to_owned(), + }; + let on = installed.map_or_else( + || "the Crew installed there".to_owned(), + |installed| format!("Crew {installed}"), + ); + match others_connected(entry) { + Some(who) => format!( + "{name} {runs}. To restart it on {on}, run {restart}; {who}, and their running agent commands stop." + ), + None => format!("{name} {runs}. To restart it on {on}, run {restart}."), + } +} + +/// A stage's detail, when the daemon wrote one: what it found or why it was skipped. +fn stage_detail(status: &Value, name: &str) -> Option { + list(status, "stages") + .iter() + .find(|stage| str_field(stage, "name") == Some(name)) + .and_then(|stage| str_field(stage, "detail")) + .map(safe_text) +} + +/// Why a job skipped the stage `name`, as the daemon said it (`host`'s Configure, AO-9). +pub fn skipped_stage_detail(status: &Value, name: &str) -> Option { + list(status, "stages") + .iter() + .find(|stage| { + str_field(stage, "name") == Some(name) && str_field(stage, "state") == Some("skipped") + }) + .and_then(|stage| str_field(stage, "detail")) + .map(safe_text) +} + /// The version an install replaced, as the daemon reports it: `upgraded_from` (a version, or /// `{version, sha256}` beside the new digest), else, when the replaced program was kept as /// `biorouter-crew.previous`, the version the check found before the install (nothing checks @@ -1205,6 +1679,591 @@ fn release_order(left: &str, right: &str) -> Option { Some(parts(left)?.cmp(&parts(right)?)) } +// --------------------------------------------------------------------------------------------- +// Account ownership (design AO-5, AO-9, AO-10, AO-12) +// --------------------------------------------------------------------------------------------- + +/// The desktop's button in the daemon's sentences for a lost or unrecognized device key +/// (`crew_device_key_missing`, `crew_device_not_recognized`), said as the command that does +/// the same here. +pub fn rejoin_sentence(sentence: &str) -> String { + const DESKTOP: &str = "Choose Add this computer again"; + const TERMINAL: &str = "Run biorouter crew rejoin"; + if sentence.contains(DESKTOP) { + sentence.replace(DESKTOP, TERMINAL) + } else { + format!("{} {TERMINAL}.", sentence.trim_end()) + } +} + +/// Said before a connection is removed (AO-11): the chats granted through it stay bound to it, +/// and a computer that only lost its key keeps the connection with `rejoin`. +pub const REMOVAL_KEEPS_CHATS_HERE: &str = "Chats given Crew access through it can't get it through a new connection. If this computer only lost its key, run biorouter crew rejoin instead: it keeps the connection."; + +/// The command that opens `name` from `login` (AO-9). +fn open_command(name: &str, login: &str) -> String { + open_command_routed(name, login, "") +} + +/// [`open_command`] with the route flags the person gave (`route`, already escaped), so it +/// reaches the server the same way. +fn open_command_routed(name: &str, login: &str, route: &str) -> String { + format!( + "biorouter crew open {} --server {}{route}", + safe_text(&super::shell_word(name.trim())), + safe_text(&super::shell_word(login.trim())) + ) +} + +/// The line that points at `open` for a workspace the account hosts and runs (AO-9), with the +/// route flags the person gave (`route`, already escaped). +pub fn open_command_line(name: &str, login: &str, route: &str) -> String { + format!("Run {}.", open_command_routed(name, login, route)) +} + +/// The daemon's sentence for a stopped workspace whose name is in use (`crew_host_name_in_use`), +/// with the route flags the person gave (`route`, already escaped) put after the login in its +/// `--start` command, which names the login alone. Left as it is when it holds no such command, +/// or when the daemon already wrote a route there. +pub fn with_route_in_start(sentence: &str, login: &str, route: &str) -> String { + let server = format!("--server {}", safe_text(&super::shell_word(login.trim()))); + let plain = format!("{server} --start "); + if route.is_empty() || !sentence.contains(&plain) { + return sentence.to_owned(); + } + sentence.replacen(&plain, &format!("{server}{route} --start "), 1) +} + +/// What removing a connection to a server whose Crew knows the account means (AO-9): nothing +/// is lost for good, since any computer that signs in as the account opens it again, the host +/// controls included. `workspace` is already escaped for display; `name` is the workspace's own +/// name for the command. +pub fn account_removal_notice( + host: bool, + workspace: &str, + name: &str, + server: &str, + login: &str, +) -> String { + let open = open_command(name, login); + if host { + format!( + "You host {workspace}. The host controls stay with your account on {server}: open it again from any computer that signs in as it, with {open}." + ) + } else { + format!( + "You can open {workspace} again from any computer that signs in as your account on {server}, with {open}." + ) + } +} + +/// Added to the host-controls refusal on a server whose Crew predates the account path (AO-9). +pub fn update_crew_for_host_controls(server: &str, login: &str) -> String { + format!( + "Or first update Crew on {server} with biorouter crew setup --server {}: then the host controls follow your account there, from any computer that signs in as it.", + safe_text(&super::shell_word(login.trim())) + ) +} + +/// AO-2's sentence for an account the workspace removed (`crew_join_removed`). +pub fn removed_sentence(workspace: Option<&str>) -> String { + let workspace = workspace + .map(str::trim) + .filter(|name| !name.is_empty()) + .map_or_else(|| "this workspace".to_owned(), display_text); + format!("You were removed from {workspace}. Ask a member to invite you again.") +} + +/// "You're in lab as its host." (AO-9): the join `open` ends with, with the person's role. +pub fn joined_as_line(workspace: Option<&str>, role: &str) -> String { + let workspace = workspace + .map(str::trim) + .filter(|name| !name.is_empty()) + .map_or_else(|| "the workspace".to_owned(), display_text); + match role { + "host" => format!("You're in {workspace} as its host."), + _ => format!("You're in {workspace} as a member."), + } +} + +/// What `auth --setup-job` says once the sign-in it opened ended. +pub fn setup_signed_in_line(server: &str) -> String { + format!("Signed in to {server}. The setup goes on by itself.") +} + +/// What tells one listed workspace from another: its ID, its host's UID and its key. Another +/// host or another key can present the same ID, and those are then two rows; entries that agree +/// on all three are one row, listed once. +fn row_key(workspace: &Value) -> (Option<&str>, Option, Option<&str>) { + ( + str_field(workspace, "workspace_id"), + workspace.get("host_uid").and_then(Value::as_u64), + str_field(workspace, "workspace_key_fingerprint"), + ) +} + +/// The workspaces a setup job listed whose name is `name`, case aside, one per row +/// ([`row_key`]). +fn named<'a>(status: &'a Value, name: &str) -> Vec<&'a Value> { + let mut rows: Vec<&Value> = Vec::new(); + for workspace in list_key(status, "workspaces").iter().filter(|workspace| { + str_field(workspace, "name").is_some_and(|listed| listed.eq_ignore_ascii_case(name)) + }) { + if !rows.iter().any(|row| row_key(row) == row_key(workspace)) { + rows.push(workspace); + } + } + rows +} + +/// The server's own sentence for a workspace whose ID more than one folder of the account holds +/// (`error` set to `identity_conflict: …` by the listing): nothing addressed by that ID can run +/// until the copies are moved, so it is said in place of anything else about it. +fn identity_conflict_in(workspace: &Value) -> Option { + let error = str_field(workspace, "error")?.trim(); + let sentence = error.strip_prefix("identity_conflict:")?.trim(); + (!sentence.is_empty()).then(|| safe_text(sentence)) +} + +/// Why a name could not be matched because nothing was listed: the job's own note. +fn unlisted(status: &Value, server: &str) -> Option { + let note = str_field(status, "workspaces_note")?; + list_key(status, "workspaces").is_empty().then(|| { + format!( + "Biorouter couldn't list the workspaces on {server}. {}", + safe_text(note) + ) + }) +} + +/// The command that starts the stopped workspace `name` the account hosts on `server` (AO-8), +/// with the route flags the person gave (`route`, already escaped), so it reaches the server +/// the same way. +fn start_command(server: &str, route: &str, name: &str) -> String { + format!( + "biorouter crew setup --server {}{route} --start {}", + safe_text(&super::shell_word(server)), + safe_text(&super::shell_word(name)) + ) +} + +/// [`named`], else, when no workspace has that name, the ones whose ID it is: the ID is what a +/// setup summary names a workspace by when the server gave it no name (C3). +fn named_or_id<'a>(status: &'a Value, name: &str) -> Vec<&'a Value> { + let by_name = named(status, name); + if !by_name.is_empty() { + return by_name; + } + let mut rows: Vec<&Value> = Vec::new(); + for workspace in list_key(status, "workspaces").iter().filter(|workspace| { + str_field(workspace, "workspace_id").is_some_and(|id| id.eq_ignore_ascii_case(name)) + }) { + if !rows.iter().any(|row| row_key(row) == row_key(workspace)) { + rows.push(workspace); + } + } + rows +} + +/// The one workspace the account hosts under `name` (or with that ID, [`named_or_id`]) in a +/// check job's listing that is not deleted, for `setup --start` and `--restart-workspace` +/// (`verb`: `start` or `restart`), or the sentence that says why there is none. +fn hosted_one<'a>( + status: &'a Value, + name: &str, + server: &str, + verb: &str, +) -> Result<&'a Value, String> { + let shown = display_text(name); + if let Some(sentence) = unlisted(status, server) { + return Err(sentence); + } + let hosted: Vec<&Value> = named_or_id(status, name) + .into_iter() + .filter(|workspace| str_field(workspace, "role") == Some("host")) + .collect(); + let live: Vec<&Value> = hosted + .iter() + .copied() + .filter(|workspace| str_field(workspace, "state") != Some("deleted")) + .collect(); + match (live.as_slice(), hosted.is_empty()) { + ([], true) => Err(format!("You don't host a workspace named {shown} on {server}.")), + ([], false) => Err(format!( + "{shown} on {server} was deleted, so it can't be {verb}ed." + )), + ([one], _) => identity_conflict_in(one).map_or(Ok(*one), Err), + (many, _) => Err(format!( + "You host {} workspaces named {shown} on {server}, so Biorouter can't tell which one to {verb}.", + many.len() + )), + } +} + +/// `setup --start NAME` (AO-8): the ID of the one stopped workspace the account hosts under +/// `name` in a check job's listing, or the sentence that says why there is none to start. +pub fn workspace_to_start(status: &Value, name: &str, server: &str) -> Result { + let typed = name.trim(); + let shown = display_text(typed); + let one = hosted_one(status, typed, server, "start")?; + match str_field(one, "state") { + Some("stopped") => str_field(one, "workspace_id") + .map(str::to_owned) + .ok_or_else(|| format!("The server didn't say which workspace {shown} is.")), + Some("running") => Err(format!( + "{shown} is already running on {server}, so there is nothing to start." + )), + _ => Err(format!( + "{shown} on {server} can't be started from here: Biorouter couldn't read whether it runs." + )), + } +} + +/// `setup --restart-workspace NAME` (AO-8): the ID of the one running workspace the account +/// hosts under `name` in a check job's listing, or the sentence that says why there is none to +/// restart. A stopped one is pointed at `--start`, with the route flags given (`route`). +pub fn workspace_to_restart( + status: &Value, + name: &str, + server: &str, + route: &str, +) -> Result { + let typed = name.trim(); + let shown = display_text(typed); + let one = hosted_one(status, typed, server, "restart")?; + match str_field(one, "state") { + Some("running") => str_field(one, "workspace_id") + .map(str::to_owned) + .ok_or_else(|| format!("The server didn't say which workspace {shown} is.")), + Some("stopped") => Err(format!( + "{shown} is stopped on {server}, so there is nothing to restart. Start it with {}.", + start_command(server, route, typed) + )), + _ => Err(format!( + "{shown} on {server} can't be restarted from here: Biorouter couldn't read whether it runs." + )), + } +} + +/// The host's username a setup job listed for a workspace. +fn host_of(workspace: &Value) -> Option { + str_field(workspace, "host_username").map(str::to_owned) +} + +/// `open NAME --server LOGIN` (AO-9): the one workspace a check job listed under `name` (and +/// `host`'s, when given) that can be opened, or the sentence that says why there is none. Two +/// hosts with a workspace of that name are named, and neither is chosen. Rows are told apart +/// by ID, host and key ([`row_key`]), so one workspace listed twice is still one. `route` is the +/// route flags the person gave, already escaped, which a suggested command repeats. +pub fn workspace_to_open( + status: &Value, + name: &str, + host: Option<&str>, + server: &str, + route: &str, +) -> Result { + let typed = name.trim(); + let shown = display_text(typed); + if let Some(sentence) = unlisted(status, server) { + return Err(sentence); + } + let host = host + .map(|host| host.trim().trim_start_matches('@')) + .filter(|host| !host.is_empty()); + let found: Vec<&Value> = named(status, typed) + .into_iter() + .filter(|workspace| host.is_none_or(|host| host_of(workspace).as_deref() == Some(host))) + .collect(); + let openable: Vec<&Value> = found + .iter() + .copied() + .filter(|workspace| { + str_field(workspace, "state") != Some("deleted") + && str_field(workspace, "role") != Some("removed") + }) + .collect(); + let hosted_by = host + .map(|host| format!(" hosted by @{}", safe_text(host))) + .unwrap_or_default(); + let one = match openable.as_slice() { + [one] => *one, + [] => return Err(none_to_open(status, &found, typed, &hosted_by, server)), + many => return Err(ambiguous_open(many, &shown, &hosted_by, server)), + }; + if let Some(sentence) = identity_conflict_in(one) { + return Err(sentence); + } + if str_field(one, "state") == Some("stopped") { + return Err(if str_field(one, "role") == Some("host") { + format!( + "{shown} is stopped. Start it with {}, then open it.", + start_command(server, route, typed) + ) + } else { + let host = host_of(one).map_or_else( + || "its host".to_owned(), + |host| format!("@{}", safe_text(&host)), + ); + format!("{shown} isn't running on {server}. Ask {host} to start it.") + }); + } + if str_field(one, "invitation").is_none() { + return Err(format!("The server didn't say how to open {shown}.")); + } + Ok(one.clone()) +} + +/// Why [`workspace_to_open`] found nothing to open among the rows named `typed` (`found`): the +/// person was removed, the host deleted it, or nothing of that name is theirs. +fn none_to_open( + status: &Value, + found: &[&Value], + typed: &str, + hosted_by: &str, + server: &str, +) -> String { + let shown = display_text(typed); + match found.first() { + Some(workspace) if str_field(workspace, "role") == Some("removed") => { + removed_sentence(Some(typed)) + } + Some(_) => format!("{shown} on {server} was deleted by its host."), + None => { + let mut sentence = format!( + "No workspace named {shown}{hosted_by} on {server} is yours or invites you." + ); + if status["workspaces_scan"]["unsupported"] + .as_u64() + .is_some_and(|n| n > 0) + { + sentence.push_str(" Some workspaces there run an older Crew, which can't say whether you belong to them: open one of those with the invitation its host sends."); + } + sentence + } + } +} + +/// Why [`workspace_to_open`] cannot choose among `many` rows of one name: the hosts, when there +/// are several, so `--host` chooses. +fn ambiguous_open(many: &[&Value], shown: &str, hosted_by: &str, server: &str) -> String { + let mut hosts: Vec = many + .iter() + .filter_map(|workspace| host_of(workspace)) + .collect(); + hosts.sort(); + hosts.dedup(); + if hosts.len() > 1 { + format!( + "More than one host on {server} has a workspace named {shown}: {}. Choose one with --host USERNAME.", + hosts + .iter() + .map(|host| format!("@{}", safe_text(host))) + .collect::>() + .join(", ") + ) + } else { + format!( + "There is more than one workspace named {shown}{hosted_by} on {server}, so Biorouter can't tell which one to open." + ) + } +} + +/// What `rejoin` says (AO-9), in the desktop's words (`rejoinCopy.done` and `evicted` in +/// `ui/desktop/src/components/crew/account/copy.ts`): this computer is back in the workspace and +/// the chats that had access through it need it again, the oldest computer the workspace +/// removed to make room, then the command that gives a chat access again. +pub fn rejoined_lines(answer: &Value) -> Vec { + let workspace = str_field(&answer["status"], "workspace_name") + .map_or_else(|| "the workspace".to_owned(), display_text); + let mut lines = vec![format!( + "This computer is back in {workspace}. Chats that had Crew access through it need access again." + )]; + if let Some(evicted) = answer + .get("evicted_device") + .filter(|device| device.is_object()) + { + lines.push(match str_field(evicted, "fingerprint") { + Some(fingerprint) => format!( + "To make room, the workspace removed your oldest computer, {}.", + safe_text(fingerprint) + ), + None => "To make room, the workspace removed your oldest computer.".to_owned(), + }); + } + lines.push( + "Give a chat access again with biorouter crew grants grant SESSION CHANNEL.".to_owned(), + ); + lines +} + +/// The hex digits of a fingerprint as a person typed it, uppercased, or `None` when it holds +/// anything but hex digits, spaces and dashes. +fn fingerprint_hex(text: &str) -> Option { + let digits: String = text + .chars() + .filter(|ch| !ch.is_whitespace() && *ch != '-') + .collect(); + digits + .chars() + .all(|ch| ch.is_ascii_hexdigit()) + .then(|| digits.to_ascii_uppercase()) +} + +/// How a computer was added (`added_via`), in the desktop's words for it in its new-computer +/// notice and in Keys (`addedViaCopy` in `ui/desktop/src/components/crew/account/copy.ts`), the +/// words a chat reads too ([`biorouter::crew::added_via_words`]). A value this build does not +/// know has no words, never its raw code. +fn added_via(device: &Value) -> Option<&'static str> { + str_field(device, "added_via").and_then(biorouter::crew::added_via_words) +} + +/// Whether two device entries are the same computer, by fingerprint. +fn same_device(left: &Value, right: &Value) -> bool { + let digits = |device: &Value| str_field(device, "fingerprint").and_then(fingerprint_hex); + digits(left).is_some() && digits(left) == digits(right) +} + +/// `profile devices` (AO-10): the computers enrolled as the person (`devices`), which one is +/// this computer, which are new since they last looked (`new_devices`), and what to do about a +/// new one that isn't theirs. +pub fn profile_device_lines(value: &Value, options: &HumanOptions) -> Vec { + let ctx = Ctx::new(options.directory.clone(), options.show_ids, options.clock); + let workspace = str_field(value, "workspace_name") + .map_or_else(|| "this workspace".to_owned(), display_text); + let devices = list_key(value, "devices"); + let new = list_key(value, "new_devices"); + let mut lines = if devices.is_empty() { + vec![format!("No computers are enrolled as you in {workspace}.")] + } else { + vec![format!("Computers enrolled as you in {workspace}:")] + }; + for device in devices { + let mut row = ctx.device_row(device); + if new.iter().any(|added| same_device(added, device)) { + row.push_str(" · new"); + } + lines.push(format!(" {row}")); + } + if new.is_empty() { + return lines; + } + if value.get("acknowledged").and_then(Value::as_bool) == Some(true) { + lines.push(format!( + "Marked {} as seen.", + count(new.len(), "new computer", "new computers") + )); + } else { + lines.push(format!( + "{} added since you last looked. If one isn't yours, take its SSH access to the server away first, then remove it with biorouter crew profile revoke-device FINGERPRINT. Mark them seen with biorouter crew profile devices --acknowledge.", + if new.len() == 1 { + "1 computer was".to_owned() + } else { + format!("{} computers were", new.len()) + } + )); + } + lines +} + +/// `profile revoke-device` (AO-5): the device of the person's own the command names, from +/// their snapshot: this computer's (`this_device`), or the one with the fingerprint typed. +pub fn device_to_revoke( + snapshot: &Value, + fingerprint: &[String], + this_device: bool, +) -> Result { + let devices = list_key(&snapshot["actor"], "devices"); + if this_device { + return devices + .iter() + .find(|device| device.get("this_device").and_then(Value::as_bool) == Some(true)) + .cloned() + .ok_or_else(|| { + "The workspace doesn't list this computer among yours, so there is nothing to remove here. biorouter crew profile devices lists them." + .to_owned() + }); + } + let typed = fingerprint.join(" "); + let wanted = fingerprint_hex(&typed).filter(|digits| digits.len() == 16); + let Some(wanted) = wanted else { + return Err( + "A fingerprint has 16 letters and digits, like 3F2A 9C1E 77B0 D4E1. biorouter crew profile devices lists yours." + .to_owned(), + ); + }; + devices + .iter() + .find(|device| { + str_field(device, "fingerprint").and_then(fingerprint_hex).as_deref() == Some(&wanted) + }) + .cloned() + .ok_or_else(|| { + format!( + "None of your computers has the fingerprint {}. biorouter crew profile devices lists them.", + safe_text(typed.trim()) + ) + }) +} + +/// The desktop's SSH-first warning before another computer is removed (`deviceCopy.sshWarning` +/// in `ui/desktop/src/components/crew/account/copy.ts`), word for word (AO-5): a computer that +/// can still sign in as the person can add itself again. +pub const REMOVE_DEVICE_SSH_FIRST: &str = "A lost computer can still sign in as you while it has an SSH key or password for your account. First remove its key from ~/.ssh/authorized_keys on the server, or change your password. Then remove it here."; + +/// Said before a computer is removed (AO-5), in the desktop's words: for this computer, that it +/// stops reaching the workspace until it is added again (`deviceCopy.thisFollows`, its button +/// here the `rejoin` command); for another, that its SSH access goes first +/// ([`REMOVE_DEVICE_SSH_FIRST`]). +pub fn remove_device_warning(here: bool) -> String { + if here { + "This computer stops reaching the workspace until you add it again with biorouter crew rejoin." + .to_owned() + } else { + REMOVE_DEVICE_SSH_FIRST.to_owned() + } +} + +/// The question `profile revoke-device` asks on a terminal, in the desktop's words +/// (`deviceCopy.confirm` and `confirmThis`). +pub fn remove_device_question(fingerprint: &str, here: bool) -> String { + if here { + "Remove this computer from your account?".to_owned() + } else { + format!("Remove {} from your account?", safe_text(fingerprint)) + } +} + +/// What `profile revoke-device` says once the workspace removed the computer (AO-5). +pub fn device_revoked_lines(fingerprint: &str, here: bool, workspace: &str) -> Vec { + if here { + vec![ + format!("Removed this computer. It can no longer use {workspace} as you."), + "Add this computer again with biorouter crew rejoin.".to_owned(), + ] + } else { + vec![format!( + "Removed {}. That computer can no longer use {workspace} as you.", + safe_text(fingerprint) + )] + } +} + +/// `grants revoke SESSION --reads` once the chat's permission is gone (AO-12). +pub fn person_read_revoked_sentence(session: &str, workspace: &str) -> String { + format!( + "Chat {} can no longer read {workspace} as you.", + safe_text(session) + ) +} + +/// `grants revoke SESSION --reads` for a chat that had no such permission (AO-12). +pub fn no_person_read_sentence(session: &str, workspace: &str) -> String { + format!( + "Chat {} had no permission to read {workspace} as you.", + safe_text(session) + ) +} + /// Markdown text as it is printed, line by line, each made terminal-safe. pub fn markdown_lines(text: &str) -> Vec { body_lines(text) @@ -1615,6 +2674,29 @@ impl Directory { } } + /// The active member a former principal's account is now, when there is one: the same + /// username, and the same UID where both are known. A member who was removed and joined + /// again, or whose earlier entry the workspace replaced, has a new principal; what they + /// wrote under the old one is theirs, so it is shown as that member, not as a former + /// member (live finding). `None` for an active principal, or one nobody holds now. + fn now_member(&self, person: &Person) -> Option<&Person> { + if person.active { + return None; + } + let username = person.username.as_deref()?; + self.people.values().find(|other| { + other.active + && other.username.as_deref() == Some(username) + && (other.uid.is_none() || person.uid.is_none() || other.uid == person.uid) + }) + } + + /// How `person` reads in a line: as the member their account is now ([`Self::now_member`]), + /// else as themselves. + fn person_label(&self, person: &Person) -> String { + self.now_member(person).unwrap_or(person).label() + } + /// The host: projected by newer brokers, else the active principal holding the host's UID /// (one active principal per UID, so the match is unique). fn host_id(&self) -> Option<&str> { @@ -1739,10 +2821,10 @@ pub fn person_label(directory: &Directory, principal_id: &str, show_ids: bool) - /// the ID when asked. #[allow(dead_code)] // For the command layer's confirmations and refusals. pub fn authority_label(directory: &Directory, principal_id: &str, show_ids: bool) -> String { - let label = directory - .people - .get(principal_id) - .map_or_else(|| UNKNOWN_MEMBER.to_owned(), Person::label); + let label = directory.people.get(principal_id).map_or_else( + || UNKNOWN_MEMBER.to_owned(), + |person| directory.person_label(person), + ); Ctx::new(directory.clone(), show_ids, Clock::System).with_id(label, "ID", Some(principal_id)) } @@ -2043,9 +3125,10 @@ impl Ctx { } fn person(&self, id: Option<&str>) -> String { - let label = id - .and_then(|id| self.dir.people.get(id)) - .map_or_else(|| UNKNOWN_MEMBER.to_owned(), |person| person.label()); + let label = id.and_then(|id| self.dir.people.get(id)).map_or_else( + || UNKNOWN_MEMBER.to_owned(), + |person| self.dir.person_label(person), + ); self.with_id(label, "ID", id) } @@ -2143,11 +3226,18 @@ impl Ctx { ]; for (title, key, row) in sections { let mut items = list_key(snapshot, key).to_vec(); + // What the workspace sent, which its totals count, before any entry is folded. + let listed = items.len(); let noun = match key { - "principals" | "former_principals" => { + "principals" => { people_in_order(&mut items, self.dir.host_id(), self.dir.actor_id.as_deref()); "" } + "former_principals" => { + people_in_order(&mut items, self.dir.host_id(), self.dir.actor_id.as_deref()); + self.one_line_per_account(&mut items); + "" + } "teams" => { teams_in_order(&mut items); "teams" @@ -2160,7 +3250,7 @@ impl Ctx { "references" => "remote references", _ => "agent grants", }; - let note = partial_list_note(noun, items.len(), snapshot["totals"][key].as_u64()); + let note = partial_list_note(noun, listed, snapshot["totals"][key].as_u64()); if items.is_empty() && note.is_none() { if matches!(title, "People" | "Teams" | "Channels") { out.push(format!("{title}: none")); @@ -2172,6 +3262,12 @@ impl Ctx { out.extend(row(self, item).into_iter().map(|line| format!(" {line}"))); } out.extend(note.map(|note| format!(" {note}"))); + if key == "former_principals" { + out.extend( + self.alike_former_note(&items) + .map(|note| format!(" {note}")), + ); + } } out } @@ -2198,7 +3294,11 @@ impl Ctx { .and_then(|id| self.dir.people.get(id)) .cloned() .unwrap_or_else(|| person_from(principal)); - let mut row = person.label(); + // An earlier entry of someone who is a member again is listed as that member. + let mut row = match self.dir.now_member(&person) { + Some(member) => format!("{} · a member again", member.label()), + None => person.label(), + }; if self.is_actor(id) { row.push_str(" · you"); } @@ -2218,11 +3318,71 @@ impl Ctx { vec![row] } + /// Former members as one line per account (live finding): an account removed and let in + /// again twice left two earlier entries, and both read "@gbellucci · a member again". The + /// earlier entries of a member again are one line, that member's + /// ([`Directory::now_member`]). Every other entry keeps its own line: the broker sends no UID + /// for a former member, so two entries of one username may be two accounts (one deleted on + /// the server and a new one made with its name), and folding them would hide one. + fn one_line_per_account(&self, former: &mut Vec) { + let mut members = std::collections::BTreeSet::new(); + former.retain( + |principal| match self.dir.now_member(&self.listed_person(principal)) { + Some(member) => members.insert((member.username.clone(), member.uid)), + None => true, + }, + ); + } + + /// Said under the former members when two of their lines read alike: entries of one + /// username that no member holds now, which only their IDs tell apart. + fn alike_former_note(&self, former: &[Value]) -> Option { + if self.show_ids { + return None; + } + let mut seen = std::collections::BTreeSet::new(); + former + .iter() + .map(|principal| self.listed_person(principal)) + .filter(|person| self.dir.now_member(person).is_none()) + .filter_map(|person| person.username) + .any(|username| !seen.insert(username)) + .then(|| { + "Some former members share a username. Add --show-ids to tell them apart." + .to_owned() + }) + } + + /// The person a listed principal is: as the directory names its ID, else as it says itself. + fn listed_person(&self, principal: &Value) -> Person { + str_field(principal, "id") + .and_then(|id| self.dir.people.get(id)) + .cloned() + .unwrap_or_else(|| person_from(principal)) + } + + /// How many of a team's or channel's `members` (principal IDs) are members now. An ID the + /// workspace lists as a former member is an earlier entry of someone, who is counted by the + /// ID they hold now, if they are in it at all; an ID nothing names is counted. + fn active_members(&self, members: &[Value]) -> usize { + members + .iter() + .filter_map(Value::as_str) + .filter(|id| self.dir.people.get(*id).is_none_or(|person| person.active)) + .count() + } + fn profile(&self, principal: &Value) -> Vec { let mut out = self.member_row(principal); let devices = list_key(principal, "devices"); if !devices.is_empty() { - out.push(format!(" Devices ({}):", devices.len())); + // A person's machine is a computer (2026-11), headed as the desktop's Keys heads it. + let heading = if self.is_actor(str_field(principal, "id")) { + "Computers enrolled as you" + } else { + "Computers" + }; + out.push(format!(" {heading} ({}):", devices.len())); for device in devices { out.push(format!(" {}", self.device_row(device))); } @@ -2231,24 +3391,78 @@ impl Ctx { } fn device_row(&self, device: &Value) -> String { - let mut parts = vec![ - str_field(device, "fingerprint").map_or_else(|| "Unknown device".into(), safe_text) - ]; + let mut parts = + vec![str_field(device, "fingerprint") + .map_or_else(|| "Unknown computer".into(), safe_text)]; if let Some(at) = device.get("added_at").and_then(Value::as_i64) { parts.push(format!("added {}", self.date(at))); } - if let Some(via) = str_field(device, "added_via") { - let via = match via { - "bootstrap" => "workspace setup".into(), - "token" => "enrollment token".into(), - "invitation_code" => "invitation code".into(), - other => sentence_case(other).to_lowercase(), - }; - parts.push(format!("via {via}")); + parts.extend(added_via(device).map(str::to_owned)); + // AO-5: the key that signed the snapshot this row came from. + if device.get("this_device").and_then(Value::as_bool) == Some(true) { + parts.push("This computer".into()); } parts.join(" · ") } + /// A new computer in the desktop's notice's words (`newDevicesCopy.one`): its fingerprint, + /// then when and how it was added, as known ("3F2A 9C1E 77B0 D4E1, added Sep 23, 2026 by + /// signing in to the server"). + fn new_device_text(&self, device: &Value) -> String { + let fingerprint = + str_field(device, "fingerprint").map_or_else(|| "Unknown computer".into(), safe_text); + let when = device + .get("added_at") + .and_then(Value::as_i64) + .map(|at| self.date(at)); + let added: Vec = when + .into_iter() + .chain(added_via(device).map(str::to_owned)) + .collect(); + if added.is_empty() { + fingerprint + } else { + format!("{fingerprint}, added {}", added.join(" ")) + } + } + + /// AO-10: a line under a connection for each computer added to the person's account there + /// since they last looked, until they mark it seen. + fn new_device_lines(&self, connection: &Value) -> Vec { + let devices = list_key(connection, "new_devices"); + if devices.is_empty() { + return Vec::new(); + } + let command = |verb: &str| match str_field(connection, "name") { + Some(name) => format!( + "biorouter crew --connection {} {verb}", + safe_text(&super::shell_word(name)) + ), + None => format!("biorouter crew {verb}"), + }; + // `profile devices` reads the workspace, so on a connection that is down it is refused + // until it connects, as every read is: the line names connect first. + let down = str_field(connection, "status").is_some_and(|status| status != "connected"); + let check = if down { + format!( + "Connect with {}, then check it with {}.", + command("connect"), + command("profile devices") + ) + } else { + format!("Check it with {}.", command("profile devices")) + }; + devices + .iter() + .map(|device| { + format!( + " New computer on your account: {}. {check}", + self.new_device_text(device) + ) + }) + .collect() + } + fn team_row(&self, team: &Value) -> Vec { let shown = display_or_name(team); let mut name = shown.map_or_else(|| "this team".into(), display_text); @@ -2260,7 +3474,7 @@ impl Ctx { } let mut parts = vec![name]; if let Some(members) = team.get("members").and_then(Value::as_array) { - parts.push(count(members.len(), "member", "members")); + parts.push(count(self.active_members(members), "member", "members")); } match str_field(team, "created_by") { creator if self.is_actor(creator) => parts.push("created by you".into()), @@ -2289,7 +3503,7 @@ impl Ctx { parts.push("Archived".into()); } if let Some(members) = channel.get("members").and_then(Value::as_array) { - parts.push(count(members.len(), "member", "members")); + parts.push(count(self.active_members(members), "member", "members")); } match str_field(channel, "owner_id") { owner if self.is_actor(owner) => parts.push("you own it".into()), @@ -2391,6 +3605,7 @@ impl Ctx { )]; out.extend(server_storage_lines(connection)); out.extend(workspace_deleted_lines(connection)); + out.extend(self.new_device_lines(connection)); out.extend(last_error_lines(connection)); out } @@ -2454,6 +3669,7 @@ impl Ctx { if let Some(file) = str_field(connection, "identity_file") { out.push(format!(" SSH key file: {}", safe_text(file))); } + out.extend(self.new_device_lines(connection)); out.extend(last_error_lines(connection)); out.extend(self.detail_ids(connection, CONNECTION_IDS)); out @@ -2638,12 +3854,19 @@ impl Ctx { /// daemon keeps (`replaced_grants`, F3): replaced by a new grant to the same chat, or left /// by a deleted chat whose id a new chat now holds. fn grants(&self, value: &Value) -> Vec { - let mut out = self.rows( - value, - "grants", - "No chats have Crew access.", - Self::grant_row, - ); + // AO-12: a chat the person let read a workspace as them is listed after the grants. + let reads = list_key(value, "person_reads"); + let mut out = if reads.is_empty() || !list_key(value, "grants").is_empty() { + self.rows( + value, + "grants", + "No chats have Crew access.", + Self::grant_row, + ) + } else { + Vec::new() + }; + out.extend(reads.iter().flat_map(|read| self.person_read_row(read))); let replaced = value .get("replaced_grants") .and_then(Value::as_array) @@ -2705,7 +3928,26 @@ impl Ctx { if let Some(epoch) = grant.get("policy_epoch").and_then(Value::as_u64) { let _ = write!(row, " · policy epoch {epoch}"); } - vec![self.with_id(row, "task ID", str_field(grant, "run_id"))] + vec![self.with_id(row, "task ID", str_field(grant, "run_id"))] + } + + /// A chat the person let read a workspace as them (AO-12): the chat, the workspace (the + /// saved connection's name the command added), and since when. `grants revoke SESSION + /// --reads` withdraws it. + fn person_read_row(&self, read: &Value) -> Vec { + let session = + str_field(read, "session_id").map_or_else(|| "this chat".to_owned(), safe_text); + let chat = match str_field(read, "session_name").filter(|title| !title.trim().is_empty()) { + Some(title) => format!("Chat {} (chat {session})", quoted_name(title)), + None => format!("Chat {session}"), + }; + let workspace = str_field(read, "connection_name") + .map_or_else(|| "its workspace".to_owned(), display_text); + let mut row = format!("{chat} reads {workspace} as you"); + if let Some(at) = read.get("granted_at").and_then(Value::as_i64) { + let _ = write!(row, " · since {}", self.date(at)); + } + vec![row] } /// A grant's state as the desktop's access list says it (`accessRows.ts`): a stop the @@ -2729,7 +3971,7 @@ impl Ctx { if stopped { return match str_field(grant, "revocation") { Some("unconfirmed") => { - "Stopped on this device; the workspace hasn't confirmed yet".into() + "Stopped on this computer; the workspace hasn't confirmed yet".into() } Some("ended_by_workspace") => "Ended: Crew settings changed".into(), _ if task => task_over(), @@ -3033,7 +4275,7 @@ impl Ctx { str_field(principal, "id"), ); let mut out = vec![format!("Signed in to {workspace} as {person}.")]; - out.extend(self.detail_ids(value, &[("device_id", "Device ID")])); + out.extend(self.detail_ids(value, &[("device_id", "Computer ID")])); if let Some(workspace) = value.get("workspace") { out.extend(self.detail_ids(workspace, &[("id", "Workspace ID")])); } @@ -3057,7 +4299,7 @@ impl Ctx { value, &[ ("preparation_id", "Preparation ID"), - ("device_id", "Device ID"), + ("device_id", "Computer ID"), ], )); out.push(format!("The preparation ID {PREPARATION_ID_USES}.")); @@ -3078,7 +4320,7 @@ impl Ctx { ), format!(" {token}"), ]; - out.extend(self.detail_ids(value, &[("uid", "UID"), ("device_id", "Device ID")])); + out.extend(self.detail_ids(value, &[("uid", "UID"), ("device_id", "Computer ID")])); out } @@ -3255,7 +4497,7 @@ const CONNECTION_IDS: &[(&str, &str)] = &[ ("workspace_id", "Workspace ID"), ("workspace_public_key", "Workspace key"), ("node_id", "Node ID"), - ("device_id", "Device ID"), + ("device_id", "Computer ID"), ("public_key", "Device key"), ("cluster_connection_id", "Cluster connection ID"), ("socket_path", "Socket"), @@ -4119,6 +5361,111 @@ mod tests { assert_no_machine_ids(&text); } + /// Live finding: a member who was removed and joined again has a new principal, and what + /// they wrote under the old one is shown as that member, not as a former member. Another + /// account that once had the name (another UID) stays a former member. + #[test] + fn an_earlier_entry_of_a_member_again_is_shown_as_that_member() { + let snapshot = json!({ + "workspace": {"id": WORKSPACE, "name": "lab", "host_uid": 1000}, + "actor": {"id": ALICE, "username": "alice", "uid": 1000}, + "principals": [ + {"id": ALICE, "username": "alice", "uid": 1000}, + {"id": BOB, "username": "dave", "display_name": "Dave Old", "uid": 1022} + ], + "former_principals": [ + {"id": DAVE, "username": "dave", "display_name": "Dave Old", "uid": 1022, "active": false}, + {"id": CAROL, "username": "carol", "uid": 1030, "active": false} + ] + }); + let page = json!({ + "messages": [message("0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", DAVE, NOW - 86_400 * 3, "Old notes.")], + "people": {DAVE: {"username":"dave","display_name":"Dave Old","active":false}}, + "channel_names": {GENERAL: "general"}, + "cursor": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", + }); + let text = named(&page, &snapshot); + assert_eq!( + text, + format!( + "{} (@dave) · Sep 21 01:50 · Restricted Old notes.", + q("Dave Old") + ) + ); + let directory = Directory::from_snapshot(&snapshot); + assert_eq!( + authority_label(&directory, DAVE, false), + format!("{} (@dave)", q("Dave Old")) + ); + let shown = named(&snapshot, &snapshot); + assert!( + shown.contains(&format!(" {} (@dave) · a member again", q("Dave Old"))), + "{shown}" + ); + assert!(shown.contains(" @carol · former member"), "{shown}"); + // The same name held by another account now is not the same person. + let mut other = snapshot.clone(); + other["principals"][1]["uid"] = json!(1099); + assert_eq!( + authority_label(&Directory::from_snapshot(&other), DAVE, false), + format!("{} (@dave) · former member", q("Dave Old")) + ); + } + + /// The restart refusals made before anything is stopped are said in the server's own + /// words, with no command to start the workspace by hand: it still runs, and that command + /// would be refused the same way. + #[test] + fn a_restart_refused_before_any_stop_says_why_in_its_own_words() { + let status = json!({ + "intent": "install", + "outcome": {"installed_version": "1.94.2", "restart_failed": [ + {"workspace_id": "a", "name": "lab", "code": "identity_conflict", + "message": "lab on leo stopped but didn't start again. Start it on leo with x.", + "command": "biorouter-crew start --workspace-id a", + "detail": "Error: identity_conflict: Workspace a is kept in more than one folder of ~/.local/share/biorouter-crew (lab, lab-2). Move every copy out of that folder, keeping the workspace's own, then try again."}, + {"workspace_id": "b", "name": "old-lab", "code": "program_missing", + "message": "old-lab on leo stopped but didn't start again. Start it on leo with x.", + "command": "biorouter-crew start --workspace-id b", + "detail": "{\"error\": \"program_missing: /home/w/.local/bin/biorouter-crew is missing or cannot run, so the running broker was left as it is.\"}"}, + {"workspace_id": "c", "name": "bare-lab", "code": "program_missing", + "message": "bare-lab stopped.", "command": "biorouter-crew start --workspace-id c"} + ]} + }); + let lines = setup_summary_lines(&status, "wanjun@leo", None, &SetupAsked::default()); + assert_eq!( + lines[1..], + [ + "lab: Workspace a is kept in more than one folder of ~/.local/share/biorouter-crew (lab, lab-2). Move every copy out of that folder, keeping the workspace's own, then try again.", + "old-lab: /home/w/.local/bin/biorouter-crew is missing or cannot run, so the running broker was left as it is. Install it again with biorouter crew setup --server wanjun@leo.", + "bare-lab: The Crew program in your account on wanjun@leo is missing or can't run, so it was left as it was. Install it again with biorouter crew setup --server wanjun@leo.", + ] + ); + } + + /// L3: the listing a job reports when it ends is the one printed, so a workspace it just + /// started or restarted reads as running on the new Crew, never as it was before. + #[test] + fn the_listing_after_a_start_shows_what_runs_now() { + let status = json!({ + "intent": "install", + "server": {"login": "wanjun@leo"}, + "workspaces": [{"workspace_id": "a", "name": "acct-lab", "role": "host", "state": "running", + "version": "1.94.2", "binary_current": true, "this_computer": "connected"}], + "outcome": {"installed_version": "1.94.2", "reused": true, + "restarted": [{"workspace_id": "a", "name": "acct-lab", "to_version": "1.94.2"}]} + }); + assert_eq!( + setup_summary_lines(&status, "wanjun@leo", None, &SetupAsked::default()), + [ + "Crew 1.94.2 was already installed in your account on wanjun@leo.", + "Workspaces on wanjun@leo for your account:", + " acct-lab · you host it · running Crew 1.94.2 · connected on this computer", + "acct-lab now runs Crew 1.94.2.", + ] + ); + } + #[test] fn show_ids_appends_the_ids_to_history() { let text = with_ids(&history(), &alice_snapshot()); @@ -4269,13 +5616,15 @@ mod tests { "Former members (1):".into(), format!(" {} (@dave) · former member", q("Dave Old")), "Teams (1):".into(), + // Dave, a former member, is still on the team's and channel's lists; he is not + // counted (live re-test). format!( - " Crew QA Lab (crew-qa-lab) · 3 members · created by {}", + " Crew QA Lab (crew-qa-lab) · 2 members · created by {}", alice() ), "Channels (1):".into(), format!( - " #general · Crew QA Lab · Restricted · 3 members · owner {}", + " #general · Crew QA Lab · Restricted · 2 members · owner {}", alice() ), "Invitations (1):".into(), @@ -4313,7 +5662,7 @@ mod tests { let profile = named(&bob_snapshot()["actor"], &bob_snapshot()); assert_eq!( profile, - format!("{} · you\n Devices (1):\n 70DC-FFAF-1751-A59B · added Sep 24, 2026 · via enrollment token", bob()) + format!("{} · you\n Computers enrolled as you (1):\n 70DC-FFAF-1751-A59B · added Sep 24, 2026 · with an invitation token", bob()) ); for text in [channels, invites, profile] { assert_no_machine_ids(&text); @@ -4567,7 +5916,7 @@ mod tests { ), ( json!({"kind": "chat", "expired": true, "revocation": "unconfirmed", "remote_revocation_confirmed": false}), - format!("Chat 20260924_3 {base} · Stopped on this device; the workspace hasn't confirmed yet · policy epoch 4"), + format!("Chat 20260924_3 {base} · Stopped on this computer; the workspace hasn't confirmed yet · policy epoch 4"), ), ( json!({"kind": "chat", "expired": true, "revocation": "ended_by_workspace", "remote_revocation_confirmed": null}), @@ -4601,7 +5950,7 @@ mod tests { [ format!("Chat 20260924_3 {base} · Active · policy epoch 4"), "Earlier access no chat holds any more:".into(), - format!(" Chat 20260924_3 {base} · Stopped on this device; the workspace hasn't confirmed yet · policy epoch 4"), + format!(" Chat 20260924_3 {base} · Stopped on this computer; the workspace hasn't confirmed yet · policy epoch 4"), "Biorouter confirms these with the workspace by itself whenever the connection is up.".into(), ] .join("\n") @@ -4990,7 +6339,7 @@ mod tests { "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\ + Preparation ID: {CONNECTION}\n Computer 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." ) @@ -5004,6 +6353,25 @@ mod tests { format!("Send this enrollment token to your colleague privately. It expires in 1 hour.\n {token}") ); assert!(!text.contains(DEVICE) && !text.contains("10002")); + // A person's machine is a computer wherever --show-ids names it: the token, a saved + // connection and a prepared key alike. + let token_ids = with_ids(&invite, &json!({})); + assert!( + token_ids.contains(&format!("\n Computer ID: {DEVICE}")), + "{token_ids}" + ); + let connection = json!({ + "id": CONNECTION, "name": "lab", "ssh_target": "bob@hpc", "workspace_id": "w", + "status": "connected", "device_id": DEVICE + }); + let connection_ids = with_ids(&connection, &json!({})); + assert!( + connection_ids.contains(&format!("Computer ID: {DEVICE}")), + "{connection_ids}" + ); + for shown in [&token_ids, &connection_ids, &shown] { + assert!(!shown.contains("Device ID"), "{shown}"); + } } #[test] @@ -5204,10 +6572,21 @@ mod tests { "already_member: @bob is already a member. Choose Add device to add another computer for them.", "@bob is already a member.\nTo add another computer for them, run: biorouter crew enroll invite @bob --add-device", ), + // The broker's sentence names the desktop's switch; here it is the flag left off. + ( + "invalid_params", + "invalid_params: @carol is not a member yet. Turn off Add another computer, then invite them.", + "@carol is not a member yet. Invite them without --add-device.\nRun: biorouter crew enroll invite @carol", + ), ( "code_mismatch", "code_mismatch: The host hasn't let this device in. Send the host the code shown on your screen.", - "The host hasn't let this device in. Send the host the code shown on your screen.", + CODE_MISMATCH, + ), + ( + "code_mismatch", + "code_mismatch: The host hasn't let this computer in. Send the host the code shown on your screen.", + CODE_MISMATCH, ), ( "forbidden", @@ -5386,6 +6765,17 @@ mod tests { ); } + /// The broker's "not a member yet" refusal, which the CLI words for this terminal's flag, is + /// the broker's own text: if it drifts, the rewording can never fire, and this says so. + #[test] + fn the_not_a_member_yet_fixture_is_the_brokers_text() { + const JOIN: &str = include_str!("../../../../biorouter-crew/src/broker/join.rs"); + assert!( + JOIN.contains("\"invalid_params: @{} is not a member yet."), + "join.rs no longer refuses an added computer for a non-member 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] @@ -5532,6 +6922,28 @@ mod tests { path.display() ); } + // A sentence with an ASCII apostrophe sits in double quotes in the deck. + assert!( + copy.contains(&format!("codeMismatch:\n \"{CODE_MISMATCH}\",")), + "{CODE_MISMATCH} is not refusalCopy.codeMismatch in {}", + path.display() + ); + assert_eq!( + broker_refusal_text( + "code_mismatch", + "code_mismatch: The host hasn't let this device in. Send the host the code shown on your screen." + ), + CODE_MISMATCH + ); + // The host-only checks read as the desktop's dialogs word them (`refusals.ts`). + let refusals = std::fs::read_to_string(path.with_file_name("refusals.ts")) + .expect("the desktop's refusal wording"); + let host_only = + broker_refusal_text("forbidden", "forbidden: workspace host device required"); + assert!( + refusals.contains(&format!("HOST_ONLY_REFUSAL = '{host_only}';")), + "{host_only}" + ); assert!(copy.contains( "`Another active member is already @${username}. Remove the old @${username} first.`" )); @@ -5685,7 +7097,7 @@ mod tests { fn setup_progress_says_each_stage_once() { let first = json!({"state": "running", "stages": [stage("check", "running"), stage("install", "pending")]}); assert_eq!( - setup_progress_lines(&Value::Null, &first, "bob@hpc"), + setup_progress_lines(&Value::Null, &first, "bob@hpc", &SetupNamed::default()), ["Checking the server…"] ); let later = json!({"state": "running", "stages": [ @@ -5693,17 +7105,17 @@ mod tests { stage("start", "skipped"), stage("install", "running") ]}); assert_eq!( - setup_progress_lines(&first, &later, "bob@hpc"), + setup_progress_lines(&first, &later, "bob@hpc", &SetupNamed::default()), [ "Making the work folder…", "Installing Crew in your account…" ] ); - assert!(setup_progress_lines(&later, &later, "bob@hpc").is_empty()); + assert!(setup_progress_lines(&later, &later, "bob@hpc", &SetupNamed::default()).is_empty()); let sign_in = json!({"state": "needs_sign_in", "server": {"login": "bob@leo"}, "stages": [stage("check", "running"), stage("sign_in", "running")]}); assert_eq!( - setup_progress_lines(&first, &sign_in, "ignored"), + setup_progress_lines(&first, &sign_in, "ignored", &SetupNamed::default()), [ "Signing in…", "bob@leo asks for your password or a verification code." @@ -5711,11 +7123,210 @@ mod tests { ); let done = json!({"state": "finished", "stages": [stage("check", "done"), stage("brand_new", "done"), stage("done", "done")]}); assert_eq!( - setup_progress_lines(&first, &done, "bob@hpc"), + setup_progress_lines(&first, &done, "bob@hpc", &SetupNamed::default()), ["Brand new…", "Ready"] ); } + /// Live re-test: `setup --start` and `--restart-workspace` list the server first, and the + /// setup that follows said "Checking the server…" and "Looking for workspaces…" again, with + /// a "Ready" in between and never the workspace's name. Each stage is said once in the + /// command, the listing leaves its "Ready" to the setup, and the restart stage names what + /// it starts or restarts. + #[test] + fn a_setup_after_its_listing_says_each_stage_once_and_names_what_it_starts() { + let listing = json!({"intent": "check", "state": "finished", "stages": [ + stage("check", "done"), stage("sign_in", "skipped"), + stage("workspaces", "done"), stage("done", "done") + ]}); + let none = SetupNamed::default(); + assert_eq!( + before_the_setup(setup_progress_lines(&Value::Null, &listing, "w@leo", &none)), + ["Checking the server…", "Looking for workspaces…"] + ); + let install = json!({"intent": "install", "state": "finished", + "stages": [ + stage("check", "done"), stage("install", "skipped"), stage("workspaces", "done"), + stage("restart", "done"), stage("done", "done") + ], + "workspaces": [ + {"workspace_id": "s", "name": "acct-lab", "role": "host"}, + {"workspace_id": "r", "name": "old-lab", "role": "host"}, + {"workspace_id": "x", "name": "chen-lab", "role": "member"} + ]}); + let said = with_said_stages(&Value::Null, &listing); + let progress = |request: Value| { + setup_progress_lines(&said, &install, "w@leo", &named_in_setup(&request)) + }; + assert_eq!( + progress(json!({"start_workspaces": ["s"]})), + ["Starting acct-lab…", "Ready"] + ); + assert_eq!( + progress(json!({"restart_workspace_ids": ["r"]})), + ["Restarting old-lab…", "Ready"] + ); + assert_eq!( + progress(json!({"start_workspaces": ["s"], "restart_workspace_ids": ["r", "x"]})), + [ + "Restarting old-lab and chen-lab, then starting acct-lab…", + "Ready" + ] + ); + // Nothing named, or an ID the job's listing doesn't name: the stage's own words. + assert_eq!( + progress(json!({})), + ["Updating running workspaces…", "Ready"] + ); + assert_eq!( + progress(json!({"start_workspaces": ["gone"]})), + ["Updating running workspaces…", "Ready"] + ); + // A job with no listing before it says its stages as it always did. + assert_eq!( + setup_progress_lines(&Value::Null, &install, "w@leo", &none)[..2], + ["Checking the server…", "Looking for workspaces…"] + ); + // The listing's stages join what this job already said, never replace it. + let running = json!({"state": "running", "stages": [stage("restart", "running")]}); + assert_eq!( + setup_progress_lines( + &with_said_stages(&running, &listing), + &install, + "w@leo", + &none + ), + ["Ready"] + ); + assert_eq!(with_said_stages(&running, &Value::Null), running); + + // A check that could not list the workspaces says why in its summary, with no bare + // "Ready" before it; an install that could not list them still says Ready. + let unlisted = json!({"intent": "check", "state": "finished", + "workspaces_note": "Crew isn't installed in your account on leo, so Biorouter can't look for workspaces there.", + "stages": [stage("check", "done"), stage("workspaces", "skipped"), stage("done", "done")]}); + assert_eq!( + setup_progress_lines(&Value::Null, &unlisted, "w@leo", &none), + ["Checking the server…"] + ); + let mut installed = unlisted.clone(); + installed["intent"] = json!("install"); + assert_eq!( + setup_progress_lines(&Value::Null, &installed, "w@leo", &none), + ["Checking the server…", "Ready"] + ); + + // Whether the account hosts a workspace, as the listing says: one place for its ID. + assert_eq!(listed_role(&install, Some("s")).as_deref(), Some("host")); + assert_eq!(listed_role(&install, Some("x")).as_deref(), Some("member")); + assert_eq!(listed_role(&install, Some("unknown")), None); + assert_eq!(listed_role(&Value::Null, Some("s")), None); + assert_eq!(listed_role(&install, None), None); + let mut twice = install.clone(); + twice["workspaces"] + .as_array_mut() + .expect("workspaces") + .push(json!({"workspace_id": "s", "name": "acct-lab", "role": "member"})); + assert_eq!(listed_role(&twice, Some("s")), None); + } + + /// Live re-test: an account removed and let in again twice left two earlier entries, which + /// teams and channels still list. The workspace's two people were "4 members", and former + /// members listed "@gbellucci · a member again" twice. Counts are of members now, and former + /// members are one line per account. + #[test] + fn a_member_let_in_again_counts_once_and_is_one_former_member() { + let snapshot = json!({ + "workspace": {"id": WORKSPACE, "name": "acct-lab", "host_uid": 1020}, + "actor": {"id": ALICE, "username": "wanjun", "uid": 1020}, + "principals": [ + {"id": ALICE, "username": "wanjun", "uid": 1020}, + {"id": BOB, "username": "gbellucci", "uid": 1022} + ], + "former_principals": [ + {"id": CAROL, "username": "gbellucci", "active": false}, + {"id": DAVE, "username": "gbellucci", "active": false} + ], + "teams": [{"id": TEAM, "name": "Account Test", "general_channel_id": GENERAL, + "members": [CAROL, BOB, DAVE, ALICE]}], + "channels": [{"id": GENERAL, "team_id": TEAM, "name": "general", + "classification": "restricted", "members": [CAROL, BOB, DAVE, ALICE]}, + {"id": METHODS, "team_id": TEAM, "name": "methods", + "classification": "restricted", "members": [BOB, DAVE, ALICE]}] + }); + let shown = named(&snapshot, &snapshot); + assert!( + shown.contains("Former members (1):\n @gbellucci · a member again\nTeams"), + "{shown}" + ); + assert!( + !shown.contains("4 members") && !shown.contains("3 members"), + "{shown}" + ); + assert_eq!(shown.matches(" · 2 members").count(), 3, "{shown}"); + // channels list counts the same way. + let listed = named(&snapshot["channels"], &snapshot); + assert_eq!(listed.matches(" · 2 members").count(), 2, "{listed}"); + // An ID nothing names is still counted: it may be someone the names don't reach. + let unnamed = named( + &json!([{"id": GENERAL, "team_id": TEAM, "name": "general", + "classification": "restricted", "members": [BOB, "someone-else"]}]), + &snapshot, + ); + assert!(unnamed.contains(" · 2 members"), "{unnamed}"); + } + + /// The broker sends no UID for a former member, so two former entries of one username that + /// no member holds now may be two accounts (one deleted on the server, another made with its + /// name). Each keeps its line, and the IDs tell them apart. Only the earlier entries of a + /// member again are folded, into that member's one line. + #[test] + fn former_entries_of_one_username_stay_apart_without_a_member_now() { + let snapshot = json!({ + "workspace": {"id": WORKSPACE, "name": "acct-lab", "host_uid": 1020}, + "actor": {"id": ALICE, "username": "wanjun", "uid": 1020}, + "principals": [{"id": ALICE, "username": "wanjun", "uid": 1020}], + "former_principals": [ + {"id": CAROL, "username": "gbellucci", "active": false}, + {"id": DAVE, "username": "gbellucci", "active": false} + ], + "teams": [], + "channels": [] + }); + let shown = named(&snapshot, &snapshot); + assert!( + shown.contains("Former members (2):\n @gbellucci · former member\n @gbellucci · former member\n Some former members share a username. Add --show-ids to tell them apart.\n"), + "{shown}" + ); + let ids = with_ids(&snapshot, &snapshot); + assert!( + ids.contains(&format!("Former members (2):\n @gbellucci · former member [ID {CAROL}]\n @gbellucci · former member [ID {DAVE}]\n")), + "{ids}" + ); + assert!(!ids.contains("share a username"), "{ids}"); + // One former entry of a username is one line, and nothing needs telling apart. + let mut one = snapshot.clone(); + one["former_principals"] = json!([{"id": CAROL, "username": "gbellucci", "active": false}]); + let shown = named(&one, &one); + assert!( + shown.contains("Former members (1):\n @gbellucci · former member\n"), + "{shown}" + ); + assert!(!shown.contains("share a username"), "{shown}"); + // Let in again, the account's earlier entries are its member's one line. + let mut again = snapshot.clone(); + again["principals"] + .as_array_mut() + .expect("principals") + .push(json!({"id": BOB, "username": "gbellucci", "uid": 1022})); + let shown = named(&again, &again); + assert!( + shown.contains("Former members (1):\n @gbellucci · a member again\n"), + "{shown}" + ); + assert!(!shown.contains("share a username"), "{shown}"); + } + /// OW-9: a check says what the server is and what is installed; an install says what it did; /// a member's setup says it connected and what agents may do. #[test] @@ -5728,16 +7339,20 @@ mod tests { "installed": {"path": "~/.local/bin/biorouter-crew", "state": "file", "version": "1.92.1"}, "artifact": {"version": "1.93.0"} }); + // AO-8: setup replaces an older Crew by default, so the plain command updates it. let found = [ "bob@leo: Linux x86_64, glibc 2.35.", - "Crew 1.92.1 is installed in your account there. This computer has Crew 1.93.0; biorouter crew setup --replace-existing updates it.", + "Crew 1.92.1 is installed in your account there. This computer has Crew 1.93.0; biorouter crew setup updates it.", ]; - assert_eq!(setup_summary_lines(&check, "bob@leo", None), found); + assert_eq!( + setup_summary_lines(&check, "bob@leo", None, &SetupAsked::default()), + found + ); // A saved connection's bridge does say, from its last connect (leo: Landlock ABI 1). let leo = json!({"server_access": {"landlock_abi": 1, "confined_execution_available": false}}); assert_eq!( - setup_summary_lines(&check, "bob@leo", Some(&leo)), + setup_summary_lines(&check, "bob@leo", Some(&leo), &SetupAsked::default()), [ found[0], found[1], @@ -5747,32 +7362,32 @@ mod tests { let confined = json!({"server_access": {"landlock_abi": 4, "confined_execution_available": true}}); assert_eq!( - setup_summary_lines(&check, "bob@leo", Some(&confined)), + setup_summary_lines(&check, "bob@leo", Some(&confined), &SetupAsked::default()), found ); let mut newer = check.clone(); newer["installed"]["version"] = json!("1.94.0"); assert_eq!( - setup_summary_lines(&newer, "bob@leo", None)[1], + setup_summary_lines(&newer, "bob@leo", None, &SetupAsked::default())[1], "Crew 1.94.0 is installed in your account there, newer than this computer's 1.93.0, so setup leaves it in place." ); let mut missing = check.clone(); missing["installed"] = json!({"state": "missing"}); assert_eq!( - setup_summary_lines(&missing, "bob@leo", None)[1], + setup_summary_lines(&missing, "bob@leo", None, &SetupAsked::default())[1], "Crew isn't installed in your account there. biorouter crew setup installs Crew 1.93.0." ); let install = json!({"intent": "install", "state": "finished", "server": {"login": "bob@leo"}, "outcome": {"installed_version": "1.93.0", "reused": false}}); assert_eq!( - setup_summary_lines(&install, "x", None), + setup_summary_lines(&install, "x", None, &SetupAsked::default()), ["Installed Crew 1.93.0 in your account on bob@leo."] ); let mut reused = install.clone(); reused["outcome"]["reused"] = json!(true); assert_eq!( - setup_summary_lines(&reused, "x", None), + setup_summary_lines(&reused, "x", None, &SetupAsked::default()), ["Crew 1.93.0 was already installed in your account on bob@leo."] ); // C25: an update replaces the older program and keeps it (`previous_kept`); the version @@ -5781,7 +7396,7 @@ mod tests { updated["outcome"]["previous_kept"] = json!(true); updated["installed"] = json!({"state": "file", "version": "1.92.1"}); assert_eq!( - setup_summary_lines(&updated, "x", None), + setup_summary_lines(&updated, "x", None, &SetupAsked::default()), ["Updated Crew from 1.92.1 to 1.93.0 in your account on bob@leo."] ); // The daemon's own `upgraded_from`, as a version or with its digest beside the new one. @@ -5790,19 +7405,19 @@ mod tests { reported["outcome"]["upgraded_from"] = json!({"version": "1.91.0", "sha256": "ab".repeat(32)}); assert_eq!( - setup_summary_lines(&reported, "x", None), + setup_summary_lines(&reported, "x", None, &SetupAsked::default()), ["Updated Crew from 1.91.0 to 1.93.0 in your account on bob@leo."] ); reported["outcome"]["upgraded_from"] = json!("1.90.2"); assert_eq!( - setup_summary_lines(&reported, "x", None), + setup_summary_lines(&reported, "x", None, &SetupAsked::default()), ["Updated Crew from 1.90.2 to 1.93.0 in your account on bob@leo."] ); // The same version with another SHA-256 is replaced, not "updated". reported["outcome"]["upgraded_from"] = json!({"version": "1.93.0", "sha256": "cd".repeat(32)}); assert_eq!( - setup_summary_lines(&reported, "x", None), + setup_summary_lines(&reported, "x", None, &SetupAsked::default()), ["Replaced Crew 1.93.0 in your account on bob@leo with this computer's copy of it."] ); @@ -5811,7 +7426,7 @@ mod tests { "outcome": {"installed_version": "1.93.0", "reused": false, "connection_id": "c", "work_folder": "/pool1/home/bob/crew-work/lab", "remote_access": "full"}}); assert_eq!( - setup_summary_lines(&member, "x", None), + setup_summary_lines(&member, "x", None, &SetupAsked::default()), [ "Installed Crew 1.93.0 in your account on bob@leo.", "Connected.", @@ -5824,7 +7439,7 @@ mod tests { .expect("outcome") .remove("remote_access"); assert_eq!( - setup_summary_lines(&made_only, "x", None)[2], + setup_summary_lines(&made_only, "x", None, &SetupAsked::default())[2], "Your work folder on bob@leo is /pool1/home/bob/crew-work/lab. Choose what agents may do there with biorouter crew connections set-access." ); // The job set folder access; the connection says whether commands run there. @@ -5833,13 +7448,13 @@ mod tests { let files_only = json!({"remote_root": "/pool1/home/bob/crew-work/lab", "remote_execution": false}); assert_eq!( - setup_summary_lines(&folder, "x", Some(&files_only))[2], + setup_summary_lines(&folder, "x", Some(&files_only), &SetupAsked::default())[2], "Agents can read and write files in /pool1/home/bob/crew-work/lab on bob@leo. Its kernel can't confine commands to a folder, so running them there needs full access: biorouter crew connections set-access full." ); let runs = json!({"remote_root": "/pool1/home/bob/crew-work/lab", "remote_execution": true}); assert_eq!( - setup_summary_lines(&folder, "x", Some(&runs))[2], + setup_summary_lines(&folder, "x", Some(&runs), &SetupAsked::default())[2], "Agents can work in /pool1/home/bob/crew-work/lab on bob@leo, confined to that folder. Change that with biorouter crew connections set-access." ); // A re-run on a connection that keeps its own folder changes nothing, and says nothing. @@ -5849,7 +7464,7 @@ mod tests { outcome.remove("work_folder"); let own = json!({"remote_root": "/scratch/bob/proj", "remote_execution": true, "remote_access": "folder"}); assert_eq!( - setup_summary_lines(&kept, "x", Some(&own)), + setup_summary_lines(&kept, "x", Some(&own), &SetupAsked::default()), [ "Installed Crew 1.93.0 in your account on bob@leo.", "Connected." @@ -5857,6 +7472,407 @@ mod tests { ); } + /// AO-6, AO-8: a check lists the workspaces on the server for the account and, in place of + /// a bare "Ready", the daemon's sentences for those this computer cannot reach; any job + /// says what it restarted or started, what still runs an older Crew and why, and which + /// restarts failed with the command that starts each by hand. + #[test] + fn setup_summaries_list_the_servers_workspaces_and_what_was_restarted() { + let reach = "You host baranzini-lab here (running). This computer has no connection to it. Open it with biorouter crew open baranzini-lab --server wanjun@leo."; + let check = json!({ + "intent": "check", "state": "finished", + "server": {"login": "wanjun@leo"}, + "installed": {"state": "file", "version": "1.94.2"}, + "artifact": {"version": "1.94.2"}, + "stages": [stage("workspaces", "done"), {"name": "done", "state": "done", "detail": reach}], + "workspaces": [ + {"workspace_id": "a", "name": "baranzini-lab", "role": "host", "host_username": "wanjun", + "state": "running", "version": "1.94.1", "this_computer": "not_saved"}, + {"workspace_id": "b", "name": "chen-lab", "role": "member", "host_username": "alice", + "state": "running", "this_computer": "connected"}, + {"workspace_id": "c", "name": "old-lab", "role": "invited", "host_username": "bob", + "state": "stopped", "this_computer": "saved"}, + {"workspace_id": "d", "role": "removed", "state": "unknown", "this_computer": "not_saved"} + ], + "workspaces_scan": {"skipped": 0, "unsupported": 2, "truncated": true}, + "outcome": {"outdated": [ + {"workspace_id": "a", "name": "baranzini-lab", "running_version": "1.94.1", + "installed_version": "1.94.2", "others_connected": 1, "reason": "not_running_installed"} + ]} + }); + assert_eq!( + setup_summary_lines(&check, "wanjun@leo", None, &SetupAsked::default()), + [ + "Crew 1.94.2 is installed in your account there.", + "Workspaces on wanjun@leo for your account:", + " baranzini-lab · you host it · running Crew 1.94.1 · not on this computer", + " chen-lab · hosted by @alice, you're a member · running · connected on this computer", + " old-lab · hosted by @bob, you're invited · stopped · saved on this computer", + " A workspace · you were removed · state unknown · not on this computer", + reach, + "2 other workspaces on wanjun@leo run an older Crew, which can't say whether you belong to them.", + "There was more on wanjun@leo than setup looks through at once, so this list may not be complete.", + "baranzini-lab runs Crew 1.94.1, not 1.94.2. To restart it on Crew 1.94.2, run biorouter crew setup --server wanjun@leo --restart-workspace baranzini-lab; 1 other person is connected, and their running agent commands stop.", + ] + ); + // C3: a check asked nothing to restart, so a workspace others are connected to is said + // as a look, never as one that "wasn't restarted", with the count the server gave. One + // with no name is restarted by its ID, never with --restart, which restarts them all. + let mut busy = check.clone(); + busy["outcome"]["outdated"] = json!([ + {"workspace_id": "b", "name": "busy lab", "running_version": "1.94.1", + "installed_version": "1.94.2", "others_connected": 3, "reason": "others_connected"}, + {"workspace_id": "e", "reason": "others_connected"} + ]); + let looked = setup_summary_lines(&busy, "wanjun@leo", None, &SetupAsked::default()); + assert_eq!( + looked[looked.len() - 2..], + [ + "busy lab runs Crew 1.94.1, not 1.94.2. To restart it on Crew 1.94.2, run biorouter crew setup --server wanjun@leo --restart-workspace 'busy lab'; 3 other people are connected, and their running agent commands stop.", + "A workspace runs an older Crew. To restart it on the Crew installed there, run biorouter crew setup --server wanjun@leo --restart-workspace e; other people may be connected, and their running agent commands stop.", + ] + ); + assert!(!looked.iter().any(|line| line.contains("wasn't restarted"))); + assert!(!looked.iter().any(|line| line.contains("--restart;"))); + // The same entry from a job asked to restart it, which left it running, says so. + let mut ran = busy.clone(); + ran["intent"] = json!("install"); + let asked_b = SetupAsked::of(&json!({"restart_workspace_ids": ["b"]}), String::new()); + assert!(setup_summary_lines(&ran, "wanjun@leo", None, &asked_b).contains( + &"busy lab still runs Crew 1.94.1, not Crew 1.94.2, and 3 other people are connected to it, so it wasn't restarted. To restart it anyway, run biorouter crew setup --server wanjun@leo --restart-workspace 'busy lab'; their running agent commands stop.".to_owned() + )); + // The reach sentences replace "Ready", so the progress lines leave it out. + assert!( + setup_progress_lines(&Value::Null, &check, "wanjun@leo", &SetupNamed::default()) + .iter() + .all(|line| line != "Ready") + ); + assert_eq!( + setup_progress_lines(&Value::Null, &check, "wanjun@leo", &SetupNamed::default()), + ["Looking for workspaces…"] + ); + + let install = json!({ + "intent": "install", "state": "finished", "server": {"login": "wanjun@leo"}, + "workspaces_note": "Crew 1.94.1 in your account on leo is too old to list workspaces.", + "outcome": { + "installed_version": "1.94.2", "reused": false, + "restarted": [ + {"workspace_id": "a", "name": "baranzini-lab", "from_version": "1.94.1", "to_version": "1.94.2"}, + {"workspace_id": "e", "name": "spare-lab", "from_version": null, "to_version": "1.94.2"} + ], + "outdated": [ + {"workspace_id": "b", "name": "busy-lab", "running_version": "1.94.1", "installed_version": "1.94.2", "others_connected": 3, "reason": "others_connected"}, + {"workspace_id": "c", "name": "new-lab", "running_version": "1.95.0", "installed_version": "1.94.2", "reason": "running_newer"}, + {"workspace_id": "d", "name": "tmp-lab", "running_version": "1.94.1", "installed_version": "1.94.2", "reason": "custom_state_dir"} + ], + "restart_failed": [ + {"workspace_id": "f", "name": "stuck-lab", "code": "stop_timeout", + "message": "The broker did not stop in 15 seconds.", + "command": "biorouter-crew start --workspace-id f"} + ] + } + }); + let every = SetupAsked::of(&json!({"restart_workspaces": true}), String::new()); + assert_eq!( + setup_summary_lines(&install, "wanjun@leo", None, &every), + [ + "Installed Crew 1.94.2 in your account on wanjun@leo.", + "Crew 1.94.1 in your account on leo is too old to list workspaces.", + "Restarted baranzini-lab: it ran Crew 1.94.1 and now runs Crew 1.94.2.", + "spare-lab now runs Crew 1.94.2.", + "busy-lab still runs Crew 1.94.1, not Crew 1.94.2, and 3 other people are connected to it, so it wasn't restarted. To restart it anyway, run biorouter crew setup --server wanjun@leo --restart-workspace busy-lab; their running agent commands stop.", + "new-lab runs Crew 1.95.0, newer than Crew 1.94.2, so it was left running.", + "tmp-lab still runs Crew 1.94.1, not Crew 1.94.2, and keeps its state outside ~/.local/share/biorouter-crew, so setup can't restart it. Restart it on wanjun@leo yourself.", + "stuck-lab: The broker did not stop in 15 seconds.", + " To start it by hand, run this on wanjun@leo: biorouter-crew start --workspace-id f", + ] + ); + assert_eq!(setup_stage_words("workspaces"), "Looking for workspaces"); + assert_eq!(setup_stage_words("restart"), "Updating running workspaces"); + assert_eq!(setup_stage_words("configure"), "Setting up the workspace"); + } + + /// C3: a workspace still on an older Crew reads as left running ("wasn't restarted") only + /// when the request asked to restart it: every one, or it by ID among those to restart or + /// start, as the desktop's `askedToRestart` decides. `--no-restart`, a setup that named + /// another one, a plain setup and a check only looked. The suggested command restarts that + /// one alone, by name, else ID, else the plain update, with the route flags given, and says + /// who else is connected as the server counted them. + #[test] + fn an_outdated_workspace_is_said_left_only_when_the_setup_was_asked_to_restart_it() { + let job = |intent: &str, outdated: Value| { + json!({"intent": intent, "state": "finished", "server": {"login": "wanjun@leo"}, + "outcome": {"installed_version": "1.94.2", "reused": true, "outdated": [outdated]}}) + }; + let busy = json!({"workspace_id": "B-1", "name": "busy-lab", "running_version": "1.94.1", + "installed_version": "1.94.2", "others_connected": 2, + "reason": "others_connected"}); + let route = " --port 2222 --identity-file /home/w/.ssh/leo --proxy-jump bastion".to_owned(); + let left = |lines: &[String]| lines.iter().any(|line| line.contains("wasn't restarted")); + let summary = |status: &Value, request: Value| { + setup_summary_lines( + status, + "wanjun@leo", + None, + &SetupAsked::of(&request, route.clone()), + ) + }; + let install = job("install", busy.clone()); + // Asked: every one, or this one by ID (in either list, any case). + for request in [ + json!({"restart_workspaces": true}), + json!({"restart_workspace_ids": ["b-1"]}), + json!({"start_workspaces": ["B-1"]}), + ] { + let lines = summary(&install, request.clone()); + assert!(left(&lines), "{request}: {lines:?}"); + assert_eq!( + lines.last().map(String::as_str), + Some("busy-lab still runs Crew 1.94.1, not Crew 1.94.2, and 2 other people are connected to it, so it wasn't restarted. To restart it anyway, run biorouter crew setup --server wanjun@leo --port 2222 --identity-file /home/w/.ssh/leo --proxy-jump bastion --restart-workspace busy-lab; their running agent commands stop.") + ); + } + // Not asked: --no-restart, another one named, a plain setup, and a check even with + // every one asked. + let looked = "busy-lab runs Crew 1.94.1, not 1.94.2. To restart it on Crew 1.94.2, run biorouter crew setup --server wanjun@leo --port 2222 --identity-file /home/w/.ssh/leo --proxy-jump bastion --restart-workspace busy-lab; 2 other people are connected, and their running agent commands stop."; + for (status, request) in [ + (&install, json!({"restart_workspaces": false})), + (&install, json!({"restart_workspace_ids": ["other"]})), + (&install, json!({})), + ( + &job("check", busy.clone()), + json!({"restart_workspaces": true}), + ), + ] { + let lines = summary(status, request.clone()); + assert!(!left(&lines), "{request}: {lines:?}"); + assert_eq!(lines.last().map(String::as_str), Some(looked), "{request}"); + } + // The count the server gave: one, nobody (no caution), or unknown. + let counted = |others: Value| { + let mut entry = busy.clone(); + entry["others_connected"] = others; + entry["reason"] = json!("not_running_installed"); + summary(&job("install", entry), json!({"restart_workspaces": false})) + .last() + .cloned() + .unwrap_or_default() + }; + assert!(counted(json!(1)).ends_with("--restart-workspace busy-lab; 1 other person is connected, and their running agent commands stop.")); + assert!(counted(json!(0)).ends_with("--restart-workspace busy-lab.")); + assert!(counted(Value::Null).ends_with("--restart-workspace busy-lab; other people may be connected, and their running agent commands stop.")); + // No name: by ID; neither: the plain update. Never --restart, which restarts them all. + let mut by_id = busy.clone(); + by_id.as_object_mut().expect("entry").remove("name"); + assert!(summary(&job("install", by_id.clone()), json!({})) + .last() + .is_some_and(|line| line.contains("--proxy-jump bastion --restart-workspace B-1;"))); + by_id.as_object_mut().expect("entry").remove("workspace_id"); + let plain = summary(&job("install", by_id), json!({})); + assert!( + plain.last().is_some_and(|line| line.contains( + "run biorouter crew setup --server wanjun@leo --port 2222 --identity-file /home/w/.ssh/leo --proxy-jump bastion; 2 other people are connected" + )), + "{plain:?}" + ); + assert!(!plain.iter().any(|line| line.contains("--restart"))); + } + + /// C3: `--restart-workspace` and `--start` take a workspace's ID too, which is what a setup + /// summary names one by when the server gave it no name. A name still wins. + #[test] + fn a_hosted_workspace_is_matched_by_its_id_when_no_name_matches() { + let listing = json!({"workspaces": [ + {"workspace_id": "B-1", "role": "host", "state": "running"}, + {"workspace_id": "c-2", "name": "d-3", "role": "host", "state": "stopped"}, + {"workspace_id": "d-3", "name": "other", "role": "host", "state": "running"} + ]}); + assert_eq!( + workspace_to_restart(&listing, "b-1", "leo", ""), + Ok("B-1".to_owned()) + ); + // "d-3" is one workspace's name and another's ID: the name is matched first. + assert_eq!( + workspace_to_start(&listing, "d-3", "leo"), + Ok("c-2".to_owned()) + ); + assert!(workspace_to_restart(&listing, "nope", "leo", "").is_err()); + } + + /// AO-6, AO-9: setup --start and open match a name to one workspace in a check job's + /// listing, and say in a sentence why when they can't. + #[test] + fn a_workspace_is_matched_by_name_in_a_listing() { + let listing = json!({"workspaces": [ + {"workspace_id": "a", "name": "lab", "role": "host", "state": "stopped", "invitation": "brcrew1:a"}, + {"workspace_id": "b", "name": "lab", "role": "host", "state": "deleted"}, + {"workspace_id": "c", "name": "shared", "role": "member", "host_username": "alice", "state": "stopped", "invitation": "brcrew1:c"} + ]}); + assert_eq!( + workspace_to_start(&listing, "LAB", "leo"), + Ok("a".to_owned()) + ); + assert_eq!( + workspace_to_start(&listing, "shared", "leo"), + Err("You don't host a workspace named shared on leo.".to_owned()) + ); + assert_eq!( + workspace_to_open(&listing, "shared", None, "leo", ""), + Err("shared isn't running on leo. Ask @alice to start it.".to_owned()) + ); + assert_eq!( + workspace_to_open(&listing, "shared", Some("bob"), "leo", ""), + Err( + "No workspace named shared hosted by @bob on leo is yours or invites you." + .to_owned() + ) + ); + let unlisted = json!({"workspaces": [], "workspaces_note": "Crew 1.94.1 in your account on leo is too old to list workspaces."}); + assert_eq!( + workspace_to_start(&unlisted, "lab", "leo"), + Err("Biorouter couldn't list the workspaces on leo. Crew 1.94.1 in your account on leo is too old to list workspaces.".to_owned()) + ); + let deleted = json!({"workspaces": [{"workspace_id": "b", "name": "lab", "role": "host", "state": "deleted"}]}); + assert_eq!( + workspace_to_start(&deleted, "lab", "leo"), + Err("lab on leo was deleted, so it can't be started.".to_owned()) + ); + assert_eq!( + workspace_to_open(&deleted, "lab", None, "leo", ""), + Err("lab on leo was deleted by its host.".to_owned()) + ); + } + + /// AO-5, AO-9: removing a computer and adding this one again are said in the desktop's + /// words (`deviceCopy` and `rejoinCopy`), read from its copy deck so the two cannot drift. + #[test] + fn removing_and_adding_a_computer_again_say_the_desktops_words() { + let copy = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../ui/desktop/src/components/crew/account/copy.ts"), + ) + .expect("the desktop copy deck"); + assert!(copy.contains(&format!("'{REMOVE_DEVICE_SSH_FIRST}'"))); + assert_eq!(remove_device_warning(false), REMOVE_DEVICE_SSH_FIRST); + assert!(copy.contains("`Remove ${fingerprint} from your account?`")); + assert_eq!( + remove_device_question("AAAA BBBB CCCC DDDD", false), + "Remove AAAA BBBB CCCC DDDD from your account?" + ); + assert!(copy.contains("'Remove this computer from your account?'")); + assert_eq!( + remove_device_question("AAAA BBBB CCCC DDDD", true), + "Remove this computer from your account?" + ); + // This computer's: the desktop's sentence, its button here the rejoin command. + assert!(copy.contains( + "'This computer stops reaching the workspace until you choose Add this computer again.'" + )); + assert_eq!( + remove_device_warning(true), + "This computer stops reaching the workspace until you add it again with biorouter crew rejoin." + ); + assert!(device_revoked_lines("AAAA BBBB CCCC DDDD", false, "lab")[0] + .starts_with("Removed AAAA BBBB CCCC DDDD.")); + assert!(device_revoked_lines("AAAA", true, "lab")[0].starts_with("Removed this computer.")); + + assert!(copy.contains( + "`This computer is back in ${workspace}. Chats that had Crew access through it need access again.`" + )); + assert!(copy.contains( + "`To make room, the workspace removed your oldest computer, ${fingerprint}.`" + )); + let rejoined = json!({ + "status": {"workspace_name": "lab"}, + "principal": {"username": "bob", "display_name": "Bob Lee"}, + "evicted_device": {"fingerprint": "AAAA BBBB CCCC DDDD", "added_at": 1_790_200_000} + }); + assert_eq!( + rejoined_lines(&rejoined), + [ + "This computer is back in lab. Chats that had Crew access through it need access again.", + "To make room, the workspace removed your oldest computer, AAAA BBBB CCCC DDDD.", + "Give a chat access again with biorouter crew grants grant SESSION CHANNEL.", + ] + ); + // The desktop names no person, so neither does this. + assert!(!rejoined_lines(&rejoined) + .iter() + .any(|line| line.contains("@bob"))); + assert_eq!( + rejoined_lines(&json!({"status": {}})), + [ + "This computer is back in the workspace. Chats that had Crew access through it need access again.", + "Give a chat access again with biorouter crew grants grant SESSION CHANNEL.", + ] + ); + } + + /// AO-5, AO-12, C2: a computer's row says how it was added, in the desktop's words, and + /// which is this computer; the grants list names a chat that reads a workspace as the person. + #[test] + fn devices_and_reads_are_said_in_words() { + let options = HumanOptions::in_utc_at(1_790_214_655); + let device = json!({"fingerprint": "3F2A 9C1E 77B0 D4E1", "added_at": 1_790_200_000, "added_via": "account", "this_device": true}); + assert_eq!( + device_text(&device, &options), + "3F2A 9C1E 77B0 D4E1 · added Sep 23, 2026 · by signing in to the server · This computer" + ); + // C2: how a computer was added reads as the desktop's notice and Keys say it, byte for + // byte (`addedViaCopy`), and one this build does not know has no words. + let copy = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../ui/desktop/src/components/crew/account/copy.ts"), + ) + .expect("the desktop copy deck"); + for (via, said) in [ + ("account", "by signing in to the server"), + ("bootstrap", "when the workspace was created"), + ("token", "with an invitation token"), + ("invitation_code", "with an invitation"), + ] { + let row = device_text(&json!({"fingerprint": "AAAA", "added_via": via}), &options); + assert_eq!(row, format!("AAAA · {said}")); + assert!(copy.contains(&format!("{via}: '{said}',")), "{via}: {said}"); + // A chat reads the same words, from the one table both use. + assert_eq!(biorouter::crew::added_via_words(via), Some(said)); + } + assert_eq!(biorouter::crew::added_via_words("migration"), None); + assert_eq!( + device_text(&json!({"added_via": "migration"}), &options), + "Unknown computer" + ); + // The status line's new computer, as the desktop's notice says it. + let ctx = Ctx::new(Directory::default(), false, options.clock); + assert_eq!( + ctx.new_device_text(&device), + "3F2A 9C1E 77B0 D4E1, added Sep 23, 2026 by signing in to the server" + ); + assert_eq!( + ctx.new_device_text(&json!({"fingerprint": "AAAA", "added_via": "account"})), + "AAAA, added by signing in to the server" + ); + assert_eq!( + ctx.new_device_text(&json!({"fingerprint": "AAAA", "added_via": "migration"})), + "AAAA" + ); + let grants = json!({"grants": [], "person_reads": [ + {"session_id": "20260924_2", "connection_id": "c", "granted_at": 1_790_200_000, "connection_name": "lab"} + ]}); + assert_eq!( + render_text(&grants, &options), + "Chat 20260924_2 reads lab as you · since Sep 23, 2026" + ); + assert_eq!( + render_text(&json!({"grants": []}), &options), + "No chats have Crew access." + ); + assert_eq!( + removed_sentence(None), + "You were removed from this workspace. Ask a member to invite you again." + ); + } + /// L3: a failed start's own words are said under its sentence, a bounded number of lines. #[test] fn a_setup_failure_shows_what_the_server_printed() { diff --git a/crates/biorouter-cli/src/commands/crew_step/host_key.rs b/crates/biorouter-cli/src/commands/crew_step/host_key.rs new file mode 100644 index 000000000..b1c90848e --- /dev/null +++ b/crates/biorouter-cli/src/commands/crew_step/host_key.rs @@ -0,0 +1,450 @@ +//! A host-key card in a terminal chat on the shared daemon (WI-2, and the owner's D2 ruling on +//! AO-12): the lines the desktop's card shows, with the daemon's fingerprint, then, for an +//! unknown key the card shows a fingerprint for, one question: trust this key? +//! +//! Only the person's own answer, the word `trust` typed at this terminal after the question, +//! posts `/crew/host-keys/trust` with the person's proof; Enter, Esc or anything else leaves the +//! key alone. Whatever was typed before the question (often the person's next chat message, +//! which could be the word itself) is thrown away first, so it never trusts anything. The +//! daemon reads the key again and writes it only when its fingerprint is the one shown, and the +//! call that parked the card, which reads the known hosts files while it waits, takes the card +//! down and tries the step again by itself. A changed key is never offered: its card only says +//! what to give IT. The person may also check the key and add it from a terminal of their own, +//! as before. + +use super::StepSurface; +use anyhow::Result; +use biorouter::conversation::message::CrewStepRequest; +use serde_json::{json, Value}; +use std::io::IsTerminal; +use zeroize::Zeroizing; + +/// Said before the question, as the desktop's card says it beside its Trust button. +pub(crate) const TRUST_WARNING: &str = + "Only trust it if IT or the server's admin gave you this fingerprint."; +/// The question: only the word `trust` answers yes. +pub(crate) const TRUST_QUESTION: &str = + "Trust this key? Type trust to add it to your known hosts file, or press Enter to leave it: "; +/// Said once the daemon added the key. +pub(crate) const TRUSTED: &str = + "Trusted. The key is in your known hosts file, and this chat goes on by itself."; +/// Said when the person left the key alone. +pub(crate) const NOT_TRUSTED: &str = + "The key wasn't trusted. Once it is in your known hosts file, this chat goes on by itself."; + +/// The most an answer to [`TRUST_QUESTION`] may be, in bytes: large, so a whole message typed +/// before the card arrived is read as one line that does not say trust. +const ANSWER_BYTES: usize = 4096; + +/// A daemon value printed only when it is a SHA-256 fingerprint and nothing else. +fn fingerprint(value: Option<&String>) -> Option<&str> { + value.map(String::as_str).filter(|value| { + value.strip_prefix("SHA256:").is_some_and(|digest| { + (32..=64).contains(&digest.len()) + && digest + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')) + }) + }) +} + +/// The lines a host-key card prints, in the order the desktop's card shows them: the title and +/// what it means, the fingerprints, then what the person does. `None` for any other step. +pub(crate) fn lines(step: &CrewStepRequest) -> Option> { + let CrewStepRequest::HostKey { + host, + offered, + known, + changed, + .. + } = step + else { + return None; + }; + let offered = fingerprint(offered.as_ref()); + let known = fingerprint(known.as_ref()); + let mut lines = Vec::new(); + if *changed { + lines.push(format!("{host}'s identity changed. Don't connect until your IT team confirms this change. Crew won't connect while the old key is in your known hosts file.")); + if let Some(known) = known { + lines.push(format!("Fingerprint Crew knew: {known}")); + } + if let Some(offered) = offered { + lines.push(format!("Fingerprint the server offered now: {offered}")); + } + lines.push("Give these lines to your IT team. Crew never trusts a changed key.".to_owned()); + } else { + lines.push(format!( + "Can't verify {host} yet. Crew only connects to servers you've already verified." + )); + // For a key nobody knows yet, ssh usually prints no fingerprint, and the daemon reads + // the key itself, straight from this computer, which can skip a jump host on the way. + // So it is named as what this computer read, which is true either way, never as what + // the server offered. + if let Some(offered) = offered { + lines.push(format!( + "Fingerprint this computer read from {host}: {offered}" + )); + } + lines.push(format!("Get {host}'s fingerprint from your IT team or your institution's directory. Check jump hosts too.")); + lines.push("Compare it using your usual SSH setup in a terminal of your own, then add the full key to your known hosts file. A fingerprint alone isn't enough.".to_owned()); + lines.push( + "Once the key is in your known hosts file, this chat goes on by itself.".to_owned(), + ); + } + Some(lines) +} + +/// The body `/crew/host-keys/trust` takes for this card: only an unknown key the card shows a +/// fingerprint for. Every value is the daemon's, from the card. +fn trust_body(step: &CrewStepRequest) -> Option { + let CrewStepRequest::HostKey { + host, + port, + login, + offered, + changed: false, + .. + } = step + else { + return None; + }; + let offered = fingerprint(offered.as_ref())?; + Some(json!({"host": host, "port": port, "login": login, "fingerprint": offered})) +} + +/// What a host-key card needs of this terminal and the shared daemon, so a test can stand in +/// for both. +trait Desk { + /// One line for the person on the chat's notice channel. + fn say(&self, line: &str) -> Result<()>; + /// Whether the person can answer a question here. + fn can_ask(&self) -> bool; + /// Throw away what was typed before the question, so it can't answer it. + fn discard_typed_ahead(&self); + /// One line the person types after `question`, `Ok(None)` for Esc or Ctrl-C. + async fn ask(&self, question: &str) -> Result>>; + /// Post `body` to `/crew/host-keys/trust`, with the person's proof. + async fn trust(&self, body: Value) -> Result; +} + +impl Desk for StepSurface<'_> { + fn say(&self, line: &str) -> Result<()> { + StepSurface::say(self, line) + } + + fn can_ask(&self) -> bool { + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() + } + + /// The terminal's own input queue, then any key the terminal reader already took from it. + fn discard_typed_ahead(&self) { + #[cfg(unix)] + // SAFETY: tcflush only discards the terminal's pending input; stdin is a terminal here. + unsafe { + libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH); + } + let raw = crossterm::terminal::is_raw_mode_enabled().unwrap_or(false); + if !raw && crossterm::terminal::enable_raw_mode().is_err() { + return; + } + while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) { + if crossterm::event::read().is_err() { + break; + } + } + if !raw { + let _ = crossterm::terminal::disable_raw_mode(); + } + } + + async fn ask(&self, question: &str) -> Result>> { + crate::commands::shared_conversation::terminal_input(question, false, ANSWER_BYTES).await + } + + async fn trust(&self, body: Value) -> Result { + self.client + .request("POST", "/crew/host-keys/trust", Some(body)) + .await + } +} + +/// Take a host-key card: print its lines, then, for an unknown key with a fingerprint and a +/// person at this terminal, ask whether to trust it. +pub(crate) async fn take(surface: &StepSurface<'_>, step: &CrewStepRequest) -> Result<()> { + take_at(surface, step).await +} + +async fn take_at(desk: &impl Desk, step: &CrewStepRequest) -> Result<()> { + for line in lines(step).unwrap_or_default() { + desk.say(&line)?; + } + let Some(body) = trust_body(step) else { + return Ok(()); + }; + if !desk.can_ask() { + return Ok(()); + } + desk.say(TRUST_WARNING)?; + desk.discard_typed_ahead(); + let typed = desk.ask(TRUST_QUESTION).await.unwrap_or(None); + let trusts = typed + .as_ref() + .is_some_and(|typed| typed.trim().eq_ignore_ascii_case("trust")); + if !trusts { + return desk.say(NOT_TRUSTED); + } + match desk.trust(body).await { + Ok(_) => desk.say(TRUSTED), + Err(error) => desk.say(&format!("Crew didn't trust the key: {error:#}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + const OFFERED: &str = "SHA256:TmV3S2V5TmV3S2V5TmV3S2V5TmV3S2V5TmV3S2V5TmU"; + const KNOWN: &str = "SHA256:Wm9vZm9vZm9vZm9vZm9vZm9vZm9vZm9vZm9vZm9vZm8"; + + /// Every line the card prints, as one block of text. + fn fallback(step: &CrewStepRequest) -> Option { + lines(step).map(|lines| lines.join("\n")) + } + + fn key(changed: bool, offered: &str) -> CrewStepRequest { + CrewStepRequest::HostKey { + host: "hpc.example.edu".into(), + port: 22, + login: "you".into(), + offered: Some(offered.into()), + known: changed.then(|| KNOWN.into()), + changed, + } + } + + /// A terminal and a daemon in memory: what the person types, what the daemon answers, and + /// everything said and posted. + struct Fake { + terminal: bool, + /// A line the person typed before the question appeared. + typed_ahead: RefCell>, + typed: Option<&'static str>, + refusal: Option<&'static str>, + said: RefCell>, + asked: RefCell>, + posted: RefCell>, + } + + impl Fake { + fn typing(typed: Option<&'static str>) -> Self { + Self { + terminal: true, + typed_ahead: RefCell::default(), + typed, + refusal: None, + said: RefCell::default(), + asked: RefCell::default(), + posted: RefCell::default(), + } + } + } + + /// The word trust typed before the question appeared (an answer to the model, say) is + /// thrown away, not read as the answer: only what is typed after the question counts. + #[tokio::test] + async fn trust_typed_before_the_question_trusts_nothing() { + for after in [Some(""), None] { + let fake = Fake::typing(after); + *fake.typed_ahead.borrow_mut() = Some("trust"); + take_at(&fake, &key(false, OFFERED)).await.unwrap(); + assert!(fake.posted.borrow().is_empty(), "{after:?}"); + assert_eq!( + fake.said.borrow().last().map(String::as_str), + Some(NOT_TRUSTED) + ); + } + let fake = Fake::typing(Some("trust")); + *fake.typed_ahead.borrow_mut() = Some("go on"); + take_at(&fake, &key(false, OFFERED)).await.unwrap(); + assert_eq!(fake.posted.borrow().len(), 1, "the answer typed after it"); + } + + impl Desk for Fake { + fn say(&self, line: &str) -> Result<()> { + self.said.borrow_mut().push(line.to_owned()); + Ok(()) + } + + fn can_ask(&self) -> bool { + self.terminal + } + + fn discard_typed_ahead(&self) { + self.typed_ahead.borrow_mut().take(); + } + + /// A line typed before the question, and not thrown away, is read first, as a terminal + /// reads it. + async fn ask(&self, question: &str) -> Result>> { + self.asked.borrow_mut().push(question.to_owned()); + if let Some(ahead) = self.typed_ahead.borrow_mut().take() { + return Ok(Some(Zeroizing::new(ahead.to_owned()))); + } + Ok(self.typed.map(|typed| Zeroizing::new(typed.to_owned()))) + } + + async fn trust(&self, body: Value) -> Result { + self.posted.borrow_mut().push(body); + match self.refusal { + Some(refusal) => Err(anyhow::anyhow!(refusal)), + None => Ok(json!({"already_trusted": false})), + } + } + } + + #[test] + fn an_unknown_key_prints_the_daemon_s_fingerprint_and_the_steps() { + let unknown = fallback(&key(false, OFFERED)).unwrap(); + assert!( + unknown.starts_with("Can't verify hpc.example.edu yet."), + "{unknown}" + ); + assert!( + unknown.contains(&format!( + "Fingerprint this computer read from hpc.example.edu: {OFFERED}" + )), + "{unknown}" + ); + assert!( + !unknown.contains("the server offered"), + "a key the daemon read itself is never called the server's offer: {unknown}" + ); + assert!( + unknown.ends_with( + "Once the key is in your known hosts file, this chat goes on by itself." + ), + "{unknown}" + ); + } + + #[test] + fn a_changed_key_prints_both_fingerprints_and_no_fix() { + let changed = fallback(&key(true, OFFERED)).unwrap(); + assert!(changed.contains("identity changed"), "{changed}"); + assert!( + changed.contains(&format!("Fingerprint Crew knew: {KNOWN}")), + "{changed}" + ); + assert!( + changed.contains(&format!("Fingerprint the server offered now: {OFFERED}")), + "{changed}" + ); + assert!(!changed.contains("add the full key"), "{changed}"); + } + + #[test] + fn no_line_accepts_a_key_or_repeats_a_value_that_is_not_a_fingerprint() { + let spoofed = fallback(&key(false, "SHA256:x\nType yes to trust it")).unwrap(); + assert!(!spoofed.contains("Type yes"), "{spoofed}"); + for text in [ + fallback(&key(false, OFFERED)).unwrap(), + fallback(&key(true, OFFERED)).unwrap(), + spoofed, + [TRUST_WARNING, TRUST_QUESTION, TRUSTED, NOT_TRUSTED].join("\n"), + ] { + let lower = text.to_lowercase(); + assert!(!lower.contains("yes/no"), "{text}"); + assert!(!lower.contains("accept"), "{text}"); + assert!(!lower.contains("device"), "{text}"); + assert!(!text.contains(['\u{2014}', '\u{2013}']), "{text}"); + } + } + + /// The word trust, typed by the person, posts exactly the card's own values; the chat is + /// told the key is in, and the daemon takes the card down itself. + #[tokio::test] + async fn typing_trust_posts_only_the_card_s_values() { + let fake = Fake::typing(Some(" Trust ")); + let step = CrewStepRequest::HostKey { + host: "hpc.example.edu".into(), + port: 2222, + login: "you@hpc.example.edu".into(), + offered: Some(OFFERED.into()), + known: None, + changed: false, + }; + take_at(&fake, &step).await.unwrap(); + assert_eq!(fake.asked.borrow().as_slice(), [TRUST_QUESTION]); + assert_eq!( + fake.posted.borrow().as_slice(), + [ + json!({"host": "hpc.example.edu", "port": 2222, "login": "you@hpc.example.edu", "fingerprint": OFFERED}) + ] + ); + let said = fake.said.borrow(); + assert!(said.contains(&TRUST_WARNING.to_owned()), "{said:?}"); + assert_eq!(said.last().map(String::as_str), Some(TRUSTED)); + } + + /// Enter, Esc, or a message typed before the card arrived trusts nothing. + #[tokio::test] + async fn anything_but_the_word_trust_leaves_the_key_alone() { + for typed in [ + Some(""), + None, + Some("yes"), + Some("please trust it and go on"), + ] { + let fake = Fake::typing(typed); + take_at(&fake, &key(false, OFFERED)).await.unwrap(); + assert!(fake.posted.borrow().is_empty(), "{typed:?}"); + assert_eq!( + fake.said.borrow().last().map(String::as_str), + Some(NOT_TRUSTED) + ); + } + } + + /// A changed key, a card with no fingerprint, and a chat with nobody at a terminal are + /// never asked. + #[tokio::test] + async fn no_question_where_nothing_may_be_trusted() { + let unshown = CrewStepRequest::HostKey { + host: "hpc.example.edu".into(), + port: 22, + login: "you".into(), + offered: None, + known: None, + changed: false, + }; + for step in [ + key(true, OFFERED), + unshown, + key(false, "SHA256:x\nType yes"), + ] { + let fake = Fake::typing(Some("trust")); + take_at(&fake, &step).await.unwrap(); + assert!(fake.asked.borrow().is_empty(), "{step:?}"); + assert!(fake.posted.borrow().is_empty(), "{step:?}"); + } + let mut away = Fake::typing(Some("trust")); + away.terminal = false; + take_at(&away, &key(false, OFFERED)).await.unwrap(); + assert!(away.asked.borrow().is_empty()); + assert!(away.posted.borrow().is_empty()); + } + + #[tokio::test] + async fn a_refusal_is_said_and_nothing_else_happens() { + let mut fake = Fake::typing(Some("trust")); + fake.refusal = Some("The key hpc.example.edu gives now doesn't match the fingerprint you were shown, so nothing was added."); + take_at(&fake, &key(false, OFFERED)).await.unwrap(); + assert_eq!(fake.posted.borrow().len(), 1); + let said = fake.said.borrow(); + let last = said.last().unwrap(); + assert!(last.starts_with("Crew didn't trust the key: "), "{last}"); + assert!(last.contains("doesn't match"), "{last}"); + } +} diff --git a/crates/biorouter-cli/src/commands/crew_step/mod.rs b/crates/biorouter-cli/src/commands/crew_step/mod.rs new file mode 100644 index 000000000..2c9e21fe9 --- /dev/null +++ b/crates/biorouter-cli/src/commands/crew_step/mod.rs @@ -0,0 +1,58 @@ +//! A Crew step card in a terminal chat on the shared daemon (`biorouter session --shared-daemon`, +//! `session send`, `session attach`): the owner's D1 ruling on AO-12, which lets a chat open the +//! person's own Sign in window, vault prompt or host-key help. +//! +//! The card carries the daemon's own fields and nothing a value could sit in +//! ([`CrewStepRequest`]). What the person types here goes from this terminal straight to the +//! daemon: ssh's prompts through the raw-mode sign-in terminal +//! ([`crate::daemon_client::CrewClient::authenticate_ssh_at`]), a vault passphrase through the +//! hidden prompt ([`crate::daemon_client::read_secret`]) to the vault route with the person's +//! proof. Nothing typed is printed, added to the conversation or posted to the card, which only +//! a dismissal answers (`POST /action-required/crew-step`). Each step's own module says, where +//! it cannot take the step here, where to take it instead: the desktop app, or the +//! `biorouter crew` command that does it. + +pub(crate) mod host_key; +pub(crate) mod sign_in; +pub(crate) mod vault; + +use crate::daemon_client::CrewClient; +use anyhow::Result; +use biorouter::conversation::message::CrewStepRequest; +use std::io::Write; + +/// The chat a Crew step card arrived in, as a step needs it. +pub(crate) struct StepSurface<'a> { + /// The shared daemon, with the person's proof. + pub(crate) client: &'a CrewClient, + /// The chat the card is in. + pub(crate) session_id: &'a str, + /// The card's ID, which a dismissal posts back. + pub(crate) card_id: &'a str, +} + +impl StepSurface<'_> { + /// One line for the person, on the chat's notice channel (stderr), with anything a + /// terminal could act on removed. Never a value the person typed. + pub(crate) fn say(&self, line: &str) -> Result<()> { + let mut stderr = std::io::stderr().lock(); + writeln!( + stderr, + "{}", + crate::commands::shared_conversation::safe_text(line) + )?; + stderr.flush()?; + Ok(()) + } +} + +/// Take a Crew step card in an interactive terminal chat: the step's own module does it. The +/// caller has already checked that a person is at this terminal; a chat with nobody at it +/// refuses before this is called, as it does for every card. +pub(crate) async fn take(surface: &StepSurface<'_>, step: &CrewStepRequest) -> Result<()> { + match step { + CrewStepRequest::SignIn { .. } => sign_in::take(surface, step).await, + CrewStepRequest::Vault { .. } => vault::take(surface, step).await, + CrewStepRequest::HostKey { .. } => host_key::take(surface, step).await, + } +} diff --git a/crates/biorouter-cli/src/commands/crew_step/sign_in.rs b/crates/biorouter-cli/src/commands/crew_step/sign_in.rs new file mode 100644 index 000000000..ed56e9578 --- /dev/null +++ b/crates/biorouter-cli/src/commands/crew_step/sign_in.rs @@ -0,0 +1,500 @@ +//! A sign-in card in a terminal chat on the shared daemon (the owner's D1 ruling on AO-12): the +//! raw-mode sign-in terminal ([`crate::daemon_client::CrewClient::authenticate_ssh_at`]) on +//! `/crew/setup/{job}/authentication` or `/crew/connections/{id}/authentication`, after a header +//! the daemon's fields fill. +//! +//! What the person types goes from this keyboard to ssh on the server through the daemon's +//! sign-in terminal, with the person's proof. It is never printed by this module, posted to the +//! card or added to the chat. The card is never answered as done: the daemon takes it down when +//! the job moves on or the sign-in is adopted. When the terminal ends with nothing signed in, +//! the card is dismissed, so the chat carries on rather than waiting for a window that closed. +//! +//! ⚠ **The person opens the sign-in; a card never opens it by itself.** On the desktop the Sign in window opens +//! only when the person presses Sign in. Here the card asks first ([`question`]), and only +//! Enter with nothing typed opens ssh's prompts. A card arrives when the model calls for it, so +//! opening at once would send whatever the person was typing (often their next message) to the +//! server's password prompt as a failed attempt. Skipping dismisses the card, as Not now does on +//! the desktop. + +use super::StepSurface; +use crate::daemon_client::CrewClient; +use anyhow::Result; +use biorouter::conversation::message::{CrewSignInTarget, CrewStepRequest}; +use serde_json::{json, Value}; +use std::io::IsTerminal; +use zeroize::Zeroizing; + +/// Where to sign in when this terminal can't: the desktop app, or `biorouter crew auth`. +pub(crate) fn fallback(step: &CrewStepRequest) -> Option { + let CrewStepRequest::SignIn { target, login, .. } = step else { + return None; + }; + Some(match target { + CrewSignInTarget::Setup { job_id } => format!( + "Sign in to {login} in the Biorouter desktop app, where this setup waits with a Sign in button, or run biorouter crew auth --setup-job {job_id} in a terminal. What you type goes to ssh, never to the chat." + ), + CrewSignInTarget::Connection { connection_id } => format!( + "Sign in to {login} in the Biorouter desktop app, with Sign in on the connection in Crew, or run biorouter crew auth --connection {connection_id} in a terminal. What you type goes to ssh, never to the chat." + ), + }) +} + +/// The header printed before the sign-in terminal opens, from the daemon's own login. +pub(crate) fn header(login: &str) -> String { + format!("Signing in to {login}. What you type goes to ssh, never to the chat.") +} + +/// Asked before anything opens, with the daemon's own login. +pub(crate) fn question(login: &str) -> String { + format!("Sign in to {login} now? Press Enter to open the sign-in, or type n to skip: ") +} + +/// Said when a line answers neither way. What was typed is not repeated or sent anywhere. +const ANSWER_AGAIN: &str = "That opened nothing and went nowhere. Press Enter with nothing typed to open the sign-in, or type n to skip."; + +/// How many answers that say neither are taken before the card is skipped. +pub(crate) const ASKS: usize = 3; + +/// The most an answer to [`question`] may be, in bytes. Large, so a whole message typed before +/// the card arrived is read as one line that answers neither way, rather than cut short with +/// its end left to answer the next question. +pub(crate) const ANSWER_BYTES: usize = 4096; + +/// What a line typed at [`question`] chooses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Choice { + /// Enter with nothing typed. + Open, + /// `n` or `no`, Esc, Ctrl-C, or a terminal that could not be read. + Skip, + /// Anything else, such as a message typed before the card arrived. + Again, +} + +pub(crate) fn choice(typed: Option<&str>) -> Choice { + match typed.map(str::trim) { + Some("") => Choice::Open, + Some(text) if text.eq_ignore_ascii_case("n") || text.eq_ignore_ascii_case("no") => { + Choice::Skip + } + Some(_) => Choice::Again, + None => Choice::Skip, + } +} + +/// Said, with the code `crew_needs_terminal`, when this chat has no terminal to show the +/// server's prompts in. +pub(crate) fn needs_terminal(step: &CrewStepRequest) -> String { + let mut sentence = + "Signing in needs a terminal, to show the server's password and verification-code prompts." + .to_owned(); + if let Some(elsewhere) = fallback(step) { + sentence.push(' '); + sentence.push_str(&elsewhere); + } + sentence +} + +/// Said once the terminal signed the person in. +const SIGNED_IN: &str = "Signed in. The chat carries on."; +/// Said once the terminal ended with nothing signed in. +const NOT_SIGNED_IN: &str = "Nothing was signed in, so the chat carries on without it."; + +/// What a sign-in card needs of this terminal and the shared daemon: the person's answer to +/// [`question`], the daemon's sign-in terminal and the card's dismissal. A trait so a test can +/// stand in for both. +pub(crate) trait SignInDaemon { + /// One line the person types after `question`, `Ok(None)` for Esc or Ctrl-C. Zeroized, + /// because a person may type a password here before ssh asks for one. + async fn ask(&self, question: &str) -> Result>>; + /// The raw-mode sign-in terminal the daemon prepares at `prepare_path`, refused with + /// `no_terminal` when there is no terminal here. + async fn sign_in_at(&self, prepare_path: &str, no_terminal: &str) -> Result; + /// Dismiss the card `card_id` in the chat `session_id` (`POST /action-required/crew-step`). + async fn dismiss(&self, session_id: &str, card_id: &str) -> Result<()>; +} + +impl SignInDaemon for CrewClient { + async fn ask(&self, question: &str) -> Result>> { + crate::commands::shared_conversation::terminal_input(question, false, ANSWER_BYTES).await + } + + async fn sign_in_at(&self, prepare_path: &str, no_terminal: &str) -> Result { + self.authenticate_ssh_at(prepare_path, no_terminal).await + } + + async fn dismiss(&self, session_id: &str, card_id: &str) -> Result<()> { + self.request( + "POST", + "/action-required/crew-step", + Some(json!({"id": card_id, "session_id": session_id, "outcome": "dismiss"})), + ) + .await + .map(|_| ()) + } +} + +/// Take a sign-in card: the question, then, once the person chose to, the header and this +/// terminal's sign-in to the card's target. +pub(crate) async fn take(surface: &StepSurface<'_>, step: &CrewStepRequest) -> Result<()> { + let terminal = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + let mut say = |line: &str| surface.say(line); + run( + surface.client, + &mut say, + surface.session_id, + surface.card_id, + step, + terminal, + ) + .await +} + +/// Whether the person chose to open the sign-in: [`question`] asked up to [`ASKS`] times, and +/// only Enter with nothing typed says yes. A terminal that could not be read says no. +async fn person_opens( + daemon: &impl SignInDaemon, + say: &mut (dyn FnMut(&str) -> Result<()> + Send), + login: &str, +) -> Result { + for asked in 1..=ASKS { + let typed = daemon.ask(&question(login)).await.unwrap_or(None); + match choice(typed.as_ref().map(|typed| typed.as_str())) { + Choice::Open => return Ok(true), + Choice::Skip => return Ok(false), + Choice::Again if asked < ASKS => say(ANSWER_AGAIN)?, + Choice::Again => {} + } + } + Ok(false) +} + +/// [`take`], against `daemon`, saying each line with `say`. `terminal` says whether a person can +/// answer ssh's prompts here; without one the card is refused with `crew_needs_terminal`, as +/// `biorouter crew auth` refuses, and left for the desktop app to take. With one, nothing opens +/// until the person chooses to ([`person_opens`]); a skip dismisses the card. +async fn run( + daemon: &impl SignInDaemon, + say: &mut (dyn FnMut(&str) -> Result<()> + Send), + session_id: &str, + card_id: &str, + step: &CrewStepRequest, + terminal: bool, +) -> Result<()> { + let CrewStepRequest::SignIn { target, login, .. } = step else { + return Ok(()); + }; + let no_terminal = needs_terminal(step); + crate::commands::needs_terminal::require(terminal, &no_terminal)?; + if !person_opens(daemon, say, login).await? { + let _ = daemon.dismiss(session_id, card_id).await; + return say(NOT_SIGNED_IN); + } + let path = match target { + CrewSignInTarget::Setup { job_id } => format!("/crew/setup/{job_id}/authentication"), + CrewSignInTarget::Connection { connection_id } => { + format!("/crew/connections/{connection_id}/authentication") + } + }; + say(&header(login))?; + let signed_in = match daemon.sign_in_at(&path, &no_terminal).await { + Ok(ended) => ended["authenticated"].as_bool() == Some(true), + Err(error) => { + if error + .chain() + .any(|cause| cause.is::()) + { + return Err(error); + } + say(&error.to_string())?; + false + } + }; + if signed_in { + return say(SIGNED_IN); + } + // The daemon may already have taken the card down (a sign-in adopted as it ended); then + // there is nothing to dismiss, and that is not a failure. + let _ = daemon.dismiss(session_id, card_id).await; + say(NOT_SIGNED_IN) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// One answer the person gives at the question. + #[derive(Clone, Copy)] + enum Typed { + Line(&'static str), + Escape, + Unreadable, + } + + /// The terminal and the shared daemon, as a sign-in card sees them: the answers typed at the + /// question in order (Enter when none are left), what was asked of the daemon, and what it + /// answers. + #[derive(Default)] + struct Daemon { + signed_in: Option, + refusal: Option, + typed: Mutex>, + questions: Mutex>, + asked: Mutex>, + } + + impl Daemon { + fn answering(typed: &[Typed]) -> Self { + Self { + typed: Mutex::new(typed.iter().rev().copied().collect()), + ..Self::default() + } + } + } + + impl SignInDaemon for Daemon { + async fn ask(&self, question: &str) -> Result>> { + self.questions.lock().unwrap().push(question.to_owned()); + let typed = self.typed.lock().unwrap().pop().unwrap_or(Typed::Line("")); + match typed { + Typed::Line(line) => Ok(Some(Zeroizing::new(line.to_owned()))), + Typed::Escape => Ok(None), + Typed::Unreadable => anyhow::bail!("Human input requires an interactive terminal"), + } + } + + async fn sign_in_at(&self, prepare_path: &str, _no_terminal: &str) -> Result { + self.asked + .lock() + .unwrap() + .push(format!("sign in at {prepare_path}")); + if let Some(refusal) = &self.refusal { + anyhow::bail!("{refusal}"); + } + Ok(json!({"authentication_id": "a", "exit_code": 0, + "authenticated": self.signed_in.unwrap_or(false)})) + } + + async fn dismiss(&self, session_id: &str, card_id: &str) -> Result<()> { + self.asked + .lock() + .unwrap() + .push(format!("dismiss {card_id} in {session_id}")); + Ok(()) + } + } + + fn setup_step() -> CrewStepRequest { + CrewStepRequest::SignIn { + target: CrewSignInTarget::Setup { + job_id: "job-1".into(), + }, + server: "hpc.example.edu".into(), + login: "you@hpc.example.edu".into(), + } + } + + async fn taken( + daemon: &Daemon, + step: &CrewStepRequest, + terminal: bool, + ) -> (Result<()>, Vec) { + let mut said = Vec::new(); + let mut say = |line: &str| { + said.push(line.to_owned()); + Ok(()) + }; + let result = run(daemon, &mut say, "chat-1", "card-1", step, terminal).await; + (result, said) + } + + #[tokio::test] + async fn a_setup_card_signs_in_on_the_job_s_path_after_the_header() { + let daemon = Daemon { + signed_in: Some(true), + ..Daemon::default() + }; + let (result, said) = taken(&daemon, &setup_step(), true).await; + result.unwrap(); + assert_eq!( + *daemon.asked.lock().unwrap(), + ["sign in at /crew/setup/job-1/authentication"] + ); + assert_eq!( + said, + [ + "Signing in to you@hpc.example.edu. What you type goes to ssh, never to the chat.", + SIGNED_IN, + ] + ); + } + + #[tokio::test] + async fn a_connection_card_signs_in_on_the_connection_s_path() { + let daemon = Daemon { + signed_in: Some(true), + ..Daemon::default() + }; + let step = CrewStepRequest::SignIn { + target: CrewSignInTarget::Connection { + connection_id: "conn-1".into(), + }, + server: "lab.example.edu".into(), + login: "me@lab.example.edu".into(), + }; + taken(&daemon, &step, true).await.0.unwrap(); + assert_eq!( + *daemon.asked.lock().unwrap(), + ["sign in at /crew/connections/conn-1/authentication"] + ); + } + + /// A terminal that ended with nothing signed in, or that the daemon refused, dismisses the + /// card, so the chat is not left waiting for a window that closed. + #[tokio::test] + async fn nothing_signed_in_dismisses_the_card() { + let daemon = Daemon::default(); + let (result, said) = taken(&daemon, &setup_step(), true).await; + result.unwrap(); + assert_eq!( + *daemon.asked.lock().unwrap(), + [ + "sign in at /crew/setup/job-1/authentication", + "dismiss card-1 in chat-1" + ] + ); + assert_eq!(said.last().unwrap(), NOT_SIGNED_IN); + + let refused = Daemon { + refusal: Some( + "This server already has a sign-in open; close it before opening another".into(), + ), + ..Daemon::default() + }; + let (result, said) = taken(&refused, &setup_step(), true).await; + result.unwrap(); + assert!( + said.iter() + .any(|line| line.contains("already has a sign-in open")), + "{said:?}" + ); + assert_eq!(refused.asked.lock().unwrap().len(), 2); + } + + /// No terminal: refused with the usage refusal (`crew_needs_terminal`), nothing asked of the + /// daemon and nothing dismissed, so the desktop app can still take the card. + #[tokio::test] + async fn without_a_terminal_the_card_is_refused_and_left() { + let daemon = Daemon::default(); + let (result, said) = taken(&daemon, &setup_step(), false).await; + let error = result.unwrap_err(); + assert!(error.is::()); + let sentence = error.to_string(); + assert!( + sentence.starts_with("Signing in needs a terminal"), + "{sentence}" + ); + assert!( + sentence.contains("biorouter crew auth --setup-job job-1"), + "{sentence}" + ); + assert!(daemon.asked.lock().unwrap().is_empty()); + assert!(daemon.questions.lock().unwrap().is_empty()); + assert!(said.is_empty()); + } + + /// A card arrives when the model calls for it, so nothing opens until the person presses + /// Enter at the question: a message typed before it arrived opens nothing and is not + /// repeated, and `n` skips, dismissing the card as Not now does on the desktop. + #[tokio::test] + async fn nothing_opens_until_the_person_chooses_to() { + let daemon = + Daemon::answering(&[Typed::Line("what's next for the cohort?"), Typed::Line("n")]); + let (result, said) = taken(&daemon, &setup_step(), true).await; + result.unwrap(); + assert_eq!( + *daemon.asked.lock().unwrap(), + ["dismiss card-1 in chat-1"], + "no sign-in was opened" + ); + assert_eq!(said, [ANSWER_AGAIN, NOT_SIGNED_IN]); + assert!(!said.iter().any(|line| line.contains("cohort")), "{said:?}"); + let questions = daemon.questions.lock().unwrap().clone(); + assert_eq!(questions.len(), 2); + assert_eq!( + questions[0], + "Sign in to you@hpc.example.edu now? Press Enter to open the sign-in, or type n to skip: " + ); + + // Esc skips, and so does a terminal that could not be read. + for typed in [Typed::Escape, Typed::Unreadable] { + let daemon = Daemon::answering(&[typed]); + let (result, said) = taken(&daemon, &setup_step(), true).await; + result.unwrap(); + assert_eq!(*daemon.asked.lock().unwrap(), ["dismiss card-1 in chat-1"]); + assert_eq!(said, [NOT_SIGNED_IN]); + } + } + + /// An answer that says neither is asked again, [`ASKS`] times at most, and then the card is + /// skipped; Enter after one opens the sign-in. + #[tokio::test] + async fn an_answer_that_says_neither_is_asked_again_then_skipped() { + let daemon = + Daemon::answering(&[Typed::Line("yes"), Typed::Line("ok"), Typed::Line("sure")]); + let (result, said) = taken(&daemon, &setup_step(), true).await; + result.unwrap(); + assert_eq!(daemon.questions.lock().unwrap().len(), ASKS); + assert_eq!(*daemon.asked.lock().unwrap(), ["dismiss card-1 in chat-1"]); + assert_eq!(said, [ANSWER_AGAIN, ANSWER_AGAIN, NOT_SIGNED_IN]); + + let daemon = Daemon { + signed_in: Some(true), + ..Daemon::answering(&[Typed::Line("hello"), Typed::Line(" ")]) + }; + let (result, said) = taken(&daemon, &setup_step(), true).await; + result.unwrap(); + assert_eq!( + *daemon.asked.lock().unwrap(), + ["sign in at /crew/setup/job-1/authentication"] + ); + assert_eq!(said.last().unwrap(), SIGNED_IN); + } + + #[test] + fn the_words_name_where_to_sign_in_and_use_no_dashes() { + let setup = fallback(&setup_step()).unwrap(); + assert!(setup.contains("you@hpc.example.edu"), "{setup}"); + assert!(setup.contains("desktop app"), "{setup}"); + assert!( + setup.contains("biorouter crew auth --setup-job job-1"), + "{setup}" + ); + let connection = fallback(&CrewStepRequest::SignIn { + target: CrewSignInTarget::Connection { + connection_id: "conn-1".into(), + }, + server: "hpc.example.edu".into(), + login: "you@hpc.example.edu".into(), + }) + .unwrap(); + assert!( + connection.contains("biorouter crew auth --connection conn-1"), + "{connection}" + ); + for text in [ + setup, + connection, + header("you@hpc.example.edu"), + question("you@hpc.example.edu"), + ANSWER_AGAIN.to_owned(), + needs_terminal(&setup_step()), + SIGNED_IN.to_owned(), + NOT_SIGNED_IN.to_owned(), + ] { + assert!(!text.contains(['\u{2014}', '\u{2013}']), "{text}"); + assert!(!text.to_lowercase().contains("device"), "{text}"); + } + } +} diff --git a/crates/biorouter-cli/src/commands/crew_step/vault.rs b/crates/biorouter-cli/src/commands/crew_step/vault.rs new file mode 100644 index 000000000..fbc7745b5 --- /dev/null +++ b/crates/biorouter-cli/src/commands/crew_step/vault.rs @@ -0,0 +1,506 @@ +//! A vault card in a terminal chat on the shared daemon: the passphrase is asked here with the +//! hidden prompt ([`crate::daemon_client::read_secret`], or [`crate::daemon_client::new_secret`] +//! twice for init) and posted to `/crew/credentials/{unlock,init}` with the person's proof, +//! through the chat's own [`crate::daemon_client::CrewClient`]. The value is never echoed, +//! printed or added to the chat; the card is never answered as done, because the call that +//! parked it reads the vault and takes it down itself. Skipping posts the card's dismissal. +//! +//! ⚠ **The person opens the prompt; a card never opens it by itself**, as on the desktop and as +//! a sign-in card here ([`super::sign_in`]). The card asks first ([`question`]), and only Enter +//! with nothing typed opens the hidden prompt. A card arrives when the model calls for it, so +//! opening at once read whatever the person was typing (often their next message) as a +//! passphrase and posted it to the vault route as a failed attempt. + +use super::sign_in::{choice, Choice, ANSWER_BYTES, ASKS}; +use super::StepSurface; +use crate::daemon_client::{new_secret, read_secret}; +use anyhow::Result; +use biorouter::conversation::message::{CrewStepRequest, CrewVaultAction}; +use serde_json::{json, Value}; +use std::io::IsTerminal; +use zeroize::Zeroizing; + +/// How many passphrases a card takes before it is left for Crew or `biorouter crew`. +const ATTEMPTS: usize = 3; + +const VAULT_PASSPHRASE: &str = "Vault passphrase:"; +const NEW_VAULT_PASSPHRASE: &str = "New vault passphrase:"; +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."; + +/// Said before anything is asked: what the card needs, and where the passphrase goes. +fn opening(action: CrewVaultAction) -> &'static str { + match action { + CrewVaultAction::Unlock => "This chat needs Crew's vault on this computer, and it is locked. Its passphrase goes only to Crew on this computer, never to the chat.", + CrewVaultAction::Init => "This chat needs a vault on this computer for Crew's keys. You choose its passphrase and type it twice. It goes only to Crew on this computer, never to the chat.", + } +} + +/// Asked before the hidden prompt opens. +fn question(action: CrewVaultAction) -> &'static str { + match action { + CrewVaultAction::Unlock => "Unlock Crew's vault now? Press Enter to open the hidden passphrase prompt, or type n to skip: ", + CrewVaultAction::Init => "Set up Crew's vault now? Press Enter to open the hidden passphrase prompt, or type n to skip: ", + } +} + +/// Said when a line answers neither way. What was typed is not repeated, posted or sent +/// anywhere: it may be a passphrase typed too early. +const ANSWER_AGAIN: &str = "That opened nothing and went nowhere. Press Enter with nothing typed to open the passphrase prompt, or type n to skip."; + +/// Said once the hidden prompt is open. +const AT_THE_PROMPT: &str = + "Type the passphrase at the hidden prompt. Press Enter with nothing typed to skip."; + +/// Said once the daemon took the passphrase. +fn done(action: CrewVaultAction) -> &'static str { + match action { + CrewVaultAction::Unlock => "Crew's vault is unlocked. The chat carries on.", + CrewVaultAction::Init => "Crew's vault is set up and unlocked. The chat carries on.", + } +} + +/// Said when the card is left: skipped, or refused [`ATTEMPTS`] times. +fn left(action: CrewVaultAction) -> &'static str { + match action { + CrewVaultAction::Unlock => "Crew's vault is still locked. You can unlock it in Crew, under Keys, or with biorouter crew credentials unlock.", + CrewVaultAction::Init => "Crew's vault is still not set up. You can set it up in Crew, under Keys, or with biorouter crew credentials init.", + } +} + +/// Where to unlock or set up the vault when this terminal can't ask: the desktop app, or +/// `biorouter crew credentials`. The card stays up meanwhile, so the chat carries on once the +/// vault is open. +pub(crate) fn fallback(step: &CrewStepRequest) -> Option { + let CrewStepRequest::Vault { action } = step else { + return None; + }; + Some(match action { + CrewVaultAction::Unlock => "Unlock Crew's vault in the Biorouter desktop app, under Keys in Crew, or run biorouter crew credentials unlock in a terminal. Type the passphrase only there, never in the chat.", + CrewVaultAction::Init => "Set up Crew's vault in the Biorouter desktop app, under Keys in Crew, or run biorouter crew credentials init in a terminal. Type the passphrase only there, never in the chat.", + }.to_owned()) +} + +/// What a vault card needs from this terminal and the daemon, so a test can stand in for both. +trait Desk { + /// One line for the person on the chat's notice channel. Never a passphrase. + fn say(&self, line: &str) -> Result<()>; + /// Whether a hidden prompt can be shown here. + fn can_prompt(&self) -> bool; + /// One line the person types after `question`, `Ok(None)` for Esc or Ctrl-C. Zeroized, + /// because a person may type a passphrase here before the prompt opens. + async fn ask(&self, question: &str) -> Result>>; + /// The passphrase for `action`, from the hidden prompt (twice for init). `Ok(None)` is a + /// skip: nothing typed. An `Err` is said, and the passphrase asked again. + async fn passphrase(&self, action: CrewVaultAction) -> Result>>; + /// Post `body` to the vault route for `action`, with the person's proof. + async fn post(&self, action: CrewVaultAction, body: Value) -> Result; + /// Take the card down as not done. + async fn dismiss(&self) -> Result<()>; +} + +impl Desk for StepSurface<'_> { + fn say(&self, line: &str) -> Result<()> { + StepSurface::say(self, line) + } + + fn can_prompt(&self) -> bool { + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() + } + + async fn ask(&self, question: &str) -> Result>> { + crate::commands::shared_conversation::terminal_input(question, false, ANSWER_BYTES).await + } + + async fn passphrase(&self, action: CrewVaultAction) -> Result>> { + let typed = match action { + CrewVaultAction::Unlock => read_secret(VAULT_PASSPHRASE, false).await, + CrewVaultAction::Init => { + new_secret( + [ + NEW_VAULT_PASSPHRASE, + VAULT_PASSPHRASE_AGAIN, + VAULT_PASSPHRASES_DIFFER, + ], + false, + read_secret, + ) + .await + } + }; + match typed { + Ok(passphrase) => Ok(Some(passphrase)), + // Two that differ are asked again; anything else (nothing typed, Ctrl-C) skips. + Err(error) if error.to_string() == VAULT_PASSPHRASES_DIFFER => Err(error), + Err(_) => Ok(None), + } + } + + async fn post(&self, action: CrewVaultAction, body: Value) -> Result { + let path = match action { + CrewVaultAction::Unlock => "/crew/credentials/unlock", + CrewVaultAction::Init => "/crew/credentials/init", + }; + self.client.request("POST", path, Some(body)).await + } + + async fn dismiss(&self) -> Result<()> { + self.client + .request( + "POST", + "/action-required/crew-step", + Some(json!({"id": self.card_id, "session_id": self.session_id, "outcome": "dismiss"})), + ) + .await + .map(|_| ()) + } +} + +/// Take a vault card: ask for the passphrase and post it, up to [`ATTEMPTS`] times. +pub(crate) async fn take(surface: &StepSurface<'_>, step: &CrewStepRequest) -> Result<()> { + let CrewStepRequest::Vault { action } = step else { + return Ok(()); + }; + take_at(surface, *action).await +} + +async fn take_at(desk: &impl Desk, action: CrewVaultAction) -> Result<()> { + if !desk.can_prompt() { + // The card stays up: unlocking in Crew or another terminal lets the chat carry on. + if let Some(line) = fallback(&CrewStepRequest::Vault { action }) { + desk.say(&line)?; + } + return Ok(()); + } + desk.say(opening(action))?; + if !person_opens(desk, action).await? { + return leave(desk, action).await; + } + desk.say(AT_THE_PROMPT)?; + for _ in 0..ATTEMPTS { + let passphrase = match desk.passphrase(action).await { + Ok(Some(passphrase)) => passphrase, + Ok(None) => return leave(desk, action).await, + Err(error) => { + desk.say(&format!("{error:#}"))?; + continue; + } + }; + let refused = match desk + .post(action, json!({"passphrase": passphrase.as_str()})) + .await + { + Ok(_) => return desk.say(done(action)), + Err(error) => format!("{error:#}"), + }; + // The daemon never repeats a passphrase; were its words ever to, they are not shown. + if refused.contains(passphrase.as_str()) { + desk.say("Crew didn't take that passphrase.")?; + } else { + desk.say(&format!("Crew didn't take that passphrase: {refused}"))?; + } + } + leave(desk, action).await +} + +/// Whether the person chose to open the hidden prompt: [`question`] asked up to [`ASKS`] +/// times, and only Enter with nothing typed says yes. A terminal that could not be read says +/// no. A line that says neither (a message, a passphrase typed too early) is never used. +async fn person_opens(desk: &impl Desk, action: CrewVaultAction) -> Result { + for asked in 1..=ASKS { + let typed = desk.ask(question(action)).await.unwrap_or(None); + match choice(typed.as_ref().map(|typed| typed.as_str())) { + Choice::Open => return Ok(true), + Choice::Skip => return Ok(false), + Choice::Again if asked < ASKS => desk.say(ANSWER_AGAIN)?, + Choice::Again => {} + } + } + Ok(false) +} + +/// Leave the card: say where to finish, and take it down so the chat goes on. +async fn leave(desk: &impl Desk, action: CrewVaultAction) -> Result<()> { + desk.say(left(action))?; + if let Err(error) = desk.dismiss().await { + desk.say(&format!("The card couldn't be closed: {error:#}"))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + const CANARY: &str = "CANARY-7f3a vault passphrase"; + + /// What the person does at one hidden prompt. + #[derive(Clone, Copy)] + enum Typed { + Text(&'static str), + Differ, + Nothing, + } + + /// A terminal and a daemon in memory: the prompts answered in order, the daemon's answers + /// in order, and everything said, posted and dismissed. + struct Fake { + terminal: bool, + typed: RefCell>, + /// The lines typed at the question, in order; Enter once none are left. `None` is Esc. + answered: RefCell>>, + questions: RefCell>, + answers: RefCell>>, + said: RefCell>, + posted: RefCell>, + dismissed: RefCell, + } + + impl Fake { + fn new(typed: &[Typed], answers: Vec>) -> Self { + Self { + terminal: true, + typed: RefCell::new(typed.iter().rev().copied().collect()), + answered: RefCell::default(), + questions: RefCell::default(), + answers: RefCell::new(answers.into_iter().rev().collect()), + said: RefCell::default(), + posted: RefCell::default(), + dismissed: RefCell::default(), + } + } + + fn answering(mut self, lines: &[Option<&'static str>]) -> Self { + self.answered = RefCell::new(lines.iter().rev().copied().collect()); + self + } + + fn said(&self) -> String { + self.said.borrow().join("\n") + } + } + + impl Desk for Fake { + fn say(&self, line: &str) -> Result<()> { + self.said.borrow_mut().push(line.to_owned()); + Ok(()) + } + + fn can_prompt(&self) -> bool { + self.terminal + } + + async fn ask(&self, question: &str) -> Result>> { + self.questions.borrow_mut().push(question.to_owned()); + Ok(self + .answered + .borrow_mut() + .pop() + .unwrap_or(Some("")) + .map(|line| Zeroizing::new(line.to_owned()))) + } + + async fn passphrase(&self, _action: CrewVaultAction) -> Result>> { + match self + .typed + .borrow_mut() + .pop() + .expect("a prompt was answered") + { + Typed::Text(text) => Ok(Some(Zeroizing::new(text.to_owned()))), + Typed::Differ => anyhow::bail!(VAULT_PASSPHRASES_DIFFER), + Typed::Nothing => Ok(None), + } + } + + async fn post(&self, action: CrewVaultAction, body: Value) -> Result { + self.posted.borrow_mut().push((action, body)); + match self.answers.borrow_mut().pop().expect("an answer") { + Ok(answer) => Ok(answer), + Err(refusal) => Err(anyhow::anyhow!(refusal)), + } + } + + async fn dismiss(&self) -> Result<()> { + *self.dismissed.borrow_mut() += 1; + Ok(()) + } + } + + fn unlocked() -> Value { + json!({"backend": "encrypted_vault", "initialized": true, "locked": false}) + } + + /// The passphrase goes only into the POST body; nothing said carries it, a refusal is said + /// and asked again, and the card is not dismissed once the daemon took it. + #[tokio::test] + async fn the_passphrase_goes_only_to_the_vault_route() { + let fake = Fake::new( + &[Typed::Text(CANARY), Typed::Text("the real one")], + vec![ + Err("Crew vault authentication failed: incorrect passphrase, profile mismatch, or damaged vault"), + Ok(unlocked()), + ], + ); + take_at(&fake, CrewVaultAction::Unlock).await.unwrap(); + let posted = fake.posted.borrow(); + assert_eq!(posted.len(), 2); + assert_eq!( + posted[0], + (CrewVaultAction::Unlock, json!({"passphrase": CANARY})) + ); + assert_eq!(posted[1].1, json!({"passphrase": "the real one"})); + let said = fake.said(); + assert!(!said.contains(CANARY), "{said}"); + assert!(!said.contains("the real one"), "{said}"); + assert!(said.contains("incorrect passphrase"), "{said}"); + assert!(said.ends_with(done(CrewVaultAction::Unlock)), "{said}"); + assert_eq!(*fake.dismissed.borrow(), 0); + } + + /// WI-17 (e): the card asks before any prompt opens, so a line the person had already typed + /// (their next message, or a passphrase typed too early) answers the question, is never + /// read as a passphrase and never posted: the prompt opens only on Enter with nothing typed. + #[tokio::test] + async fn the_prompt_opens_only_when_the_person_presses_enter() { + let early = "CANARY-7f3a my next chat message"; + let fake = Fake::new(&[Typed::Text("the real one")], vec![Ok(unlocked())]) + .answering(&[Some(early)]); + take_at(&fake, CrewVaultAction::Unlock).await.unwrap(); + assert_eq!( + *fake.questions.borrow(), + [question(CrewVaultAction::Unlock); 2], + "asked again after a line that said neither" + ); + assert_eq!( + *fake.posted.borrow(), + [( + CrewVaultAction::Unlock, + json!({"passphrase": "the real one"}) + )], + "only the passphrase typed at the open prompt is posted" + ); + let said = fake.said(); + assert!(!said.contains("CANARY-7f3a"), "{said}"); + assert!(said.contains(ANSWER_AGAIN), "{said}"); + assert!(said.ends_with(done(CrewVaultAction::Unlock)), "{said}"); + + // `n` (or Esc) skips: no prompt opens, nothing is posted, and the card is dismissed. + for skip in [Some("n"), None] { + let fake = Fake::new(&[], vec![]).answering(&[skip]); + take_at(&fake, CrewVaultAction::Init).await.unwrap(); + assert!(fake.posted.borrow().is_empty(), "{skip:?}"); + assert_eq!(*fake.dismissed.borrow(), 1, "{skip:?}"); + assert!( + fake.said().ends_with(left(CrewVaultAction::Init)), + "{skip:?}" + ); + } + + // Three lines that say neither skip too, and none of them reaches the vault route. + let fake = Fake::new(&[], vec![]).answering(&[Some(early), Some(early), Some(early)]); + take_at(&fake, CrewVaultAction::Unlock).await.unwrap(); + assert!(fake.posted.borrow().is_empty()); + assert_eq!(*fake.dismissed.borrow(), 1); + assert!(!fake.said().contains("CANARY-7f3a")); + } + + /// A refusal that ever repeated the passphrase is not shown. + #[tokio::test] + async fn a_refusal_that_repeats_the_passphrase_is_not_shown() { + let fake = Fake::new( + &[Typed::Text(CANARY), Typed::Nothing], + vec![Err("rejected CANARY-7f3a vault passphrase")], + ); + take_at(&fake, CrewVaultAction::Unlock).await.unwrap(); + let said = fake.said(); + assert!(!said.contains(CANARY), "{said}"); + assert!(said.contains("Crew didn't take that passphrase."), "{said}"); + } + + /// Nothing typed leaves the card: nothing is posted, and the card is dismissed. + #[tokio::test] + async fn nothing_typed_posts_nothing_and_dismisses_the_card() { + let fake = Fake::new(&[Typed::Nothing], vec![]); + take_at(&fake, CrewVaultAction::Init).await.unwrap(); + assert!(fake.posted.borrow().is_empty()); + assert_eq!(*fake.dismissed.borrow(), 1); + assert!(fake.said().ends_with(left(CrewVaultAction::Init))); + } + + /// Two passphrases that differ are said and asked again; the init route gets the third. + #[tokio::test] + async fn init_asks_again_when_the_two_differ() { + let fake = Fake::new(&[Typed::Differ, Typed::Text(CANARY)], vec![Ok(unlocked())]); + take_at(&fake, CrewVaultAction::Init).await.unwrap(); + assert!(fake.said().contains(VAULT_PASSPHRASES_DIFFER)); + assert_eq!( + *fake.posted.borrow(), + [(CrewVaultAction::Init, json!({"passphrase": CANARY}))] + ); + assert!(!fake.said().contains(CANARY)); + } + + /// Three refusals leave the card for Crew or `biorouter crew`. + #[tokio::test] + async fn three_refusals_leave_the_card() { + let fake = Fake::new( + &[Typed::Text("one"), Typed::Text("two"), Typed::Text("three")], + vec![Err("no"), Err("no"), Err("no")], + ); + take_at(&fake, CrewVaultAction::Unlock).await.unwrap(); + assert_eq!(fake.posted.borrow().len(), ATTEMPTS); + assert_eq!(*fake.dismissed.borrow(), 1); + assert!(fake.said().ends_with(left(CrewVaultAction::Unlock))); + } + + /// With no terminal to prompt in, it says where to do it and leaves the card up, so the + /// chat carries on once the vault is open; nothing is asked or posted. + #[tokio::test] + async fn without_a_terminal_it_says_where_and_leaves_the_card_up() { + let mut fake = Fake::new(&[], vec![]); + fake.terminal = false; + take_at(&fake, CrewVaultAction::Unlock).await.unwrap(); + assert_eq!( + fake.said(), + fallback(&CrewStepRequest::Vault { + action: CrewVaultAction::Unlock + }) + .unwrap() + ); + assert!(fake.posted.borrow().is_empty()); + assert_eq!(*fake.dismissed.borrow(), 0); + } + + #[test] + fn the_fallback_names_the_desktop_and_the_command_for_the_action() { + for (action, command) in [ + (CrewVaultAction::Unlock, "biorouter crew credentials unlock"), + (CrewVaultAction::Init, "biorouter crew credentials init"), + ] { + let text = fallback(&CrewStepRequest::Vault { action }).unwrap(); + assert!(text.contains(command), "{text}"); + assert!(text.contains("desktop app"), "{text}"); + assert!(text.contains("never in the chat"), "{text}"); + assert!(left(action).contains(command), "{action:?}"); + } + } + + #[test] + fn the_words_follow_the_copy_rules() { + for action in [CrewVaultAction::Unlock, CrewVaultAction::Init] { + for text in [ + opening(action), + question(action), + ANSWER_AGAIN, + AT_THE_PROMPT, + done(action), + left(action), + ] { + assert!(!text.contains(['\u{2014}', '\u{2013}']), "{text}"); + assert!(!text.to_lowercase().contains("device"), "{text}"); + } + } + } +} diff --git a/crates/biorouter-cli/src/commands/mod.rs b/crates/biorouter-cli/src/commands/mod.rs index 687911b96..1fe55f1c4 100644 --- a/crates/biorouter-cli/src/commands/mod.rs +++ b/crates/biorouter-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod bench; pub mod configure; pub mod crew; +pub(crate) mod crew_step; pub mod decide; pub mod decision_pull; pub mod doctor; diff --git a/crates/biorouter-cli/src/commands/needs_terminal.rs b/crates/biorouter-cli/src/commands/needs_terminal.rs index a4ce94b44..9f0e6662d 100644 --- a/crates/biorouter-cli/src/commands/needs_terminal.rs +++ b/crates/biorouter-cli/src/commands/needs_terminal.rs @@ -50,6 +50,26 @@ pub fn prompt_can_run() -> bool { std::io::stdin().is_terminal() && std::io::stderr().is_terminal() } +/// The variable an agent's own shell carries: `developer__shell` and the retry runner set +/// `BIOROUTER_TERMINAL=1` on every command they run (`biorouter-mcp/src/developer/shell.rs`, +/// `biorouter/src/agents/retry.rs`). +pub const AGENT_SHELL_MARKER: &str = "BIOROUTER_TERMINAL"; + +/// Whether this process runs in an agent's own shell rather than at a person's terminal. +/// +/// ⚠ **Accident prevention, not a defence against forgery.** An agent can unset the variable; +/// what it stops is a model's ordinary command answering a card as the person (`printf +/// 'allow_once\n' | biorouter session attach …`), the way D5 stops `biorouter crew` changes +/// there. +pub fn in_agent_shell() -> bool { + agent_shell_marked(std::env::var_os(AGENT_SHELL_MARKER).as_deref()) +} + +/// [`in_agent_shell`] for the marker's value, so a test needs no process environment. +fn agent_shell_marked(value: Option<&std::ffi::OsStr>) -> bool { + value.is_some_and(|value| !value.is_empty()) +} + /// `Ok` when a prompt can run, otherwise the refusal carrying `sentence`. /// /// `terminal` is a parameter, not a call to [`prompt_can_run`], so a test can @@ -90,4 +110,25 @@ mod tests { "a context layer must not hide the refusal from main's downcast" ); } + + /// The marker the agent's shell sets is what is read, and set to anything it counts. Read + /// from the setter's source, because the CLI cannot import it: a rename there would + /// otherwise leave every agent shell looking like a person's terminal. + #[test] + fn an_agent_shell_is_known_by_its_marker() { + use std::ffi::OsStr; + assert!(agent_shell_marked(Some(OsStr::new("1")))); + assert!(agent_shell_marked(Some(OsStr::new("true")))); + assert!(!agent_shell_marked(Some(OsStr::new("")))); + assert!(!agent_shell_marked(None)); + let shell = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../biorouter-mcp/src/developer/shell.rs"), + ) + .expect("the developer shell's source"); + assert!( + shell.contains(&format!(".env(\"{AGENT_SHELL_MARKER}\", \"1\")")), + "developer__shell no longer sets {AGENT_SHELL_MARKER}" + ); + } } diff --git a/crates/biorouter-cli/src/commands/session.rs b/crates/biorouter-cli/src/commands/session.rs index 214ea55fc..bb3e61977 100644 --- a/crates/biorouter-cli/src/commands/session.rs +++ b/crates/biorouter-cli/src/commands/session.rs @@ -1148,6 +1148,148 @@ pub async fn prompt_interactive_session_selection( } } +/// Said on stderr when an interactive `biorouter session` runs its chat on the shared daemon +/// because one is running (D4). +pub(crate) const RUNS_ON_THE_SHARED_DAEMON: &str = "This chat runs on the shared Biorouter daemon, so it can use Crew as you. Add --local to run a chat in this terminal only."; + +/// How long [`shared_daemon_for_chat`] waits for a running daemon to answer before the chat +/// runs in this process instead. +const SHARED_DAEMON_PROBE: std::time::Duration = std::time::Duration::from_secs(3); + +/// What decides where an interactive chat runs (D4), read from its command line and process. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ChatRequest { + /// `--local`: this process, whatever is running. + pub local: bool, + /// Standard input, output and error are all a terminal, so a person is there to answer the + /// chat's cards. A redirected or piped command keeps its chat in this process. + pub at_a_terminal: bool, + /// An option only a chat in this process takes was given: `--name`, `--path`, `--debug`, + /// `--max-tool-repetitions`, `--max-turns` or an extension added on the command line. + pub in_process_option: bool, + /// The provider and model the chat starts on are known ([`chat_model`]), or, for a chat + /// resumed by its ID, `--provider` and `--model` were given together or not at all. + pub model_known: bool, + /// The command runs in an agent's own shell ([`needs_terminal::in_agent_shell`]). A chat + /// that acts as the person is never started from there. + pub agent_shell: bool, +} + +impl ChatRequest { + /// Whether the chat may run on the shared daemon, before any daemon is asked. + pub fn may_use_the_shared_daemon(&self) -> bool { + !self.local + && self.at_a_terminal + && !self.in_process_option + && self.model_known + && !self.agent_shell + } +} + +/// Whether standard input, output and error are all a terminal. +pub(crate) fn at_a_terminal() -> bool { + use std::io::IsTerminal; + std::io::stdin().is_terminal() + && std::io::stdout().is_terminal() + && std::io::stderr().is_terminal() +} + +/// D4: the shared daemon an interactive chat runs on, or `None` to run it in this process. A +/// daemon is used only when [`ChatRequest::may_use_the_shared_daemon`] holds, the user-action +/// key file its starter saved is present, and it answers that key within +/// [`SHARED_DAEMON_PROBE`]. Never starts, stops or replaces a daemon: `--shared-daemon` is how +/// a person asks for one to be started. +pub(crate) async fn shared_daemon_for_chat( + request: &ChatRequest, +) -> Option { + if !request.may_use_the_shared_daemon() { + return None; + } + // Without the file, connecting waits for a starter to save one; a chat that can run here + // does not wait for that. + if fs::symlink_metadata(biorouter::daemon_runtime::user_action_key_path()).is_err() { + return None; + } + let connect = crate::daemon_client::CrewClient::connect(true); + match tokio::time::timeout(SHARED_DAEMON_PROBE, connect).await { + Ok(Ok(client)) => Some(client), + Ok(Err(_)) | Err(_) => { + tracing::debug!("no usable shared daemon, so the chat runs in this process"); + None + } + } +} + +/// The provider and model a new chat on the shared daemon starts on, as an in-process chat +/// chooses them: `--provider` and `--model` together, or with neither, the `configured` pair. +/// `None` when the pair is incomplete; such a chat stays in this process, which resolves a lone +/// `--provider` its own way. +pub(crate) fn chat_model( + provider: Option<&str>, + model: Option<&str>, + configured: impl FnOnce() -> (Option, Option), +) -> Option<(String, String)> { + fn named(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + } + match (provider, model) { + (None, None) => { + let (provider, model) = configured(); + Some((named(provider.as_deref())?, named(model.as_deref())?)) + } + (provider, model) => Some((named(provider)?, named(model)?)), + } +} + +/// The configured provider and model, as [`chat_model`] reads them. +pub(crate) fn configured_model() -> (Option, Option) { + let config = biorouter::config::Config::global(); + ( + config.get_biorouter_provider().ok(), + config.get_biorouter_model().ok(), + ) +} + +/// The chat `--resume` with no ID resumes on the shared daemon: the most recent one, found as +/// an in-process chat finds it. +pub(crate) async fn latest_chat_id() -> Result> { + Ok(SessionManager::instance() + .list_sessions() + .await? + .first() + .map(|session| session.id.clone())) +} + +/// Start a new chat on the shared daemon the way an in-process `biorouter session` starts one: +/// in `working_dir`, on `provider` and `model`, with the configured extensions. The request +/// names no extensions, so the daemon starts the configured ones, as for a new desktop chat. +/// Answers the chat's ID, which the conversation then checks before using it. +pub(crate) async fn start_daemon_chat( + client: &crate::daemon_client::CrewClient, + working_dir: &std::path::Path, + provider: &str, + model: &str, +) -> Result { + let session = client + .request( + "POST", + "/agent/start", + Some(serde_json::json!({ + "working_dir": working_dir, + "provider": provider, + "model": model, + })), + ) + .await?; + session["id"] + .as_str() + .map(str::to_owned) + .context("The daemon did not return a chat ID") +} + /// 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. /// @@ -1208,6 +1350,279 @@ pub(crate) fn restrict_by_crew_grant(session_ids: &[&str]) { std::fs::write(®istry, serde_json::to_vec(&saved).unwrap()).unwrap(); } +/// D4: where an interactive chat runs, against a daemon fixture in this test binary's sandbox. +#[cfg(all(test, unix))] +mod chat_place_tests { + use super::*; + use biorouter::daemon_runtime::{self, Descriptor, Endpoint}; + use bytes::Bytes; + use http_body_util::{BodyExt, Full}; + use hyper::server::conn::http1 as server_http1; + use hyper::{service::service_fn, Request, Response}; + use hyper_util::rt::TokioIo; + use serial_test::serial; + use std::os::unix::fs::PermissionsExt; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + const KEY: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + + /// Every request the fixture answered other than the identity check: method, path, body. + type Seen = Arc>>; + + /// A runtime directory with no daemon record, socket or saved key in it. + fn clean_runtime() -> PathBuf { + let directory = daemon_runtime::runtime_directory(); + daemon_runtime::private_directory(&directory).expect("the runtime directory is private"); + for file in ["owner.lock", "owner.lock.old", "daemon.sock"] { + let _ = fs::remove_file(directory.join(file)); + } + let _ = fs::remove_file(daemon_runtime::descriptor_path()); + let _ = fs::remove_file(daemon_runtime::user_action_key_path()); + directory + } + + /// A running daemon for this profile: `/daemon/identity` answers its identity, + /// `/agent/start` a new chat's ID, and every other path `{"ok":true}`. + async fn running_daemon() -> (Descriptor, Seen) { + let directory = clean_runtime(); + let socket = directory.join("daemon.sock"); + let listener = tokio::net::UnixListener::bind(&socket).expect("the socket binds"); + fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).expect("private socket"); + let descriptor = Descriptor { + version: daemon_runtime::VERSION, + profile_id: daemon_runtime::profile_identity().expect("the sandbox profile"), + instance_id: "44444444-4444-4444-8444-444444444444".to_owned(), + pid: std::process::id(), + endpoint: Endpoint::Unix { path: socket }, + api_secret: "daemon-secret-for-chat-place-tests-1".to_owned(), + user_action_installed: true, + }; + daemon_runtime::write_private(&daemon_runtime::descriptor_path(), &descriptor) + .expect("the record writes"); + let identity = descriptor.identity(); + let seen = Seen::default(); + let recorded = seen.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let (identity, recorded) = (identity.clone(), recorded.clone()); + tokio::spawn(async move { + let service = service_fn(move |request: Request| { + let (identity, recorded) = (identity.clone(), recorded.clone()); + async move { + let method = request.method().to_string(); + let path = request.uri().path().to_owned(); + let bytes = request + .into_body() + .collect() + .await + .map(|body| body.to_bytes()) + .unwrap_or_default(); + let body = + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + let answer = match path.as_str() { + "/daemon/identity" => serde_json::to_vec(&identity).unwrap(), + "/agent/start" => br#"{"id":"20261107_9"}"#.to_vec(), + _ => br#"{"ok":true}"#.to_vec(), + }; + if path != "/daemon/identity" { + recorded.lock().unwrap().push((method, path, body)); + } + Ok::<_, std::convert::Infallible>( + Response::builder() + .header("content-type", "application/json") + .body(Full::new(Bytes::from(answer))) + .expect("the answer builds"), + ) + } + }); + let _ = server_http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .await; + }); + } + }); + (descriptor, seen) + } + + /// A chat that may use the shared daemon: at a terminal, with a known model. + fn at_a_terminal_chat() -> ChatRequest { + ChatRequest { + at_a_terminal: true, + model_known: true, + ..ChatRequest::default() + } + } + + /// With a running daemon and its saved key, a terminal chat runs on it, and the daemon was + /// asked with that key before it was chosen. + #[tokio::test] + #[serial] + async fn a_terminal_chat_runs_on_the_running_shared_daemon() { + let (descriptor, seen) = running_daemon().await; + daemon_runtime::write_user_action_key(&descriptor, KEY).expect("the key saves"); + let chosen = shared_daemon_for_chat(&at_a_terminal_chat()).await; + assert!(chosen.is_some(), "the chat runs on the shared daemon"); + assert!( + seen.lock() + .unwrap() + .iter() + .any(|(method, path, _)| method == "GET" && path == "/crew/connections"), + "the daemon answered the saved key" + ); + clean_runtime(); + } + + /// With no daemon running, the chat runs in this process. + #[tokio::test] + #[serial] + async fn with_no_daemon_the_chat_runs_here() { + clean_runtime(); + assert!(shared_daemon_for_chat(&at_a_terminal_chat()) + .await + .is_none()); + } + + /// `--local` keeps the chat in this process, and the running daemon is never asked. + #[tokio::test] + #[serial] + async fn local_keeps_the_chat_here_without_asking_the_daemon() { + let (descriptor, seen) = running_daemon().await; + daemon_runtime::write_user_action_key(&descriptor, KEY).expect("the key saves"); + let local = ChatRequest { + local: true, + ..at_a_terminal_chat() + }; + assert!(shared_daemon_for_chat(&local).await.is_none()); + assert!(seen.lock().unwrap().is_empty(), "--local never reaches it"); + clean_runtime(); + } + + /// A running daemon whose key file is absent is not waited for: the chat runs here at once + /// and the daemon is never asked (connecting would wait seconds for a starter to save one). + #[tokio::test] + #[serial] + async fn a_daemon_with_no_saved_key_is_not_waited_for() { + let (_descriptor, seen) = running_daemon().await; + let started = std::time::Instant::now(); + assert!(shared_daemon_for_chat(&at_a_terminal_chat()) + .await + .is_none()); + assert!( + started.elapsed() < Duration::from_secs(2), + "{:?}", + started.elapsed() + ); + assert!(seen.lock().unwrap().is_empty()); + clean_runtime(); + } + + /// Each condition alone keeps the chat in this process. + #[test] + fn each_condition_alone_keeps_the_chat_here() { + assert!(at_a_terminal_chat().may_use_the_shared_daemon()); + let base = at_a_terminal_chat(); + for (what, request) in [ + ( + "--local", + ChatRequest { + local: true, + ..base + }, + ), + ( + "no terminal", + ChatRequest { + at_a_terminal: false, + ..base + }, + ), + ( + "an in-process option", + ChatRequest { + in_process_option: true, + ..base + }, + ), + ( + "no known model", + ChatRequest { + model_known: false, + ..base + }, + ), + ( + "an agent's shell", + ChatRequest { + agent_shell: true, + ..base + }, + ), + ] { + assert!(!request.may_use_the_shared_daemon(), "{what}"); + } + } + + /// A new chat on the shared daemon is started in this folder on the chosen model, and the + /// request names no extensions, so the daemon starts the configured ones. + #[tokio::test] + #[serial] + async fn a_new_daemon_chat_starts_with_the_configured_extensions() { + let (descriptor, seen) = running_daemon().await; + let client = crate::daemon_client::CrewClient::for_test(descriptor, KEY); + let id = start_daemon_chat( + &client, + std::path::Path::new("/tmp/chat-place-project"), + "synthetic-provider", + "synthetic-model", + ) + .await + .expect("the chat starts"); + assert_eq!(id, "20261107_9"); + let seen = seen.lock().unwrap(); + let (method, _, body) = seen + .iter() + .find(|(_, path, _)| path == "/agent/start") + .expect("the daemon was asked to start a chat"); + assert_eq!(method, "POST"); + assert_eq!(body["working_dir"], "/tmp/chat-place-project"); + assert_eq!(body["provider"], "synthetic-provider"); + assert_eq!(body["model"], "synthetic-model"); + assert!( + body.get("extension_overrides").is_none(), + "naming no extensions starts the configured ones: {body}" + ); + drop(seen); + clean_runtime(); + } + + /// A new chat's model: both flags, or with neither the configured pair; anything partial + /// or blank is not known, and the configured pair is read only when no flag was given. + #[test] + fn a_chat_model_is_both_flags_or_the_configured_pair() { + let configured = || { + ( + Some("cfg-provider".to_owned()), + Some("cfg-model".to_owned()), + ) + }; + let never = || -> (Option, Option) { + panic!("the configured pair is read only when no flag was given") + }; + let pair = |p: &str, m: &str| Some((p.to_owned(), m.to_owned())); + assert_eq!(chat_model(Some("p"), Some("m"), never), pair("p", "m")); + assert_eq!( + chat_model(None, None, configured), + pair("cfg-provider", "cfg-model") + ); + assert_eq!(chat_model(Some("p"), None, never), None); + assert_eq!(chat_model(None, Some("m"), never), None); + assert_eq!(chat_model(Some(" "), Some("m"), never), None); + assert_eq!(chat_model(None, None, || (Some("p".into()), None)), None); + assert_eq!(chat_model(None, None, || (None, None)), None); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/biorouter-cli/src/commands/session_watch.rs b/crates/biorouter-cli/src/commands/session_watch.rs index 1f2eea2dd..d85d44322 100644 --- a/crates/biorouter-cli/src/commands/session_watch.rs +++ b/crates/biorouter-cli/src/commands/session_watch.rs @@ -9,6 +9,13 @@ //! it renders where the conversation has got to, follows it live, and delivers //! what you type into the running turn. `cancel` stops that turn. //! +//! Every card the chat waits on (an approval, a question, a credential request, +//! a Crew step) is one line in all three ([`cards`]), and `attach` answers an +//! approval in place: it shows the card whole, then the next line typed answers +//! it, posted with the user-action key the attach already holds, and never +//! reaches the chat. Only a person typing at a terminal answers one; a pipe or +//! an agent's own shell gets the card and the desktop's name instead. +//! //! **`attach` is not `--resume`, and cannot be.** `biorouter session --resume` //! builds a second `Agent` in the CLI process over the shared sessions store, //! while the daemon's single-turn lock (`AppState::active_turns`) is an @@ -34,6 +41,8 @@ use zeroize::Zeroizing; use super::apps::{configured_port, daemon_ok, DAEMON_HOST}; use super::session_grouping::{listed_session_types, SessionRow}; +pub(crate) mod cards; + /// The header the daemon's reach gate reads the caller's capability from. /// /// ⚠ **Spelled here and asserted from the other side.** The daemon's copy is @@ -71,7 +80,8 @@ pub(crate) struct DaemonAuth { /// the daemon to judge. /// /// Sent only on the requests the proof can change: the attached event - /// stream, `/interrupt`, `/agent/cancel` and `/reply`. It is never sourced + /// stream, `/interrupt`, `/agent/cancel`, `/reply`, and the approval an + /// attach answers (`/action-required/tool-confirmation`). It is never sourced /// from argv, environment, config, or desktop settings. user_action: Option>>, } @@ -412,9 +422,13 @@ pub(crate) fn no_daemon_at(port: u16) -> String { ) } +/// Asks an event stream to say when each card ends (WI-17, the daemon's `CARD_ENDINGS_HEADER`): +/// `watch` and `attach` read the `ActionRequiredEnded` frame it sends. +const CARD_ENDINGS: &str = "X-Card-Endings: 1\r\n"; + pub(crate) fn build_get_request(path: &str, host: &str, auth: &DaemonAuth) -> String { format!( - "GET {path} HTTP/1.1\r\nHost: {host}\r\n{}\ + "GET {path} HTTP/1.1\r\nHost: {host}\r\n{}{CARD_ENDINGS}\ Accept: text/event-stream\r\nConnection: close\r\n\r\n", auth.headers() ) @@ -445,6 +459,7 @@ fn build_protected_get_request(path: &str, host: &str, auth: &DaemonAuth) -> Zer auth.headers() )); push_proof(&mut request, auth); + request.push_str(CARD_ENDINGS); request.push_str("Accept: text/event-stream\r\nConnection: close\r\n\r\n"); request } @@ -461,8 +476,8 @@ pub(crate) fn build_post_request(path: &str, host: &str, auth: &DaemonAuth, body } /// A POST to a route the user-action proof can change — `/interrupt`, -/// `/agent/cancel`, `/reply` — carrying the proof exactly when `auth` holds the -/// key. See [`push_proof`]. +/// `/agent/cancel`, `/reply`, `/action-required/tool-confirmation` — carrying +/// the proof exactly when `auth` holds the key. See [`push_proof`]. fn build_protected_post_request( path: &str, host: &str, @@ -569,6 +584,18 @@ fn inline_notice_line(message: &serde_json::Value) -> Option { (!notices.is_empty()).then(|| format!("[notice] {}", notices.join(" "))) } +/// The output for a message with no text and no tool call: its cards (an approval, a +/// question, a credential request, a Crew step), one line each and never their arguments +/// ([`cards::message_card_lines`]), or else its inline notices. +fn wordless_message_line(message: &serde_json::Value) -> Option { + let cards = cards::message_card_lines(message); + if cards.is_empty() { + inline_notice_line(message) + } else { + Some(cards.join("\n")) + } +} + /// One line of human output for a frame, or `None` for frames a human does not /// need to see (heartbeats, token bookkeeping). pub(crate) fn render_frame(frame: &serde_json::Value) -> Option { @@ -657,7 +684,7 @@ pub(crate) fn render_frame(frame: &serde_json::Value) -> Option { }) .unwrap_or_default(); if text.trim().is_empty() && tools.is_empty() { - return inline_notice_line(message); + return wordless_message_line(message); } let mut line = format!("[{role}]{provenance} {text}"); if !tools.is_empty() { @@ -698,6 +725,8 @@ pub(crate) fn render_frame(frame: &serde_json::Value) -> Option { .and_then(serde_json::Value::as_str) .unwrap_or("stop") )), + // WI-17: a card this chat showed stopped waiting. + "ActionRequiredEnded" => Some(cards::ended_frame_line(frame)), _ => None, } } @@ -845,7 +874,7 @@ async fn stream_request( render: Render, status: Option>, ) -> Result { - stream_request_bytes(request.as_bytes(), until, render, status).await + stream_request_bytes(request.as_bytes(), until, render, status, None).await } /// A connection to the configured daemon, or the actionable "no daemon" error. @@ -863,15 +892,18 @@ async fn connect_to_daemon_at(port: u16) -> Result { Ok(biorouter::net::connect_non_inheritable(format!("{DAEMON_HOST}:{port}")).await?) } +/// `cards`, when given, is told about every card and turn end the stream carries +/// ([`cards::card_events`]): `attach`'s way to answer an approval. async fn stream_request_bytes( request: &[u8], until: Until, render: Render, status: Option>, + cards: Option, ) -> Result { let mut stream = connect_to_daemon().await?; stream.write_all(request).await?; - read_response(&mut stream, until, render, status).await + read_response(&mut stream, until, render, status, cards.as_ref()).await } /// Read one HTTP response off `stream` and return the status it carried. @@ -885,6 +917,7 @@ async fn read_response( until: Until, render: Render, status: Option>, + cards: Option<&cards::CardSink>, ) -> Result { let mut status = status; let mut raw = Vec::new(); @@ -927,13 +960,13 @@ async fn read_response( buffer.clear(); pending.clear(); let frames = absorb(&mut pending, &mut buffer, &raw[end + 4..]); - if print_frames(&frames, until, render, &mut seen) { + if print_frames(&frames, until, render, &mut seen, cards) { return Ok(Streamed::read(code, seen)); } continue; } let frames = absorb(&mut pending, &mut buffer, &chunk[..read]); - if print_frames(&frames, until, render, &mut seen) { + if print_frames(&frames, until, render, &mut seen, cards) { return Ok(Streamed::read(200, seen)); } } @@ -983,6 +1016,9 @@ fn stream_frame_lines(frame: &serde_json::Value, render: Render, joined: &mut bo lines.push("── live from here ──".to_string()); lines } + // `attach` names the card that ended from what it showed ([`cards::Waiting`]), so the + // nameless `watch` line would say it twice. + Render::JoinThenLines if kind == Some("ActionRequiredEnded") => Vec::new(), Render::Lines | Render::JoinThenLines => render_frame(frame).into_iter().collect(), } } @@ -992,16 +1028,23 @@ fn stream_frame_lines(frame: &serde_json::Value, render: Render, joined: &mut bo /// is left unprinted, so `--no-wait` stops at the frame that named its turn /// rather than wherever a TCP read happened to end. (Nothing follows a terminal /// frame in a turn's log, so for the other two modes this changes nothing.) +/// +/// A frame's cards go to `cards` after its line is printed, so `attach` asks +/// about a card below the line that names it. fn print_frames( frames: &[serde_json::Value], until: Until, render: Render, seen: &mut Progress, + cards: Option<&cards::CardSink>, ) -> bool { for frame in frames { for line in stream_frame_lines(frame, render, &mut seen.joined) { println!("{line}"); } + if let Some(sink) = cards { + cards::forward(frame, sink); + } if seen.turn_id.is_none() { seen.turn_id = frame .get("turn_id") @@ -1470,10 +1513,10 @@ where { stream.write_all(request).await?; let streamed = if wait { - read_response(stream, Until::Terminal, Render::Lines, None).await? + read_response(stream, Until::Terminal, Render::Lines, None, None).await? } else { let (status_tx, mut status_rx) = tokio::sync::oneshot::channel(); - let named = read_response(stream, Until::Named, Render::Lines, Some(status_tx)); + let named = read_response(stream, Until::Named, Render::Lines, Some(status_tx), None); match tokio::time::timeout(deadline, named).await { Ok(read) => read?, // Out of time. A status line already read IS the acceptance: the turn @@ -1995,6 +2038,7 @@ async fn post_reply_quiet( Until::Terminal, Render::Silent, Some(status_tx), + None, ) .await; window.close(); @@ -2324,6 +2368,66 @@ where } } +fn print_lines(lines: &[String]) { + for line in lines { + println!("{line}"); + } +} + +/// One line typed into `attach`: the answer to the approval waiting first, or, +/// when none waits, a message for the chat. +/// +/// ⚠ While an approval waits, a line that answers nothing is NOT sent to the +/// chat: it was typed at a question, and sending it would hand the model +/// whatever the person meant for the card. The answer goes out on its own task +/// with the user-action key this attach holds, so the live rendering never waits +/// on it. +fn take_typed_line( + line: String, + waiting: &mut cards::Waiting, + send_tx: &tokio::sync::mpsc::Sender, + session_id: &str, + auth: &DaemonAuth, +) -> Result<()> { + match waiting.on_line(&line) { + cards::LineUse::Message => {} + cards::LineUse::Unanswered(lines) => { + print_lines(&lines); + return Ok(()); + } + cards::LineUse::Answer { card, action, next } => { + cards::spawn_answer( + configured_port(), + session_id.to_string(), + card, + action, + auth.clone(), + next, + ); + return Ok(()); + } + } + if line.trim().is_empty() { + return Ok(()); + } + // `try_send`, never `send().await`: awaiting a full queue here blocks the + // `select!`, and the live rendering stops with it. Refusing one line loudly + // beats freezing the display. + match send_tx.try_send(line) { + Ok(()) => Ok(()), + Err(tokio::sync::mpsc::error::TrySendError::Full(text)) => { + println!( + "[not sent] {SEND_QUEUE} messages are already waiting to go out; \ + let them clear first, then send this again:\n {text}" + ); + Ok(()) + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + Err(anyhow!("the delivery worker stopped; nothing was sent")) + } + } +} + /// `biorouter session attach ` — render where the session is, follow it /// live, and steer it from stdin. /// @@ -2377,6 +2481,18 @@ pub async fn handle_session_attach( let send_tx = spawn_delivery_worker(session_id.clone(), auth.clone(), window.clone()); + // The approvals waiting for an answer from this terminal: the observer + // reports each card it prints, and while one waits a typed line answers it. + // Only a person typing here answers one: a pipe or an agent's own shell gets + // the card printed and the desktop named, and its lines stay messages. + let answering = cards::Answering::for_attach( + read_only, + std::io::stdin().is_terminal(), + super::needs_terminal::in_agent_shell(), + ); + let mut waiting = cards::Waiting::new(&session_id, answering); + let (card_tx, mut card_rx) = tokio::sync::mpsc::unbounded_channel(); + // The observer stream, exactly as `watch --follow`, except that its first // frame is rendered as a transcript. It is READ-ONLY: its task in the daemon // merely returns when the channel closes and cancels nothing, so detaching @@ -2392,6 +2508,7 @@ pub async fn handle_session_attach( Until::Closed, Render::JoinThenLines, None, + Some(card_tx), ); tokio::pin!(observer); @@ -2413,26 +2530,12 @@ pub async fn handle_session_attach( )), } } + Some(event) = card_rx.recv() => print_lines(&waiting.on_event(event)), typed = line_rx.recv() => { // Unreachable while `_line_tx` lives; an error rather than a // `continue`, which would spin this loop at full speed. let line = typed.ok_or_else(|| anyhow!("the stdin channel closed"))?; - if line.trim().is_empty() { - continue; - } - // `try_send`, never `send().await`: awaiting a full queue here - // blocks the `select!`, and the live rendering stops with it. - // Refusing one line loudly beats freezing the display. - match send_tx.try_send(line) { - Ok(()) => {} - Err(tokio::sync::mpsc::error::TrySendError::Full(text)) => println!( - "[not sent] {SEND_QUEUE} messages are already waiting to go out; \ - let them clear first, then send this again:\n {text}" - ), - Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { - return Err(anyhow!("the delivery worker stopped; nothing was sent")) - } - } + take_typed_line(line, &mut waiting, &send_tx, &session_id, &auth)?; } _ = interrupts.recv() => { match window.on_ctrl_c() { @@ -2930,6 +3033,12 @@ mod tests { assert!(get.starts_with("GET /sessions/abc/events HTTP/1.1\r\n")); assert!(get.contains("X-Secret-Key: s3cret\r\n")); assert!(get.contains("Accept: text/event-stream\r\n")); + // WI-17: `watch` and `attach` ask to be told when each card ends; without it the daemon + // never sends the frame, and a held approval waits until the turn ends. + assert!(get.contains("X-Card-Endings: 1\r\n"), "{get}"); + let attach = build_protected_get_request("/sessions/abc/events", "127.0.0.1", &auth); + assert!(attach.contains("X-Card-Endings: 1\r\n")); + assert!(attach.ends_with("\r\n\r\n")); let post = build_post_request("/reply", "127.0.0.1", &auth, "{\"a\":1}"); assert!(post.starts_with("POST /reply HTTP/1.1\r\n")); @@ -3301,6 +3410,116 @@ mod tests { .to_string() } + /// WI-7: an approval answered in `attach` goes to the route the desktop's + /// button posts to, in the same body, with the user-action key this attach + /// holds: the proof is what lets the daemon count it as the person's. + #[tokio::test] + async fn an_attach_answer_posts_the_approval_with_the_key() { + let daemon = FakeDaemon::start(vec![http("200 OK", r#"{"status":"delivered"}"#)]).await; + let card = cards::ApprovalCard { + id: "card-1".into(), + read_consent: true, + details: Vec::new(), + }; + let line = cards::answer_approval( + daemon.port, + "20261106_4", + &card, + "allow_once", + &DaemonAuth::for_test_with_user_action("s3cret", "", "the-typed-key"), + ) + .await + .unwrap(); + assert_eq!( + line, + "[answered] Allowed. This chat may read the workspace as you until you revoke it \ + with biorouter crew grants revoke 20261106_4 --reads." + ); + let requests = daemon.requests(); + assert_eq!(requests.len(), 1, "{requests:?}"); + let request = &requests[0]; + assert!( + request.starts_with("POST /action-required/tool-confirmation HTTP/1.1\r\n"), + "{request}" + ); + assert!( + request.contains("\r\nX-User-Action: the-typed-key\r\n"), + "{request}" + ); + assert!( + request.contains("\r\nX-Secret-Key: s3cret\r\n"), + "{request}" + ); + assert_eq!( + json_object(request), + Some(serde_json::json!({ + "sessionId": "20261106_4", + "id": "card-1", + "principalType": "Tool", + "action": "allow_once", + })) + ); + } + + /// WI-7: the observer stream tells `attach` about the approval it printed + /// and about the turn ending, which retires any approval still asked. + #[tokio::test] + async fn the_observer_reports_each_approval_and_the_turn_end_to_attach() { + use biorouter::conversation::message::{ + ActionRequired, ActionRequiredData, Message, MessageContent, + }; + let card = + Message::assistant().with_content(MessageContent::ActionRequired(ActionRequired { + data: ActionRequiredData::ToolConfirmation { + id: "card-1".into(), + tool_name: "crew__change".into(), + arguments: serde_json::Map::new(), + prompt: Some( + "Create the channel #attach-test in lab\nIt is public to the workspace." + .into(), + ), + risk: None, + preview: None, + }, + })); + let script = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n\ + data: {}\n\ndata: {}\n\n", + serde_json::json!({ "type": "Message", "message": card }), + serde_json::json!({ "type": "Finish", "reason": "stop" }), + ); + let (mut client, mut daemon) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + let _ = daemon.write_all(script.as_bytes()).await; + }); + let (sink, mut events) = tokio::sync::mpsc::unbounded_channel(); + read_response( + &mut client, + Until::Closed, + Render::Silent, + None, + Some(&sink), + ) + .await + .unwrap(); + drop(sink); + let mut seen = Vec::new(); + while let Some(event) = events.recv().await { + seen.push(event); + } + let [cards::CardEvent::Approval(approval), cards::CardEvent::TurnEnded] = seen.as_slice() + else { + panic!("an approval, then the turn's end: {seen:?}"); + }; + assert_eq!(approval.id, "card-1"); + assert!(!approval.read_consent); + // The first line is the card's own line, already printed above it. + assert_eq!( + approval.details, + vec!["It is public to the workspace.".to_string()] + ); + } + /// An empty refusal is an empty refusal however the daemon framed it, and /// either way it is the answer that asks the person for the key. #[tokio::test] @@ -3806,6 +4025,21 @@ mod tests { assert!(!silent); } + /// WI-17: `watch` prints a card's end in a line that names no card (it keeps none), and + /// `attach` prints nothing for the frame itself, since its card tracker names the card. + #[test] + fn a_cards_end_is_one_line_in_watch_and_left_to_the_tracker_in_attach() { + let end = serde_json::json!({ + "type": "ActionRequiredEnded", "id": "card-1", "ending": "timed_out", "seq": 9 + }); + let mut joined = false; + assert_eq!( + stream_frame_lines(&end, Render::Lines, &mut joined), + vec!["[closed] A request in this chat is over: timed out.".to_string()] + ); + assert!(stream_frame_lines(&end, Render::JoinThenLines, &mut joined).is_empty()); + } + /// Reading a response off an in-memory pipe, so the status/termination rules /// below are pinned without a daemon, a port or a race. async fn read_from(script: &'static [u8], until: Until) -> Result { @@ -3814,7 +4048,7 @@ mod tests { let _ = daemon.write_all(script).await; // and the connection closes here. }); - read_response(&mut client, until, Render::Silent, None) + read_response(&mut client, until, Render::Silent, None, None) .await .map(|streamed| streamed.code) } @@ -3894,6 +4128,7 @@ mod tests { Until::Terminal, Render::Silent, Some(status_tx), + None, ), ) .await @@ -3995,6 +4230,7 @@ mod tests { Until::Terminal, Render::Silent, Some(status_tx), + None, ) .await }); diff --git a/crates/biorouter-cli/src/commands/session_watch/cards.rs b/crates/biorouter-cli/src/commands/session_watch/cards.rs new file mode 100644 index 000000000..588b4e777 --- /dev/null +++ b/crates/biorouter-cli/src/commands/session_watch/cards.rs @@ -0,0 +1,1647 @@ +//! Cards in `session watch` and `session attach`: an approval, a question, a credential +//! request or a Crew step that a chat is waiting on. +//! +//! The observer stream carries each card as a `Message` frame whose content is one +//! `actionRequired` block. `watch` prints it as one line ([`card_line`]), so a turn that stops +//! on a card no longer looks stuck for no reason. `attach` prints the same line and, for an +//! approval, asks the person at this terminal and posts the answer with the user-action key it +//! already holds ([`Waiting`], [`answer_approval`]). +//! +//! ⚠ **An approval is shown whole before it is answered** (AO-12: "the payload is shown +//! whole"). The details `attach` prints are every line of the card's prompt and the call's +//! arguments in full, never a clipped copy ([`approval_details`]): a person who types +//! `allow_once` here posts, as themselves, exactly what they read. +//! +//! ⚠ **`attach` answers only the person typing at it** ([`Answering`]). Reading a pipe or +//! running in an agent's own shell (`BIOROUTER_TERMINAL`), it prints the card and points to the +//! desktop app, and its lines still go to the chat as messages. This is accident prevention, as +//! D5 is for `biorouter crew`: a script or an agent's shell must not approve a card by writing +//! `allow_once` to it. +//! +//! ⚠ **No argument whose name looks like a credential is ever printed.** A line names the tool +//! and the first line of the card's own prompt, never its arguments. The details `attach` shows +//! before it asks hide every value under a credential-like key and pass the rest through the +//! secret-output redactor ([`approval_details`]), and a shared-daemon chat shows a card whole +//! the same way ([`shown_card_json`]). A credential card prints key names only; a Crew step +//! prints the daemon's own fields and says where to take it, never here. +//! +//! The read consent (AO-12) lasts until the person revokes it, so every terminal offers it as +//! `allow` or `deny` and names the command that takes it back ([`read_consent_choices`]). + +use std::collections::VecDeque; + +use anyhow::Result; +use biorouter::conversation::message::{ + ActionRequiredData, CrewStepRequest, CrewVaultAction, SecretKeyRequest, +}; +use biorouter::conversation::tool_preview::ToolPreview; +use biorouter::crew::tool_host::PersonTool; +use biorouter::permission::tool_risk::ToolRisk; +use serde_json::{Map, Value}; + +use super::DaemonAuth; +use crate::commands::shared_conversation::safe_text; + +/// The marker Crew's daemon host sets in a read consent card's arguments +/// (`Ctx::read_consent` in `biorouter-server/src/routes/crew_tools/mod.rs`, `"action": "read as +/// you"`). The desktop reads the same marker (`isCrewReadConsent` in `toolCallCopy.ts`). +const READ_CONSENT_ACTION: &str = "read as you"; + +/// The heading every read consent prompt starts with (`consent_prompt` on the daemon), which the +/// desktop also accepts when the marker is missing. +const READ_CONSENT_HEADING: &str = "Let this chat read "; + +/// Where a card this terminal can't answer is answered, where the chat's ID is not at hand. +/// +/// ⚠ It used to add "or in a biorouter session --shared-daemon chat", which promised more than +/// is true: a terminal chat answers only the cards of its own conversation, so a fresh one +/// answers nothing here. Where the ID is at hand, [`answer_elsewhere`] names the one terminal +/// chat that can. +const ANSWER_ELSEWHERE: &str = "Answer it in the desktop app"; + +/// [`ANSWER_ELSEWHERE`] for the chat `session_id`: the desktop app, or a terminal chat on this +/// same chat, which is the only terminal chat that answers its cards. +fn answer_elsewhere(session_id: &str) -> String { + format!( + "{ANSWER_ELSEWHERE}, or open this chat in a terminal with biorouter session --shared-daemon --resume --session-id {session_id}" + ) +} + +/// The one-line form of a card (`watch`, and the line `attach` prints first) clips its heading +/// to this many characters. The details `attach` asks with are never clipped. +const LINE_CHARS: usize = 240; + +/// What a value this terminal never prints shows instead: the word the daemon's secret-output +/// redactor writes for a value under a secret's name, which is what the desktop's tool card shows +/// for a hidden value. The redactor never rewrites its own marker, so a value hidden by key name +/// and one the redactor hid read the same, whatever the key's spelling. +const HIDDEN_VALUE: &str = "[REDACTED:secret-value]"; + +/// Argument names whose values are never printed, matched case-insensitively on a substring. +const CREDENTIAL_NAMES: &[&str] = &[ + "password", + "passwd", + "passphrase", + "passcode", + "secret", + "token", + "credential", + "api_key", + "apikey", + "private_key", + "privatekey", + "authorization", + "cookie", +]; + +/// Whether `action` is AO-12's read consent: the one `crew__read` card. Unlike any other +/// approval it lasts, for this chat and workspace, until the person revokes it, so it is never +/// offered as "once". +pub(crate) fn is_read_consent(action: &ActionRequiredData) -> bool { + let ActionRequiredData::ToolConfirmation { + tool_name, + arguments, + prompt, + .. + } = action + else { + return false; + }; + tool_name == PersonTool::Read.name() + && (arguments.get("action").and_then(Value::as_str) == Some(READ_CONSENT_ACTION) + || prompt + .as_deref() + .is_some_and(|prompt| prompt.starts_with(READ_CONSENT_HEADING))) +} + +/// The read consent's two choices, in the words every terminal offers them with: allowing it +/// lasts until the person revokes it, and the sentence says how. +pub(crate) fn read_consent_choices(session_id: &str) -> String { + format!( + "allow (until you revoke it with biorouter crew grants revoke {session_id} --reads) or deny" + ) +} + +/// Said once a read consent is allowed. +pub(crate) fn read_consent_allowed(session_id: &str) -> String { + format!( + "Allowed. This chat may read the workspace as you until you revoke it with biorouter crew grants revoke {session_id} --reads." + ) +} + +/// The daemon's answer word for what the person typed, or `None` when it answers nothing. A +/// read consent is allowed with `allow`; any other approval with `allow_once`. Both send +/// `allow_once`, the one allow a card with a prompt takes (a read consent then lasts because +/// the daemon records it, not because of the word). +pub(crate) fn approval_answer(typed: &str, read_consent: bool) -> Option<&'static str> { + let typed = typed.trim(); + let allow = if read_consent { "allow" } else { "allow_once" }; + if typed.eq_ignore_ascii_case(allow) { + Some("allow_once") + } else if typed.eq_ignore_ascii_case("deny") { + Some("deny") + } else { + None + } +} + +/// The first line of `text`, clipped, or `None` when it is blank. +fn first_line(text: &str) -> Option { + let line = text.lines().map(str::trim).find(|line| !line.is_empty())?; + Some(clip(line, LINE_CHARS)) +} + +fn clip(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_string(); + } + let mut clipped: String = text.chars().take(max).collect(); + clipped.push('…'); + clipped +} + +/// A Crew step's heading, in the desktop card's words (`CrewSignInStep.copy.ts`, +/// `CrewVaultStep.copy.ts`, and `trustCopy` for `CrewHostKeyStep.copy.ts`), which the +/// shared-daemon chat's own step lines also use (`crew_step/host_key.rs`). +fn crew_step_heading(step: &CrewStepRequest) -> String { + match step { + CrewStepRequest::SignIn { login, .. } => format!("Sign in to {login}"), + CrewStepRequest::Vault { + action: CrewVaultAction::Unlock, + } => "Unlock Crew's vault".to_string(), + CrewStepRequest::Vault { + action: CrewVaultAction::Init, + } => "Set up Crew's vault".to_string(), + CrewStepRequest::HostKey { + host, + changed: true, + .. + } => format!("{host}'s identity changed"), + CrewStepRequest::HostKey { host, .. } => format!("Can't verify {host} yet"), + } +} + +fn key_names(keys: &[SecretKeyRequest]) -> String { + keys.iter() + .map(|key| key.key.as_str()) + .collect::>() + .join(", ") +} + +/// One line for a card's `data`, or `None` for a record that is not a card (an answered +/// question's response, which carries what the person typed and is never printed). +pub(crate) fn card_line(data: &Value) -> Option { + let line = match serde_json::from_value::(data.clone()) { + Ok(ActionRequiredData::ToolConfirmation { + tool_name, prompt, .. + }) => match prompt.as_deref().and_then(first_line) { + Some(heading) => format!("[approval] {tool_name}: {heading}"), + None => format!("[approval] {tool_name}"), + }, + Ok(ActionRequiredData::Elicitation { message, .. }) => { + format!("[question] {}", first_line(&message).unwrap_or_default()) + } + Ok(ActionRequiredData::ElicitationResponse { .. }) => return None, + Ok(ActionRequiredData::SecretRequest { prompt, keys, .. }) => format!( + "[credentials] {} ({}). {ANSWER_ELSEWHERE}.", + first_line(&prompt).unwrap_or_default(), + key_names(&keys) + ), + // A changed host key's notice (WI-17) waits for nothing and is kept in the chat, so it + // asks for no answer, live or read back. + Ok(ActionRequiredData::CrewStep { step, .. }) + if biorouter::pending_user_action::crew_step_is_a_notice(&step) => + { + format!( + "[crew] {}. Nothing connects to it until the server's IT team confirms the change.", + crew_step_heading(&step) + ) + } + Ok(ActionRequiredData::CrewStep { step, .. }) => format!( + "[crew] {}. {ANSWER_ELSEWHERE}, never by typing here.", + crew_step_heading(&step) + ), + Err(_) => format!("[waiting] This chat is waiting for your answer. {ANSWER_ELSEWHERE}."), + }; + Some(safe_text(&redacted(&line))) +} + +/// The card lines of an observer `message`: one per `actionRequired` block. +pub(crate) fn message_card_lines(message: &Value) -> Vec { + message + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|block| block.get("type").and_then(Value::as_str) == Some("actionRequired")) + .filter_map(|block| block.get("data")) + .filter_map(card_line) + .collect() +} + +fn looks_like_a_credential(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + CREDENTIAL_NAMES.iter().any(|needle| name.contains(needle)) +} + +/// `value` with every value under a credential-like key replaced, at any depth. +fn hide_credentials(value: &Value) -> Value { + match value { + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(name, value)| { + let shown = if looks_like_a_credential(name) { + Value::String(HIDDEN_VALUE.into()) + } else { + hide_credentials(value) + }; + (name.clone(), shown) + }) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(hide_credentials).collect()), + other => other.clone(), + } +} + +/// `text` with any credential the secret-output guard recognises withheld. +fn redacted(text: &str) -> String { + biorouter::guardrails::secret_output::redact_text(text) + .map(|(text, _)| text) + .unwrap_or_else(|| text.to_string()) +} + +/// An approval card as a shared-daemon chat shows it whole (`shared_conversation`): its JSON, +/// with every credential-like argument value hidden, the arguments preview (a second copy of the +/// same arguments) left out, and the rest passed through the secret-output redactor. A diff, a +/// new file or a command is still shown in full. +pub(crate) fn shown_card_json(action: &ActionRequiredData) -> String { + let mut value = serde_json::to_value(action).unwrap_or_default(); + if let Some(card) = value.as_object_mut() { + if let Some(arguments) = card.get("arguments").map(hide_credentials) { + card.insert("arguments".into(), arguments); + } + let repeats_arguments = card + .get("preview") + .and_then(|preview| preview.get("kind")) + .and_then(Value::as_str) + == Some("arguments"); + if repeats_arguments { + card.remove("preview"); + } + } + redacted(&serde_json::to_string_pretty(&value).unwrap_or_default()) +} + +/// `text` under `label`: its first line after the label, every other line indented below it. +fn labelled_block(label: &str, text: &str) -> Vec { + let mut lines = text.lines(); + let first = lines.next().unwrap_or_default(); + let mut shown = vec![format!("{label} {first}").trim_end().to_string()]; + shown.extend(lines.map(|line| format!(" {line}"))); + shown +} + +/// The call's arguments, whole and pretty-printed, with every credential-like value hidden and +/// the rest passed through the secret-output redactor. Nothing for a call with none. +fn argument_lines(arguments: &Map) -> Vec { + if arguments.is_empty() { + return Vec::new(); + } + let hidden = hide_credentials(&Value::Object(arguments.clone())); + let text = serde_json::to_string_pretty(&hidden).unwrap_or_default(); + labelled_block("Arguments:", &redacted(&text)) +} + +fn risk_line(risk: ToolRisk) -> &'static str { + match risk { + ToolRisk::Low => "Risk: read-only", + ToolRisk::Medium => "Risk: modifies data", + ToolRisk::High => "Risk: destructive", + ToolRisk::Unknown => "Risk: unverified", + } +} + +/// The command a shell card will run, when its preview holds it whole: then it is what the call +/// runs, read more easily than the arguments. `None` for a clipped command or any other preview. +fn whole_command(preview: &ToolPreview) -> Option> { + match preview { + ToolPreview::Shell { + command, + truncated: false, + } => Some(labelled_block("Command:", &redacted(command))), + _ => None, + } +} + +/// A one-line summary of what a preview shows, for a preview whose body is not shown here: the +/// arguments below it are. The diff and the new file are clipped by the daemon to fit a card, +/// so they are never what a person answers from in this terminal. +fn preview_summary(preview: &ToolPreview) -> Option { + Some(match preview { + ToolPreview::FileEdit { + path, + added, + removed, + .. + } => format!("Edits {path}: {added} lines added, {removed} removed"), + ToolPreview::FileWrite { + path, line_count, .. + } => format!("Writes {path}: {line_count} lines"), + ToolPreview::FolderAccess { folder, access, .. } => { + format!("Folder: {folder} ({access})") + } + ToolPreview::Shell { .. } | ToolPreview::Arguments { .. } => return None, + }) +} + +/// What a person reads before answering an approval, whole (AO-12): every line of the card's +/// prompt (the daemon's or the tool's own words), how risky the call is, then what it will do. +/// A shell command is shown as the preview has it only when the preview holds it whole; any +/// other call shows its arguments, whole, because a preview is clipped to fit a card and the +/// arguments are what runs. Nothing is clipped. Every value under a credential-like key is +/// hidden, everything passes through the secret-output redactor, and each line is safe for a +/// terminal. Empty for anything but an approval. +pub(crate) fn approval_details(action: &ActionRequiredData) -> Vec { + let ActionRequiredData::ToolConfirmation { + arguments, + prompt, + risk, + preview, + .. + } = action + else { + return Vec::new(); + }; + let mut lines: Vec = redacted(prompt.as_deref().unwrap_or_default()) + .lines() + .filter(|line| !line.trim().is_empty()) + .map(str::to_string) + .collect(); + lines.extend(risk.map(|risk| risk_line(risk).to_string())); + match preview.as_ref().and_then(whole_command) { + Some(command) => lines.extend(command), + None => { + lines.extend(preview.as_ref().and_then(preview_summary)); + lines.extend(argument_lines(arguments)); + } + } + lines.iter().map(|line| safe_text(line)).collect() +} + +/// Whether the one-line form of a card shows its prompt's first line whole, so the details may +/// leave that line out rather than print it twice. +fn heading_is_whole(prompt: Option<&str>) -> bool { + prompt + .and_then(|prompt| prompt.lines().map(str::trim).find(|line| !line.is_empty())) + .is_some_and(|line| line.chars().count() <= LINE_CHARS) +} + +/// What the observer stream tells `attach` about cards. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CardEvent { + /// An approval this terminal can answer. + Approval(ApprovalCard), + /// A question only another surface answers. Its line is already printed. + Question, + /// A tool call's response, carrying the call's ID. The agent's own approval card carries + /// the same ID, so this means that card was answered, here or anywhere else. + Responded(String), + /// A card only another surface answers (a credential card, a Crew step). Its line is + /// already printed; its heading names it again when it ends. + Shown { id: String, heading: String }, + /// The daemon said a card ended (WI-17, `ActionRequiredEnded`): answered, dismissed, out of + /// time or withdrawn, here or anywhere else. Its ID and the daemon's word for how, nothing + /// more. The one way a card raised by a tool (a Crew change, a bridged call), whose ID is + /// not its call's, is known to be over before the turn ends. + Ended { id: String, ending: String }, + /// The turn ended, so no card of it is still waiting. + TurnEnded, +} + +/// An approval waiting for this terminal's answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ApprovalCard { + pub(crate) id: String, + pub(crate) read_consent: bool, + pub(crate) details: Vec, +} + +/// Where the observer reader sends [`CardEvent`]s. +pub(crate) type CardSink = tokio::sync::mpsc::UnboundedSender; + +/// What one content block of a live message tells `attach`: a card, or a tool call's response. +fn block_event(block: &Value) -> Option { + match block.get("type").and_then(Value::as_str)? { + "toolResponse" => block + .get("id") + .and_then(Value::as_str) + .map(|id| CardEvent::Responded(id.to_string())), + "actionRequired" => { + let action = + serde_json::from_value::(block.get("data")?.clone()).ok()?; + match &action { + ActionRequiredData::ToolConfirmation { id, prompt, .. } => { + // The card's line already shows the prompt's first line, unless it had to + // clip it: then the details show it whole. + let heading = heading_is_whole(prompt.as_deref()); + Some(CardEvent::Approval(ApprovalCard { + id: id.clone(), + read_consent: is_read_consent(&action), + details: approval_details(&action) + .into_iter() + .skip(usize::from(heading)) + .collect(), + })) + } + ActionRequiredData::Elicitation { .. } => Some(CardEvent::Question), + ActionRequiredData::SecretRequest { id, prompt, .. } => Some(CardEvent::Shown { + id: id.clone(), + heading: first_line(prompt).unwrap_or_else(|| "A credential card".into()), + }), + // A changed host key's notice waits for nothing, so nothing will say it ended. + ActionRequiredData::CrewStep { id, step } + if !biorouter::pending_user_action::crew_step_is_a_notice(step) => + { + Some(CardEvent::Shown { + id: id.clone(), + heading: crew_step_heading(step), + }) + } + _ => None, + } + } + _ => None, + } +} + +/// How a card ended, in a phrase ([`CardEvent::Ended`]); the daemon's word is one of four, and +/// a word this build does not know still means the card stopped waiting. +fn ended_phrase(ending: &str) -> &'static str { + match ending { + "answered" => "answered", + "dismissed" => "dismissed", + "timed_out" => "timed out", + _ => "no longer waiting", + } +} + +/// The line `watch` prints for a card's end (`ActionRequiredEnded`): it keeps no card, so it +/// names none. `attach` names the card instead ([`Waiting`]). +pub(crate) fn ended_frame_line(frame: &Value) -> String { + let ending = frame + .get("ending") + .and_then(Value::as_str) + .unwrap_or_default(); + format!( + "[closed] A request in this chat is over: {}.", + ended_phrase(ending) + ) +} + +/// What one observer frame tells `attach` about cards: each live card and tool response in a +/// `Message` frame, each card's end, and the end of a turn. The join snapshot never carries a +/// card that waits, which is never stored; the cards still waiting follow it as live +/// `Message` frames. +pub(crate) fn card_events(frame: &Value) -> Vec { + match frame.get("type").and_then(Value::as_str) { + Some("Finish" | "Error") => vec![CardEvent::TurnEnded], + Some("ActionRequiredEnded") => frame + .get("id") + .and_then(Value::as_str) + .map(|id| CardEvent::Ended { + id: id.to_owned(), + ending: frame + .get("ending") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + }) + .into_iter() + .collect(), + Some("Message") => frame + .get("message") + .and_then(|message| message.get("content")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(block_event) + .collect(), + _ => Vec::new(), + } +} + +/// Send `frame`'s card events to `sink`. A closed sink means `attach` has stopped reading. +pub(crate) fn forward(frame: &Value, sink: &CardSink) { + for event in card_events(frame) { + let _ = sink.send(event); + } +} + +/// What a line typed into `attach` is for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum LineUse { + /// No approval waits: the line goes to the chat as it always did. + Message, + /// An approval waits and the line answers nothing: these lines are printed, and the line + /// is not sent to the chat. + Unanswered(Vec), + /// The line answers the first waiting approval. `next` is what to print for the one after. + Answer { + card: ApprovalCard, + action: &'static str, + next: Vec, + }, +} + +/// Whether an `attach` answers approvals, and, when it does not, why. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Answering { + /// A person types at this terminal: while an approval waits, a line answers it. + Here, + /// `--read-only`: nothing is read from stdin. + ReadOnly, + /// stdin is a pipe or a file, not a person typing. Its lines still go to the chat. + NotATerminal, + /// This runs in an agent's own shell. Its lines still go to the chat. + AgentShell, +} + +impl Answering { + /// How an `attach` answers, from its flag and what it runs in. An agent's shell is named + /// first: its stdin is never the person's keyboard, whatever it is. + pub(crate) fn for_attach(read_only: bool, stdin_is_terminal: bool, agent_shell: bool) -> Self { + if read_only { + Answering::ReadOnly + } else if agent_shell { + Answering::AgentShell + } else if !stdin_is_terminal { + Answering::NotATerminal + } else { + Answering::Here + } + } + + /// What is printed under a card this `attach` of the chat `session_id` won't answer. + fn elsewhere(self, session_id: &str) -> String { + let elsewhere = answer_elsewhere(session_id); + match self { + Answering::Here => format!("{elsewhere}."), + Answering::ReadOnly => { + format!("{elsewhere}. Or attach without --read-only to answer it here.") + } + Answering::NotATerminal => format!( + "{elsewhere}. This attach isn't reading your typing, so it answers no request. What it reads still goes to the chat as a message." + ), + Answering::AgentShell => format!( + "{elsewhere}. This attach runs in an agent's shell, so it answers no request. What it reads still goes to the chat as a message." + ), + } + } +} + +/// How many cards that `attach` cannot answer it remembers at once, to name each when it ends. +const SHOWN_LIMIT: usize = 64; + +/// The approvals an `attach` is waiting to answer, oldest first. While one waits, a typed line +/// answers it and is never sent to the chat. +pub(crate) struct Waiting { + session_id: String, + /// Whether a line read here answers an approval ([`Answering::Here`]) or none ever does. + answering: Answering, + cards: VecDeque, + /// The cards shown here that only another surface answers, by ID and heading, oldest first, + /// so their end can name them ([`CardEvent::Shown`]). + shown: VecDeque<(String, String)>, +} + +impl Waiting { + pub(crate) fn new(session_id: &str, answering: Answering) -> Self { + Self { + session_id: session_id.to_string(), + answering, + cards: VecDeque::new(), + shown: VecDeque::new(), + } + } + + /// Stop holding the approval at `at`, saying `closed`: the next line typed here is a + /// message again, or the answer to the request after it. Says nothing for one behind the + /// first, which was never asked. + fn close_held(&mut self, at: usize, closed: &str) -> Vec { + self.cards.remove(at); + if at > 0 { + return Vec::new(); + } + let mut lines = vec![closed.to_string()]; + if let Some(next) = self.cards.front() { + lines.extend(self.ask(next)); + } + lines + } + + /// What to print for the daemon's word that the card `id` ended as `ending`. An approval + /// held here stops being held. A card shown here that another surface answers is named + /// again. One this terminal answered, or never showed, needs nothing. + fn on_ended(&mut self, id: &str, ending: &str) -> Vec { + if let Some(at) = self.cards.iter().position(|held| held.id == id) { + let closed = match ending { + "answered" => "[approval closed] This request was answered somewhere else.", + "dismissed" => "[approval closed] This request was dismissed somewhere else.", + "timed_out" => "[approval closed] This request timed out.", + _ => "[approval closed] This request is no longer waiting.", + }; + return self.close_held(at, closed); + } + let Some(at) = self.shown.iter().position(|(shown, _)| shown == id) else { + return Vec::new(); + }; + let Some((_, heading)) = self.shown.remove(at) else { + return Vec::new(); + }; + vec![safe_text(&redacted(&format!( + "[closed] {}: {}.", + clip(&heading, LINE_CHARS), + ended_phrase(ending) + )))] + } + + /// The question for `card`: its details, then which words answer it. + fn ask(&self, card: &ApprovalCard) -> Vec { + let choices = if card.read_consent { + read_consent_choices(&self.session_id) + } else { + "allow_once or deny".to_string() + }; + let mut lines = card.details.clone(); + lines.push(format!( + "Type {choices}, then press Enter. Your next line answers this request and is not sent to the chat." + )); + lines + } + + /// What to print for `event`. + pub(crate) fn on_event(&mut self, event: CardEvent) -> Vec { + match event { + // The same card shown again is still one request. + CardEvent::Approval(card) if self.cards.iter().any(|held| held.id == card.id) => { + Vec::new() + } + // Shown whole as anywhere, but never held: a line read here stays a message. + CardEvent::Approval(card) if self.answering != Answering::Here => { + let mut lines = card.details; + lines.push(self.answering.elsewhere(&self.session_id)); + lines + } + CardEvent::Approval(card) => { + self.cards.push_back(card); + if self.cards.len() == 1 { + self.ask(&self.cards[0]) + } else { + vec!["This request is asked once you answer the one before it.".to_string()] + } + } + CardEvent::Question if self.answering == Answering::ReadOnly => { + vec![format!("{}.", answer_elsewhere(&self.session_id))] + } + CardEvent::Question => vec![format!( + "{}. What you type here goes to the chat as a message.", + answer_elsewhere(&self.session_id) + )], + // Answered somewhere else: the next line typed here is a message again, or the + // answer to the request after it. One this terminal answered is already gone. + CardEvent::Responded(id) => { + let Some(at) = self.cards.iter().position(|held| held.id == id) else { + return Vec::new(); + }; + self.close_held( + at, + "[approval closed] This request was answered somewhere else.", + ) + } + // Its line is already printed; it is named again only when it ends. + CardEvent::Shown { id, heading } => { + if !self.shown.iter().any(|(shown, _)| *shown == id) { + self.shown.push_back((id, heading)); + while self.shown.len() > SHOWN_LIMIT { + self.shown.pop_front(); + } + } + Vec::new() + } + CardEvent::Ended { id, ending } => self.on_ended(&id, &ending), + CardEvent::TurnEnded if self.cards.is_empty() => { + // Each card's own end came first; a turn's end leaves none waiting. + self.shown.clear(); + Vec::new() + } + CardEvent::TurnEnded => { + self.shown.clear(); + self.cards.clear(); + vec![ + "[approval closed] The turn ended, so its request is no longer waiting." + .to_string(), + ] + } + } + } + + /// What `typed` is for. Only a line typed by a person at this terminal answers anything. + pub(crate) fn on_line(&mut self, typed: &str) -> LineUse { + if self.answering != Answering::Here { + return LineUse::Message; + } + let Some(card) = self.cards.front() else { + return LineUse::Message; + }; + let Some(action) = approval_answer(typed, card.read_consent) else { + let choices = if card.read_consent { + read_consent_choices(&self.session_id) + } else { + "allow_once or deny".to_string() + }; + return LineUse::Unanswered(vec![format!( + "Not sent to the chat. A request is waiting: type {choices}." + )]); + }; + let Some(card) = self.cards.pop_front() else { + return LineUse::Message; + }; + let next = self + .cards + .front() + .map(|next| self.ask(next)) + .unwrap_or_default(); + LineUse::Answer { card, action, next } + } +} + +/// The line for the daemon's answer to an approval this terminal posted. +pub(crate) fn answer_outcome( + code: u16, + body: &str, + action: &str, + read_consent: bool, + session_id: &str, +) -> String { + let answer = super::json_object(body); + let field = |name: &str| { + answer + .as_ref() + .and_then(|answer| answer.get(name)) + .and_then(Value::as_str) + .map(str::to_string) + }; + let line = match (code, field("status").as_deref()) { + (200, Some("delivered")) if action == "deny" => "[answered] Denied.".to_string(), + (200, Some("delivered")) if read_consent => { + format!("[answered] {}", read_consent_allowed(session_id)) + } + (200, Some("delivered")) => "[answered] Allowed once.".to_string(), + (200, Some("unknown" | "already_resolved")) => { + "[not answered] This request is no longer waiting, so nothing was approved here." + .to_string() + } + (403, _) => format!( + "[not answered] {} {ANSWER_ELSEWHERE}.", + field("error").unwrap_or_else(|| "The daemon refused this answer.".to_string()) + ), + (401, _) => "[not answered] The daemon refused this terminal: BIOROUTER_SERVER__SECRET_KEY does not match the daemon's.".to_string(), + (code, _) => { + format!("[not answered] The daemon answered HTTP {code}. {ANSWER_ELSEWHERE}.") + } + }; + safe_text(&line) +} + +/// Post the person's `action` on approval `card_id` to the daemon on `port`, with the +/// user-action key when `auth` holds one, and answer the line to print. +pub(crate) async fn answer_approval( + port: u16, + session_id: &str, + card: &ApprovalCard, + action: &str, + auth: &DaemonAuth, +) -> Result { + let body = serde_json::json!({ + "sessionId": session_id, + "id": card.id, + "principalType": "Tool", + "action": action, + }) + .to_string(); + let (code, answer) = + super::post_json_to(port, "/action-required/tool-confirmation", &body, auth).await?; + Ok(answer_outcome( + code, + &answer, + action, + card.read_consent, + session_id, + )) +} + +/// [`answer_approval`] on its own task, so the live rendering never waits on the daemon: the +/// outcome is printed when it comes, then `next`, the question for the approval after it. +pub(crate) fn spawn_answer( + port: u16, + session_id: String, + card: ApprovalCard, + action: &'static str, + auth: DaemonAuth, + next: Vec, +) { + tokio::spawn(async move { + match answer_approval(port, &session_id, &card, action, &auth).await { + Ok(line) => println!("{line}"), + Err(error) => println!( + "{}", + safe_text(&format!("[not answered] {error} {ANSWER_ELSEWHERE}.")) + ), + } + for line in next { + println!("{line}"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use biorouter::conversation::message::{ + ActionRequired, CrewSignInTarget, Message, MessageContent, SecretDestination, + }; + + const CANARY: &str = "CANARY-7f3a"; + + fn frame_of(action: ActionRequiredData) -> Value { + let message = + Message::assistant().with_content(MessageContent::ActionRequired(ActionRequired { + data: action, + })); + serde_json::json!({ "type": "Message", "message": message }) + } + + fn confirmation(tool: &str, arguments: Value, prompt: Option<&str>) -> ActionRequiredData { + ActionRequiredData::ToolConfirmation { + id: "card-1".into(), + tool_name: tool.into(), + arguments: arguments.as_object().cloned().unwrap_or_default(), + prompt: prompt.map(str::to_string), + risk: None, + preview: None, + } + } + + fn read_consent() -> ActionRequiredData { + confirmation( + "crew__read", + serde_json::json!({ + "action": "read as you", + "workspace": "lab", + "server": "hpc.example.edu", + "connection": "lab", + }), + Some("Let this chat read lab as you\nIt can then read what you see in lab."), + ) + } + + fn text_of(action: ActionRequiredData) -> String { + super::super::render_frame(&frame_of(action)).expect("a card is printed") + } + + #[test] + fn every_card_is_one_line_and_no_credential_argument_is_printed() { + let approval = text_of(confirmation( + "developer__shell", + serde_json::json!({ + "command": "ls", + "password": CANARY, + "nested": { "api_key": CANARY }, + }), + None, + )); + assert_eq!(approval, "[approval] developer__shell"); + + let change = text_of(confirmation( + "crew__change", + serde_json::json!({ "action": "channels_create", "token": CANARY }), + Some("Create the channel #attach-test in lab\nIt is public to the workspace."), + )); + assert_eq!( + change, + "[approval] crew__change: Create the channel #attach-test in lab" + ); + + let question = text_of(ActionRequiredData::Elicitation { + id: "q-1".into(), + message: "Which dataset?\nPick one.".into(), + requested_schema: serde_json::json!({ + "type": "object", + "properties": { "password": { "type": "string", "default": CANARY } } + }), + }); + assert_eq!(question, "[question] Which dataset?"); + + let step = text_of(ActionRequiredData::CrewStep { + id: "step-1".into(), + step: CrewStepRequest::SignIn { + target: CrewSignInTarget::Setup { + job_id: "job-1".into(), + }, + server: "hpc.example.edu".into(), + login: "you@hpc.example.edu".into(), + }, + }); + assert_eq!( + step, + "[crew] Sign in to you@hpc.example.edu. Answer it in the desktop app, never by typing here." + ); + + let credentials = text_of(ActionRequiredData::SecretRequest { + id: "s-1".into(), + prompt: "Connect the extension".into(), + keys: vec![SecretKeyRequest { + key: "EXAMPLE_API_KEY".into(), + label: "API key".into(), + description: None, + required: true, + }], + destination: SecretDestination::Keyring, + }); + assert!( + credentials.starts_with("[credentials] Connect the extension (EXAMPLE_API_KEY)."), + "{credentials}" + ); + + for line in [approval, change, question, step, credentials] { + assert!(!line.contains('\n'), "one line each: {line}"); + assert!(!line.contains(CANARY), "{line}"); + assert!(!line.contains(['\u{2014}', '\u{2013}']), "{line}"); + } + } + + #[test] + fn an_answered_question_prints_nothing_of_what_the_person_typed() { + let response = frame_of(ActionRequiredData::ElicitationResponse { + id: "q-1".into(), + user_data: serde_json::json!({ "answer": CANARY }), + }); + assert_eq!(super::super::render_frame(&response), None); + } + + #[test] + fn a_card_this_build_does_not_know_still_says_something_is_waiting() { + let line = card_line(&serde_json::json!({ "actionType": "somethingNew", "id": "x" })) + .expect("a card"); + assert_eq!( + line, + "[waiting] This chat is waiting for your answer. Answer it in the desktop app." + ); + } + + #[test] + fn a_crew_step_names_the_daemons_values_for_each_kind() { + let vault = card_line( + &serde_json::to_value(ActionRequiredData::CrewStep { + id: "v".into(), + step: CrewStepRequest::Vault { + action: CrewVaultAction::Unlock, + }, + }) + .unwrap(), + ) + .unwrap(); + assert!(vault.starts_with("[crew] Unlock Crew's vault. "), "{vault}"); + let changed = card_line( + &serde_json::to_value(ActionRequiredData::CrewStep { + id: "h".into(), + step: CrewStepRequest::HostKey { + host: "hpc.example.edu".into(), + port: 22, + login: "you".into(), + offered: Some("SHA256:abc".into()), + known: Some("SHA256:def".into()), + changed: true, + }, + }) + .unwrap(), + ) + .unwrap(); + assert!( + changed.starts_with("[crew] hpc.example.edu's identity changed. "), + "{changed}" + ); + assert!( + !changed.contains("SHA256:"), + "a line names no key: {changed}" + ); + // WI-17: a changed key's card is a notice kept in the chat, so its line asks for no + // answer anywhere. + assert!(changed.contains("IT team confirms the change"), "{changed}"); + assert!(!changed.contains("Answer it"), "{changed}"); + let unknown = card_line( + &serde_json::to_value(ActionRequiredData::CrewStep { + id: "u".into(), + step: CrewStepRequest::HostKey { + host: "hpc.example.edu".into(), + port: 22, + login: "you".into(), + offered: Some("SHA256:abc".into()), + known: None, + changed: false, + }, + }) + .unwrap(), + ) + .unwrap(); + assert!( + unknown.starts_with("[crew] Can't verify hpc.example.edu yet. "), + "{unknown}" + ); + } + + #[test] + fn the_details_hide_credentials_and_redact_a_command() { + let details = approval_details(&confirmation( + "crew__change", + serde_json::json!({ + "action": "channels_create", + "name": "attach-test", + "client_secret": CANARY, + "headers": { "Authorization": CANARY }, + }), + Some("Create the channel #attach-test in lab\nIt is public to the workspace."), + )); + assert_eq!(details[0], "Create the channel #attach-test in lab"); + assert_eq!(details[1], "It is public to the workspace."); + assert_eq!(details[2], "Arguments: {", "{details:?}"); + let arguments = details[2..].join("\n"); + assert!(arguments.contains("attach-test"), "{arguments}"); + assert!(!arguments.contains(CANARY), "{arguments}"); + + let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAAB\n-----END OPENSSH PRIVATE KEY-----"; + let shell = ActionRequiredData::ToolConfirmation { + id: "card-2".into(), + tool_name: "developer__shell".into(), + arguments: Map::new(), + prompt: None, + risk: Some(ToolRisk::High), + preview: Some(ToolPreview::Shell { + command: format!("printf '%s' '{pem}'"), + truncated: false, + }), + }; + let details = approval_details(&shell); + assert_eq!(details[0], "Risk: destructive"); + assert!(details[1].starts_with("Command: "), "{details:?}"); + assert!( + !details.join("\n").contains("b3BlbnNzaC1rZXkt"), + "{details:?}" + ); + } + + /// AO-12: "the payload is shown whole". `attach` posts an allow with the person's proof, so + /// what it shows first is every line of the prompt and every character of the arguments, + /// however long; a preview the daemon clipped to fit a card is never what is shown. + #[test] + fn an_approval_is_shown_whole_before_it_is_answered() { + let message = format!("{}END-OF-MESSAGE", "word ".repeat(1_200)); + let long_line = format!("Post as you in #general: {}", "x".repeat(400)); + let prompt = std::iter::once(long_line.clone()) + .chain((1..=60).map(|n| format!("Line {n} of the card"))) + .collect::>() + .join("\n"); + let mut card = confirmation( + "crew__change", + serde_json::json!({ "action": "send", "channel": "general", "text": message }), + Some(&prompt), + ); + if let ActionRequiredData::ToolConfirmation { + arguments, preview, .. + } = &mut card + { + *preview = Some(ToolPreview::arguments_within(arguments, 300 * 1024)); + } + let shown = approval_details(&card).join("\n"); + assert!(shown.contains(&message), "the whole message is shown"); + assert!( + shown.contains(&long_line), + "a long prompt line is shown whole" + ); + assert!(shown.contains("Line 60 of the card"), "every prompt line"); + assert!(!shown.contains('…'), "nothing is clipped: {shown}"); + + // The card's one-line form clips that first line, so the details keep it rather than + // leave it out as already shown. + let events = card_events(&frame_of(card)); + let [CardEvent::Approval(approval)] = events.as_slice() else { + panic!("one approval: {events:?}"); + }; + assert_eq!(approval.details[0], long_line); + + // A command the daemon clipped to fit the card is shown from the call's own arguments. + let command = format!("echo {}", "y".repeat(5_000)); + let clipped = ActionRequiredData::ToolConfirmation { + id: "card-4".into(), + tool_name: "developer__shell".into(), + arguments: serde_json::json!({ "command": command }) + .as_object() + .cloned() + .unwrap(), + prompt: None, + risk: None, + preview: Some(ToolPreview::Shell { + command: command.chars().take(4_000).collect(), + truncated: true, + }), + }; + assert!(approval_details(&clipped).join("\n").contains(&command)); + + // A new file is shown from the arguments too: the preview's copy of it is clipped. + let body = "line\n".repeat(3_000); + let write = ActionRequiredData::ToolConfirmation { + id: "card-5".into(), + tool_name: "developer__text_editor".into(), + arguments: + serde_json::json!({ "command": "write", "path": "notes.md", "file_text": body }) + .as_object() + .cloned() + .unwrap(), + prompt: None, + risk: None, + preview: Some(ToolPreview::FileWrite { + path: "notes.md".into(), + content: body.chars().take(8_000).collect(), + line_count: 3_000, + truncated: true, + }), + }; + let shown = approval_details(&write); + assert_eq!(shown[0], "Writes notes.md: 3000 lines"); + let encoded = serde_json::to_string(&body).unwrap(); + assert!( + shown.join("\n").contains(&encoded), + "the whole file is in the arguments shown" + ); + } + + #[test] + fn a_shared_daemon_chat_shows_the_card_whole_but_never_a_credential() { + let mut card = confirmation( + "extensionmanager__install", + serde_json::json!({ "name": "lab-tools", "env": { "LAB_API_TOKEN": CANARY } }), + Some("Install lab-tools"), + ); + if let ActionRequiredData::ToolConfirmation { + arguments, preview, .. + } = &mut card + { + *preview = Some(ToolPreview::arguments_within(arguments, 4096)); + } + let shown = shown_card_json(&card); + assert!(!shown.contains(CANARY), "{shown}"); + assert!(shown.contains("lab-tools"), "{shown}"); + // The key is still named and its value hidden. + assert!( + shown.contains("\"LAB_API_TOKEN\": \"[REDACTED:secret-value]\""), + "{shown}" + ); + assert!(!shown.contains("\"preview\""), "{shown}"); + assert_eq!( + hide_credentials(&serde_json::json!({ + "folder": "data", + "nested": [{ "Passphrase": CANARY, "label": "lab" }], + })), + serde_json::json!({ + "folder": "data", + "nested": [{ "Passphrase": HIDDEN_VALUE, "label": "lab" }], + }) + ); + + let edit = ActionRequiredData::ToolConfirmation { + id: "card-3".into(), + tool_name: "developer__text_editor".into(), + arguments: Map::new(), + prompt: None, + risk: None, + preview: Some(ToolPreview::FileWrite { + path: "notes.md".into(), + content: "# Notes".into(), + line_count: 1, + truncated: false, + }), + }; + assert!( + shown_card_json(&edit).contains("# Notes"), + "a new file is still shown" + ); + } + + /// A value hidden by its credential-like key reads the same whatever the key's spelling: an + /// environment-style name (which the secret-output redactor also knows) and any other used + /// to show two different words, because the redactor rewrote this terminal's own marker. + /// Both now show the redactor's word, which is the desktop tool card's, in a shared-daemon + /// chat and in `attach` alike. + #[test] + fn every_hidden_value_reads_the_same_whatever_its_key() { + let card = confirmation( + "extensionmanager__install", + serde_json::json!({ + "name": "lab-tools", + "env": { "LAB_API_TOKEN": CANARY }, + "passphrase": CANARY, + "headers": { "Authorization": CANARY }, + }), + Some("Install lab-tools"), + ); + let hidden = |key: &str| format!("\"{key}\": \"{HIDDEN_VALUE}\""); + for (surface, shown) in [ + ("shared-daemon chat", shown_card_json(&card)), + ("attach", approval_details(&card).join("\n")), + ] { + assert!(!shown.contains(CANARY), "{surface}: {shown}"); + assert!(!shown.contains("(hidden)"), "{surface}: {shown}"); + for key in ["LAB_API_TOKEN", "passphrase", "Authorization"] { + assert!(shown.contains(&hidden(key)), "{surface}, {key}: {shown}"); + } + } + assert_eq!( + biorouter::guardrails::secret_output::redact_text(&hidden("LAB_API_TOKEN")), + None, + "the redactor leaves the marker as it is" + ); + } + + #[test] + fn the_read_consent_is_offered_as_lasting_until_revoked() { + let consent = read_consent(); + assert!(is_read_consent(&consent)); + assert!(!is_read_consent(&confirmation( + "crew__read", + serde_json::json!({ "action": "setup_check" }), + Some("Check hpc.example.edu"), + ))); + assert!(!is_read_consent(&confirmation( + "crew__change", + serde_json::json!({ "action": "read as you" }), + None, + ))); + + let choices = read_consent_choices("20261106_4"); + assert_eq!( + choices, + "allow (until you revoke it with biorouter crew grants revoke 20261106_4 --reads) or deny" + ); + assert!(!choices.contains("once"), "{choices}"); + assert_eq!(approval_answer("allow", true), Some("allow_once")); + assert_eq!(approval_answer(" Deny ", true), Some("deny")); + assert_eq!( + approval_answer("allow_once", true), + None, + "a lasting consent is never allowed as once" + ); + assert_eq!(approval_answer("allow_once", false), Some("allow_once")); + assert_eq!(approval_answer("allow", false), None); + assert_eq!(approval_answer("yes", false), None); + + let mut waiting = Waiting::new("20261106_4", Answering::Here); + let events = card_events(&frame_of(consent)); + let [CardEvent::Approval(card)] = events.as_slice() else { + panic!("one approval: {events:?}"); + }; + assert!(card.read_consent); + let asked = waiting.on_event(CardEvent::Approval(card.clone())); + assert_eq!( + asked.last().unwrap(), + "Type allow (until you revoke it with biorouter crew grants revoke 20261106_4 --reads) or deny, then press Enter. Your next line answers this request and is not sent to the chat." + ); + assert_eq!( + answer_outcome(200, r#"{"status":"delivered"}"#, "allow_once", true, "20261106_4"), + "[answered] Allowed. This chat may read the workspace as you until you revoke it with biorouter crew grants revoke 20261106_4 --reads." + ); + for line in asked { + assert!(!line.contains(['\u{2014}', '\u{2013}']), "{line}"); + } + } + + #[test] + fn while_an_approval_waits_a_typed_line_answers_it_and_never_reaches_the_chat() { + let mut waiting = Waiting::new("s", Answering::Here); + assert_eq!(waiting.on_line("hello"), LineUse::Message); + let card = |id: &str| ApprovalCard { + id: id.into(), + read_consent: false, + details: vec![format!("Do {id}")], + }; + assert_eq!( + waiting.on_event(CardEvent::Approval(card("a"))), + vec![ + "Do a".to_string(), + "Type allow_once or deny, then press Enter. Your next line answers this request and is not sent to the chat.".to_string() + ] + ); + assert_eq!( + waiting.on_event(CardEvent::Approval(card("b"))), + vec!["This request is asked once you answer the one before it.".to_string()] + ); + assert_eq!( + waiting.on_line("please do it"), + LineUse::Unanswered(vec![ + "Not sent to the chat. A request is waiting: type allow_once or deny.".to_string() + ]) + ); + let LineUse::Answer { + card: a, + action, + next, + } = waiting.on_line("allow_once") + else { + panic!("the line answers the first card"); + }; + assert_eq!((a.id.as_str(), action), ("a", "allow_once")); + assert_eq!(next.first().map(String::as_str), Some("Do b")); + assert!(matches!(waiting.on_line("deny"), LineUse::Answer { .. })); + assert_eq!(waiting.on_line("back to chatting"), LineUse::Message); + + waiting.on_event(CardEvent::Approval(card("c"))); + assert_eq!( + waiting.on_event(CardEvent::TurnEnded), + vec![ + "[approval closed] The turn ended, so its request is no longer waiting." + .to_string() + ] + ); + assert_eq!(waiting.on_line("next message"), LineUse::Message); + } + + /// The agent's own approval card carries its tool call's ID, so the call's response says it + /// was answered (on the desktop, say): `attach` stops holding the next line for it, and the + /// same card shown twice is still one request. + #[test] + fn a_request_answered_somewhere_else_stops_holding_the_next_line() { + let mut waiting = Waiting::new("s", Answering::Here); + let card = |id: &str| ApprovalCard { + id: id.into(), + read_consent: false, + details: vec![format!("Do {id}")], + }; + assert_eq!(waiting.on_event(CardEvent::Approval(card("a"))).len(), 2); + assert_eq!( + waiting.on_event(CardEvent::Approval(card("a"))), + Vec::::new(), + "the same card again asks nothing new" + ); + waiting.on_event(CardEvent::Approval(card("b"))); + + let response = serde_json::json!({ + "type": "Message", + "message": { + "role": "user", + "content": [{ + "type": "toolResponse", + "id": "a", + "toolResult": { "status": "success", "value": { "content": [] } } + }] + } + }); + let events = card_events(&response); + assert_eq!(events, vec![CardEvent::Responded("a".into())]); + let lines = waiting.on_event(events[0].clone()); + assert_eq!( + lines.first().map(String::as_str), + Some("[approval closed] This request was answered somewhere else.") + ); + assert_eq!(lines.get(1).map(String::as_str), Some("Do b"), "{lines:?}"); + assert_eq!( + waiting.on_event(CardEvent::Responded("unrelated".into())), + Vec::::new() + ); + let LineUse::Answer { card: b, .. } = waiting.on_line("deny") else { + panic!("the next line answers b, not a"); + }; + assert_eq!(b.id, "b"); + assert_eq!(waiting.on_line("back to chatting"), LineUse::Message); + } + + #[test] + fn a_read_only_attach_says_where_to_answer_and_holds_nothing() { + let mut waiting = Waiting::new("s", Answering::ReadOnly); + let lines = waiting.on_event(CardEvent::Approval(ApprovalCard { + id: "a".into(), + read_consent: false, + details: Vec::new(), + })); + assert_eq!( + lines, + vec!["Answer it in the desktop app, or open this chat in a terminal with biorouter session --shared-daemon --resume --session-id s. Or attach without --read-only to answer it here.".to_string()] + ); + assert_eq!(waiting.on_line("anything"), LineUse::Message); + } + + /// WI-17 (4): where a card is answered names only what can answer it. A terminal chat + /// answers only its own conversation's cards, so a line that has the chat's ID names the + /// one terminal chat that can, and a line without it names the desktop app alone. + #[test] + fn where_to_answer_never_promises_a_terminal_that_cannot() { + let line = + card_line(&serde_json::json!({ "actionType": "somethingNew", "id": "x" })).unwrap(); + assert!(!line.contains("--shared-daemon"), "{line}"); + let mut waiting = Waiting::new("chat-7", Answering::Here); + let lines = waiting.on_event(CardEvent::Question); + assert!( + lines[0].contains("biorouter session --shared-daemon --resume --session-id chat-7"), + "{lines:?}" + ); + assert!(!lines[0].contains("a biorouter session --shared-daemon chat")); + } + + /// WI-17 (c) and (d) in `attach`: an approval the daemon says ended stops holding the next + /// line at once, whatever its ID (a card a tool raised, whose ID is not its call's, used to + /// hold every line until the turn ended); a Crew step or credential card shown here is named + /// again when it ends; and an end for a card this terminal answered or never showed says + /// nothing. + #[test] + fn an_end_stops_the_wait_and_names_what_ended() { + let mut waiting = Waiting::new("s", Answering::Here); + let card = |id: &str| ApprovalCard { + id: id.into(), + read_consent: false, + details: vec![format!("Details of {id}")], + }; + waiting.on_event(CardEvent::Approval(card("a"))); + waiting.on_event(CardEvent::Approval(card("b"))); + let lines = waiting.on_event(CardEvent::Ended { + id: "a".into(), + ending: "dismissed".into(), + }); + assert_eq!( + lines[0], + "[approval closed] This request was dismissed somewhere else." + ); + assert!(lines.contains(&"Details of b".to_string()), "{lines:?}"); + // The one behind the first, ended before it was asked, goes quietly. + let _ = waiting.on_event(CardEvent::Approval(card("c"))); + assert!(waiting + .on_event(CardEvent::Ended { + id: "c".into(), + ending: "timed_out".into(), + }) + .is_empty()); + let lines = waiting.on_event(CardEvent::Ended { + id: "b".into(), + ending: "a word from a newer daemon".into(), + }); + assert_eq!( + lines, + ["[approval closed] This request is no longer waiting."] + ); + assert_eq!(waiting.on_line("my next message"), LineUse::Message); + + waiting.on_event(CardEvent::Shown { + id: "step".into(), + heading: "Sign in to you@hpc.example.edu".into(), + }); + assert_eq!( + waiting.on_event(CardEvent::Ended { + id: "step".into(), + ending: "withdrawn".into(), + }), + ["[closed] Sign in to you@hpc.example.edu: no longer waiting."] + ); + assert!(waiting + .on_event(CardEvent::Ended { + id: "never-shown".into(), + ending: "answered".into(), + }) + .is_empty()); + } + + /// The frames that carry a card's life reach `attach` as card events: a Crew step and a + /// credential card as shown, a changed host key's notice as nothing that will end, and the + /// daemon's end frame as its ID and ending. `watch` prints the end without naming the card. + #[test] + fn the_end_frame_and_the_cards_that_end_reach_attach() { + let step = frame_of(ActionRequiredData::CrewStep { + id: "step-1".into(), + step: CrewStepRequest::Vault { + action: CrewVaultAction::Unlock, + }, + }); + assert_eq!( + card_events(&step), + [CardEvent::Shown { + id: "step-1".into(), + heading: "Unlock Crew's vault".into(), + }] + ); + let notice = frame_of(ActionRequiredData::CrewStep { + id: "notice-1".into(), + step: CrewStepRequest::HostKey { + host: "hpc.example.edu".into(), + port: 22, + login: "you".into(), + offered: None, + known: None, + changed: true, + }, + }); + assert!(card_events(¬ice).is_empty()); + let end = serde_json::json!({ + "type": "ActionRequiredEnded", "id": "step-1", "ending": "dismissed", "seq": 4 + }); + assert_eq!( + card_events(&end), + [CardEvent::Ended { + id: "step-1".into(), + ending: "dismissed".into(), + }] + ); + assert_eq!( + ended_frame_line(&end), + "[closed] A request in this chat is over: dismissed." + ); + } + + /// Accident prevention, as D5 is for `biorouter crew`: `printf 'allow_once\n' | biorouter + /// session attach `, or the same from an agent's own shell, prints the card and the + /// desktop's name and approves nothing. The line still reaches the chat as a message, as + /// any steer from a pipe does. + #[test] + fn a_pipe_or_an_agents_shell_never_answers_an_approval() { + assert_eq!(Answering::for_attach(false, true, false), Answering::Here); + assert_eq!( + Answering::for_attach(false, false, false), + Answering::NotATerminal + ); + assert_eq!( + Answering::for_attach(false, true, true), + Answering::AgentShell, + "an agent's shell never answers, even with a terminal" + ); + assert_eq!(Answering::for_attach(true, true, true), Answering::ReadOnly); + + for (answering, reason) in [ + (Answering::NotATerminal, "isn't reading your typing"), + (Answering::AgentShell, "runs in an agent's shell"), + ] { + let mut waiting = Waiting::new("s", answering); + let lines = waiting.on_event(CardEvent::Approval(ApprovalCard { + id: "a".into(), + read_consent: false, + details: vec!["Do a".into()], + })); + assert_eq!(lines[0], "Do a", "the card is still shown whole"); + let pointer = lines.last().unwrap(); + assert!( + pointer.starts_with("Answer it in the desktop app"), + "{pointer}" + ); + assert!(pointer.contains(reason), "{pointer}"); + assert!(!pointer.contains(['\u{2014}', '\u{2013}']), "{pointer}"); + assert_eq!( + waiting.on_line("allow_once"), + LineUse::Message, + "nothing is answered from here" + ); + assert_eq!( + waiting.on_event(CardEvent::TurnEnded), + Vec::::new(), + "nothing was held" + ); + } + } + + #[test] + fn the_daemons_answer_is_said_in_plain_words() { + assert_eq!( + answer_outcome(200, r#"{"status":"delivered"}"#, "allow_once", false, "s"), + "[answered] Allowed once." + ); + assert_eq!( + answer_outcome(200, r#"{"status":"delivered"}"#, "deny", true, "s"), + "[answered] Denied." + ); + assert_eq!( + answer_outcome( + 200, + r#"{"status":"already_resolved"}"#, + "allow_once", + false, + "s" + ), + "[not answered] This request is no longer waiting, so nothing was approved here." + ); + assert_eq!( + answer_outcome( + 403, + r#"{"status":"refused","reason":"unproven","error":"The person decides this."}"#, + "allow_once", + false, + "s" + ), + "[not answered] The person decides this. Answer it in the desktop app." + ); + } +} diff --git a/crates/biorouter-cli/src/commands/shared_conversation.rs b/crates/biorouter-cli/src/commands/shared_conversation.rs index 64fdcdbca..8acf7e5d0 100644 --- a/crates/biorouter-cli/src/commands/shared_conversation.rs +++ b/crates/biorouter-cli/src/commands/shared_conversation.rs @@ -1,4 +1,5 @@ //! Terminal presentation of the profile daemon's personal conversation services. +use crate::commands::session_watch::cards; use crate::daemon_client::{CrewClient, DaemonEventStream, DaemonRefusal}; use anyhow::{bail, ensure, Context, Result}; use biorouter::conversation::message::{ActionRequiredData, Message, MessageContent}; @@ -304,12 +305,8 @@ impl Conversation { safe_text(&state["initialization_error"].to_string()) ); if let Some(results) = state["extension_results"].as_array() { - for result in results { - ensure!( - result["success"].as_bool() != Some(false), - "A session extension failed to initialize: {}", - safe_text(&result.to_string()) - ); + for line in extension_warnings(results) { + self.notice(&line)?; } } Ok(state) @@ -627,7 +624,8 @@ impl Conversation { let id = match &action { ActionRequiredData::ToolConfirmation { id, .. } | ActionRequiredData::Elicitation { id, .. } - | ActionRequiredData::SecretRequest { id, .. } => id.clone(), + | ActionRequiredData::SecretRequest { id, .. } + | ActionRequiredData::CrewStep { id, .. } => id.clone(), ActionRequiredData::ElicitationResponse { .. } => return Ok(Interaction::Continue), }; validate_id(&id)?; @@ -650,6 +648,17 @@ impl Conversation { requested_schema, .. } => self.elicitation(&id, &message, &requested_schema).await?, + // D1: the person's own Sign in window, vault prompt or host-key help, in this + // terminal. Nothing typed comes back through the conversation. + ActionRequiredData::CrewStep { step, .. } => { + let surface = crate::commands::crew_step::StepSurface { + client: &self.client, + session_id: &self.session_id, + card_id: &id, + }; + crate::commands::crew_step::take(&surface, &step).await?; + Interaction::Continue + } ActionRequiredData::ElicitationResponse { .. } => unreachable!(), }; self.answered.insert(id); @@ -657,7 +666,9 @@ impl Conversation { } async fn confirm_tool(&self, action: &ActionRequiredData, id: &str) -> Result { - self.notice(&serde_json::to_string_pretty(action)?)?; + // The card whole, but never an argument whose name looks like a credential, one line + // of it per line on the terminal. + self.notice_lines(&card_lines(action))?; let folder_access = matches!( action, ActionRequiredData::ToolConfirmation { @@ -667,28 +678,32 @@ impl Conversation { .. } ); + // AO-12's read consent lasts until the person revokes it, so it is never offered as + // "once": the choice is `allow` or `deny`, and the prompt says how to take it back. + let read_consent = cards::is_read_consent(action); + let question = approval_question(read_consent, folder_access, &self.session_id); loop { - let Some(answer) = terminal_input( - if folder_access { - "Type allow_once, allow_session, or deny (Esc cancels the turn): " - } else { - "Type allow_once or deny (Esc cancels the turn): " - }, - false, - 32, - ) - .await? - else { + let Some(answer) = terminal_input(&question, false, 32).await? else { return Ok(Interaction::Stop); }; - if !(matches!(answer.trim(), "allow_once" | "deny") - || folder_access && answer.trim() == "allow_session") - { + let decision = if folder_access && answer.trim() == "allow_session" { + Some("allow_session") + } else { + cards::approval_answer(&answer, read_consent) + }; + let Some(decision) = decision else { continue; - } - let result = self.client.request("POST", "/action-required/tool-confirmation", Some(json!({ - "sessionId":self.session_id,"id":id,"principalType":"Tool","action":answer.trim() - }))).await?; + }; + let result = self + .client + .request( + "POST", + "/action-required/tool-confirmation", + Some(json!({ + "sessionId":self.session_id,"id":id,"principalType":"Tool","action":decision + })), + ) + .await?; ensure!( matches!( result["status"].as_str(), @@ -701,6 +716,8 @@ impl Conversation { Some("unknown" | "already_resolved") ) { self.notice("This request is no longer pending; no new tool call was approved.")?; + } else if read_consent && decision == "allow_once" { + self.notice(&cards::read_consent_allowed(&self.session_id))?; } return Ok(Interaction::Continue); } @@ -852,6 +869,16 @@ impl Conversation { stderr.flush()?; Ok(()) } + /// [`Self::notice`] for text already split into `lines`, each printed on its own line and + /// passed through [`safe_text`] (which leaves an escaped line as it is). + fn notice_lines(&self, lines: &[String]) -> Result<()> { + let mut stderr = std::io::stderr().lock(); + for line in lines { + writeln!(stderr, "{}", safe_text(line))?; + } + stderr.flush()?; + Ok(()) + } /// 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. @@ -1029,6 +1056,21 @@ fn safe_content(value: &str) -> String { }) .collect() } +/// The question an approval card is answered with in this terminal. A read consent offers +/// `allow`, which lasts until the person revokes it, and says how; a folder grant can also be +/// allowed for the session; any other approval is allowed once. +fn approval_question(read_consent: bool, folder_access: bool, session_id: &str) -> String { + if read_consent { + format!( + "Type {} (Esc cancels the turn): ", + cards::read_consent_choices(session_id) + ) + } else if folder_access { + "Type allow_once, allow_session, or deny (Esc cancels the turn): ".to_string() + } else { + "Type allow_once or deny (Esc cancels the turn): ".to_string() + } +} fn sensitive_schema(value: &Value) -> bool { match value { Value::Object(fields) => { @@ -1075,7 +1117,43 @@ fn erase_values(values: &mut Map) { fn terminal_control(ch: char) -> bool { ch.is_control() || biorouter::utils::is_invisible_formatting(ch) } -fn safe_text(value: &str) -> String { +/// The secret-output redactor's word for a private key it withheld, which it writes on a line of +/// its own (`secret_output::redact_text`), so inside a JSON string of a card. +const WITHHELD_PRIVATE_KEY: &str = "\n[REDACTED:private-key]\n"; + +/// An approval card as this terminal prints it before asking: its JSON +/// ([`cards::shown_card_json`]), one line per line of the pretty-printed document, each with every +/// control and invisible formatting character escaped. Splitting at its line breaks lets no +/// untrusted text start a line of its own, because JSON writes a line break inside a string as +/// `\n`. The one other line break is the redactor's around a withheld private key, which is +/// written back as `\n` first, so every line break left is the printer's. +fn card_lines(action: &ActionRequiredData) -> Vec { + cards::shown_card_json(action) + .replace( + WITHHELD_PRIVATE_KEY, + &WITHHELD_PRIVATE_KEY.replace('\n', "\\n"), + ) + .split('\n') + .map(safe_text) + .collect() +} + +/// One warning for each session extension the daemon could not start, worded as an in-process +/// chat words it (`session::builder`): the chat goes on without it. Printed through +/// [`Conversation::notice`], so the daemon's words pass [`safe_text`]. +fn extension_warnings(results: &[Value]) -> Vec { + results + .iter() + .filter(|result| result["success"].as_bool() == Some(false)) + .map(|result| { + let name = result["name"].as_str().unwrap_or("unknown"); + let reason = result["error"].as_str().unwrap_or("no reason given"); + format!("Warning: Failed to start extension '{name}' ({reason}), continuing without it") + }) + .collect() +} + +pub(crate) fn safe_text(value: &str) -> String { value .chars() .flat_map(|ch| { @@ -1116,7 +1194,9 @@ impl Drop for TerminalMode { let _ = crossterm::terminal::disable_raw_mode(); } } -async fn terminal_input( +/// One line typed at this terminal after `prompt`, echoed unless `hidden`, at most `limit` +/// bytes. `Ok(None)` when the person pressed Esc, Ctrl-C or Ctrl-D. +pub(crate) async fn terminal_input( prompt: &str, hidden: bool, limit: usize, @@ -1217,10 +1297,106 @@ fn binding_notice(format: Format, session_id: &str, session: &Value, binding: &V #[cfg(test)] mod tests { use super::{ - 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, + approval_question, binding_change, binding_notice, card_lines, cards, extension_warnings, + 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, }; + use biorouter::conversation::message::ActionRequiredData; + + /// Each extension that failed to start gets one warning naming it and the daemon's reason, + /// in an in-process chat's words; one that started gets none. + #[test] + fn a_failed_extension_s_warning_names_it_and_its_reason() { + let results = [ + serde_json::json!({"name": "lab-mcp", "success": false, "error": "spawn failed"}), + serde_json::json!({"name": "developer", "success": true}), + serde_json::json!({"success": false}), + ]; + assert_eq!( + extension_warnings(&results), + [ + "Warning: Failed to start extension 'lab-mcp' (spawn failed), continuing without it", + "Warning: Failed to start extension 'unknown' (no reason given), continuing without it", + ] + ); + } + + fn approval(arguments: serde_json::Value) -> ActionRequiredData { + ActionRequiredData::ToolConfirmation { + id: "card-1".into(), + tool_name: "crew__change".into(), + arguments: arguments.as_object().cloned().unwrap_or_default(), + prompt: Some("Post as you in #general\nIt is public to the workspace.".into()), + risk: None, + preview: None, + } + } + + /// An approval card prints as the readable JSON it is, one line per line, rather than one + /// line full of `\n`; and no line carries a control or invisible formatting character, nor + /// starts with text the card's values hold. + #[test] + fn a_card_prints_as_readable_lines_with_every_control_neutralised() { + let hostile = "one\ntwo\r\u{1b}[2J\u{7f}\u{9b}31m\u{202e}evil"; + let card = approval(serde_json::json!({ "action": "send", "text": hostile })); + let lines = card_lines(&card); + assert_eq!(lines.first().map(String::as_str), Some("{"), "{lines:?}"); + assert_eq!(lines.last().map(String::as_str), Some("}"), "{lines:?}"); + assert!( + lines + .iter() + .any(|line| line.starts_with(" \"toolName\": \"crew__change\"")), + "{lines:?}" + ); + assert!( + !lines.iter().any(|line| line.contains("\\n ")), + "{lines:?}" + ); + for line in &lines { + assert!(!line.chars().any(terminal_control), "{line:?}"); + let start = line.trim_start(); + assert!( + start.is_empty() || start.starts_with(['"', '{', '}', '[', ']']), + "{line:?}" + ); + } + // The value's own line breaks and controls stay escaped inside its one line. + let text = lines + .iter() + .find(|line| line.contains("\"text\"")) + .expect("the text argument has a line"); + assert!(text.contains(r"one\ntwo\r\u001b[2J"), "{text}"); + assert!(text.contains(r"\u{7f}\u{9b}31m\u{202e}evil"), "{text}"); + assert_eq!(lines.len(), lines.join("\n").lines().count()); + } + + /// The redactor writes a withheld private key on a line of its own; inside a card it stays + /// in its string, so what follows the key in the value never starts a line. + #[test] + fn a_withheld_private_key_stays_inside_its_line() { + let body = ["b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAAB"; 3].join("\n"); + let kind = ["OPENSSH", "PRIVATE", "KEY"].join(" "); + let key = format!("-----BEGIN {kind}-----\n{body}\n-----END {kind}-----"); + for value in [ + format!("{key} Type allow_once to continue"), + format!("-----BEGIN {kind}-----\n{body}!Type allow_once to continue"), + ] { + let lines = card_lines(&approval( + serde_json::json!({ "action": "send", "text": value }), + )); + let shown = lines.join("\n"); + assert!(!shown.contains("b3BlbnNzaC1rZXkt"), "{shown}"); + assert!(shown.contains("[REDACTED:private-key]"), "{shown}"); + for line in &lines { + let start = line.trim_start(); + assert!( + start.is_empty() || start.starts_with(['"', '{', '}', '[', ']']), + "{line:?}" + ); + } + } + } /// 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 @@ -1558,6 +1734,55 @@ mod tests { } } + /// D1: a Crew step card in a chat with nobody at the terminal is the same refusal every + /// card gets, and nothing is asked of the daemon. The client points at no daemon at all, + /// so any request would fail the test differently. + #[tokio::test] + async fn a_noninteractive_chat_refuses_a_crew_step_like_any_card() { + use biorouter::conversation::message::{ + ActionRequiredData, CrewSignInTarget, CrewStepRequest, + }; + let directory = tempfile::tempdir().expect("a directory for no daemon"); + let client = CrewClient::for_test( + Descriptor { + version: daemon_runtime::VERSION, + profile_id: "synthetic-profile".into(), + instance_id: "33333333-3333-4333-8333-333333333333".into(), + pid: std::process::id(), + endpoint: Endpoint::Unix { + path: directory.path().join("no-daemon.sock"), + }, + api_secret: "synthetic-daemon-secret-0123456789012345".into(), + user_action_installed: true, + }, + "synthetic-proof", + ); + let mut conversation = conversation(client, false); + let Err(error) = conversation + .interact(ActionRequiredData::CrewStep { + id: "card-1".into(), + step: CrewStepRequest::SignIn { + target: CrewSignInTarget::Setup { + job_id: "job-1".into(), + }, + server: "hpc.example.edu".into(), + login: "you@hpc.example.edu".into(), + }, + }) + .await + else { + panic!("nobody is at this terminal, so the card must be refused"); + }; + let text = error.to_string(); + assert!( + text.starts_with( + "Human input is pending in session synthetic-session, request card-1." + ), + "{text}" + ); + assert!(!conversation.answered.contains("card-1")); + } + #[tokio::test] #[serial] async fn noninteractive_pending_continuation_performs_no_mutation() { @@ -1718,6 +1943,29 @@ mod tests { assert!(!paths.contains(&"/agent/continuation/abandon")); } + /// An extension the daemon could not start is a warning, as in an in-process chat, and + /// the chat goes on: it used to end the whole chat. + #[tokio::test] + #[serial] + async fn a_failed_extension_is_a_warning_and_the_chat_goes_on() { + if !crate::test_sandbox::in_a_process_of_its_own() { + return; + } + let mut state = pending("owned", None); + state["pending_continuation"] = Value::Null; + state["extension_results"] = json!([ + {"name": "lab-mcp", "success": false, "error": "spawn failed"}, + {"name": "developer", "success": true}, + ]); + let daemon = FakeDaemon::start_with_resume(StatusCode::OK, json!({}), state).await; + let conversation = conversation(daemon.client(), false); + let resumed = conversation + .resume(true) + .await + .unwrap_or_else(|error| panic!("a failed extension ended the chat: {error:#}")); + assert_eq!(resumed["session"]["id"], "synthetic-session"); + } + /// Ruling 28: the daemon's agent-loading resume answers with the row and /// no transcript, so `--history` reads it from the resume that loads /// nothing. A state that already carries one (an older daemon) costs no @@ -1863,4 +2111,34 @@ mod tests { &serde_json::json!({"properties":{"cohort":{"type":"string"}}}) )); } + + /// WI-7: the read consent lasts until the person revokes it, so this terminal never + /// offers it as "once": it asks for allow or deny and names the command that takes it + /// back. Every other approval keeps its words. + #[test] + fn the_read_consent_is_asked_as_lasting_until_revoked() { + let consent = approval_question(true, false, "20261106_4"); + assert_eq!( + consent, + "Type allow (until you revoke it with biorouter crew grants revoke 20261106_4 \ + --reads) or deny (Esc cancels the turn): " + ); + assert!(!consent.contains("once"), "{consent}"); + assert!(!consent.contains(['\u{2014}', '\u{2013}']), "{consent}"); + assert_eq!( + approval_question(false, false, "20261106_4"), + "Type allow_once or deny (Esc cancels the turn): " + ); + assert_eq!( + approval_question(false, true, "20261106_4"), + "Type allow_once, allow_session, or deny (Esc cancels the turn): " + ); + assert_eq!(cards::approval_answer("allow", true), Some("allow_once")); + assert_eq!(cards::approval_answer("allow_once", true), None); + assert_eq!( + cards::read_consent_allowed("20261106_4"), + "Allowed. This chat may read the workspace as you until you revoke it with biorouter \ + crew grants revoke 20261106_4 --reads." + ); + } } diff --git a/crates/biorouter-cli/src/daemon_client.rs b/crates/biorouter-cli/src/daemon_client.rs index 62a0139e9..5ebfe3625 100644 --- a/crates/biorouter-cli/src/daemon_client.rs +++ b/crates/biorouter-cli/src/daemon_client.rs @@ -50,6 +50,9 @@ pub struct DaemonRefusal { /// in neither list, so a field the daemon adds is never silently dropped from JSON output. pub const REFUSAL_FIELDS: &[&str] = &[ "host", + "is_host", + "server_login", + "host_username", "connection_id", "reason", "missing", @@ -730,8 +733,10 @@ impl CrewClient { /// The sign-in terminal the daemon prepares at `prepare_path`: a saved connection's /// `…/authentication`, or a setup job's `/crew/setup/{job}/authentication` (OW-9), whose - /// sign-in setup then rides. The server's own prompts (host key, password, verification - /// code) are shown here in raw mode and answered from this keyboard; the terminal itself is + /// sign-in setup then rides. The server's own prompts (a password, a key's passphrase, a + /// verification code) are shown here in raw mode and answered from this keyboard. A host + /// key is never asked here: every Crew ssh runs with `StrictHostKeyChecking yes`, so an + /// unknown or changed key is refused before any prompt. The terminal itself is /// `GET /crew/authentication/{id}/terminal` either way. `no_terminal` is the usage refusal /// said when there is no terminal to sign in at. /// @@ -856,8 +861,9 @@ fn bridge_missing_frame(frame: &Value) -> Option { } /// 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."; +/// takes this computer's SSH key alone needs no prompt, so `connect` is named for it. It names +/// no host-key prompt: Crew's ssh never shows one (`StrictHostKeyChecking yes`). +pub const AUTH_NEEDS_A_TERMINAL: &str = "Signing in needs a terminal, to show the server's password 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)] @@ -1295,7 +1301,7 @@ async fn discard_legacy_approval_line(approval_key_stdin: bool) -> Result<()> { /// A new vault passphrase, chosen now: read once, and on a terminal read again and compared, /// because a hidden typo would become a passphrase 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( +pub(crate) async fn new_secret( prompts: [&'static str; 3], from_stdin: bool, mut read: R, @@ -1345,7 +1351,10 @@ fn secret_prompt_possible(terminal: bool) -> Result<(), needs_terminal::NeedsTer needs_terminal::require(terminal, SECRET_NEEDS_A_TERMINAL) } -async fn read_secret(prompt: &'static str, from_stdin: bool) -> Result> { +pub(crate) async fn read_secret( + prompt: &'static str, + from_stdin: bool, +) -> Result> { tokio::task::spawn_blocking(move || { let secret = if from_stdin { let mut bytes = Zeroizing::new(Vec::new()); @@ -3006,6 +3015,8 @@ mod tests { assert_eq!(text, AUTH_NEEDS_A_TERMINAL); assert!(text.contains("biorouter crew connect"), "{text}"); assert!(!text.contains("MFA"), "{text}"); + // Crew's ssh never shows a host-key prompt (`StrictHostKeyChecking yes`). + assert!(!text.contains("host-key"), "{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::())); diff --git a/crates/biorouter-cli/src/session/export.rs b/crates/biorouter-cli/src/session/export.rs index e1462aee2..6612600a0 100644 --- a/crates/biorouter-cli/src/session/export.rs +++ b/crates/biorouter-cli/src/session/export.rs @@ -402,6 +402,14 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin .join(", ") )); } + // The step's kind only: an export leaves the machine, and a Crew step card + // names a server and a login the person signs in as. + ActionRequiredData::CrewStep { step, .. } => { + md.push_str(&format!( + "**Action Required** (crew_step): {}\n\n", + step.kind() + )); + } }, MessageContent::Text(text) => { md.push_str(&text.text); @@ -1449,4 +1457,27 @@ found 0 vulnerabilities"#; assert!(response_result.contains("added 57 packages")); assert!(response_result.contains("found 0 vulnerabilities")); } + + /// An export leaves the machine: a Crew step card says its kind, never the server or the + /// login it names. + #[test] + fn a_crew_step_card_exports_its_kind_only() { + use biorouter::conversation::message::{CrewSignInTarget, CrewStepRequest}; + let card = Message::assistant().with_content(MessageContent::action_required_crew_step( + "card-1", + CrewStepRequest::SignIn { + target: CrewSignInTarget::Connection { + connection_id: "conn-1".into(), + }, + server: "secret-server.example.edu".into(), + login: "canary@secret-server.example.edu".into(), + }, + )); + let markdown = message_to_markdown(&card, true); + assert_eq!( + markdown.trim_end(), + "**Action Required** (crew_step): sign_in" + ); + assert!(!markdown.contains("secret-server"), "{markdown}"); + } } diff --git a/crates/biorouter-cli/src/session/output.rs b/crates/biorouter-cli/src/session/output.rs index 08cbd7dd5..1c2b12650 100644 --- a/crates/biorouter-cli/src/session/output.rs +++ b/crates/biorouter-cli/src/session/output.rs @@ -292,6 +292,11 @@ pub fn render_message(message: &Message, debug: bool) { .join(", ") ) } + // The step's kind only: this line reaches a terminal that may be piped or + // logged, and the card is the person's to take in its own window. + ActionRequiredData::CrewStep { step, .. } => { + println!("action_required(crew_step): {}", step.kind()) + } }, MessageContent::Text(text) => print_markdown(&text.text, theme), MessageContent::ToolRequest(req) => render_tool_request(req, theme, debug), diff --git a/crates/biorouter-cli/src/workflows/secret_discovery.rs b/crates/biorouter-cli/src/workflows/secret_discovery.rs index de673c877..3200c11e5 100644 --- a/crates/biorouter-cli/src/workflows/secret_discovery.rs +++ b/crates/biorouter-cli/src/workflows/secret_discovery.rs @@ -173,6 +173,7 @@ mod tests { ]), knowledge_bases: None, skills: None, + capabilities: None, settings: None, activities: None, author: None, @@ -220,6 +221,7 @@ mod tests { extensions: None, knowledge_bases: None, skills: None, + capabilities: None, settings: None, activities: None, author: None, @@ -269,6 +271,7 @@ mod tests { ]), knowledge_bases: None, skills: None, + capabilities: None, settings: None, activities: None, author: None, @@ -320,6 +323,7 @@ mod tests { }]), knowledge_bases: None, skills: None, + capabilities: None, sub_workflows: Some(vec![SubWorkflow { name: "child-workflow".to_string(), path: "path/to/child.yaml".to_string(), diff --git a/crates/biorouter-crew/src/broker.rs b/crates/biorouter-crew/src/broker.rs index a834fb30e..5e88c7ae4 100644 --- a/crates/biorouter-crew/src/broker.rs +++ b/crates/biorouter-crew/src/broker.rs @@ -347,6 +347,10 @@ struct Cached { /// [`Quotas::dedupe_ttl_secs`]; one cached before this field existed is pruned first. #[serde(default, skip_serializing_if = "Option::is_none")] at: Option, + /// Commit order survives equal or decreasing wall times. Legacy results have no order + /// metadata and are evicted before results cached by this version. + #[serde(default, skip_serializing_if = "Option::is_none")] + sequence: Option, } struct RunPolicyConsent { public_provider: bool, @@ -518,6 +522,10 @@ pub struct Connection { /// 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, + /// The UID this connection is counted under once it made a request, for the host's + /// `account.status` `others_connected` (AO-3). + #[cfg(feature = "join-by-name")] + counted_uid: Option, } impl Connection { pub fn new() -> Self { @@ -855,13 +863,15 @@ const ADMIN_METHODS: [&str; 8] = [ /// 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. Deleting a channel -/// or a team also shrinks the state. -const ACCESS_REMOVAL_METHODS: [&str; 5] = [ +/// or a team also shrinks the state. `device.revoke` (AO-5) is also not counted against the +/// member's journal share, so a person past it can still revoke a lost computer. +const ACCESS_REMOVAL_METHODS: [&str; 6] = [ "run.revoke", "membership.revoke", "channel.archive", "channel.delete", "team.delete", + "device.revoke", ]; /// 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, @@ -953,6 +963,8 @@ const WORKER_HISTORY_BYTES: usize = CONTEXT_MANIFEST_BYTES; /// double it to. Only control characters escape to more. const MESSAGE_ESCAPED_BYTES: usize = 2 * 65_536; +#[cfg(feature = "join-by-name")] +mod account; #[cfg(feature = "join-by-name")] mod join; #[derive(Clone)] @@ -1157,11 +1169,28 @@ fn replay_journal(journal: &File) -> Result { removed_runs, }) } +/// What `start`, `serve` and [`Broker::open`] say when a state directory holds no workspace +/// and they were not asked to create one (AO-7). +const NO_WORKSPACE: &str = "not_found: This state directory holds no workspace. To start a new workspace there, pass --create."; +/// [`NO_WORKSPACE`] unless `root` holds a journal: checked before anything is created there, +/// not even `root` itself. +fn refuse_without_journal(root: &Path) -> Result<()> { + match fs::symlink_metadata(root.join("journal.jsonl")) { + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => bail!(NO_WORKSPACE), + Err(error) => Err(error).context("unsafe_storage: cannot read the journal"), + } +} +/// The workspace state replayed from the journal, or a new workspace when the journal holds +/// none and `may_create` allows one (AO-7: only with `--bootstrap-key` or `--create`). A new +/// workspace stores `bootstrap_key` as given, the empty string when none was: never null or +/// absent, so an older broker still replays it, and `auth.bootstrap` admits nobody then. fn recovered_state( state_value: Value, sequence: u64, bootstrap_key: &str, initial_name: Option<&str>, + may_create: bool, ) -> Result { let recovered = if sequence > 0 { Some( @@ -1187,10 +1216,13 @@ fn recovered_state( s } None => { - let key: [u8; 32] = hex::decode(bootstrap_key)? - .try_into() - .map_err(|_| anyhow!("bootstrap_key must be a 32-byte Ed25519 public key"))?; - VerifyingKey::from_bytes(&key)?; + ensure!(may_create, NO_WORKSPACE); + if !bootstrap_key.is_empty() { + let key: [u8; 32] = hex::decode(bootstrap_key)? + .try_into() + .map_err(|_| anyhow!("bootstrap_key must be a 32-byte Ed25519 public key"))?; + VerifyingKey::from_bytes(&key)?; + } State { workspace: Workspace { id: id(), @@ -1316,17 +1348,45 @@ impl Broker { pub fn state_json(&self) -> Value { serde_json::to_value(&self.state).expect("state serializes") } + /// Create a workspace with no bootstrap key, as `start --create` without `--bootstrap-key` + /// does (AO-7): the key is stored empty, so the host's first device can only come from + /// `auth.account`. An existing workspace is opened as it is. Test builds only. + #[cfg(feature = "test-seams")] + pub fn create_with_directory( + root: &Path, + directory: Box, + ) -> Result { + Self::open_inner_with(root, "", directory, None, true) + } /// Open (or initialize) a workspace. `initial_name` names a workspace this call creates; an - /// existing workspace keeps its stored name. + /// existing workspace keeps its stored name. A workspace is created only when a bootstrap + /// key is given; see [`Broker::open_inner_with`]. fn open_inner( root: &Path, bootstrap_key: &str, directory: Box, initial_name: Option<&str>, + ) -> Result { + let may_create = !bootstrap_key.is_empty(); + Self::open_inner_with(root, bootstrap_key, directory, initial_name, may_create) + } + /// [`Broker::open_inner`], creating a workspace only when `root` holds none and + /// `may_create` (AO-7: `--bootstrap-key` or `--create`). Without it, a directory with no + /// journal is refused [`NO_WORKSPACE`] before anything is created in it, not even the + /// directory itself. + fn open_inner_with( + root: &Path, + bootstrap_key: &str, + directory: Box, + initial_name: Option<&str>, + may_create: bool, ) -> Result { if let Some(name) = initial_name { validate_workspace_name(name).map_err(|error| anyhow!(error.wire()))?; } + if !may_create { + refuse_without_journal(root)?; + } private_dir(root)?; let canonical_root = fs::canonicalize(root)?; for ancestor in canonical_root.ancestors().skip(1) { @@ -1375,7 +1435,13 @@ impl Broker { actor_bytes, removed_runs, } = replay_journal(&journal)?; - let state = recovered_state(state_value, sequence, bootstrap_key, initial_name)?; + let state = recovered_state( + state_value, + sequence, + bootstrap_key, + initial_name, + may_create, + )?; if let Some(torn_bytes) = torn_tail { let tail = root.join(format!("torn-tail-{}", id())); let mut file = private_file(&tail, false)?; @@ -1616,6 +1682,8 @@ impl Broker { if let Some(principal) = connection.principal.take() { self.release_presence(&principal); } + #[cfg(feature = "join-by-name")] + account::connection_closed(self, connection); } /// The active people who are online: a connection they signed on is open, or they made a /// signed request within the presence window. @@ -1654,6 +1722,9 @@ impl Broker { }) } pub fn handle(&mut self, uid: u32, connection: &mut Connection, request: Request) -> Response { + // Counted for the host's `account.status` (AO-3) until `connection_closed`. + #[cfg(feature = "join-by-name")] + account::note_connection(self, uid, connection); let result = self.process(uid, connection, &request); match result { Ok(value) => Response { @@ -1692,6 +1763,12 @@ impl Broker { if let Some(result) = join::pre_auth(self, uid, conn, req) { return result; } + // `auth.account` and `account.status` (AO-2, AO-3) likewise: the kernel UID and (for + // `auth.account`) a signature by the key it adds. + #[cfg(feature = "join-by-name")] + if let Some(result) = account::pre_auth(self, uid, conn, req) { + return result; + } let actor = match self.authenticate_actor(uid, conn, req)? { Admission::Actor(actor) => *actor, Admission::Replay(result) => return Ok(result), @@ -1889,7 +1966,10 @@ impl Broker { let allowance = Allowance { state_headroom: administrative || ACCESS_REMOVAL_METHODS.contains(&method), journal_headroom: administrative, - member: !host, + // A person revoking one of their own devices (AO-5) is never stopped by their + // journal share. Each revoke removes one of their devices, so the records it can + // write are bounded by the devices they hold. + member: !host && method != "device.revoke", }; let committed = (|| -> Result<()> { let share = self.quotas.member_state_bytes; @@ -1927,6 +2007,7 @@ impl Broker { digest: fingerprint, result: cached, at: Some(now), + sequence: Some(self.state.sequence + 1), }, ); self.commit_with(state, &actor.id, &req.method, allowance) @@ -2029,6 +2110,10 @@ impl Broker { .to_bytes(), ); 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, like `state` (AO-4): the broker's package version, for display and for + // setup's decision to restart an older broker. Neither signature covers it, and a + // reader that predates it ignores it. + hello["version"] = json!(env!("CARGO_PKG_VERSION")); // Unsigned: whether this broker is still saving changes, or the host deleted the // workspace and it is about to erase itself. The storage state is for display only; a // member's daemon ends the connection on `deleted` (L7), as it does on the equally @@ -2067,8 +2152,10 @@ impl Broker { "remote_access_v1", "wide_runs_v1", ]; + // `account_identity_v1` (AO-4) comes last, after `join_by_name_v1`, so the v2 signature + // covers it; both are compiled only with `join-by-name`. #[cfg(feature = "join-by-name")] - capabilities.push("join_by_name_v1"); + capabilities.extend(["join_by_name_v1", "account_identity_v1"]); capabilities } fn challenge(&self, uid: u32, conn: &mut Connection, req: &Request) -> Result { @@ -2136,9 +2223,13 @@ impl Broker { let public_key = text(&req.params, "public_key")?; let mut existing_principal_id = None; if req.method == "auth.bootstrap" { + // A workspace created without `--bootstrap-key` stores it empty (AO-7), and an + // empty stored key admits nobody: the host's first device then comes from + // `auth.account`. ensure!( uid == self.state.workspace.host_uid && self.state.devices.is_empty() + && !self.state.bootstrap_key.is_empty() && public_key == self.state.bootstrap_key, "forbidden: bootstrap key/identity mismatch or already enrolled" ); @@ -2609,7 +2700,8 @@ impl Broker { // Stored only when open; said either way here, so a client never has to infer it. workspace["governance"] = json!(s.workspace.governance); let principals = self.snapshot_principals(s, &index, host); - let actor_wire = Self::actor_wire(s, &index, &actor.id); + let signing_device = req.auth.as_ref().map(|auth| auth.device_id.as_str()); + let actor_wire = Self::actor_wire(s, &index, &actor.id, signing_device); // The actor's live runs only (a revoked, expired or superseded run grants nothing), // newest first. let mut runs: Vec<&Run> = s @@ -2955,9 +3047,15 @@ impl Broker { (positions, unread) } /// The actor's own principal with its display name and its devices - /// (`{fingerprint, added_at, added_via}`; the fingerprint is the grouped first 16 hex digits - /// of the device ID). Nobody else's devices are ever projected. - fn actor_wire(s: &State, index: &PeopleIndex, actor_id: &str) -> Option { + /// (`{fingerprint, added_at, added_via, this_device}`; the fingerprint is the grouped first + /// 16 hex digits of the device ID, and `this_device` says whether it is `signing_device`, + /// the key that signed this request, AO-5). Nobody else's devices are ever projected. + fn actor_wire( + s: &State, + index: &PeopleIndex, + actor_id: &str, + signing_device: Option<&str>, + ) -> Option { s.principals.get(actor_id).map(|principal| { let mut wire = index.principal_wire(principal); wire["devices"] = Value::Array( @@ -2969,6 +3067,7 @@ impl Broker { "fingerprint": crate::invitation::grouped_fingerprint(device_id), "added_at": device.added_at, "added_via": device.added_via, + "this_device": signing_device == Some(device_id.as_str()), }) }) .collect(), @@ -3414,6 +3513,10 @@ impl Broker { "blob.begin" => self.mutate_blob_begin(s, actor, req), "blob.chunk" => self.mutate_blob_chunk(s, actor, req), "blob.finish" => self.mutate_blob_finish(s, actor, req), + // AO-5: compiled with the rest of the account path, so a broker without it answers + // as an older one does. + #[cfg(feature = "join-by-name")] + "device.revoke" => account::revoke_device(s, actor, req), _ => bail!("unsupported: operation is not supported by this broker"), } } @@ -5222,19 +5325,27 @@ fn remember(s: &mut State, quotas: &Quotas, actor: &str, key: String, cached: Ca 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 + let mut own: Vec<_> = 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))) + .map(|(k, c)| { + ( + k.starts_with(&human), + c.sequence, + 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 mut bytes = own.iter().fold(0usize, |total, (_, _, _, _, size)| { + total.saturating_add(*size) + }); let incoming = size(&key, &cached); - for (_, _, old, size) in own { + for (_, _, _, old, size) in own { if count < quotas.dedupe_actor_entries && bytes.saturating_add(incoming) <= quotas.dedupe_actor_bytes { @@ -5245,14 +5356,17 @@ fn remember(s: &mut State, quotas: &Quotas, actor: &str, key: String, cached: Ca 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(); + let mut all: Vec<(Option, Option, String)> = s + .dedupe + .iter() + .map(|(k, c)| (c.sequence, 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) { + for (_, _, old) in all.into_iter().take(excess) { s.dedupe.remove(&old); } } @@ -6012,8 +6126,10 @@ impl Drop for ConnectionPermit { } /// Run the broker in the foreground. `name` names a workspace this call initializes, or an /// existing workspace that has no name yet; a workspace already named otherwise is refused -/// (rename it with `workspace.rename`). -pub fn serve(root: &Path, bootstrap_key: &str, name: Option<&str>) -> Result<()> { +/// (rename it with `workspace.rename`). A workspace is created only when `root` holds none and +/// `bootstrap_key` is given or `create` is set (AO-7); otherwise an empty `root` is refused +/// `not_found`. +pub fn serve(root: &Path, bootstrap_key: &str, name: Option<&str>, create: bool) -> Result<()> { ensure!( cfg!(target_os = "linux"), "unsupported: serve requires Linux" @@ -6029,22 +6145,15 @@ pub fn serve(root: &Path, bootstrap_key: &str, name: Option<&str>) -> Result<()> return Ok(()); } let node_id = node_identity()?; - let mut broker = Broker::open_inner(root, bootstrap_key, Box::new(SystemDirectory), name)?; + let (create, directory) = ( + create || !bootstrap_key.is_empty(), + Box::new(SystemDirectory), + ); + let mut broker = Broker::open_inner_with(root, bootstrap_key, directory, name, create)?; if broker.state.workspace.deleted_at.is_some() { // The broker stopped between the tombstone and the erase (OW-5). Nobody can be // connected to a broker that is starting, so the erase is finished now. - let workspace_id = broker.state.workspace.id.clone(); - let runtime = broker.state.runtime_basename.clone(); - let leftovers = erase_store(&broker.root, &workspace_id)?; - if let Some(basename) = runtime { - let uid = broker.state.workspace.host_uid; - retire_runtime(&broker.runtime_root, uid, &basename, &workspace_id); - } - println!( - "{}", - json!({"deleted": true, "workspace_id": workspace_id, "leftovers": leftovers}) - ); - return Ok(()); + return erase_tombstoned(&broker); } if let Some(name) = name { match broker.state.workspace.name.as_deref() { @@ -6076,7 +6185,9 @@ pub fn serve(root: &Path, bootstrap_key: &str, name: Option<&str>) -> Result<()> .try_into() .map_err(|_| anyhow!("storage_corrupt: workspace identity"))?; let public_key = SigningKey::from_bytes(&secret).verifying_key().to_bytes(); - let info = json!({"pid":std::process::id(),"socket":socket,"workspace_id":broker.workspace().id,"host_uid":uid,"protocol":1,"node_id":node_id,"workspace_public_key":hex::encode(public_key),"workspace_key_fingerprint":digest(&public_key)}); + // `version` (AO-4) is the broker's package version, for `workspaces` and setup to tell a + // workspace still running an older program. A reader that predates it ignores it. + let info = json!({"pid":std::process::id(),"socket":socket,"workspace_id":broker.workspace().id,"host_uid":uid,"protocol":1,"node_id":node_id,"workspace_public_key":hex::encode(public_key),"workspace_key_fingerprint":digest(&public_key),"version":env!("CARGO_PKG_VERSION")}); let state_root = broker.root.clone(); let root = state_root.as_path(); write_runtime(root, &info)?; @@ -6116,6 +6227,22 @@ pub fn serve(root: &Path, bootstrap_key: &str, name: Option<&str>) -> Result<()> } Ok(()) } +/// Finish erasing a workspace whose broker stopped after its tombstone went in (OW-5), and +/// print what was erased. +fn erase_tombstoned(broker: &Broker) -> Result<()> { + let workspace_id = broker.state.workspace.id.clone(); + let runtime = broker.state.runtime_basename.clone(); + let leftovers = erase_store(&broker.root, &workspace_id)?; + if let Some(basename) = runtime { + let uid = broker.state.workspace.host_uid; + retire_runtime(&broker.runtime_root, uid, &basename, &workspace_id); + } + println!( + "{}", + json!({"deleted": true, "workspace_id": workspace_id, "leftovers": leftovers}) + ); + Ok(()) +} /// 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 @@ -6642,7 +6769,19 @@ pub fn bridge(socket: &Path, owner: u32, workspace: &str) -> Result<()> { 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(); - relay(&mut input, &mut output, stream, reader, &reconnect).map_err(|error| { + // A bridge started inside a full-access job carries the mark its supervisor set + // (accident prevention, see `relay_from`). + let inside_job = std::env::var_os(crate::remote_full::JOB_ENVIRONMENT) + .is_some_and(|value| !value.is_empty()); + relay_from( + &mut input, + &mut output, + stream, + reader, + &reconnect, + inside_job, + ) + .map_err(|error| { // A broker that went because its host deleted the workspace left the tombstone in its // directory before it stopped: say so, so a member connected through the grace period // learns it now (L7). @@ -6696,12 +6835,33 @@ const BROKER_GONE: &str = /// `bridge.*` requests are the bridge's own (design OW-6, OW-8) and are answered here, never /// forwarded, an unknown one with a local refusal. `reconnect` opens the second broker /// connection a full-access transfer runs on. +#[cfg(test)] fn relay( + input: &mut BufReader, + output: &mut impl Write, + stream: UnixStream, + reader: BufReader, + reconnect: &crate::remote_full::Connect, +) -> Result<()> { + relay_from(input, output, stream, reader, reconnect, false) +} +/// The methods a bridge running inside a full-access job never forwards: adding a device for +/// the account and revoking one (account ownership, Security position). +const JOB_REFUSED_METHODS: [&str; 2] = ["auth.account", "device.revoke"]; +/// What such a bridge answers them, under the code `agent_session_refused`. +const AGENT_SESSION_REFUSED: &str = "agent_session_refused: An agent's job on the server cannot add or remove your computers. Do this from Biorouter on your computer."; +/// [`relay`] for a bridge that may run inside a full-access job (`inside_job`, from the mark +/// the job supervisor puts in each job's environment). Such a bridge answers +/// [`JOB_REFUSED_METHODS`] itself with [`AGENT_SESSION_REFUSED`] and never forwards them. This +/// is accident prevention, not a boundary: a job that opens the broker's socket itself is not +/// stopped. +fn relay_from( input: &mut BufReader, output: &mut impl Write, mut stream: UnixStream, mut reader: BufReader, reconnect: &crate::remote_full::Connect, + inside_job: bool, ) -> Result<()> { loop { if input.buffer().is_empty() { @@ -6720,7 +6880,19 @@ fn relay( return Ok(()); }; let request: Request = serde_json::from_slice(&frame)?; - if request.method.starts_with("bridge.") { + if inside_job && JOB_REFUSED_METHODS.contains(&request.method.as_str()) { + write_response( + output, + Response { + id: request.id, + result: None, + error: Some(ProtocolError { + code: "agent_session_refused".into(), + message: AGENT_SESSION_REFUSED.into(), + }), + }, + )?; + } else if request.method.starts_with("bridge.") { let response = match crate::probe::bridge_method(&request.method) { Ok(value) => Response { id: request.id, @@ -6874,11 +7046,21 @@ fn write_not_delivered(output: &mut impl Write, frame: &[u8]) -> Result<()> { output.flush()?; Ok(()) } +// Starting and restarting a workspace by its ID (AO-7), and listing the workspaces on this +// server for this account (AO-6). Both are children of this module so they use its helpers +// as they are; their entry points are associated functions of `Broker`. (`by_id` is named +// apart from the `lifecycle` function so the crate's re-export of that function is plain.) +#[path = "broker/lifecycle.rs"] +mod by_id; +#[path = "workspaces.rs"] +mod workspaces; + /// `start`, `status` or `stop` a broker for the state directory `root`. `name` (`start` only) -/// names the workspace; see [`serve`]. +/// names the workspace; see [`serve`]. `start` creates a workspace only when `key` is given; +/// [`Broker::start_state_dir`] also takes `--create`. pub fn lifecycle(command: &str, root: &Path, key: &str, name: Option<&str>) -> Result { match command { - "start" => start(root, key, name), + "start" => start(root, key, name, false, &std::env::current_exe()?), "status" => { let info = existing_runtime_descriptor(root)?.ok_or_else(|| anyhow!(NOT_RUNNING))?; let socket = PathBuf::from(text(&info, "socket")?); @@ -7035,14 +7217,69 @@ fn status_answer(root: &Path, mut info: Value, hello: &Value) -> Result { /// How long `start` waits for the broker it launched to answer `hello`. const START_READY_TIMEOUT: Duration = Duration::from_secs(15); -/// Launch `serve` detached, wait until it answers `hello`, and print the workspace's -/// invitation line (`brcrew1:…`) for the host to paste into Crew. A name is validated and -/// checked against the host account's running sibling workspaces **before** anything is -/// spawned. -fn start(root: &Path, key: &str, name: Option<&str>) -> Result { +/// Whether `root` holds a journal with something in it: a workspace `start` would open rather +/// than create. +fn holds_journal(root: &Path) -> bool { + fs::symlink_metadata(root.join("journal.jsonl")) + .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) +} + +/// What `start` launches: `program serve --state-dir root`, in a session of its own, with +/// `--bootstrap-key`, `--create` and `--name` only when given (`serve` creates a workspace only +/// with a key or `--create`). +fn serve_command( + program: &Path, + root: &Path, + key: &str, + name: Option<&str>, + create: bool, +) -> std::process::Command { + use std::os::unix::process::CommandExt; + let mut command = std::process::Command::new(program); + unsafe { + command.pre_exec(|| { + if libc::setsid() < 0 { + return Err(std::io::Error::last_os_error()); + } + // `restart` ignores SIGHUP while it runs; the broker it starts does not inherit that. + libc::signal(libc::SIGHUP, libc::SIG_DFL); + Ok(()) + }); + } + command.arg("serve").arg("--state-dir").arg(root); + if !key.is_empty() { + command.arg("--bootstrap-key").arg(key); + } + if create { + command.arg("--create"); + } + if let Some(name) = name { + command.arg("--name").arg(name); + } + command +} + +/// Launch `program` as `serve`, detached, wait until it answers `hello`, and print the +/// workspace's invitation line (`brcrew1:…`) for the host to paste into Crew. A name is +/// validated and checked against the host account's running sibling workspaces **before** +/// anything is spawned. +/// +/// A new workspace is created only when `root` holds none and `key` is given or `create` is +/// set (AO-7); otherwise such a `root` is refused [`NO_WORKSPACE`] and nothing is created, not +/// even the directory. The answer carries `created: true` when this start wrote a new journal. +fn start( + root: &Path, + key: &str, + name: Option<&str>, + create: bool, + program: &Path, +) -> Result { if let Some(name) = name { validate_workspace_name(name).map_err(|error| anyhow!(error.wire()))?; } + let existed = holds_journal(root); + let create = create || !key.is_empty(); + ensure!(existed || create, NO_WORKSPACE); private_dir(root)?; // A deleted workspace an erase did not finish is never started; `serve` would finish the // erase, which is `delete`'s to say. @@ -7076,26 +7313,7 @@ fn start(root: &Path, key: &str, name: Option<&str>) -> Result { .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()?); - unsafe { - command.pre_exec(|| { - if libc::setsid() < 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - command - .arg("serve") - .arg("--state-dir") - .arg(root) - .arg("--bootstrap-key") - .arg(key); - if let Some(name) = name { - command.arg("--name").arg(name); - } - let mut child = command + let mut child = serve_command(program, root, key, name, create && !existed) .stdin(std::process::Stdio::null()) .stdout(log.try_clone()?) .stderr(log) @@ -7118,6 +7336,7 @@ fn start(root: &Path, key: &str, name: Option<&str>) -> Result { "status_command": "status", "workspace_id": hello["workspace_id"], "name": hello["name"], + "created": !existed, }); if previous_socket .as_ref() @@ -7140,7 +7359,10 @@ fn start(root: &Path, key: &str, name: Option<&str>) -> Result { } } if Instant::now() >= deadline { - return Ok(json!({"started_pid":pid,"state":"starting","status_command":"status"})); + let created = !existed && holds_journal(root); + return Ok( + json!({"started_pid":pid,"state":"starting","status_command":"status","created":created}), + ); } std::thread::sleep(Duration::from_millis(100)); } @@ -8277,3 +8499,167 @@ mod bridge_tests { assert_eq!(ids, vec![json!("one"), json!("two")]); } } + +#[cfg(test)] +mod dedupe_eviction_tests { + use super::*; + + fn state() -> State { + recovered_state(Value::Null, 0, "", None, true).unwrap() + } + + fn quotas(actor_entries: usize, dedupe_entries: usize) -> Quotas { + let mut quotas = Quotas::STANDARD; + quotas.dedupe_actor_entries = actor_entries; + quotas.dedupe_entries = dedupe_entries; + quotas + } + + fn cached(at: u64, sequence: u64) -> Cached { + serde_json::from_value(json!({ + "digest": format!("digest-{sequence}"), + "result": {"sequence": sequence}, + "at": at, + "sequence": sequence, + })) + .unwrap() + } + + #[test] + fn actor_quota_evicts_the_oldest_commit_when_timestamps_tie() { + let actor = "actor"; + let mut state = state(); + let quotas = quotas(2, 100); + remember( + &mut state, + "as, + actor, + "actor:human:z-old".to_string(), + cached(7, 1), + ); + remember( + &mut state, + "as, + actor, + "actor:human:a-new".to_string(), + cached(7, 2), + ); + remember( + &mut state, + "as, + actor, + "actor:human:m-last".to_string(), + cached(7, 3), + ); + + assert!(!state.dedupe.contains_key("actor:human:z-old")); + assert!(state.dedupe.contains_key("actor:human:a-new")); + assert!(state.dedupe.contains_key("actor:human:m-last")); + } + + #[test] + fn workspace_quota_evicts_the_oldest_commit_when_timestamps_tie() { + let mut state = state(); + let quotas = quotas(100, 2); + remember( + &mut state, + "as, + "z-old-actor", + "z-old-actor:human:z-old".to_string(), + cached(7, 1), + ); + remember( + &mut state, + "as, + "a-new-actor", + "a-new-actor:human:a-new".to_string(), + cached(7, 2), + ); + remember( + &mut state, + "as, + "m-last-actor", + "m-last-actor:human:m-last".to_string(), + cached(7, 3), + ); + + assert!(!state.dedupe.contains_key("z-old-actor:human:z-old")); + assert!(state.dedupe.contains_key("a-new-actor:human:a-new")); + assert!(state.dedupe.contains_key("m-last-actor:human:m-last")); + } + + #[test] + fn actor_quota_keeps_human_results_ahead_of_newer_agent_results() { + let actor = "actor"; + let mut state = state(); + let quotas = quotas(2, 100); + remember( + &mut state, + "as, + actor, + "actor:run-1:agent".to_string(), + cached(7, 3), + ); + remember( + &mut state, + "as, + actor, + "actor:human:z-old".to_string(), + cached(7, 1), + ); + remember( + &mut state, + "as, + actor, + "actor:human:a-new".to_string(), + cached(7, 2), + ); + + assert!(!state.dedupe.contains_key("actor:run-1:agent")); + assert!(state.dedupe.contains_key("actor:human:z-old")); + assert!(state.dedupe.contains_key("actor:human:a-new")); + } + + #[test] + fn legacy_entries_are_evicted_before_known_sequences_even_with_newer_timestamps() { + let actor = "actor"; + let mut state = state(); + let quotas = quotas(1, 100); + let legacy: Cached = serde_json::from_value(json!({ + "digest": "legacy", + "result": {"legacy": true}, + "at": 99, + })) + .unwrap(); + remember( + &mut state, + "as, + actor, + "actor:human:legacy".to_string(), + legacy, + ); + remember( + &mut state, + "as, + actor, + "actor:human:known".to_string(), + cached(7, 3), + ); + + assert!(!state.dedupe.contains_key("actor:human:legacy")); + assert!(state.dedupe.contains_key("actor:human:known")); + } + + #[test] + fn cached_result_without_order_metadata_round_trips_without_the_new_field() { + let legacy: Cached = serde_json::from_value(json!({ + "digest": "legacy", + "result": {"ok": true}, + "at": 7, + })) + .unwrap(); + let encoded = serde_json::to_value(legacy).unwrap(); + + assert!(encoded.get("sequence").is_none()); + } +} diff --git a/crates/biorouter-crew/src/broker/account.rs b/crates/biorouter-crew/src/broker/account.rs new file mode 100644 index 000000000..197fd409f --- /dev/null +++ b/crates/biorouter-crew/src/broker/account.rs @@ -0,0 +1,570 @@ +//! Account ownership (AO-1 to AO-5 of `docs/crew/design/account-ownership.md`), compiled only +//! with the `join-by-name` feature, beside `join.rs`, whose checks its invited branch reuses. +//! +//! The server account is the person: a process running as an account may add a device for that +//! account by itself (`auth.account`), with no invitation code and no other device. Every +//! request is still authenticated twice, by the kernel UID and by a device signature; what +//! changes is only how a device key is admitted. `broker.rs` calls exactly these functions: +//! +//! - [`pre_auth`]: in `Broker::process`, after the deleted-workspace gate and before +//! `authenticate_actor`, for the pre-authentication methods `auth.account` and +//! `account.status`. `None` falls through to authentication. +//! - [`revoke_device`]: `device.revoke` in `Broker::mutate`, a person's signed mutation. +//! - [`note_connection`] and [`connection_closed`]: `Broker::handle` and +//! `Broker::connection_closed`, so `account.status` can tell the host how many connections +//! other accounts hold (`others_connected`). +//! - `hello` advertises `account_identity_v1` whenever this module is compiled. +//! +//! `auth.account` decides its branch from state alone, so a caller the workspace knows nothing +//! about costs no account lookup, and makes exactly one lookup (`by_uid`) once the key is +//! proved. `account.status` makes none. + +use super::*; + +/// How a device bound by `auth.account` is labeled in `actor.devices`. +const ADDED_VIA: &str = "account"; + +/// The account the broker could not look up, or one whose name the invitation rules refuse. +const ACCOUNT_UNAVAILABLE: &str = "identity_unavailable: This server can't look up your account. Try again, or ask the server's administrator."; +const NAME_UNUSABLE: &str = + "identity_unavailable: This account's name on the server can't be used with Crew."; +/// `replaces_device_id` names a device of another principal. +const REPLACES_ANOTHER: &str = + "forbidden: The computer to replace belongs to someone else. Nothing was changed."; +/// `device.revoke` names no device, or more than one, of the caller's own. +const NO_SUCH_DEVICE: &str = + "invalid_params: None of your computers has that fingerprint. Check it in Keys and try again."; +const BAD_FINGERPRINT: &str = + "invalid_params: fingerprint must be the 16 hex digits of a computer's fingerprint, as Keys shows it"; +const AGENT_REFUSED: &str = "forbidden: only a person can remove a computer"; + +/// In-memory state of the account path, never journaled: a restart forgets it, which only +/// resets a quota allowance and the connection counts (every connection is new then too). +#[derive(Default)] +pub(super) struct Runtime { + /// Per principal, when `auth.account` last committed past the member's journal share. + over_share: BTreeMap, + /// Open connections per UID that have made at least one request. + connections: BTreeMap, +} + +impl Runtime { + /// Whether `principal` may commit one `auth.account` past their journal share at `now`. + fn may_pass_share(&self, principal: &str, now: u64) -> bool { + self.over_share + .get(principal) + .is_none_or(|at| now.saturating_sub(*at) >= DAY_SECS) + } +} + +/// `auth.account` and `account.status`, before authentication. Any other method: `None`. +pub(super) fn pre_auth( + broker: &mut Broker, + uid: u32, + conn: &mut Connection, + req: &Request, +) -> Option> { + match req.method.as_str() { + "auth.account" => Some(claim(broker, uid, conn, req)), + "account.status" => Some(Ok(status(broker, uid))), + _ => None, + } +} + +/// `connection` made a request as `uid`: count it once, for the host's `others_connected`. +pub(super) fn note_connection(broker: &mut Broker, uid: u32, connection: &mut Connection) { + if connection.counted_uid.is_none() { + connection.counted_uid = Some(uid); + *broker + .join_runtime + .account + .connections + .entry(uid) + .or_default() += 1; + } +} + +/// `connection` has closed: it no longer counts. +pub(super) fn connection_closed(broker: &mut Broker, connection: &mut Connection) { + let Some(uid) = connection.counted_uid.take() else { + return; + }; + let connections = &mut broker.join_runtime.account.connections; + if let Some(count) = connections.get_mut(&uid) { + *count = count.saturating_sub(1); + if *count == 0 { + connections.remove(&uid); + } + } +} + +/// The workspace as a refusal names it. +fn workspace_label(s: &State) -> String { + s.workspace + .name + .clone() + .unwrap_or_else(|| "this workspace".to_owned()) +} + +/// The host's active principal. +fn host_principal(s: &State) -> Option<&Principal> { + s.principals + .values() + .find(|p| p.active && p.uid == s.workspace.host_uid) +} + +/// `account.status {}`: what the workspace knows about the peer UID, from state alone (no +/// account lookup, no write). It never answers about another account. +fn status(broker: &Broker, uid: u32) -> Value { + let s = &broker.state; + let active = s.principals.values().find(|p| p.active && p.uid == uid); + let join = s + .pending_joins + .get(&uid.to_string()) + .filter(|join| join.uid == uid); + let devices = |principal: Option<&Principal>| { + principal.map_or(0, |principal| { + s.devices + .values() + .filter(|device| device.principal_id == principal.id) + .count() + }) + }; + let mut status = json!({"workspace_name": s.workspace.name}); + if let Some(host) = host_principal(s) { + status["host_username"] = json!(host.username); + } + if uid == s.workspace.host_uid { + status["role"] = json!("host"); + if let Some(principal) = active { + status["username"] = json!(principal.username); + } + status["devices"] = json!(devices(active)); + let others: usize = broker + .join_runtime + .account + .connections + .iter() + .filter(|(other, _)| **other != uid) + .map(|(_, count)| *count) + .sum(); + status["others_connected"] = json!(others); + } else if let Some(principal) = active { + status["role"] = json!("member"); + status["username"] = json!(principal.username); + status["devices"] = json!(devices(active)); + } else if let Some(join) = join { + status["role"] = json!("invited"); + status["username"] = json!(join.username); + if let Some(inviter) = s.principals.get(&join.inviter_id) { + let index = PeopleIndex::new(s); + status["invited_by"] = json!({ + "username": inviter.username, + "display_name": index.display_name(inviter), + }); + } + status["expires_at"] = json!(join.expires_at); + status["expired"] = json!(join.is_expired(now())); + } else if let Some(principal) = s.principals.values().find(|p| p.uid == uid) { + status["role"] = json!("removed"); + status["username"] = json!(principal.username); + } else { + status["role"] = json!("none"); + } + status +} + +/// Which way `auth.account` admits the caller, decided from state alone. +enum Branch { + /// An active principal holds the UID: a member, or the host when the UID is `host_uid`. + Member(Principal), + /// The UID is `host_uid` and no principal has ever held it. + HostFirst, + /// A live pending join for the UID. + Invited(PendingJoin), +} + +/// AO-2 step 1, in its order. A UID the workspace never knew, or knew and removed, is refused +/// here, before any signature check or account lookup. +fn branch(s: &State, uid: u32, now: u64) -> Result { + if let Some(principal) = s.principals.values().find(|p| p.active && p.uid == uid) { + return Ok(Branch::Member(principal.clone())); + } + if uid == s.workspace.host_uid && !s.principals.values().any(|p| p.uid == uid) { + return Ok(Branch::HostFirst); + } + if let Some(join) = s + .pending_joins + .get(&uid.to_string()) + .filter(|join| join.uid == uid && !join.is_expired(now)) + { + return Ok(Branch::Invited(join.clone())); + } + let workspace = workspace_label(s); + if s.principals.values().any(|p| p.uid == uid) { + bail!("removed: You were removed from {workspace}. Ask a member to invite you again."); + } + // `scripts/check-crew-broker-join.sh` reads this sentence's first words as the marker that + // a built broker carries the account path; change the script with it. + bail!("not_member: This account is not a member of {workspace}. Ask a member to invite it.") +} + +/// An optional string parameter; absent or `null` is `None`. +fn optional_text<'a>(params: &'a Value, key: &str) -> Result> { + match params.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) if !value.is_empty() && value.len() <= 128 => { + Ok(Some(value.as_str())) + } + Some(_) => bail!("invalid_params: {key} must be a device ID"), + } +} + +/// `auth.account {public_key, replaces_device_id?}`: a computer adds itself for the account it +/// runs as (AO-2). Any refusal writes nothing. +fn claim(broker: &mut Broker, uid: u32, conn: &mut Connection, req: &Request) -> Result { + ensure!( + req.credential.is_none(), + "invalid_request: mixed authentication" + ); + let now = now(); + let public_key = text(&req.params, "public_key")?; + let replaces = optional_text(&req.params, "replaces_device_id")?; + // 1. The branch, from state alone. + let branch = branch(&broker.state, uid, now)?; + // 2. Possession of the key, over a single-use, socket-bound, 60-second nonce. + broker.verify(uid, conn, req, public_key)?; + let key: [u8; 32] = hex::decode(public_key)? + .try_into() + .map_err(|_| anyhow!("unauthorized: invalid key"))?; + let device_id = digest(&key); + // 3. The one account lookup. + let account = broker + .directory + .by_uid(uid) + .map_err(|_| anyhow!(ACCOUNT_UNAVAILABLE))?; + ensure!(account.uid == uid, ACCOUNT_UNAVAILABLE); + join::check_username_characters(&account.name).map_err(|_| anyhow!(NAME_UNUSABLE))?; + // 4. The branch's own checks. + let mut state = broker.state.clone(); + join::prune_expired(&mut state, now); + let host_uid = state.workspace.host_uid; + let (mut state, principal_id, admitted, changed) = + admit(broker, state, branch, uid, host_uid, &account)?; + // 5. The key. One already bound to this principal is a retry: it commits nothing but a + // host rename this request found or an add-device join it consumed. + if let Some(device) = state.devices.get(&device_id) { + ensure!( + device.principal_id == principal_id, + "device_conflict: this device key is already enrolled in this workspace; use a new device key" + ); + if changed { + commit(broker, state, &principal_id, uid == host_uid, now)?; + } + return Ok(answer(broker, &principal_id, &device_id, "device", None)); + } + if let Some(old) = replaces { + match state.devices.get(old) { + Some(device) if device.principal_id == principal_id => { + state.devices.remove(old); + } + Some(_) => bail!(REPLACES_ANOTHER), + None => {} + } + } + // 6. The cap: the oldest device makes room, so nobody is refused for too many computers. + let evicted = evict_oldest(&mut state, &principal_id); + state.devices.insert( + device_id.clone(), + Device { + principal_id: principal_id.clone(), + public_key: public_key.into(), + added_at: Some(now), + added_via: Some(ADDED_VIA.into()), + }, + ); + // 7. Commit, as the principal. + commit(broker, state, &principal_id, uid == host_uid, now)?; + broker.join_runtime.forget_stale(&broker.state); + Ok(answer(broker, &principal_id, &device_id, admitted, evicted)) +} + +/// AO-2 step 4: the branch's own checks, made on `state`. Returns `state` with the caller +/// admitted, the principal's ID, how it was admitted, and whether the record changes anything +/// besides the device (a followed host rename, or an add-device join it consumes). +fn admit( + broker: &Broker, + mut state: State, + branch: Branch, + uid: u32, + host_uid: u32, + account: &Account, +) -> Result<(State, String, &'static str, bool)> { + let mut changed = false; + let (principal_id, admitted) = match branch { + Branch::Member(member) => { + if member.username != account.name { + // Only the account the broker runs as is followed through a rename; a member + // keeps `account_changed`, which guards against a recycled UID. + ensure!( + uid == host_uid, + "account_changed: This server account is no longer named @{0}, the name it joined with. Ask the host to remove @{0} and invite you again.", + member.username + ); + Broker::ensure_username_free(&state, uid, &account.name).map_err(|_| { + host_rename_conflict(&state, uid, &member.username, &account.name) + })?; + let principal = state + .principals + .get_mut(&member.id) + .expect("the branch's principal"); + if principal.nickname == principal.username { + principal.nickname = account.name.clone(); + } + principal.username = account.name.clone(); + changed = true; + } + // A member reaches this branch before the invited one, so an add-device join for + // this principal ends here, in the same record that adds the device. + changed |= consume_add_device_join(&mut state, uid, &member.id); + (member.id, "device") + } + Branch::HostFirst => { + Broker::ensure_username_free(&state, uid, &account.name)?; + let principal = Principal { + id: id(), + uid, + username: account.name.clone(), + nickname: account.name.clone(), + avatar: None, + active: true, + }; + state + .principals + .insert(principal.id.clone(), principal.clone()); + admit_everywhere(&mut state, &principal.id); + (principal.id, "host") + } + Branch::Invited(pending) => { + // `auth.join`'s checks 2, 3 and 3b, without the code. + ensure!(account.name == pending.username, join::ACCOUNT_CHANGED); + join::check_generation(&broker.state, &pending)?; + ensure!( + join::inviter_holds_authority(broker, &broker.state, &pending), + join::INVITER_CHANGED + ); + Broker::ensure_username_free(&state, uid, &pending.username)?; + let principal_id = match &pending.existing_principal_id { + Some(existing) => { + ensure!( + state + .principals + .get(existing) + .is_some_and(|p| p.active && p.uid == uid), + join::MEMBERSHIP_CHANGED + ); + existing.clone() + } + None => { + let principal = Principal { + id: id(), + uid, + username: pending.username.clone(), + nickname: pending.username.clone(), + avatar: None, + active: true, + }; + state + .principals + .insert(principal.id.clone(), principal.clone()); + admit_everywhere(&mut state, &principal.id); + principal.id + } + }; + state.pending_joins.remove(&uid.to_string()); + state + .enrollments + .retain(|_, enrollment| enrollment.uid != uid); + (principal_id, "invitation") + } + }; + Ok((state, principal_id, admitted, changed)) +} + +/// AO-2 step 4 for a member: an add-device join selecting `principal_id` is removed, with any +/// legacy enrollment for the UID, as the invited branch removes a join it admits. Whether one +/// was. +fn consume_add_device_join(state: &mut State, uid: u32, principal_id: &str) -> bool { + let key = uid.to_string(); + let selects_this_principal = state.pending_joins.get(&key).is_some_and(|join| { + join.uid == uid && join.existing_principal_id.as_deref() == Some(principal_id) + }); + if selects_this_principal { + state.pending_joins.remove(&key); + state + .enrollments + .retain(|_, enrollment| enrollment.uid != uid); + } + selects_this_principal +} + +/// D3's refusal of a followed host rename, in words the host can act on. Every device of the +/// host stops authenticating once its account is renamed, so the host can remove the other +/// member only after renaming the account back. +fn host_rename_conflict(state: &State, uid: u32, old: &str, new: &str) -> anyhow::Error { + let other = state + .principals + .values() + .find(|p| p.active && p.uid != uid && names_collide(&p.username, new)) + .map_or(new, |p| p.username.as_str()); + let workspace = workspace_label(state); + anyhow!( + "identity_conflict: Crew can't follow this account's new name, @{new}, because @{other} already uses it in {workspace}. Rename the account back to @{old} on the server and remove @{other} from {workspace} before renaming it again." + ) +} + +/// When `principal_id` already holds [`MAX_DEVICES_PER_PRINCIPAL`] devices or more, remove the +/// one added longest ago (a device without `added_at` counts as oldest) and describe it. +fn evict_oldest(state: &mut State, principal_id: &str) -> Option { + let own: Vec<(&String, &Device)> = state + .devices + .iter() + .filter(|(_, device)| device.principal_id == principal_id) + .collect(); + if own.len() < MAX_DEVICES_PER_PRINCIPAL { + return None; + } + let (oldest, device) = own + .into_iter() + .min_by_key(|(id, device)| (device.added_at.is_some(), device.added_at, *id))?; + let (oldest, added_at) = (oldest.clone(), device.added_at); + state.devices.remove(&oldest); + Some(json!({ + "fingerprint": crate::invitation::grouped_fingerprint(&oldest), + "added_at": added_at, + })) +} + +/// Commit an `auth.account` record as `principal_id`. The host's uses the administrative +/// allowance. A member's uses the state's headroom and counts toward their journal share; once +/// that share is used up, one record per principal per day is still admitted, so a member can +/// always get reading back on a new computer. +fn commit( + broker: &mut Broker, + state: State, + principal_id: &str, + host: bool, + now: u64, +) -> Result<()> { + if host { + return broker.commit(state, principal_id, "auth.account"); + } + let allowance = Allowance { + state_headroom: true, + journal_headroom: false, + member: true, + }; + if !broker + .join_runtime + .account + .may_pass_share(principal_id, now) + { + return broker.commit_with(state, principal_id, "auth.account", allowance); + } + match broker.commit_with(state.clone(), principal_id, "auth.account", allowance) { + Err(error) if error.to_string() == MEMBER_JOURNAL_QUOTA => { + let past_share = Allowance { + member: false, + ..allowance + }; + broker.commit_with(state, principal_id, "auth.account", past_share)?; + broker + .join_runtime + .account + .over_share + .insert(principal_id.to_owned(), now); + Ok(()) + } + committed => committed, + } +} + +/// `{principal: {id, username, display_name}, device_id, workspace, role, admitted, +/// evicted_device?}`, from the committed state. +fn answer( + broker: &Broker, + principal_id: &str, + device_id: &str, + admitted: &str, + evicted: Option, +) -> Value { + let s = &broker.state; + let principal = s.principals.get(principal_id); + let display_name = principal.map(|principal| PeopleIndex::new(s).display_name(principal)); + let host = principal.is_some_and(|principal| principal.uid == s.workspace.host_uid); + let mut answer = json!({ + "principal": { + "id": principal_id, + "username": principal.map(|principal| principal.username.as_str()), + "display_name": display_name, + }, + "device_id": device_id, + "workspace": s.workspace, + "role": if host { "host" } else { "member" }, + "admitted": admitted, + }); + if let Some(evicted) = evicted { + answer["evicted_device"] = evicted; + } + answer +} + +/// The grouped fingerprint a person typed or copied: exactly 16 hex digits, spaces allowed +/// between them, any case. +fn fingerprint_param(params: &Value) -> Result { + let typed = text(params, "fingerprint").map_err(|_| anyhow!(BAD_FINGERPRINT))?; + let digits = typed.chars().filter(char::is_ascii_hexdigit).count(); + ensure!( + digits == 16 && typed.chars().all(|c| c.is_ascii_hexdigit() || c == ' '), + BAD_FINGERPRINT + ); + Ok(crate::invitation::grouped_fingerprint(typed)) +} + +/// `device.revoke {fingerprint}`: remove one device of the actor's own principal, the one +/// signing this request included (AO-5). Never through an agent's grant. +pub(super) fn revoke_device(s: &mut State, actor: &Actor, req: &Request) -> Result { + ensure!(actor.run.is_none(), AGENT_REFUSED); + let fingerprint = fingerprint_param(&req.params)?; + let mut matching = s + .devices + .iter() + .filter(|(device_id, device)| { + device.principal_id == actor.id + && crate::invitation::grouped_fingerprint(device_id) == fingerprint + }) + .map(|(device_id, _)| device_id.clone()); + let (Some(device_id), None) = (matching.next(), matching.next()) else { + bail!(NO_SUCH_DEVICE); + }; + s.devices.remove(&device_id); + let this_device = req + .auth + .as_ref() + .is_some_and(|auth| auth.device_id == device_id); + Ok(json!({"revoked": true, "fingerprint": fingerprint, "this_device": this_device})) +} + +#[cfg(test)] +mod tests { + /// C1: `device.revoke` through an agent's grant is refused in a person's words, where their + /// machine is a computer. The worker gate answers first on the wire, so this sentence is + /// pinned here. + #[test] + fn an_agents_revoke_is_refused_naming_a_computer() { + assert_eq!( + super::AGENT_REFUSED, + "forbidden: only a person can remove a computer" + ); + } +} diff --git a/crates/biorouter-crew/src/broker/join.rs b/crates/biorouter-crew/src/broker/join.rs index e094777ff..4937b1822 100644 --- a/crates/biorouter-crew/src/broker/join.rs +++ b/crates/biorouter-crew/src/broker/join.rs @@ -31,6 +31,14 @@ //! directly, and only after [`human_manager`] has admitted the caller: the host, or in an open //! workspace any member (OW-2). `enrollment.pending` makes none; `auth.join` makes exactly one, //! the fresh `by_uid` of check 2. +//! +//! *Amended for account ownership (AO-2):* an invited account can also join with +//! `auth.account` (`account.rs`, compiled with this module), which runs checks 1 to 3 and the +//! inviter check here ([`check_generation`], [`inviter_holds_authority`]) without the device +//! code. Signing in as the account is then the proof, so the device code no longer stands +//! between a process running as the joiner's account (M2) and the workspace; the owner accepted +//! that on 2026-11-05 (the design page's "Security position"). `auth.join` and the code still +//! work, for daemons and brokers that predate the account path. use super::*; use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; @@ -52,12 +60,12 @@ const JOIN_EXPIRED: &str = "join_expired: This invitation expired. Ask the host to invite you again."; const JOIN_CHANGED: &str = "join_changed: The host sent a new invitation. Check your join status and try again."; -const ACCOUNT_CHANGED: &str = "account_changed: This server account changed since the host invited it. Ask the host to invite you again."; -const MEMBERSHIP_CHANGED: &str = "account_changed: Membership for this account changed since the host invited it. Ask the host to invite you again."; +pub(super) const ACCOUNT_CHANGED: &str = "account_changed: This server account changed since the host invited it. Ask the host to invite you again."; +pub(super) const MEMBERSHIP_CHANGED: &str = "account_changed: Membership for this account changed since the host invited it. Ask the host to invite you again."; /// The person who invited (or, in an open workspace, admitted) the joiner no longer holds that /// authority: they were removed, or the workspace became managed and they are not the host. -const INVITER_CHANGED: &str = "account_changed: The person who invited you can no longer let people in. Ask the host to invite you again."; -const CODE_MISMATCH: &str = "code_mismatch: The host hasn't let this device in. Send the host the code shown on your screen."; +pub(super) const INVITER_CHANGED: &str = "account_changed: The person who invited you can no longer let people in. Ask the host to invite you again."; +const CODE_MISMATCH: &str = "code_mismatch: The host hasn't let this computer in. Send the host the code shown on your screen."; const NAME_INVALID: &str = "name_invalid: Type the account's name on the server, with no spaces, slashes, colons or invisible characters."; const NAME_DIGITS: &str = "name_invalid: Type the account's name on the server, not its numeric user ID."; @@ -80,11 +88,14 @@ pub(super) struct Runtime { last_refusal: BTreeMap>, /// Typed name to canonical account, with the time it was resolved. canonical: BTreeMap, + /// The account path's own in-memory state (`account.rs`), kept here because both modules + /// are compiled with the same feature and live as long as the broker. + pub(super) account: super::account::Runtime, } impl Runtime { /// Drop entries for joins that no longer exist (cancelled, replaced, joined or purged). - fn forget_stale(&mut self, s: &State) { + pub(super) fn forget_stale(&mut self, s: &State) { let live: BTreeSet<&str> = s .pending_joins .values() @@ -230,7 +241,7 @@ fn claim(broker: &mut Broker, uid: u32, conn: &mut Connection, req: &Request) -> /// Whether `join`'s inviter may still let people in: the host, or in an open workspace any /// active member (the same rule [`human_manager`] applied when they invited). -fn inviter_holds_authority(broker: &Broker, s: &State, join: &PendingJoin) -> bool { +pub(super) fn inviter_holds_authority(broker: &Broker, s: &State, join: &PendingJoin) -> bool { if open_governance(s) { s.principals .get(&join.inviter_id) @@ -262,7 +273,7 @@ fn generation(s: &State, uid: u32, username: &str) -> Vec { .collect() } -fn check_generation(s: &State, join: &PendingJoin) -> Result<()> { +pub(super) fn check_generation(s: &State, join: &PendingJoin) -> Result<()> { let mut expected = join.generation.clone(); expected.sort(); expected.dedup(); @@ -415,7 +426,7 @@ fn flag(params: &Value, key: &str) -> Result { /// digits), and no character that renders as nothing or reorders text (categories Cf, Co, Cs, /// Cn, Zl, Zp, or `Default_Ignorable_Code_Point`), so `@bob` and a lookalike with an invisible /// character can never both be shown. -fn check_username_characters(name: &str) -> Result<()> { +pub(super) fn check_username_characters(name: &str) -> Result<()> { ensure!( name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()), NAME_DIGITS @@ -482,7 +493,7 @@ fn admission_target(s: &State, account: &Account, add_device: bool) -> Result { ensure!( add_device, - "already_member: @{} is already a member. Choose Add device to add another computer for them.", + "already_member: @{} is already a member. To add another computer for them, choose Add another computer.", member.username ); Ok(Some(member.id.clone())) @@ -496,7 +507,7 @@ fn admission_target(s: &State, account: &Account, add_device: bool) -> Result { ensure!( !add_device, - "invalid_params: @{} is not a member yet. Invite them without Add device.", + "invalid_params: @{} is not a member yet. Turn off Add another computer, then invite them.", account.name ); Ok(None) diff --git a/crates/biorouter-crew/src/broker/lifecycle.rs b/crates/biorouter-crew/src/broker/lifecycle.rs new file mode 100644 index 000000000..0b2e1c74f --- /dev/null +++ b/crates/biorouter-crew/src/broker/lifecycle.rs @@ -0,0 +1,635 @@ +//! Starting and restarting a workspace by its ID (account ownership, AO-7). +//! +//! `biorouter-crew start --workspace-id UUID` and `biorouter-crew restart --workspace-id UUID` +//! find the workspace's state directory under `~/.local/share/biorouter-crew/` by the ID its +//! `journal.jsonl` holds, so setup only ever substitutes an ID it checked, never a path the +//! server printed. Neither ever creates a journal: an ID no directory holds is refused +//! `not_found`, and so is a directory whose journal went away before the broker opened it. An +//! ID more than one directory holds (a copy of a workspace's folder kept beside it) is refused +//! `identity_conflict`, naming the folders, before anything is started or stopped. +//! +//! `start --workspace-id` answers exactly as `start --state-dir` does, with `created: false`. +//! +//! `restart --workspace-id` is one command, so a dropped SSH connection can never leave a +//! workspace stopped halfway through: +//! +//! 1. It takes the account's restart lock, an exclusive `flock` on +//! `~/.local/share/biorouter-crew/.restart-lock`, refused `busy` while another restart holds +//! it (as setup's install lock refuses a second install). Without an installed program to +//! start in the old one's place it stops nothing (`program_missing`). +//! 2. It leaves the terminal's session (`setsid`, `SIGHUP` ignored) before it stops anything. +//! 3. It stops the running broker, checked as `stop` checks it (the listener's kernel PID is the +//! one `runtime.json` records, and it answers `hello` for this workspace), and waits on the +//! process's pidfd until it has exited: at most 15 seconds, else `stop_timeout`, and then +//! nothing is started. +//! 4. It starts the installed program, `~/.local/bin/biorouter-crew`, and checks that `hello` +//! answers for the same workspace, else `start_failed` with the last line of `broker.log`. +//! 5. It answers `{"restarted": true, "from_version": "1.93.0", "to_version": "1.94.1"}`, where +//! `from_version` is the version the stopped broker reported (`null` for a broker too old to +//! say, or when the workspace was not running, in which case it is only started) and +//! `to_version` the version the new one records in `runtime.json`. +//! +//! A deleted workspace counting down to its erase is never restarted (`workspace_deleted`). +use super::*; + +/// The account's workspace store: `/.local/share/biorouter-crew`. +pub(super) fn store_of(home: &Path) -> PathBuf { + home.join(".local").join("share").join("biorouter-crew") +} + +/// The program setup installs: `/.local/bin/biorouter-crew`. +pub(super) fn installed_program(home: &Path) -> PathBuf { + home.join(".local").join("bin").join("biorouter-crew") +} + +/// The file whose `flock` serializes restarts in one account. +const RESTART_LOCK: &str = ".restart-lock"; +/// How long `restart` waits for the old broker to exit. +const STOP_TIMEOUT: Duration = Duration::from_secs(15); +/// The most entries of the store a lookup by ID reads. +const STORE_SCAN_LIMIT: usize = 4096; +/// The first journal record is read up to this bound, as replay bounds every record. +const FIRST_RECORD_BYTES: u64 = 16 * 1024 * 1024; + +const RESTART_BUSY: &str = "busy: Another restart is running for a workspace in this account. Try again when it has finished."; +#[cfg(target_os = "linux")] +const RESTART_DELETED: &str = + "workspace_deleted: This workspace was deleted and is erasing itself, so it is not restarted."; + +fn not_found(workspace_id: &str) -> anyhow::Error { + anyhow!("not_found: No workspace with ID {workspace_id} is kept in this account.") +} + +/// The kernel's account and process for the other end of `stream` (`SO_PEERCRED`). +#[cfg(target_os = "linux")] +pub(super) fn peer_credentials(stream: &UnixStream) -> Result<(u32, i32)> { + let mut cred = std::mem::MaybeUninit::::uninit(); + let mut length = std::mem::size_of::() as libc::socklen_t; + ensure!( + unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + cred.as_mut_ptr().cast(), + &mut length, + ) + } == 0 + && length as usize == std::mem::size_of::(), + "identity_unavailable: kernel peer credentials unavailable" + ); + let cred = unsafe { cred.assume_init() }; + Ok((cred.uid, cred.pid)) +} +#[cfg(not(target_os = "linux"))] +pub(super) fn peer_credentials(_stream: &UnixStream) -> Result<(u32, i32)> { + bail!("unsupported: Crew brokers require Linux SO_PEERCRED") +} + +/// The workspace ID `journal` names, read from its first record alone: the record that +/// initialized the workspace carries the whole first state, and a workspace's ID never changes. +/// `None` when the journal is missing, not this account's private regular file, empty, or +/// shaped otherwise. +pub(super) fn journal_workspace_id(journal: &Path) -> Option { + let file = OpenOptions::new() + .read(true) + // A FIFO in its place must not stall the lookup. + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(journal) + .ok()?; + let metadata = file.metadata().ok()?; + if !(metadata.is_file() + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o077 == 0) + { + return None; + } + let mut line = Vec::new(); + BufReader::new(file.take(FIRST_RECORD_BYTES)) + .read_until(b'\n', &mut line) + .ok()?; + if !line.ends_with(b"\n") { + return None; + } + let record: Value = serde_json::from_slice(&line).ok()?; + let state = match record.get("version").and_then(Value::as_u64) { + Some(1) => record.get("state")?, + Some(2) => { + let first = record.get("patches")?.as_array()?.first()?; + let root = first.get("path")?.as_array()?.is_empty(); + if first.get("op").and_then(Value::as_str) != Some("set") || !root { + return None; + } + first.get("value")? + } + _ => return None, + }; + let id = state.get("workspace")?.get("id")?.as_str()?; + broker_id(id).then(|| id.to_owned()) +} + +/// The refusal for a workspace ID that more than one folder of the store holds: a copy kept +/// beside the workspace's own folder (`cp -a lab lab-backup`). Which one is the workspace +/// cannot be told from the folders, so nothing is started or stopped, and the listing reports +/// each of them with this sentence instead of guessing. +pub(super) fn held_by_several_folders(workspace_id: &str, folders: &[PathBuf]) -> String { + let names: Vec = folders + .iter() + .map(|folder| { + folder.file_name().map_or_else( + || folder.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ) + }) + .collect(); + format!( + "identity_conflict: Workspace {workspace_id} is kept in more than one folder of ~/.local/share/biorouter-crew ({}). Move every copy out of that folder, keeping the workspace's own, then try again.", + names.join(", ") + ) +} + +/// The state directory under `store` whose journal holds `workspace_id`: a directory (not a +/// symbolic link) this account owns. Directory names are never trusted to say which workspace +/// is inside; a renamed workspace keeps its folder. When more than one folder holds the ID +/// (a copy of the folder kept in the store), none is chosen: [`held_by_several_folders`]. +pub(super) fn find_state_dir(store: &Path, workspace_id: &str) -> Result { + ensure!( + broker_id(workspace_id), + "invalid_params: --workspace-id must be a workspace ID (a lowercase UUID)" + ); + let entries = match fs::read_dir(store) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(not_found(workspace_id)) + } + Err(error) => { + return Err(error).context("unsafe_storage: cannot read the workspace folder") + } + }; + let euid = unsafe { libc::geteuid() }; + // Sorted before the cap, so which folders are read never depends on directory order. + let mut names: Vec = entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name()) + .filter(|name| !name.to_string_lossy().starts_with('.')) + .collect(); + names.sort(); + names.truncate(STORE_SCAN_LIMIT); + let mut holding = Vec::new(); + for name in names { + let dir = store.join(&name); + let owned = fs::symlink_metadata(&dir) + .is_ok_and(|metadata| metadata.is_dir() && metadata.uid() == euid); + if !owned { + continue; + } + let held = journal_workspace_id(&dir.join("journal.jsonl")).or_else(|| { + // A journal whose first record is shaped otherwise is read whole, as `start` reads + // one. + stored_workspace(&dir) + .ok() + .flatten() + .map(|stored| stored.id) + }); + if held.as_deref() == Some(workspace_id) { + holding.push(dir); + } + } + match holding.len() { + 0 => Err(not_found(workspace_id)), + 1 => Ok(holding.remove(0)), + _ => bail!(held_by_several_folders(workspace_id, &holding)), + } +} + +/// The account's restart lock, held for as long as this value lives (and by a child this +/// process forks, which shares the open file). +struct RestartLock { + _file: File, +} + +fn restart_lock(store: &Path) -> Result { + let file = private_file(&store.join(RESTART_LOCK), false)?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EWOULDBLOCK) { + bail!(RESTART_BUSY); + } + return Err(error).context("restart_lock: cannot lock the restart lock file"); + } + Ok(RestartLock { _file: file }) +} + +/// Leave the terminal's session before anything is stopped, so a closed SSH connection or a +/// hung-up terminal cannot end the restart between stopping the old broker and starting the +/// new one. `SIGHUP` is ignored, and the process starts a session of its own. A process that +/// already leads a process group (a shell's job, or a program a login shell replaced itself +/// with) cannot, so it carries on in a child that can, while the parent waits for it and exits +/// with its status. Called while the process has only its main thread. +fn leave_session() -> Result<()> { + unsafe { + libc::signal(libc::SIGHUP, libc::SIG_IGN); + } + if unsafe { libc::setsid() } >= 0 { + return Ok(()); + } + match unsafe { libc::fork() } { + -1 => Err(std::io::Error::last_os_error()).context("restart: cannot leave the terminal"), + 0 => { + ensure!( + unsafe { libc::setsid() } >= 0, + "restart: cannot leave the terminal's session" + ); + Ok(()) + } + child => { + let mut status = 0; + while unsafe { libc::waitpid(child, &mut status, 0) } < 0 { + if std::io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { + std::process::exit(1); + } + } + let code = if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else { + 1 + }; + std::process::exit(code) + } + } +} + +/// The version a broker reports: `hello`'s, else its `runtime.json`'s; `None` for a broker too +/// old to say. +fn reported_version(hello: Option<&Value>, info: Option<&Value>) -> Option { + hello + .and_then(|hello| hello.get("version")) + .or_else(|| info.and_then(|info| info.get("version"))) + .and_then(Value::as_str) + .map(str::to_owned) +} + +/// Stop `workspace_id`'s broker running from `root` and wait until its process has exited, at +/// most `timeout`. `Ok(None)` when no broker is running there; `Ok(Some(version))` once the one +/// that was has exited, with the version it reported. The process is verified as `stop` +/// verifies it, and signalled through its pidfd, so a reused PID is never signalled. +#[cfg(target_os = "linux")] +fn stop_and_wait( + root: &Path, + workspace_id: &str, + timeout: Duration, +) -> Result>> { + use std::os::fd::FromRawFd; + let Some(info) = existing_runtime_descriptor(root)? else { + return Ok(None); + }; + ensure!( + info.get("workspace_id").and_then(Value::as_str) == Some(workspace_id), + "identity_mismatch: runtime.json names another workspace" + ); + 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")?); + let mut stream = match connect_recorded(&info, &socket, owner) { + Ok(stream) => stream, + Err(error) if error.to_string() == NOT_RUNNING => return Ok(None), + Err(error) => return Err(error), + }; + let (uid, pid) = peer_credentials(&stream)?; + ensure!( + uid == owner && pid as u64 == number(&info, "pid")?, + "identity_mismatch" + ); + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; + ensure!(fd >= 0, "unsupported: safe stop requires Linux pidfd_open"); + let process = unsafe { File::from_raw_fd(fd as i32) }; + stream.set_read_timeout(Some(Duration::from_secs(3)))?; + stream.write_all( + b"{\"version\":1,\"id\":\"restart-verify\",\"method\":\"hello\",\"params\":{}}\n", + )?; + let response: Response = serde_json::from_slice( + &read_frame(&mut BufReader::new(stream))?.ok_or_else(|| anyhow!("unavailable"))?, + )?; + let hello = response.result.unwrap_or_default(); + ensure!( + hello.get("workspace_id").and_then(Value::as_str) == Some(workspace_id), + "identity_mismatch" + ); + ensure!( + hello.get("state").and_then(Value::as_str) != Some("deleted"), + RESTART_DELETED + ); + let from_version = reported_version(Some(&hello), Some(&info)); + ensure!( + unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + process.as_raw_fd(), + libc::SIGTERM, + std::ptr::null::(), + 0, + ) + } == 0, + "stop_failed: the broker could not be signalled" + ); + // The pidfd becomes readable when the process has exited. + let deadline = Instant::now() + timeout; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + let mut poll = libc::pollfd { + fd: process.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + let millis = left.as_millis().min(i32::MAX as u128) as i32; + let ready = unsafe { libc::poll(&mut poll, 1, millis) }; + if ready > 0 { + ensure!( + poll.revents & libc::POLLIN != 0, + "stop_failed: cannot wait for the broker to exit" + ); + return Ok(Some(from_version)); + } + if ready < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(error).context("stop_failed: cannot wait for the broker to exit"); + } + bail!( + "stop_timeout: The workspace's broker (process {pid}) was asked to stop and was still running after {} seconds, so the new program was not started. Try the restart again once it has stopped; biorouter-crew status --state-dir {} says whether it still runs.", + timeout.as_secs(), + root.display() + ); + } +} +#[cfg(not(target_os = "linux"))] +fn stop_and_wait( + _root: &Path, + _workspace_id: &str, + _timeout: Duration, +) -> Result>> { + bail!("unsupported: restart requires Linux pidfd") +} + +/// `restart --workspace-id` for the account whose home is `home` (see the module's +/// documentation). `leave` detaches from the terminal first; only the command does, never a +/// test. +fn restart(home: &Path, workspace_id: &str, stop_timeout: Duration, leave: bool) -> Result { + let store = store_of(home); + let root = find_state_dir(&store, workspace_id)?; + let _lock = restart_lock(&store)?; + // Nothing is stopped unless there is a program to start in its place. This is its own + // code: `start_failed` means the broker was stopped and the new one did not start. + let program = installed_program(home); + ensure!( + fs::metadata(&program).is_ok_and(|file| file.is_file() && file.mode() & 0o111 != 0), + "program_missing: {} is missing or cannot run, so the running broker was left as it is.", + program.display() + ); + if leave { + leave_session()?; + } + let from_version = stop_and_wait(&root, workspace_id, stop_timeout)?.flatten(); + let started = start(&root, "", None, false, &program)?; + if started.get("state").and_then(Value::as_str) != Some("running") { + bail!( + "start_failed: the broker did not answer within {} seconds; last log line: {}", + START_READY_TIMEOUT.as_secs(), + last_log_line(&root) + ); + } + ensure!( + started.get("workspace_id").and_then(Value::as_str) == Some(workspace_id), + "start_failed: the broker that started answers for another workspace; last log line: {}", + last_log_line(&root) + ); + let runtime = read_runtime_descriptor(&root).ok().flatten(); + Ok(json!({ + "restarted": true, + "from_version": from_version, + "to_version": reported_version(None, runtime.as_ref()), + })) +} + +impl Broker { + /// `biorouter-crew start (--state-dir PATH | --name NAME) [--bootstrap-key HEX] + /// [--create]`: launch this program as the workspace's broker. A workspace is created only + /// when `root` holds none and a key or `create` is given (AO-7), and then the answer says + /// `created: true`. + pub fn start_state_dir( + root: &Path, + bootstrap_key: &str, + name: Option<&str>, + create: bool, + ) -> Result { + start(root, bootstrap_key, name, create, &std::env::current_exe()?) + } + + /// `biorouter-crew start --workspace-id UUID` for the account whose home is `home`: start + /// the workspace its store holds under that ID. Never creates one (`not_found`). + pub fn start_by_id(home: &Path, workspace_id: &str) -> Result { + let root = find_state_dir(&store_of(home), workspace_id)?; + start(&root, "", None, false, &std::env::current_exe()?) + } + + /// `biorouter-crew restart --workspace-id UUID` for the account whose home is `home`: stop + /// the workspace's running broker and start the installed program in its place, as one + /// command that leaves the terminal first. See the module's documentation for the answer + /// and the refusals. + pub fn restart_by_id(home: &Path, workspace_id: &str) -> Result { + restart(home, workspace_id, STOP_TIMEOUT, true) + } + + /// [`Broker::restart_by_id`] waiting at most `stop_timeout` for the old broker, without + /// leaving the test's session. Test builds only. + #[cfg(feature = "test-seams")] + pub fn restart_by_id_with( + home: &Path, + workspace_id: &str, + stop_timeout: Duration, + ) -> Result { + restart(home, workspace_id, stop_timeout, false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn private_temp(label: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!("crew-by-id-{label}-{}", Uuid::new_v4())); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + path + } + + fn write_private(path: &Path, bytes: &[u8]) { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .unwrap(); + file.write_all(bytes).unwrap(); + } + + /// The first record of a journal names its workspace in either record version; anything + /// else names none. + #[test] + fn the_first_record_names_the_workspace() { + let dir = private_temp("first-record"); + let id = Uuid::new_v4().to_string(); + let v2 = dir.join("v2.jsonl"); + let record = json!({"version": 2, "sequence": 1, "patches": [ + {"op": "set", "path": [], "value": {"workspace": {"id": id}}} + ]}); + write_private(&v2, format!("{record}\n{{\"later\":true}}\n").as_bytes()); + assert_eq!(journal_workspace_id(&v2).as_deref(), Some(id.as_str())); + + let v1 = dir.join("v1.jsonl"); + let record = json!({"version": 1, "state": {"workspace": {"id": id}}}); + write_private(&v1, format!("{record}\n").as_bytes()); + assert_eq!(journal_workspace_id(&v1).as_deref(), Some(id.as_str())); + + // A torn first record, a patch that is not the root, and an ID not shaped like one. + let torn = dir.join("torn.jsonl"); + write_private(&torn, b"{\"version\":2"); + assert_eq!(journal_workspace_id(&torn), None); + let nested = dir.join("nested.jsonl"); + let record = json!({"version": 2, "patches": [ + {"op": "set", "path": ["workspace"], "value": {"id": id}} + ]}); + write_private(&nested, format!("{record}\n").as_bytes()); + assert_eq!(journal_workspace_id(&nested), None); + let shaped = dir.join("shaped.jsonl"); + let record = json!({"version": 1, "state": {"workspace": {"id": "../lab"}}}); + write_private(&shaped, format!("{record}\n").as_bytes()); + assert_eq!(journal_workspace_id(&shaped), None); + // Another account could read a journal others can read; it is not taken as this one's. + let open = dir.join("open.jsonl"); + let record = json!({"version": 1, "state": {"workspace": {"id": id}}}); + write_private(&open, format!("{record}\n").as_bytes()); + fs::set_permissions(&open, fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(journal_workspace_id(&open), None); + let _ = fs::remove_dir_all(&dir); + } + + /// The relay of a bridge inside a full-access job answers `auth.account` and + /// `device.revoke` itself with `agent_session_refused` and never forwards them; anything + /// else still reaches the broker. Outside a job nothing is refused. + #[test] + fn a_relay_inside_a_job_never_forwards_device_changes() { + use std::os::fd::FromRawFd; + for inside_job in [true, false] { + let mut fds = [0; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let (input, mut daemon) = + unsafe { (File::from_raw_fd(fds[0]), File::from_raw_fd(fds[1])) }; + let (bridge_side, broker_side) = UnixStream::pair().unwrap(); + let seen = Arc::new(Mutex::new(Vec::::new())); + let record = seen.clone(); + std::thread::spawn(move || { + let mut reader = BufReader::new(broker_side.try_clone().unwrap()); + let mut writer = broker_side; + while let Ok(Some(frame)) = read_frame(&mut reader) { + let request: Value = serde_json::from_slice(&frame).unwrap(); + record + .lock() + .unwrap() + .push(request["method"].as_str().unwrap_or_default().to_owned()); + let answer = json!({"id": request["id"], "result": {"ok": true}}); + writer.write_all(format!("{answer}\n").as_bytes()).unwrap(); + } + }); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut input = BufReader::new(input); + let reader = BufReader::new(bridge_side.try_clone().unwrap()); + let mut output = Vec::new(); + let reconnect: crate::remote_full::Connect = + Arc::new(|| bail!("no second broker connection in this test")); + let result = relay_from( + &mut input, + &mut output, + bridge_side, + reader, + &reconnect, + inside_job, + ); + let _ = sender.send((result.is_ok(), output)); + }); + for (id, method) in [ + ("a", "auth.account"), + ("b", "device.revoke"), + ("c", "workspace.snapshot"), + ] { + let request = json!({"version": 1, "id": id, "method": method, "params": {}}); + daemon.write_all(format!("{request}\n").as_bytes()).unwrap(); + } + drop(daemon); + let (ended, output) = receiver + .recv_timeout(Duration::from_secs(10)) + .expect("the relay ends when the daemon closes its end"); + assert!(ended); + let answers: Vec = output + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).unwrap()) + .collect(); + assert_eq!(answers.len(), 3); + let forwarded = seen.lock().unwrap().clone(); + if inside_job { + for answer in &answers[..2] { + assert_eq!(answer["error"]["code"], "agent_session_refused", "{answer}"); + assert_eq!(answer["error"]["message"], AGENT_SESSION_REFUSED); + } + assert_eq!(answers[2]["result"]["ok"], true); + assert_eq!(forwarded, vec!["workspace.snapshot".to_owned()]); + } else { + assert!(answers.iter().all(|answer| answer["result"]["ok"] == true)); + assert_eq!( + forwarded, + vec![ + "auth.account".to_owned(), + "device.revoke".to_owned(), + "workspace.snapshot".to_owned() + ] + ); + } + } + } + + /// A second restart is refused `busy` while the first holds the lock, and the lock can be + /// taken once the first has ended. + #[test] + fn the_restart_lock_admits_one_restart_at_a_time() { + let store = private_temp("lock"); + let held = restart_lock(&store).unwrap(); + let error = restart_lock(&store) + .err() + .expect("a second restart waits its turn"); + assert!(error.to_string().starts_with("busy: "), "{error}"); + drop(held); + // A flock belongs to the open file description, and a fork gives the child a share of + // every description the process has open, O_CLOEXEC or not, until the child execs or + // exits. Other tests in this binary fork on their own threads (a bare `fork`, and + // `Command` with `pre_exec`), so one of their children can still hold the first lock + // for a moment after `drop(held)`. The lock must come free, so this waits a bounded + // time for it. The busy check above stays strict: a fork only ever prolongs a lock. The + // restart command never meets this, because it holds the lock with one thread and + // `spawn` returns only once its child has exec'd. + let deadline = Instant::now() + Duration::from_secs(5); + while let Err(error) = restart_lock(&store) { + assert!( + error.to_string().starts_with("busy: ") && Instant::now() < deadline, + "free again once the first one ends: {error}" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let _ = fs::remove_dir_all(&store); + } +} diff --git a/crates/biorouter-crew/src/lib.rs b/crates/biorouter-crew/src/lib.rs index c0cf3d2a4..9e85ae7de 100644 --- a/crates/biorouter-crew/src/lib.rs +++ b/crates/biorouter-crew/src/lib.rs @@ -308,6 +308,11 @@ pub struct RemoteReference { /// How long a pending workspace join stays valid after the host invites (24 hours). pub const PENDING_JOIN_LIFETIME_SECS: u64 = 24 * 60 * 60; +/// The most devices one principal keeps (account ownership, AO-2). When `auth.account` would +/// add one more, the broker removes the principal's oldest device in the same record, so +/// nobody is refused for having too many computers. +pub const MAX_DEVICES_PER_PRINCIPAL: usize = 32; + /// The host's invitation for one Unix account to join the workspace (S3a), stored in the /// broker's `pending_joins` map keyed by the decimal UID. Public so the daemon's tests can build /// one. diff --git a/crates/biorouter-crew/src/main.rs b/crates/biorouter-crew/src/main.rs index 90f8efeeb..30b82c8f1 100644 --- a/crates/biorouter-crew/src/main.rs +++ b/crates/biorouter-crew/src/main.rs @@ -1,7 +1,15 @@ use anyhow::{bail, Result}; #[cfg(unix)] -const USAGE: &str = "usage: biorouter-crew serve|start|status|stop (--state-dir PATH | --name NAME) [--name NAME] [--bootstrap-key HEX]; delete (--state-dir PATH | --name NAME) --workspace-id UUID; bridge --stdio --socket PATH --owner-uid UID --workspace-id UUID; probe|doctor [--output-format json]"; +const USAGE: &str = "usage: biorouter-crew serve|start|status|stop (--state-dir PATH | --name NAME) [--name NAME] [--bootstrap-key HEX] [--create]; start|restart --workspace-id UUID; workspaces [--output-format json]; delete (--state-dir PATH | --name NAME) --workspace-id UUID; bridge --stdio --socket PATH --owner-uid UID --workspace-id UUID; probe|doctor [--output-format json]"; + +/// The account's home directory, which must be an absolute path. +#[cfg(unix)] +fn home_dir() -> Option { + std::env::var_os("HOME") + .map(std::path::PathBuf::from) + .filter(|home| home.is_absolute()) +} /// Where a named workspace keeps its state when `--state-dir` is not given: /// `$HOME/.local/share/biorouter-crew/`. The name is validated first, so it is a plain @@ -9,12 +17,9 @@ const USAGE: &str = "usage: biorouter-crew serve|start|status|stop (--state-dir #[cfg(unix)] fn default_state_dir(name: &str) -> Result { biorouter_crew::validate_workspace_name(name).map_err(|error| anyhow::anyhow!(error.wire()))?; - let home = std::env::var_os("HOME") - .map(std::path::PathBuf::from) - .filter(|home| home.is_absolute()) - .ok_or_else(|| { - anyhow::anyhow!("--state-dir required: HOME is not set to an absolute path") - })?; + let home = home_dir().ok_or_else(|| { + anyhow::anyhow!("--state-dir required: HOME is not set to an absolute path") + })?; Ok(home .join(".local") .join("share") @@ -35,30 +40,78 @@ fn json_output(args: &[String]) -> Result { } } +/// `biorouter-crew workspaces [--output-format json]` (AO-6): JSON is the only format, as for +/// probe, printed on one line because setup reads it whole. +#[cfg(unix)] +fn workspaces(args: &[String]) -> Result<()> { + json_output(args)?; + let home = home_dir().ok_or_else(|| anyhow::anyhow!("HOME is not set to an absolute path"))?; + let answer = biorouter_crew::Broker::list_workspaces(&home); + println!("{}", serde_json::to_string(&answer)?); + Ok(()) +} + +/// `biorouter-crew start|restart --workspace-id UUID` (AO-7): the state directory is found +/// from the journals in ~/.local/share/biorouter-crew, and no journal is ever created. +#[cfg(unix)] +fn by_id(command: &str, args: &[String]) -> Result<()> { + let workspace_id = args + .windows(2) + .find(|pair| pair[0] == "--workspace-id") + .map(|pair| pair[1].as_str()) + .ok_or_else(|| { + anyhow::anyhow!("--workspace-id required: the ID of the workspace to {command}") + })?; + let addressed_otherwise = ["--state-dir", "--name", "--bootstrap-key", "--create"]; + if args + .iter() + .any(|arg| addressed_otherwise.contains(&arg.as_str())) + { + bail!("--workspace-id names the workspace by itself; leave out --state-dir, --name, --bootstrap-key and --create"); + } + let home = home_dir().ok_or_else(|| anyhow::anyhow!("HOME is not set to an absolute path"))?; + let answer = if command == "start" { + biorouter_crew::Broker::start_by_id(&home, workspace_id)? + } else { + biorouter_crew::Broker::restart_by_id(&home, workspace_id)? + }; + println!("{answer}"); + Ok(()) +} + +#[cfg(unix)] +const HELP: &str = "Usage: biorouter-crew start --name NAME --bootstrap-key HEX [--create] [--state-dir PATH]\n biorouter-crew serve|start|status|stop --state-dir PATH [--name NAME] [--bootstrap-key HEX] [--create]\n biorouter-crew start|restart --workspace-id UUID\n biorouter-crew workspaces [--output-format json]\n biorouter-crew delete (--state-dir PATH | --name NAME) --workspace-id UUID\n biorouter-crew bridge --stdio --socket PATH --owner-uid UID --workspace-id UUID\n biorouter-crew probe|doctor [--output-format json]\n biorouter-crew --version [--output-format json]\n--name names the workspace (lowercase letters, numbers and hyphens); without --state-dir its state lives in ~/.local/share/biorouter-crew/NAME. start prints the workspace's invitation line (brcrew1:...) to paste into Crew. serve and start create a new workspace only in a state directory that holds none, and only with --bootstrap-key or --create. start --workspace-id starts a stopped workspace of this account by its ID; restart --workspace-id stops its running broker and starts the installed program in its place. workspaces lists, as JSON, the workspaces this account hosts on this server and the ones of other accounts it belongs to, is invited to or was removed from. delete erases a stopped workspace's state directory, only the files Crew created; --workspace-id must be the ID of the workspace it holds. probe prints fixed facts about this server and account as JSON; doctor adds what blocks hosting here and what limits agents.\nBroker and bridge operations require Linux."; + +/// `biorouter-crew --version [--output-format json]`. +#[cfg(unix)] +fn version(args: &[String]) -> Result<()> { + if json_output(args)? { + // A cheap compatibility check for setup: the broker's capabilities (what `hello` + // advertises) and the bridge's own. + println!( + "{}", + serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "protocol": biorouter_crew::PROTOCOL_VERSION, + "capabilities": biorouter_crew::Broker::capabilities(), + "bridge_capabilities": biorouter_crew::probe::BRIDGE_CAPABILITIES, + }) + ); + } else { + println!("biorouter-crew {}", env!("CARGO_PKG_VERSION")); + } + Ok(()) +} + #[cfg(unix)] fn main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); let command = args.first().map(String::as_str).unwrap_or("help"); if matches!(command, "--version" | "-V") { - if json_output(&args)? { - // A cheap compatibility check for setup: the broker's capabilities (what `hello` - // advertises) and the bridge's own. - println!( - "{}", - serde_json::json!({ - "version": env!("CARGO_PKG_VERSION"), - "protocol": biorouter_crew::PROTOCOL_VERSION, - "capabilities": biorouter_crew::Broker::capabilities(), - "bridge_capabilities": biorouter_crew::probe::BRIDGE_CAPABILITIES, - }) - ); - } else { - println!("biorouter-crew {}", env!("CARGO_PKG_VERSION")); - } - return Ok(()); + return version(&args); } if matches!(command, "help" | "--help" | "-h") { - println!("Usage: biorouter-crew start --name NAME --bootstrap-key HEX [--state-dir PATH]\n biorouter-crew serve|start|status|stop --state-dir PATH [--name NAME] [--bootstrap-key HEX]\n biorouter-crew delete (--state-dir PATH | --name NAME) --workspace-id UUID\n biorouter-crew bridge --stdio --socket PATH --owner-uid UID --workspace-id UUID\n biorouter-crew probe|doctor [--output-format json]\n biorouter-crew --version [--output-format json]\n--name names the workspace (lowercase letters, numbers and hyphens); without --state-dir its state lives in ~/.local/share/biorouter-crew/NAME. start prints the workspace's invitation line (brcrew1:...) to paste into Crew. delete erases a stopped workspace's state directory, only the files Crew created; --workspace-id must be the ID of the workspace it holds. probe prints fixed facts about this server and account as JSON; doctor adds what blocks hosting here and what limits agents.\nBroker and bridge operations require Linux."); + println!("{HELP}"); return Ok(()); } @@ -67,6 +120,7 @@ fn main() -> Result<()> { .find(|pair| pair[0] == name) .map(|pair| pair[1].as_str()) }; + let flag = |name: &str| args.iter().any(|arg| arg == name); if command == "__crew-exec" { biorouter_crew::remote::exec_helper()?; } else if command == "__crew-run" { @@ -83,6 +137,10 @@ fn main() -> Result<()> { biorouter_crew::probe::doctor() }; println!("{}", serde_json::to_string_pretty(&facts)?); + } else if command == "workspaces" { + workspaces(&args)?; + } else if command == "restart" || (command == "start" && flag("--workspace-id")) { + by_id(command, &args)?; } else if command == "bridge" { biorouter_crew::bridge( std::path::Path::new( @@ -98,6 +156,10 @@ fn main() -> Result<()> { if args.iter().any(|arg| arg == "--name") && name.is_none() { bail!("--name requires a workspace name"); } + let create = flag("--create"); + if create && !matches!(command, "serve" | "start") { + bail!("--create applies to serve and start only"); + } let root = match (option("--state-dir"), name) { (Some(root), _) => std::path::PathBuf::from(root), (None, Some(name)) => { @@ -124,10 +186,14 @@ fn main() -> Result<()> { })?; println!("{}", biorouter_crew::delete_workspace(&root, workspace_id)?); } else if command == "serve" { - biorouter_crew::serve(&root, key, name)?; + biorouter_crew::serve(&root, key, name, create)?; + } else if command == "start" { + println!( + "{}", + biorouter_crew::Broker::start_state_dir(&root, key, name, create)? + ); } else { - let name = if command == "start" { name } else { None }; - println!("{}", biorouter_crew::lifecycle(command, &root, key, name)?); + println!("{}", biorouter_crew::lifecycle(command, &root, key, None)?); } } else { bail!("{USAGE}"); diff --git a/crates/biorouter-crew/src/remote_full.rs b/crates/biorouter-crew/src/remote_full.rs index f66a0394d..37d455391 100644 --- a/crates/biorouter-crew/src/remote_full.rs +++ b/crates/biorouter-crew/src/remote_full.rs @@ -68,6 +68,11 @@ const TRANSFER_CHUNK: usize = 262_144; /// A transfer writes its progress at most this often (in bytes moved). const PROGRESS_EVERY: u64 = 4 * 1024 * 1024; +/// The variable that marks a full-access job's environment: its job ID. The supervisor sets it +/// on every job it starts, and a `biorouter-crew bridge` that finds it refuses to add or revoke +/// a device (`agent_session_refused`, accident prevention under account ownership). +pub(crate) const JOB_ENVIRONMENT: &str = "BIOROUTER_CREW_JOB_ID"; + /// How a background transfer reaches the broker: a second connection, opened the way the /// bridge opened its first, and the worker credential of the frame that asked for it. pub type Connect = Arc Result<(UnixStream, BufReader)> + Send + Sync>; @@ -1004,7 +1009,7 @@ fn spawn_supervisor(context: &Context, dir: &Path, id: &str) -> Result<()> { let mut child = std::process::Command::new(&context.program) .arg("__crew-run") .arg(dir) - .env("BIOROUTER_CREW_JOB_ID", id) + .env(JOB_ENVIRONMENT, id) .current_dir("/") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) @@ -1148,9 +1153,16 @@ fn linux_supervise(dir: &Path) -> Result<()> { } }; let cwd = record["cwd"].as_str().unwrap_or("/").to_owned(); + // The job's own ID (its directory's name) marks its environment, whatever this supervisor + // inherited, so a bridge the job starts knows it runs inside one and refuses to add or + // revoke a device. + let job = dir + .file_name() + .map_or_else(|| "job".into(), |name| name.to_os_string()); let mut command = std::process::Command::new(&argv[0]); command .args(&argv[1..]) + .env(JOB_ENVIRONMENT, job) .current_dir(&cwd) .stdin(std::process::Stdio::null()) .stdout(stdout) diff --git a/crates/biorouter-crew/src/workspaces.rs b/crates/biorouter-crew/src/workspaces.rs new file mode 100644 index 000000000..f5a89a1fe --- /dev/null +++ b/crates/biorouter-crew/src/workspaces.rs @@ -0,0 +1,1440 @@ +//! `biorouter-crew workspaces [--output-format json]` (account ownership, AO-6): what is on this +//! server for the account that runs it. A server command setup runs over SSH as the account. It +//! changes nothing, never fails because of one workspace, and finishes within 20 seconds. +//! +//! # The answer +//! +//! One line of JSON, at most 48 KiB: +//! +//! ```json +//! {"account": {"uid": 1001, "username": "wanjun"}, +//! "workspaces": [ENTRY, ...], +//! "skipped": 0, "unsupported": 0, "truncated": false} +//! ``` +//! +//! `username` is `null` when the account cannot be resolved. `skipped` and `unsupported` are +//! counts, `truncated` is `true` or `false`: +//! +//! - `skipped`: workspaces of other accounts that were not described: a candidate left untried +//! by the per-account or total cap, one that did not answer within its deadline, and one whose +//! answer did not check out (a bad signature, or a name, institution or version larger than +//! a real broker sends). A running workspace of this account that did not answer counts too. +//! - `unsupported`: brokers of other accounts that answered but do not advertise +//! `account_identity_v1`, so they cannot say whether this account belongs to them. They are +//! never listed. +//! - `truncated`: whether workspaces were left out to keep the answer within its bounds: hosted +//! folders past the 64th, runtime directories of this account past the 32nd, or entries that +//! no longer fit in 48 KiB (each is passed over, and smaller ones after it are still listed). +//! +//! Each ENTRY is: +//! +//! ```json +//! {"workspace_id": "…", "name": "baranzini-lab", "role": "host", +//! "host_uid": 1001, "host_username": "wanjun", +//! "socket_path": "/tmp/crew-1001-…/broker.sock", +//! "workspace_key_fingerprint": "…", "mode": "private", "institution_id": "ucsf", +//! "state": "running", "version": "1.94.1", "binary_current": true, +//! "state_dir": "/home/wanjun/.local/share/biorouter-crew/baranzini-lab", +//! "found_by": "state_dir", "invitation": "brcrew1:…", "others_connected": 0} +//! ``` +//! +//! - `role`: `host` for a workspace this account hosts; `member`, `invited` or `removed` for +//! another account's workspace, as its broker's `account.status` answers. +//! - `state`: `running`, `stopped`, `deleted`, `storage_failed` or `unknown`. An entry that could +//! not be read is `unknown` with an `error` (a coded sentence), and its other fields are +//! `null` where they could not be learned. +//! - `version`: the version a running broker reports (`hello`'s, else its `runtime.json`'s); +//! `null` for a stopped workspace or a broker too old to say. +//! - `binary_current`: for a running workspace this account hosts, whether the broker's process +//! (the PID the kernel reports for the `hello` connection, never the PID `runtime.json` +//! records) runs the same file (device and inode) as `~/.local/bin/biorouter-crew`; `null` +//! otherwise. +//! - `state_dir`: the hosted workspace's state directory, for display only (setup addresses a +//! workspace by its ID); `null` for a workspace started with a custom `--state-dir`, found +//! only by its running broker, and for another account's. +//! - `found_by`: `state_dir` (a journal under `~/.local/share/biorouter-crew/`), `runtime` (a +//! running broker of this account in `/tmp` with no state directory there) or `scan` (another +//! account's broker). +//! - `invitation`: the workspace's `brcrew1:` line without `ssh_host`, the one `start` prints, +//! so a computer with nothing saved opens it through the ordinary join path; `null` for a +//! deleted or unknown workspace, or when a field it needs is missing. +//! - `others_connected`: for a running workspace this account hosts whose broker advertises +//! `account_identity_v1`, how many live connections it holds from other accounts (AO-3; setup +//! asks it before a restart, AO-8); `null` otherwise. It is not in AO-6's list: setup has no +//! other way to learn it over SSH. +//! +//! # Where it looks +//! +//! **Hosted by this account:** every directory (not a symbolic link) under +//! `~/.local/share/biorouter-crew/` holding a journal or a deleted-workspace marker, at most 64, +//! and this account's own runtime directories in `/tmp`, at most 32, for workspaces started +//! with a custom `--state-dir`. A running workspace is described by `runtime.json` and a +//! `hello` checked as `status` checks it (socket and listener this account's, the recorded +//! workspace and key), without replaying the journal; a stopped one by its replayed journal. +//! A running broker found by its runtime directory is the same workspace as a listed folder +//! only when ID, host and key all agree; otherwise both are listed. An ID that more than one +//! folder holds (a copy of a workspace's folder kept in the store) lists each of those folders +//! as `unknown` with the `identity_conflict` sentence `start` and `restart` refuse it with. +//! +//! **Elsewhere:** runtime sockets `/tmp/crew--<32 hex>/broker.sock` of other accounts. +//! Each candidate is checked before it is probed, as the bridge checks a workspace that moved: +//! the directory (not a symbolic link) and the socket belong to the UID its name carries and +//! nobody else can write into the directory, and the listener runs as that UID. At most 4 per +//! account, taken round-robin across accounts, and 64 in all, are probed concurrently, each +//! with a 1-second deadline and a 16 KiB cap on what it reads, within 5 seconds for the whole +//! scan. `hello` must be signed (v1 and v2) by the key it presents over a fresh nonce, its +//! signed `host_uid` must be the socket's owner, and its `name`, `institution_id` and unsigned +//! `version` must be as small as a real broker's (the workspace-name rule, the institution +//! rule, at most 64 printable bytes); `host_username` comes from resolving that UID, never +//! from the answer. Only `member`, `invited` and `removed` answers are listed. Entries of +//! different brokers are never merged, even when they share a workspace ID: each keeps its +//! own `host_uid`, socket and key fingerprint, so a reader can tell them apart. +//! +//! A workspace found by the scan is trusted on first use, bounded by what the kernel vouches +//! for: a socket in a directory only its host account controls, answered by a process running +//! as that account. +use super::by_id::{held_by_several_folders, installed_program, peer_credentials, store_of}; +use super::*; +use std::sync::mpsc; + +/// Hosted state directories described, at most. +const HOSTED_LIMIT: usize = 64; +/// This account's own runtime directories probed, at most. +const OWN_RUNTIME_LIMIT: usize = 32; +/// Another account's runtime sockets probed, at most. +const SCAN_PER_UID: usize = 4; +/// Other accounts' runtime sockets probed, at most, in all. +const SCAN_LIMIT: usize = 64; +/// One probe of another account's broker, from connecting to the last byte read. +const SCAN_PROBE: Duration = Duration::from_secs(1); +/// The whole scan of other accounts. +const SCAN_DEADLINE: Duration = Duration::from_secs(5); +/// One probe of this account's own broker, as `status` gives one. +const OWN_PROBE: Duration = Duration::from_secs(3); +/// How long probes that have not answered are waited for past their deadline. +const COLLECT_GRACE: Duration = Duration::from_millis(250); +/// No journal replay starts later than this into the command. +const REPLAY_BUDGET: Duration = Duration::from_secs(15); +/// The most bytes read for one answer of a probed broker. +const FRAME_CAP: usize = 16 * 1024; +/// The whole answer stays within this. +const ANSWER_BYTES: usize = 48 * 1024; +/// What a broker that can say whether an account belongs to it advertises (AO-4). +const ACCOUNT_CAPABILITY: &str = "account_identity_v1"; +/// The longest journal a stopped workspace is replayed from, as the broker opens one. +const JOURNAL_BYTES: u64 = 1024 * 1024 * 1024; + +/// What one listing looks at and as whom. +struct Scope<'a> { + home: &'a Path, + /// `/tmp`, where runtime directories live. + runtime_root: &'a Path, + /// The account the listing is for: hosted runtime directories are this UID's, and the scan + /// looks at every other UID's. + account_uid: u32, + directory: &'a dyn Directory, +} + +impl Broker { + /// `biorouter-crew workspaces` for the account whose home is `home`: the JSON answer the + /// module documentation describes. Never fails; what cannot be read is said in it. + pub fn list_workspaces(home: &Path) -> Value { + report(&Scope { + home, + runtime_root: Path::new("/tmp"), + account_uid: unsafe { libc::geteuid() }, + directory: &SystemDirectory, + }) + } + + /// [`Broker::list_workspaces`] with another runtime root, another account UID (so the test + /// account's own runtime directories can stand in for another account's) and an injected + /// account directory. Test builds only. + #[cfg(feature = "test-seams")] + pub fn list_workspaces_with( + home: &Path, + runtime_root: &Path, + account_uid: u32, + directory: &dyn Directory, + ) -> Value { + report(&Scope { + home, + runtime_root, + account_uid, + directory, + }) + } +} + +// --------------------------------------------------------------------------------------------- +// Probing a broker +// --------------------------------------------------------------------------------------------- + +/// What one probe found. +enum Outcome { + /// The broker answered `hello` (and `account.status` when asked and advertised). + Answered { + hello: Value, + /// The listener's process, as the kernel reports it for this connection. + pid: Option, + /// `account.status`'s answer, or the refusal it gave. + status: Option>, + }, + /// Nothing listens there: the socket is missing or refuses connections. + Gone, + /// Anything else: a refusal, a timeout, an answer that is not a broker's. + Failed(String), +} + +/// The time left before `deadline`, or `timed_out`. +fn left(deadline: Instant) -> Result { + let left = deadline.saturating_duration_since(Instant::now()); + ensure!( + !left.is_zero(), + "timed_out: the broker did not answer in time" + ); + Ok(left) +} + +/// One newline-terminated frame from `stream`, read before `deadline` and at most +/// [`FRAME_CAP`] bytes. A peer that sends a byte at a time cannot hold the read past the +/// deadline: every read is bounded by the time left. +fn read_line_within(stream: &mut UnixStream, deadline: Instant) -> Result> { + let mut bytes = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + stream.set_read_timeout(Some(left(deadline)?))?; + let count = match stream.read(&mut chunk) { + Ok(count) => count, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + bail!("timed_out: the broker did not answer in time") + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.into()), + }; + ensure!(count > 0, "unavailable: the broker closed the connection"); + bytes.extend_from_slice(&chunk[..count]); + if let Some(end) = bytes.iter().position(|byte| *byte == b'\n') { + ensure!(end < FRAME_CAP, "invalid_frame: the answer is too large"); + bytes.truncate(end + 1); + return Ok(bytes); + } + ensure!( + bytes.len() < FRAME_CAP, + "invalid_frame: the answer is too large" + ); + } +} + +/// Send `request` and read its answer, both before `deadline`. +fn ask(stream: &mut UnixStream, deadline: Instant, request: &Value) -> Result { + let mut bytes = serde_json::to_vec(request)?; + bytes.push(b'\n'); + stream.set_write_timeout(Some(left(deadline)?))?; + stream.write_all(&bytes)?; + let line = read_line_within(stream, deadline)?; + serde_json::from_slice(&line).context("invalid_response: the answer is not a broker's") +} + +/// Whether `hello` advertises `capability`. +fn advertises(hello: &Value, capability: &str) -> bool { + hello + .get("capabilities") + .and_then(Value::as_array) + .is_some_and(|list| list.iter().any(|item| item.as_str() == Some(capability))) +} + +/// `hello` (with the challenge `nonce`), then `account.status` when `ask_status` and the broker +/// advertises it, on one connection to `socket`, a socket of `owner`'s, all before `deadline`. +fn probe(socket: &Path, owner: u32, deadline: Instant, nonce: &str, ask_status: bool) -> Outcome { + let gone = |error: &std::io::Error| { + matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) + }; + if let Err(error) = check_runtime_socket(socket, owner) { + return match error.downcast_ref::() { + Some(io) if gone(io) => Outcome::Gone, + _ => Outcome::Failed(format!("{error:#}")), + }; + } + let stream = match UnixStream::connect(socket) { + Ok(stream) => stream, + Err(error) if gone(&error) => return Outcome::Gone, + Err(error) => return Outcome::Failed(error.to_string()), + }; + match exchange(stream, owner, deadline, nonce, ask_status) { + Ok(outcome) => outcome, + Err(error) => Outcome::Failed(format!("{error:#}")), + } +} + +fn exchange( + mut stream: UnixStream, + owner: u32, + deadline: Instant, + nonce: &str, + ask_status: bool, +) -> Result { + let (uid, pid) = peer_credentials(&stream)?; + ensure!( + uid == owner, + "identity_mismatch: the listener runs as another account" + ); + let hello = ask( + &mut stream, + deadline, + &json!({"version": 1, "id": "workspaces-hello", "method": "hello", + "params": {"challenge_nonce": nonce}}), + )?; + if let Some(error) = hello.error { + bail!("{}: {}", error.code, error.message); + } + let hello = hello + .result + .ok_or_else(|| anyhow!("invalid_response: hello answered nothing"))?; + let status = if ask_status && advertises(&hello, ACCOUNT_CAPABILITY) { + let answer = ask( + &mut stream, + deadline, + &json!({"version": 1, "id": "workspaces-status", "method": "account.status", + "params": {}}), + )?; + Some(match (answer.result, answer.error) { + (Some(result), None) => Ok(result), + (_, Some(error)) => Err(format!("{}: {}", error.code, error.message)), + (None, None) => Err("invalid_response: account.status answered nothing".to_owned()), + }) + } else { + None + }; + Ok(Outcome::Answered { + hello, + pid: Some(pid), + status, + }) +} + +/// Probes running on their own threads, answered over one channel. +struct Probes { + receiver: mpsc::Receiver<(usize, Outcome)>, + count: usize, + /// Every probe's own deadline: none answers later. + deadline: Instant, +} + +/// Start one probe per `(socket, owner)` target, all at once, each within `per_probe`, with the +/// matching challenge nonce. A probe whose thread cannot start fails at once. +fn spawn_probes( + targets: &[(PathBuf, u32)], + nonces: &[String], + per_probe: Duration, + ask_status: bool, +) -> Probes { + let (sender, receiver) = mpsc::channel(); + let deadline = Instant::now() + per_probe; + for (index, ((socket, owner), nonce)) in targets.iter().zip(nonces).enumerate() { + let (socket, owner, nonce) = (socket.clone(), *owner, nonce.clone()); + let thread_sender = sender.clone(); + let spawned = std::thread::Builder::new() + .name("crew-workspaces-probe".into()) + .spawn(move || { + let outcome = probe(&socket, owner, deadline, &nonce, ask_status); + let _ = thread_sender.send((index, outcome)); + }); + if let Err(error) = spawned { + let _ = sender.send((index, Outcome::Failed(error.to_string()))); + } + } + Probes { + receiver, + count: targets.len(), + deadline, + } +} + +/// The probes' outcomes, waiting at most until their deadline plus [`COLLECT_GRACE`] and never +/// past `until`. A probe that has not answered by then is `None`; its thread is left to end on +/// its own (every read and write it makes is bounded by its deadline). +fn collect(probes: Probes, until: Instant) -> Vec> { + let until = until.min(probes.deadline + COLLECT_GRACE); + let mut outcomes: Vec> = (0..probes.count).map(|_| None).collect(); + let mut answered = 0; + while answered < probes.count { + let wait = until.saturating_duration_since(Instant::now()); + match probes.receiver.recv_timeout(wait) { + Ok((index, outcome)) => { + if outcomes[index].is_none() { + answered += 1; + } + outcomes[index] = Some(outcome); + } + Err(_) => break, + } + } + outcomes +} + +/// The longest `version` a listed broker may report. It is unsigned and shown as it is. +const VERSION_BYTES: usize = 64; + +/// Whether what an entry would repeat from another account's `hello` stays as small as a real +/// broker keeps it: a `name` the workspace-name rule accepts, a canonical `institution_id`, and +/// a short printable `version`. The signatures only say the answer is the broker's own, and +/// that broker is another account's: without these bounds a few of its brokers could fill the +/// answer's 48 KiB and leave every later workspace out. +fn listed_fields_bounded(hello: &Value) -> bool { + let present = |key: &str| hello.get(key).filter(|value| !value.is_null()); + present("name").is_none_or(|name| name.as_str().is_some_and(workspace_name_valid)) + && present("institution_id") + .is_none_or(|id| id.as_str().is_some_and(is_canonical_institution_id)) + && present("version").is_none_or(|version| { + version.as_str().is_some_and(|version| { + (1..=VERSION_BYTES).contains(&version.len()) + && version.bytes().all(|byte| byte.is_ascii_graphic()) + }) + }) +} + +/// Whether another account's `hello` checks out for the socket `owner` owns: it is signed, v1 +/// and v2, over `nonce` by the workspace key it presents, its signed `host_uid` is `owner`, and +/// the fields an entry repeats are bounded ([`listed_fields_bounded`]). The v2 signature is +/// what makes its capabilities, name, mode and institution trustworthy. +fn foreign_hello_checks_out(hello: &Value, owner: u32, nonce: &str) -> bool { + let check = || -> Result<()> { + ensure!(listed_fields_bounded(hello), "a listed field is too large"); + let workspace_id = text(hello, "workspace_id")?; + ensure!(broker_id(workspace_id), "invalid workspace ID"); + let host_uid = u32::try_from(number(hello, "host_uid")?)?; + ensure!(host_uid == owner, "host_uid is not the socket's owner"); + let public_key = text(hello, "workspace_public_key")?; + let key_bytes: [u8; 32] = hex::decode(public_key)? + .try_into() + .map_err(|_| anyhow!("invalid key"))?; + let key = VerifyingKey::from_bytes(&key_bytes)?; + let node_id = text(hello, "node_id")?; + let signature = |field: &str| -> Result { + Ok(Signature::from_slice(&hex::decode(text(hello, field)?)?)?) + }; + key.verify_strict( + &hello_v1_payload(workspace_id, host_uid, nonce, public_key, node_id), + &signature("signature")?, + )?; + let mode = mode(hello, "mode")?; + let capabilities: Vec<&str> = hello + .get("capabilities") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("capabilities missing"))? + .iter() + .map(|item| item.as_str().ok_or_else(|| anyhow!("invalid capability"))) + .collect::>()?; + let payload = HelloV2 { + workspace_id, + host_uid, + challenge_nonce: nonce, + workspace_public_key: public_key, + node_id, + mode: &mode, + institution_id: hello.get("institution_id").and_then(Value::as_str), + policy_epoch: number(hello, "policy_epoch")?, + name: hello.get("name").and_then(Value::as_str), + capabilities: &capabilities, + } + .signing_payload(); + key.verify_strict(&payload, &signature("signature_v2")?)?; + Ok(()) + }; + check().is_ok() +} + +/// Whether the process `pid` runs the same file (device and inode) as `installed`; `None` when +/// the process's executable cannot be read (no `/proc`, or not this account's process). +fn runs_installed(pid: i32, installed: &Path) -> Option { + let running = fs::metadata(format!("/proc/{pid}/exe")).ok()?; + Some( + fs::metadata(installed) + .is_ok_and(|file| file.dev() == running.dev() && file.ino() == running.ino()), + ) +} + +// --------------------------------------------------------------------------------------------- +// Entries +// --------------------------------------------------------------------------------------------- + +/// One workspace as the answer lists it (see the module documentation). +#[derive(Default)] +struct Entry { + workspace_id: Option, + name: Option, + role: &'static str, + host_uid: Option, + host_username: Option, + socket_path: Option, + /// The workspace public key, hex. + public_key: Option, + mode: Option, + institution_id: Option, + state: &'static str, + version: Option, + binary_current: Option, + state_dir: Option, + found_by: &'static str, + others_connected: Option, + error: Option, +} + +impl Entry { + /// The workspace's `brcrew1:` line without `ssh_host`, when it can be opened and every field + /// the line needs is known. + fn invitation(&self) -> Option { + if !matches!(self.state, "running" | "stopped" | "storage_failed") { + return None; + } + crate::invitation::encode(&crate::invitation::WorkspaceInvitation { + workspace_id: self.workspace_id.clone()?, + workspace_public_key: self.public_key.clone()?, + socket_path: self.socket_path.clone()?, + owner_uid: self.host_uid?, + workspace_name: self.name.clone(), + host_username: Some(self.host_username.clone()?), + host_display_name: None, + mode: Some(self.mode.clone()?), + institution_id: self.institution_id.clone(), + ssh_host: None, + ssh_port: None, + proxy_jump: None, + invitee_username: None, + }) + .ok() + } + + fn wire(&self) -> Value { + let fingerprint = self + .public_key + .as_deref() + .and_then(crate::invitation::workspace_key_fingerprint); + let mut wire = json!({ + "workspace_id": self.workspace_id, + "name": self.name, + "role": self.role, + "host_uid": self.host_uid, + "host_username": self.host_username, + "socket_path": self.socket_path, + "workspace_key_fingerprint": fingerprint, + "mode": self.mode, + "institution_id": self.institution_id, + "state": self.state, + "version": self.version, + "binary_current": self.binary_current, + "state_dir": self.state_dir, + "found_by": self.found_by, + "invitation": self.invitation(), + "others_connected": self.others_connected, + }); + if let Some(error) = &self.error { + wire["error"] = json!(error); + } + wire + } + + /// The entry for a broker that answered `hello`: its state, name, mode, institution, key + /// and version as it says them. + fn from_hello( + hello: &Value, + socket: &Path, + role: &'static str, + found_by: &'static str, + ) -> Self { + let state = match hello.get("state").and_then(Value::as_str) { + Some("deleted") => "deleted", + Some("storage_failed") => "storage_failed", + _ => "running", + }; + let field = |key: &str| hello.get(key).and_then(Value::as_str).map(str::to_owned); + Entry { + workspace_id: field("workspace_id"), + name: field("name"), + role, + host_uid: hello + .get("host_uid") + .and_then(Value::as_u64) + .and_then(|uid| u32::try_from(uid).ok()), + socket_path: socket.to_str().map(str::to_owned), + public_key: field("workspace_public_key"), + mode: hello + .get("mode") + .and_then(|mode| serde_json::from_value(mode.clone()).ok()), + institution_id: field("institution_id"), + state, + version: field("version"), + found_by, + ..Entry::default() + } + } +} + +/// `others_connected` from the host's own `account.status` answer. +fn others_connected(status: &Option>) -> Option { + match status { + Some(Ok(status)) if status.get("role").and_then(Value::as_str) == Some("host") => { + status.get("others_connected").and_then(Value::as_u64) + } + _ => None, + } +} + +// --------------------------------------------------------------------------------------------- +// Hosted workspaces +// --------------------------------------------------------------------------------------------- + +/// What a hosted state directory holds, before any broker is probed. +enum Hosted { + /// `runtime.json` names a process that is still there: probe its broker. + Running(Value), + /// No broker runs from it: read its journal. + Stopped, + /// An erase the host started and a crash interrupted. + Deleted(Option), + /// It could not be read. + Unknown(Option, String), +} + +/// The state directories under `store` holding a journal or a deleted-workspace marker, in name +/// order, at most [`HOSTED_LIMIT`], and how many more there were. Symbolic links are passed +/// over: the broker never keeps a workspace behind one. +fn hosted_dirs(store: &Path) -> (Vec, usize) { + let Ok(entries) = fs::read_dir(store) else { + return (Vec::new(), 0); + }; + let mut dirs: Vec = entries + .filter_map(|entry| entry.ok()) + .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.')) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| entry.path()) + .filter(|dir| { + fs::symlink_metadata(dir.join("journal.jsonl")).is_ok() + || fs::symlink_metadata(dir.join(DELETED_MARKER)).is_ok() + }) + .collect(); + dirs.sort(); + let over = dirs.len().saturating_sub(HOSTED_LIMIT); + dirs.truncate(HOSTED_LIMIT); + (dirs, over) +} + +fn classify(dir: &Path) -> Hosted { + let private = fs::symlink_metadata(dir).is_ok_and(|metadata| { + metadata.is_dir() + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o077 == 0 + }); + if !private { + return Hosted::Unknown( + None, + "unsafe_storage: state directory must be owner-only and not a symlink".into(), + ); + } + match deleted_marker(dir) { + Ok(Some(marked)) => return Hosted::Deleted(marked_workspace(dir, marked)), + Ok(None) => {} + Err(error) => return Hosted::Unknown(None, format!("{error:#}")), + } + match existing_runtime_descriptor(dir) { + Ok(Some(info)) if !recorded_process_gone(&info) => Hosted::Running(info), + Ok(_) => Hosted::Stopped, + Err(error) => Hosted::Unknown(None, format!("{error:#}")), + } +} + +/// The replayed state of the stopped workspace in `dir`. +fn stopped_state(dir: &Path) -> Result { + let journal = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(dir.join("journal.jsonl")) + .context("unsafe_storage: cannot read the journal")?; + let metadata = journal.metadata()?; + ensure!( + metadata.is_file() + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o077 == 0, + "unsafe_storage: file ownership, mode or links invalid" + ); + ensure!( + metadata.len() <= JOURNAL_BYTES, + "quota_exceeded: journal exceeds supported replay size of 1 GiB" + ); + let replay = replay_journal(&journal)?; + ensure!( + replay.sequence > 0, + "not_found: This state directory holds no workspace." + ); + Ok(replay.state_value) +} + +/// The entry for the stopped workspace whose replayed state is `state`. +fn stopped_entry(state: &Value, dir: &Path, runtime_root: &Path) -> Entry { + let workspace = &state["workspace"]; + let field = |key: &str| { + workspace + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) + }; + let public_key = state + .get("workspace_signing_key") + .and_then(Value::as_str) + .and_then(|secret| hex::decode(secret).ok()) + .and_then(|secret| <[u8; 32]>::try_from(secret).ok()) + .map(|secret| hex::encode(SigningKey::from_bytes(&secret).verifying_key().to_bytes())); + let socket_path = state + .get("runtime_basename") + .and_then(Value::as_str) + .and_then(|basename| { + runtime_root + .join(basename) + .join("broker.sock") + .to_str() + .map(str::to_owned) + }); + Entry { + workspace_id: field("id"), + name: field("name"), + role: "host", + host_uid: workspace + .get("host_uid") + .and_then(Value::as_u64) + .and_then(|uid| u32::try_from(uid).ok()), + socket_path, + public_key, + mode: workspace + .get("mode") + .and_then(|mode| serde_json::from_value(mode.clone()).ok()), + institution_id: field("institution_id"), + state: if workspace.get("deleted_at").is_some_and(|at| !at.is_null()) { + "deleted" + } else { + "stopped" + }, + state_dir: dir.to_str().map(str::to_owned), + found_by: "state_dir", + ..Entry::default() + } +} + +/// An entry that could not be read. +fn unknown_entry( + workspace_id: Option, + dir: Option<&Path>, + found_by: &'static str, + error: String, +) -> Entry { + Entry { + workspace_id, + role: "host", + state: "unknown", + state_dir: dir.and_then(Path::to_str).map(str::to_owned), + found_by, + error: Some(error), + ..Entry::default() + } +} + +/// Hosted entries whose ID more than one state directory holds (a copy of a workspace's +/// folder kept in the store) become `unknown`, each with the refusal `start` and `restart` +/// give for that ID: which folder is the workspace cannot be told from here, so none is listed +/// as it, and a stopped copy never stands in for the running workspace. +fn mark_copies(entries: &mut [Entry]) { + let mut folders: BTreeMap> = BTreeMap::new(); + for entry in entries.iter().filter(|entry| entry.found_by == "state_dir") { + if let (Some(id), Some(dir)) = (&entry.workspace_id, &entry.state_dir) { + folders + .entry(id.clone()) + .or_default() + .push(PathBuf::from(dir)); + } + } + for entry in entries + .iter_mut() + .filter(|entry| entry.found_by == "state_dir") + { + let Some(id) = entry.workspace_id.clone() else { + continue; + }; + if let Some(held) = folders.get(&id).filter(|held| held.len() > 1) { + let mut held = held.clone(); + held.sort(); + entry.state = "unknown"; + entry.error = Some(held_by_several_folders(&id, &held)); + } + } +} + +/// This account's runtime sockets in `runtime_root` (`crew--<32 hex>/broker.sock`, the +/// directory and the socket its own), in name order, at most [`OWN_RUNTIME_LIMIT`], and how +/// many more there were. +fn own_runtime_sockets(runtime_root: &Path, uid: u32) -> (Vec, usize) { + let Ok(entries) = fs::read_dir(runtime_root) else { + return (Vec::new(), 0); + }; + let mut sockets: Vec = entries + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter(|name| runtime_basename_of(name, uid)) + .map(|name| runtime_root.join(name).join("broker.sock")) + .filter(|socket| check_runtime_socket(socket, uid).is_ok()) + .collect(); + sockets.sort(); + let over = sockets.len().saturating_sub(OWN_RUNTIME_LIMIT); + sockets.truncate(OWN_RUNTIME_LIMIT); + (sockets, over) +} + +// --------------------------------------------------------------------------------------------- +// Other accounts' workspaces +// --------------------------------------------------------------------------------------------- + +/// The UID a runtime directory's name carries: `crew--<32 hex>`, with the UID written as +/// the broker writes it. +fn runtime_owner(name: &str) -> Option { + let uid: u32 = name + .strip_prefix("crew-")? + .split('-') + .next()? + .parse() + .ok()?; + runtime_basename_of(name, uid).then_some(uid) +} + +/// Other accounts' runtime sockets in `runtime_root`, by UID, each checked before anything is +/// opened: the directory (not a symbolic link) and the socket belong to the UID the name +/// carries, and no other account can write into the directory. Decoys anyone can create (a +/// name with another UID, a file, a link, a directory without a socket) are never candidates +/// and take no slot. +fn foreign_candidates(runtime_root: &Path, account_uid: u32) -> BTreeMap> { + let mut candidates: BTreeMap> = BTreeMap::new(); + let Ok(entries) = fs::read_dir(runtime_root) else { + return candidates; + }; + for name in entries.filter_map(|entry| entry.ok()?.file_name().into_string().ok()) { + let Some(uid) = runtime_owner(&name) else { + continue; + }; + if uid == account_uid { + continue; + } + let socket = runtime_root.join(&name).join("broker.sock"); + if check_runtime_socket(&socket, uid).is_ok() { + candidates.entry(uid).or_default().push(socket); + } + } + candidates +} + +/// The candidates to probe: at most [`SCAN_PER_UID`] per account, in name order, taken +/// round-robin across accounts, and [`SCAN_LIMIT`] in all, with how many were left untried. +/// One account's many directories never take another account's turn. +fn select_candidates(mut candidates: BTreeMap>) -> (Vec<(PathBuf, u32)>, usize) { + for sockets in candidates.values_mut() { + sockets.sort(); + } + let total: usize = candidates.values().map(Vec::len).sum(); + let mut selected = Vec::new(); + 'rounds: for round in 0..SCAN_PER_UID { + for (uid, sockets) in &candidates { + if selected.len() == SCAN_LIMIT { + break 'rounds; + } + if let Some(socket) = sockets.get(round) { + selected.push((socket.clone(), *uid)); + } + } + } + let untried = total - selected.len(); + (selected, untried) +} + +// --------------------------------------------------------------------------------------------- +// The listing +// --------------------------------------------------------------------------------------------- + +/// One listing in progress: its scope, its counts and the account names resolved so far. +struct Listing<'a> { + scope: &'a Scope<'a>, + started: Instant, + installed: PathBuf, + usernames: BTreeMap>, + skipped: usize, + unsupported: usize, + truncated: usize, +} + +/// The `(socket, owner)` targets the running hosted workspaces' runtime descriptors name, and +/// which directory each belongs to. +fn running_targets(hosted: &[Hosted]) -> (Vec<(PathBuf, u32)>, Vec) { + let mut targets = Vec::new(); + let mut of = Vec::new(); + for (index, kind) in hosted.iter().enumerate() { + if let Hosted::Running(info) = kind { + let socket = info + .get("socket") + .and_then(Value::as_str) + .map(PathBuf::from); + let owner = info + .get("host_uid") + .and_then(Value::as_u64) + .and_then(|uid| u32::try_from(uid).ok()); + if let (Some(socket), Some(owner)) = (socket, owner) { + targets.push((socket, owner)); + of.push(index); + } + } + } + (targets, of) +} + +fn nonces(count: usize) -> Vec { + (0..count).map(|_| token()).collect() +} + +impl Listing<'_> { + fn username(&mut self, uid: u32) -> Option { + let directory = self.scope.directory; + self.usernames + .entry(uid) + .or_insert_with(|| directory.by_uid(uid).ok().map(|account| account.name)) + .clone() + } + + /// The entry for the running hosted workspace in `dir` that `info` records, from its + /// broker's answer; `None` when nothing listens there any more, so its journal is read. + fn running_entry( + &mut self, + dir: &Path, + info: &Value, + outcome: Option, + ) -> Option { + let recorded = info + .get("workspace_id") + .and_then(Value::as_str) + .map(str::to_owned); + let (hello, pid, status) = match outcome { + Some(Outcome::Answered { hello, pid, status }) => (hello, pid, status), + Some(Outcome::Gone) => return None, + Some(Outcome::Failed(error)) => { + return Some(unknown_entry(recorded, Some(dir), "state_dir", error)) + } + None => { + let error = "timed_out: the workspace's broker did not answer in time"; + return Some(unknown_entry( + recorded, + Some(dir), + "state_dir", + error.into(), + )); + } + }; + let matches = hello.get("workspace_id") == info.get("workspace_id") + && hello.get("host_uid") == info.get("host_uid") + && info + .get("workspace_public_key") + .is_none_or(|key| hello.get("workspace_public_key") == Some(key)); + if !matches { + let error = "identity_mismatch: the broker at the recorded socket answers for another workspace"; + return Some(unknown_entry( + recorded, + Some(dir), + "state_dir", + error.into(), + )); + } + let socket = PathBuf::from(info["socket"].as_str().unwrap_or_default()); + let mut entry = Entry::from_hello(&hello, &socket, "host", "state_dir"); + entry.version = entry.version.or_else(|| { + info.get("version") + .and_then(Value::as_str) + .map(str::to_owned) + }); + entry.host_username = entry.host_uid.and_then(|uid| self.username(uid)); + entry.binary_current = pid.and_then(|pid| runs_installed(pid, &self.installed)); + entry.state_dir = dir.to_str().map(str::to_owned); + entry.others_connected = others_connected(&status); + Some(entry) + } + + /// The entries for the hosted state directories `dirs`, which `hosted` classified; the + /// running ones from their brokers' answers (`probed`, by directory), the stopped ones + /// from their journals. + fn hosted_entries( + &mut self, + dirs: &[PathBuf], + hosted: &[Hosted], + mut probed: BTreeMap>, + ) -> Vec { + let mut entries = Vec::new(); + let mut to_replay = Vec::new(); + for (index, (dir, kind)) in dirs.iter().zip(hosted).enumerate() { + match kind { + Hosted::Deleted(id) => entries.push(Entry { + workspace_id: id.clone(), + role: "host", + state: "deleted", + state_dir: dir.to_str().map(str::to_owned), + found_by: "state_dir", + ..Entry::default() + }), + Hosted::Unknown(id, error) => entries.push(unknown_entry( + id.clone(), + Some(dir), + "state_dir", + error.clone(), + )), + Hosted::Stopped => to_replay.push(dir), + Hosted::Running(info) => match probed.remove(&index) { + None => { + let id = info.get("workspace_id").and_then(Value::as_str); + let error = "unsafe_runtime: runtime.json names no socket or host"; + let id = id.map(str::to_owned); + entries.push(unknown_entry(id, Some(dir), "state_dir", error.into())); + } + Some(outcome) => match self.running_entry(dir, info, outcome) { + Some(entry) => entries.push(entry), + None => to_replay.push(dir), + }, + }, + } + } + to_replay.sort(); + for dir in to_replay { + entries.push(self.stopped(dir)); + } + entries + } + + /// The entry for the stopped workspace in `dir`, read from its journal while there is time. + fn stopped(&mut self, dir: &Path) -> Entry { + if self.started.elapsed() >= REPLAY_BUDGET { + let error = "timed_out: there was no time left to read this workspace's journal"; + return unknown_entry(None, Some(dir), "state_dir", error.into()); + } + match stopped_state(dir) { + Ok(state) => { + let mut entry = stopped_entry(&state, dir, self.scope.runtime_root); + entry.host_username = entry.host_uid.and_then(|uid| self.username(uid)); + entry + } + Err(error) => unknown_entry(None, Some(dir), "state_dir", format!("{error:#}")), + } + } + + /// Running brokers of this account found by their runtime directories: workspaces started + /// with a custom `--state-dir`, added to `entries`. One already listed is not listed twice; + /// a stopped listing of a workspace whose broker turns out to run (its runtime.json went + /// missing) says it runs. + fn runtime_entries( + &mut self, + entries: &mut Vec, + sockets: &[PathBuf], + outcomes: Vec>, + ) { + let account_uid = u64::from(self.scope.account_uid); + for (socket, outcome) in sockets.iter().zip(outcomes) { + let (hello, pid, status) = match outcome { + Some(Outcome::Answered { hello, pid, status }) => (hello, pid, status), + Some(Outcome::Gone) => continue, + Some(Outcome::Failed(_)) | None => { + self.skipped += 1; + continue; + } + }; + let host_uid = hello.get("host_uid").and_then(Value::as_u64); + if host_uid != Some(account_uid) { + self.skipped += 1; + continue; + } + let mut entry = Entry::from_hello(&hello, socket, "host", "runtime"); + entry.host_username = entry.host_uid.and_then(|uid| self.username(uid)); + entry.binary_current = pid.and_then(|pid| runs_installed(pid, &self.installed)); + entry.others_connected = others_connected(&status); + // The same workspace only when its ID, host and key all agree: two entries sharing + // an ID are otherwise kept apart, so a reader can tell them apart. + let listed = entries.iter_mut().find(|listed| { + listed.workspace_id.is_some() + && listed.workspace_id == entry.workspace_id + && listed.host_uid == entry.host_uid + && listed.public_key == entry.public_key + }); + match listed { + Some(listed) if listed.state == "stopped" => { + entry.state_dir = listed.state_dir.take(); + entry.found_by = "state_dir"; + *listed = entry; + } + Some(_) => {} + None => entries.push(entry), + } + } + } + + /// Other accounts' workspaces this account belongs to, was invited to or was removed from, + /// from the probes of `targets` (challenged with `nonces`). + fn scanned_entries( + &mut self, + targets: &[(PathBuf, u32)], + nonces: &[String], + outcomes: Vec>, + ) -> Vec { + let mut found = Vec::new(); + for (((socket, owner), nonce), outcome) in targets.iter().zip(nonces).zip(outcomes) { + let (hello, status) = match outcome { + Some(Outcome::Answered { hello, status, .. }) => (hello, status), + Some(Outcome::Gone) => continue, + Some(Outcome::Failed(_)) | None => { + self.skipped += 1; + continue; + } + }; + if !foreign_hello_checks_out(&hello, *owner, nonce) { + self.skipped += 1; + continue; + } + if !advertises(&hello, ACCOUNT_CAPABILITY) { + self.unsupported += 1; + continue; + } + if hello.get("state").and_then(Value::as_str) == Some("deleted") { + continue; + } + // A refusal (a workspace counting down to its erase) is neither listed nor counted. + let Some(Ok(status)) = status else { + continue; + }; + let role = match status.get("role").and_then(Value::as_str) { + Some("member") => "member", + Some("invited") => "invited", + Some("removed") => "removed", + _ => continue, + }; + let mut entry = Entry::from_hello(&hello, socket, role, "scan"); + entry.host_uid = Some(*owner); + entry.host_username = self.username(*owner); + found.push(entry); + } + found.sort_by(|a, b| { + (&a.host_username, &a.name, &a.workspace_id).cmp(&( + &b.host_username, + &b.name, + &b.workspace_id, + )) + }); + found + } + + /// The answer: the account, every entry, in order, that still fits in [`ANSWER_BYTES`], the + /// counts, and whether anything was left out (`truncated` counts what was). An entry that + /// does not fit is passed over, so one large entry never hides the smaller ones after it. + fn answer(mut self, entries: Vec) -> Value { + let account_uid = self.scope.account_uid; + let account = json!({"uid": account_uid, "username": self.username(account_uid)}); + // Room for the envelope and the counts, then each entry that fits. + let mut used = json_len(&account) + 128; + let mut listed = Vec::new(); + for entry in entries { + let wire = entry.wire(); + let length = json_len(&wire) + 1; + if used + length > ANSWER_BYTES { + self.truncated += 1; + continue; + } + used += length; + listed.push(wire); + } + json!({ + "account": account, + "workspaces": listed, + "skipped": self.skipped, + "unsupported": self.unsupported, + "truncated": self.truncated > 0, + }) + } +} + +fn report(scope: &Scope) -> Value { + let mut listing = Listing { + scope, + started: Instant::now(), + installed: installed_program(scope.home), + usernames: BTreeMap::new(), + skipped: 0, + unsupported: 0, + truncated: 0, + }; + let account_uid = scope.account_uid; + + // Other accounts' brokers are probed first, so their deadline runs while this account's + // own workspaces are read. + let (others, untried) = select_candidates(foreign_candidates(scope.runtime_root, account_uid)); + listing.skipped += untried; + let other_nonces = nonces(others.len()); + let other_probes = spawn_probes(&others, &other_nonces, SCAN_PROBE, true); + + // This account's state directories, and its runtime directories for custom state dirs. + let (dirs, over) = hosted_dirs(&store_of(scope.home)); + listing.truncated += over; + let hosted: Vec = dirs.iter().map(|dir| classify(dir)).collect(); + let (running, running_of) = running_targets(&hosted); + let (own_sockets, own_over) = own_runtime_sockets(scope.runtime_root, account_uid); + listing.truncated += own_over; + let own: Vec<(PathBuf, u32)> = own_sockets + .iter() + .map(|socket| (socket.clone(), account_uid)) + .collect(); + let running_probes = spawn_probes(&running, &nonces(running.len()), OWN_PROBE, true); + let own_probes = spawn_probes(&own, &nonces(own.len()), OWN_PROBE, true); + let own_until = listing.started + OWN_PROBE + COLLECT_GRACE; + + let probed = running_of + .into_iter() + .zip(collect(running_probes, own_until)) + .collect(); + let mut entries = listing.hosted_entries(&dirs, &hosted, probed); + listing.runtime_entries(&mut entries, &own_sockets, collect(own_probes, own_until)); + mark_copies(&mut entries); + entries.sort_by(|a, b| { + (&a.name, &a.workspace_id, &a.state_dir).cmp(&(&b.name, &b.workspace_id, &b.state_dir)) + }); + let scanned = collect(other_probes, listing.started + SCAN_DEADLINE); + entries.extend(listing.scanned_entries(&others, &other_nonces, scanned)); + listing.answer(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sockets(uid: u32, count: usize) -> Vec { + (0..count) + .map(|index| PathBuf::from(format!("/tmp/crew-{uid}-{index:032x}/broker.sock"))) + .collect() + } + + /// Four per account, round-robin, sixty-four in all: an account with many directories + /// never takes another account's turn, and what is left untried is counted. + #[test] + fn candidates_are_taken_round_robin_within_both_caps() { + let mut candidates = BTreeMap::new(); + candidates.insert(2000, sockets(2000, 100)); + candidates.insert(2001, sockets(2001, 1)); + candidates.insert(2002, sockets(2002, 2)); + let (selected, untried) = select_candidates(candidates); + let per = |uid: u32| selected.iter().filter(|(_, owner)| *owner == uid).count(); + assert_eq!((per(2000), per(2001), per(2002)), (4, 1, 2)); + assert_eq!(untried, 96); + // The first round reaches every account before anyone's second socket. + let first: Vec = selected.iter().take(3).map(|(_, uid)| *uid).collect(); + assert_eq!(first, vec![2000, 2001, 2002]); + + // Twenty accounts with five sockets each: 80 within the per-account cap, 64 probed. + let candidates: BTreeMap> = (0..20) + .map(|uid| (3000 + uid, sockets(3000 + uid, 5))) + .collect(); + let (selected, untried) = select_candidates(candidates); + assert_eq!(selected.len(), SCAN_LIMIT); + assert_eq!(untried, 100 - SCAN_LIMIT); + // Every account got its first three turns before anyone's fourth. + for uid in 3000..3020 { + assert!( + selected.iter().filter(|(_, owner)| *owner == uid).count() >= 3, + "{uid}" + ); + } + } + + #[test] + fn a_runtime_name_carries_its_owner_as_the_broker_writes_it() { + let hex = "0".repeat(32); + assert_eq!(runtime_owner(&format!("crew-1001-{hex}")), Some(1001)); + assert_eq!(runtime_owner(&format!("crew-01001-{hex}")), None); + assert_eq!( + runtime_owner(&format!("crew-1001-{}", "A".repeat(32))), + None + ); + assert_eq!(runtime_owner("crew-1001-short"), None); + assert_eq!(runtime_owner(&format!("crt-1001-{hex}")), None); + } + + /// A directory that knows no account. + struct NoAccounts; + impl Directory for NoAccounts { + fn by_uid(&self, _uid: u32) -> Result { + bail!("no such account") + } + fn by_name(&self, _name: &str) -> Result { + bail!("no such account") + } + } + + fn scanned(name: &str, error: Option) -> Entry { + Entry { + workspace_id: Some(Uuid::new_v4().to_string()), + name: Some(name.into()), + role: "member", + host_uid: Some(1002), + state: "running", + found_by: "scan", + error, + ..Entry::default() + } + } + + /// One entry too large for what is left of the 48 KiB is passed over, and the smaller ones + /// after it are still listed; every entry left out counts as truncated. + #[test] + fn an_entry_that_does_not_fit_never_hides_the_ones_after_it() { + let root = std::env::temp_dir(); + let scope = Scope { + home: &root, + runtime_root: &root, + account_uid: 1001, + directory: &NoAccounts, + }; + let listing = || Listing { + scope: &scope, + started: Instant::now(), + installed: PathBuf::new(), + usernames: BTreeMap::new(), + skipped: 0, + unsupported: 0, + truncated: 0, + }; + let huge = || Some("x".repeat(20 * 1024)); + let entries = vec![ + scanned("first", huge()), + scanned("second", huge()), + scanned("third", huge()), + scanned("after", None), + ]; + let answer = listing().answer(entries); + let names: Vec<&str> = answer["workspaces"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["first", "second", "after"], "{answer:#}"); + assert_eq!(answer["truncated"], true); + assert!(json_len(&answer) <= ANSWER_BYTES); + + let fits = listing().answer(vec![scanned("only", None)]); + assert_eq!(fits["truncated"], false); + } + + /// Another account's broker may repeat only a name, institution and version as small as a + /// real broker's; anything larger does not check out, whatever its signatures say. + #[test] + fn a_foreign_hello_repeats_only_bounded_fields() { + let real = json!({"name": "baranzini-lab", "institution_id": "ucsf", "version": "1.94.1"}); + assert!(listed_fields_bounded(&real)); + assert!(listed_fields_bounded( + &json!({"name": null, "institution_id": null}) + )); + assert!(listed_fields_bounded(&json!({}))); + for bad in [ + json!({"name": "a".repeat(15 * 1024)}), + json!({"name": "Baranzini Lab"}), + json!({"name": 7}), + json!({"institution_id": "u".repeat(65)}), + json!({"institution_id": "UCSF"}), + json!({"version": "1".repeat(VERSION_BYTES + 1)}), + json!({"version": "1.94.1 \u{202e}"}), + json!({"version": ""}), + json!({"version": 194}), + ] { + assert!(!listed_fields_bounded(&bad), "{bad}"); + } + assert!(listed_fields_bounded( + &json!({"version": "1".repeat(VERSION_BYTES)}) + )); + } + + fn hosted_entry(workspace_id: &str, state: &'static str, dir: &str) -> Entry { + Entry { + workspace_id: Some(workspace_id.into()), + name: Some("lab".into()), + role: "host", + host_uid: Some(1001), + state, + state_dir: Some(dir.into()), + found_by: "state_dir", + ..Entry::default() + } + } + + /// Two folders holding one ID: neither is listed as the workspace. A running workspace is + /// never reported stopped because a copy of its folder sorts first, and the sentence is + /// the one `start` and `restart` refuse that ID with. + #[test] + fn folders_sharing_an_id_are_listed_as_a_conflict() { + let id = Uuid::new_v4().to_string(); + let other = Uuid::new_v4().to_string(); + let mut entries = vec![ + hosted_entry( + &id, + "stopped", + "/home/a/.local/share/biorouter-crew/backup-lab", + ), + hosted_entry(&id, "running", "/home/a/.local/share/biorouter-crew/lab"), + hosted_entry( + &other, + "stopped", + "/home/a/.local/share/biorouter-crew/notes", + ), + ]; + mark_copies(&mut entries); + for entry in &entries[..2] { + assert_eq!(entry.state, "unknown"); + let error = entry.error.as_deref().unwrap(); + assert!(error.starts_with("identity_conflict: "), "{error}"); + assert!(error.contains("(backup-lab, lab)"), "{error}"); + assert!(entry.invitation().is_none()); + } + assert_eq!(entries[2].state, "stopped"); + assert!(entries[2].error.is_none()); + } + + /// An entry that cannot be opened carries no invitation, and one with every field does. + #[test] + fn an_invitation_needs_every_field_and_an_open_workspace() { + let key = SigningKey::from_bytes(&[3; 32]); + let mut entry = Entry { + workspace_id: Some(Uuid::new_v4().to_string()), + name: Some("lab".into()), + role: "host", + host_uid: Some(1001), + host_username: Some("wanjun".into()), + socket_path: Some(format!("/tmp/crew-1001-{}/broker.sock", "a".repeat(32))), + public_key: Some(hex::encode(key.verifying_key().to_bytes())), + mode: Some(Mode::Private), + state: "stopped", + found_by: "state_dir", + ..Entry::default() + }; + let line = entry + .invitation() + .expect("a stopped workspace can be opened"); + let parsed = crate::invitation::parse(&line).unwrap().invitation; + assert_eq!(parsed.host_username.as_deref(), Some("wanjun")); + assert_eq!(parsed.ssh_host, None); + assert!(entry.wire()["workspace_key_fingerprint"].is_string()); + entry.state = "deleted"; + assert!(entry.invitation().is_none()); + entry.state = "running"; + entry.host_username = None; + assert!(entry.invitation().is_none()); + } +} diff --git a/crates/biorouter-crew/tests/account_contract.rs b/crates/biorouter-crew/tests/account_contract.rs new file mode 100644 index 000000000..45e90d1ac --- /dev/null +++ b/crates/biorouter-crew/tests/account_contract.rs @@ -0,0 +1,1206 @@ +//! Broker contract for account ownership: `auth.account`, `account.status`, `device.revoke`, +//! the `account_identity_v1` capability and `hello`'s `version` (AO-1 to AO-5 of +//! `docs/crew/design/account-ownership.md`). +//! +//! The account path is compiled with the `join-by-name` feature, so most of this file needs it: +//! +//! ```text +//! cargo test -p biorouter-crew --test account_contract +//! cargo test -p biorouter-crew --no-default-features --test account_contract +//! ``` +//! +//! **Always compiled:** the downgrade replay. A journal holding every record shape the account +//! path writes (an empty bootstrap key, the host's first device, an invited member's join, a +//! replaced device, a followed host rename and a revoked device) replays and keeps working on +//! a broker built **without** the feature, which stands in for a broker that predates the +//! account path: the `--no-default-features` run is that build. The feature build pins that +//! the real broker writes exactly those shapes, so the two runs together cover the downgrade. +//! +//! **The previous broker, for the reviewer.** The released v1.94.1 broker predates the account +//! path. The run, on any Unix (it uses the account-directory seam, so no second account): +//! +//! 1. Extract `git archive v1.94.1 Cargo.toml crates/biorouter-crew` into a scratch folder, give +//! its workspace version a pre-release suffix so the two packages differ, and link it beside +//! this tree's crate in one small harness (`crew_old`, `crew_new`, both with `test-seams`). +//! 2. Alternate the two over one state directory: new (create with no bootstrap key, the host's +//! first device by `auth.account`, a policy, a team, invite `@bob`), old (replay; the host's +//! account-added device signs a snapshot; `auth.bootstrap` stays shut; a team), new (`@bob` +//! joins with no code, his laptop replaces his desktop with `replaces_device_id`, the host +//! account is renamed and followed, a second host device is revoked by fingerprint), old +//! (replay; the renamed host and Bob's laptop authenticate, the replaced and revoked devices +//! are `unknown device`, `auth.account` and `account.status` are refused as unknown, two +//! mutations), new (replay; a retried key commits nothing; `account.status`), old (replay). +//! 3. Every step must succeed. +//! +//! Measured 2026-11-05 on macOS against this tree: every step passed (14 journal records). +//! +//! Tests that sign `hello` need Linux (a machine identity); the rest run on every Unix. +#![cfg(unix)] + +mod support; + +use serde_json::{json, Value}; +use support::*; + +const BOB: u32 = 71_001; + +/// A device as the account path stores it. +fn account_device(principal_id: &str, key: &ed25519_dalek::SigningKey) -> Value { + json!({ + "principal_id": principal_id, + "public_key": key_hex(key), + "added_at": now(), + "added_via": "account", + }) +} + +/// A journal holding each record the account path writes replays on a broker built without +/// it, which then keeps authenticating the devices those records added and keeps committing. +#[test] +fn a_journal_holding_account_records_replays_without_the_feature() { + let ws = Workspace::unbootstrapped("account-downgrade"); + let host = uuid(); + let bob = uuid(); + let host_second = key(41); + let bob_desktop = key(42); + let bob_laptop = key(43); + let (host_device, second_device) = (device_id(&key(7)), device_id(&host_second)); + let (desktop_device, laptop_device) = (device_id(&bob_desktop), device_id(&bob_laptop)); + let join = json!({ + "join_id": "0b".repeat(16), "uid": BOB, "username": "bob", "full_name": null, + "inviter_id": host, "existing_principal_id": null, "generation": [], + "approved_code": null, "created_at": now(), "expires_at": now() + 86_400, + }); + let dedupe_key = format!("{host}:human:revoke-1"); + let mut ws = ws.edit_journal(|journal| { + // A workspace created without `--bootstrap-key` (AO-7). + journal.append( + "system", + "workspace.initialize", + vec![set(&["bootstrap_key"], json!(""))], + ); + // The host's first device (AO-2, host's first device). + journal.append( + &host, + "auth.account", + vec![ + set( + &["principals", host.as_str()], + legacy_principal(&host, host_uid(), "alice", "alice", true), + ), + set( + &["devices", host_device.as_str()], + account_device(&host, &key(7)), + ), + ], + ); + // An invited member joins with no code. + journal.append( + &host, + "enrollment.invite", + vec![set(&["pending_joins"], json!({ "71001": join }))], + ); + journal.append( + &bob, + "auth.account", + vec![ + set( + &["principals", bob.as_str()], + legacy_principal(&bob, BOB, "bob", "bob", true), + ), + set( + &["devices", desktop_device.as_str()], + account_device(&bob, &bob_desktop), + ), + remove(&["pending_joins"]), + ], + ); + // A device replaced in place. + journal.append( + &bob, + "auth.account", + vec![ + remove(&["devices", desktop_device.as_str()]), + set( + &["devices", laptop_device.as_str()], + account_device(&bob, &bob_laptop), + ), + ], + ); + // The host's account renamed and followed, with a second device. + journal.append( + &host, + "auth.account", + vec![ + set(&["principals", host.as_str(), "username"], json!("alicia")), + set(&["principals", host.as_str(), "nickname"], json!("alicia")), + set( + &["devices", second_device.as_str()], + account_device(&host, &host_second), + ), + ], + ); + // That second device revoked by fingerprint. + journal.append( + &host, + "device.revoke", + vec![ + remove(&["devices", second_device.as_str()]), + set( + &["dedupe", dedupe_key.as_str()], + json!({"digest": "0".repeat(64), "result": {"revoked": true}, "at": now()}), + ), + ], + ); + }); + ws.directory.set(host_uid(), "alicia", None); + ws.directory.set(BOB, "bob", None); + ws.host.principal_id = host.clone(); + let snapshot = ws.host_snapshot(); + assert_eq!(snapshot["actor"]["username"], "alicia"); + assert_eq!(snapshot["workspace"]["host_principal_id"], json!(host)); + let mut laptop = Member::new(BOB, bob_laptop); + laptop.principal_id = bob.clone(); + assert_eq!(ws.snapshot(&mut laptop)["actor"]["id"], json!(bob)); + // The replaced and the revoked devices stay gone. + let mut desktop = Member::new(BOB, bob_desktop); + let (_, message) = refused(ws.call(&mut desktop, "workspace.snapshot", json!({}))); + assert_eq!(message, "unauthorized: unknown device"); + ws.host_team("Lab"); + let mut ws = ws.reopen(); + ws.host_team("Methods"); + assert_eq!(ws.broker.state_json()["bootstrap_key"], ""); +} + +#[cfg(feature = "join-by-name")] +mod account { + use super::*; + use biorouter_crew::{Broker, Connection, Quotas, Request, MAX_DEVICES_PER_PRINCIPAL}; + + const CAROL: u32 = 71_002; + const DAVE: u32 = 71_003; + const STRANGER: u32 = 71_050; + const LOOKALIKE: u32 = 71_009; + + /// A workspace named `baranzini-lab`, so refusals name it. + fn named(label: &str) -> Workspace { + let mut ws = Workspace::new(label); + ws.host_ok("workspace.rename", json!({"name": "baranzini-lab"})); + ws + } + + /// `member`'s own devices, as its snapshot lists them. + fn devices(ws: &mut Workspace, member: &mut Member) -> Vec { + ws.snapshot(member)["actor"]["devices"] + .as_array() + .unwrap() + .clone() + } + + fn assert_unknown_device(ws: &mut Workspace, member: &mut Member) { + let (_, message) = refused(ws.call(member, "workspace.snapshot", json!({}))); + assert_eq!(message, "unauthorized: unknown device"); + } + + // ── The host ────────────────────────────────────────────────────────────────────────── + + /// AO-1: a host who lost every computer signs in as the host account from a new one and is + /// the host again, with no invitation, code or other device. + #[test] + fn the_host_gets_a_fresh_key_after_losing_every_device() { + let mut ws = named("account-host-lost"); + let lost = device_id(&ws.host.key); + let mut fresh = Member::new(host_uid(), key(50)); + let answer = ws.account_ok(&mut fresh, json!({"replaces_device_id": lost})); + assert_eq!(answer["role"], "host"); + assert_eq!(answer["admitted"], "device"); + assert_eq!(answer["principal"]["id"], json!(ws.host.principal_id)); + assert_eq!(answer["principal"]["username"], "alice"); + assert_eq!(answer["device_id"], json!(device_id(&fresh.key))); + assert_eq!(answer["workspace"]["name"], "baranzini-lab"); + assert!(answer.get("evicted_device").is_none()); + // The lost key is gone, and the new one carries the host's authority. + let mut lost_host = Member::new(host_uid(), key(7)); + assert_unknown_device(&mut ws, &mut lost_host); + ws.call_ok(&mut fresh, "workspace.rename", json!({"name": "lab"})); + let listed = devices(&mut ws, &mut fresh); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0]["added_via"], "account"); + assert_eq!(listed[0]["this_device"], true); + } + + /// The host's first device comes from `auth.account` when the workspace was created + /// without a bootstrap key, and `auth.bootstrap` never admits an empty stored key (AO-7). + #[test] + fn the_hosts_first_device_needs_no_bootstrap_key() { + let mut ws = Workspace::without_bootstrap_key("account-host-first"); + assert_eq!(ws.broker.state_json()["bootstrap_key"], ""); + let mut host = Member::new(host_uid(), key(7)); + let params = json!({"public_key": key_hex(&host.key)}); + let (code, message) = refused(signed_raw( + &mut ws.broker, + &mut host, + "auth.bootstrap", + params, + )); + assert_eq!(code, "forbidden"); + assert_eq!( + message, + "forbidden: bootstrap key/identity mismatch or already enrolled" + ); + let answer = ws.account_ok(&mut host, json!({})); + assert_eq!(answer["role"], "host"); + assert_eq!(answer["admitted"], "host"); + assert_eq!(answer["principal"]["username"], "alice"); + let record = ws.last_record(); + assert_eq!(record["operation"], "auth.account"); + assert_eq!(record["actor"], answer["principal"]["id"]); + ws.host = host; + let snapshot = ws.host_snapshot(); + assert_eq!( + snapshot["workspace"]["host_principal_id"], + answer["principal"]["id"] + ); + ws.host_ok( + "policy.set", + json!({"mode": "private", "institution_id": "ucsf"}), + ); + // Once the host has a principal, `auth.bootstrap` stays shut whatever the key. + let mut other = Member::new(host_uid(), key(51)); + let params = json!({"public_key": key_hex(&other.key)}); + let (code, _) = refused(signed_raw( + &mut ws.broker, + &mut other, + "auth.bootstrap", + params, + )); + assert_eq!(code, "forbidden"); + } + + /// A workspace that does have a bootstrap key still lets the host account add its first + /// device by account, and the bootstrap key is then shut (devices are no longer empty). + #[test] + fn the_hosts_first_device_by_account_beside_a_bootstrap_key() { + let mut ws = Workspace::unbootstrapped("account-host-first-keyed"); + let mut host = Member::new(host_uid(), key(52)); + assert_eq!(ws.account_ok(&mut host, json!({}))["admitted"], "host"); + let mut bootstrap = Member::new(host_uid(), key(7)); + let params = json!({"public_key": key_hex(&bootstrap.key)}); + let (code, _) = refused(signed_raw( + &mut ws.broker, + &mut bootstrap, + "auth.bootstrap", + params, + )); + assert_eq!(code, "forbidden"); + } + + /// A renamed host account is followed: the host principal takes the new name in the same + /// record, and the host's older devices authenticate again. + #[test] + fn the_renamed_host_is_followed() { + let mut ws = named("account-host-renamed"); + ws.directory.set(host_uid(), "alicia", Some("Alice Chen")); + let (_, message) = refused(ws.host_call("workspace.snapshot", json!({}))); + assert_eq!(message, "unauthorized: account enrollment changed"); + let mut fresh = Member::new(host_uid(), key(53)); + let answer = ws.account_ok(&mut fresh, json!({})); + assert_eq!(answer["principal"]["username"], "alicia"); + assert_eq!(answer["principal"]["display_name"], "alicia"); + assert_eq!(answer["principal"]["id"], json!(ws.host.principal_id)); + let snapshot = ws.host_snapshot(); + assert_eq!(snapshot["actor"]["username"], "alicia"); + assert_eq!(snapshot["actor"]["nickname"], "alicia"); + // A retry of the same key after the next rename commits only the rename. + ws.directory.set(host_uid(), "alix", None); + let records = ws.journal_records(); + let answer = ws.account_ok(&mut fresh, json!({})); + assert_eq!(answer["admitted"], "device"); + assert_eq!(answer["principal"]["username"], "alix"); + assert_eq!(ws.journal_records(), records + 1); + assert!(!ws + .last_record() + .to_string() + .contains(&device_id(&fresh.key))); + } + + /// D3 on the account path: a host rename that would collide with an active member's name is + /// refused, and nothing is written. + #[test] + fn a_host_rename_onto_a_members_name_is_refused() { + let mut ws = named("account-host-rename-d3"); + ws.enroll(BOB, "bob", 11); + ws.directory.set(host_uid(), "Bob", None); + let records = ws.journal_records(); + let mut fresh = Member::new(host_uid(), key(54)); + let (code, message) = refused(ws.account(&mut fresh, json!({}))); + assert_eq!(code, "identity_conflict", "{message}"); + // The host cannot remove @bob from a device the rename has already locked out, so the + // sentence says to rename the account back first. + assert_eq!( + message, + "identity_conflict: Crew can't follow this account's new name, @Bob, because @bob already uses it in baranzini-lab. Rename the account back to @alice on the server and remove @bob from baranzini-lab before renaming it again." + ); + assert_eq!(ws.journal_records(), records); + } + + // ── A member ────────────────────────────────────────────────────────────────────────── + + /// A member adds a second computer by signing in as their account: no invitation, no code, + /// nobody's approval. The record is the member's own. + #[test] + fn a_member_adds_a_second_device_without_approval() { + let mut ws = named("account-member-second"); + let mut desktop = ws.enroll(BOB, "bob", 11); + let mut laptop = Member::new(BOB, key(60)); + let answer = ws.account_ok(&mut laptop, json!({})); + assert_eq!(answer["role"], "member"); + assert_eq!(answer["admitted"], "device"); + assert_eq!(answer["principal"]["id"], json!(desktop.principal_id)); + let record = ws.last_record(); + assert_eq!(record["actor"], json!(desktop.principal_id)); + assert_eq!(record["operation"], "auth.account"); + let listed = devices(&mut ws, &mut laptop); + assert_eq!(listed.len(), 2); + let mine: Vec<&Value> = listed.iter().filter(|d| d["this_device"] == true).collect(); + assert_eq!(mine.len(), 1); + assert_eq!(mine[0]["fingerprint"], json!(fingerprint(&laptop.key))); + assert_eq!(mine[0]["added_via"], "account"); + let from_desktop = devices(&mut ws, &mut desktop); + let mine: Vec<&Value> = from_desktop + .iter() + .filter(|d| d["this_device"] == true) + .collect(); + assert_eq!(mine[0]["fingerprint"], json!(fingerprint(&desktop.key))); + } + + /// AO-2 step 4: an add-device join is selected and removed. A member reaches the member + /// branch first, so the record that adds the device by account ends the join and any + /// legacy enrollment for the UID; nothing is left waiting for the member's code. + #[test] + fn a_member_adding_a_device_by_account_ends_an_add_device_join() { + let mut ws = named("account-member-add-device-join"); + let desktop = ws.enroll(BOB, "bob", 11); + let invited = ws.host_ok( + "enrollment.invite", + json!({"username": "bob", "add_device": true}), + ); + assert_eq!(invited["add_device"], true); + ws.host_ok( + "enrollment.invite", + json!({"uid": BOB, "public_key": key_hex(&key(65)), + "existing_principal_id": desktop.principal_id}), + ); + let state = ws.broker.state_json(); + assert!(state["pending_joins"].get(BOB.to_string()).is_some()); + assert!(!state["enrollments"].as_object().unwrap().is_empty()); + + let mut laptop = Member::new(BOB, key(66)); + let answer = ws.account_ok(&mut laptop, json!({})); + assert_eq!(answer["admitted"], "device"); + assert_eq!(answer["principal"]["id"], json!(desktop.principal_id)); + let state = ws.broker.state_json(); + assert!( + state + .get("pending_joins") + .is_none_or(|joins| joins.get(BOB.to_string()).is_none()), + "{state:#}" + ); + assert!( + state + .get("enrollments") + .is_none_or(|enrollments| enrollments.as_object().unwrap().is_empty()), + "{state:#}" + ); + let record = ws.last_record(); + assert_eq!(record["actor"], json!(desktop.principal_id)); + assert_eq!(record["operation"], "auth.account"); + // The host no longer sees @bob waiting to add a device. + assert_eq!(ws.host_snapshot()["pending_joins"], json!([])); + + // With the join gone, a retry of the same key commits nothing again. + let records = ws.journal_records(); + ws.account_ok(&mut laptop, json!({})); + assert_eq!(ws.journal_records(), records); + } + + /// A join made after the device was already added is still ended by the next sign-in from + /// that computer, which otherwise commits nothing. + #[test] + fn a_retried_key_still_ends_a_later_add_device_join() { + let mut ws = named("account-member-retry-join"); + let desktop = ws.enroll(BOB, "bob", 11); + let mut laptop = Member::new(BOB, key(67)); + ws.account_ok(&mut laptop, json!({})); + ws.host_ok( + "enrollment.invite", + json!({"username": "bob", "add_device": true}), + ); + let records = ws.journal_records(); + let again = ws.account_ok(&mut laptop, json!({})); + assert_eq!(again["admitted"], "device"); + assert_eq!(ws.journal_records(), records + 1); + let state = ws.broker.state_json(); + assert!( + state + .get("pending_joins") + .is_none_or(|joins| joins.get(BOB.to_string()).is_none()), + "{state:#}" + ); + assert_eq!(ws.last_record()["actor"], json!(desktop.principal_id)); + } + + /// The same key sent again is a retry: the same principal, `admitted: "device"`, and no + /// record. A key bound to someone else is `device_conflict`. + #[test] + fn a_retry_commits_nothing_and_another_principals_key_conflicts() { + let mut ws = named("account-retry"); + let bob = ws.enroll(BOB, "bob", 11); + let mut laptop = Member::new(BOB, key(61)); + ws.account_ok(&mut laptop, json!({})); + let records = ws.journal_records(); + let again = ws.account_ok(&mut laptop, json!({})); + assert_eq!(again["admitted"], "device"); + assert_eq!(again["principal"]["id"], json!(bob.principal_id)); + assert_eq!(ws.journal_records(), records); + ws.enroll(CAROL, "carol", 12); + let records = ws.journal_records(); + let mut carol_with_bobs_key = Member::new(CAROL, key(61)); + let (code, _) = refused(ws.account(&mut carol_with_bobs_key, json!({}))); + assert_eq!(code, "device_conflict"); + assert_eq!(ws.journal_records(), records); + } + + /// `replaces_device_id`: one of the principal's own devices goes in the same record; an + /// unknown one is ignored; another principal's is refused `forbidden` and nothing is written. + #[test] + fn replaces_device_id_removes_own_ignores_unknown_and_refuses_anothers() { + let mut ws = named("account-replace"); + let desktop = ws.enroll(BOB, "bob", 11); + let mut first = Member::new(BOB, key(62)); + ws.account_ok( + &mut first, + json!({"replaces_device_id": device_id(&desktop.key)}), + ); + let mut old = Member::new(BOB, key(11)); + assert_unknown_device(&mut ws, &mut old); + assert_eq!(devices(&mut ws, &mut first).len(), 1); + let mut second = Member::new(BOB, key(63)); + ws.account_ok(&mut second, json!({"replaces_device_id": "ab".repeat(32)})); + assert_eq!(devices(&mut ws, &mut second).len(), 2); + let records = ws.journal_records(); + let mut third = Member::new(BOB, key(64)); + let (code, message) = refused(ws.account( + &mut third, + json!({"replaces_device_id": device_id(&ws.host.key)}), + )); + assert_eq!(code, "forbidden"); + assert_eq!( + message, + "forbidden: The computer to replace belongs to someone else. Nothing was changed." + ); + assert_eq!(ws.journal_records(), records); + ws.host_snapshot(); + } + + /// At most 32 devices: the 33rd evicts the oldest (one without `added_at` first), in the + /// same record, and says which. Nobody is refused for having too many computers. + #[test] + fn the_cap_evicts_the_oldest_device() { + let mut ws = named("account-cap"); + let bob = ws.enroll(BOB, "bob", 11); + let bob_id = bob.principal_id.clone(); + let first = device_id(&key(11)); + // Bob's first device predates `added_at`. + let mut ws = ws.edit_journal(|journal| { + journal.append( + "system", + "test.legacy", + vec![set( + &["devices", first.as_str()], + legacy_device(&bob_id, &key(11)), + )], + ); + }); + let mut members = Vec::new(); + for seed in 100..(100 + MAX_DEVICES_PER_PRINCIPAL as u8 - 1) { + let mut member = Member::new(BOB, key(seed)); + let answer = ws.account_ok(&mut member, json!({})); + assert!(answer.get("evicted_device").is_none()); + members.push(member); + } + let count = |ws: &Workspace| { + ws.broker.state_json()["devices"] + .as_object() + .unwrap() + .values() + .filter(|device| device["principal_id"] == json!(bob_id)) + .count() + }; + assert_eq!(count(&ws), MAX_DEVICES_PER_PRINCIPAL); + let mut over = Member::new(BOB, key(200)); + let answer = ws.account_ok(&mut over, json!({})); + assert_eq!( + answer["evicted_device"], + json!({"fingerprint": fingerprint(&key(11)), "added_at": null}) + ); + assert_eq!(count(&ws), MAX_DEVICES_PER_PRINCIPAL); + let mut legacy = Member::new(BOB, key(11)); + assert_unknown_device(&mut ws, &mut legacy); + // Next, the oldest `added_at`, the lowest device ID among equals. + let state = ws.broker.state_json(); + let mut own: Vec<(u64, String)> = state["devices"] + .as_object() + .unwrap() + .iter() + .filter(|(_, device)| device["principal_id"] == json!(bob_id)) + .map(|(id, device)| (device["added_at"].as_u64().unwrap(), id.clone())) + .collect(); + own.sort(); + let (added_at, oldest) = own[0].clone(); + let mut next = Member::new(BOB, key(201)); + let answer = ws.account_ok(&mut next, json!({})); + assert_eq!( + answer["evicted_device"], + json!({ + "fingerprint": biorouter_crew::invitation::grouped_fingerprint(&oldest), + "added_at": added_at, + }) + ); + assert_eq!(count(&ws), MAX_DEVICES_PER_PRINCIPAL); + let record = ws.last_record(); + let removed: Vec<&Value> = record["patches"] + .as_array() + .unwrap() + .iter() + .filter(|patch| patch["op"] == "remove") + .collect(); + assert_eq!(removed.len(), 1, "{record}"); + assert_eq!(removed[0]["path"], json!(["devices", oldest])); + } + + // ── An invited account ──────────────────────────────────────────────────────────────── + + /// An account the host invited by name joins by signing in as it: no device code. The join + /// and any legacy enrollment for the UID go in the same record, written as the new principal. + #[test] + fn an_invited_account_joins_with_no_code_in_a_managed_workspace() { + let mut ws = named("account-invited"); + ws.directory.set(BOB, "bob", Some("Bob Lee")); + ws.invite_by_name("bob"); + assert!(ws.broker.state_json().get("pending_joins").is_some()); + let mut bob = Member::new(BOB, key(11)); + let answer = ws.account_ok(&mut bob, json!({})); + assert_eq!(answer["admitted"], "invitation"); + assert_eq!(answer["role"], "member"); + assert_eq!(answer["principal"]["username"], "bob"); + let state = ws.broker.state_json(); + assert!(state.get("pending_joins").is_none()); + let record = ws.last_record(); + assert_eq!(record["actor"], json!(bob.principal_id)); + assert_eq!(record["operation"], "auth.account"); + assert_eq!(ws.snapshot(&mut bob)["actor"]["username"], "bob"); + // A managed workspace adds the new member nowhere. + let (team, _) = ws.host_team("Lab"); + let teams = ws.snapshot(&mut bob)["teams"].as_array().unwrap().len(); + assert_eq!(teams, 0, "{team}"); + } + + /// In an open workspace the new member lands in every team and unarchived channel, and a + /// member's invitation admits too. + #[test] + fn an_invited_account_joins_with_no_code_in_an_open_workspace() { + let mut ws = named("account-invited-open"); + let (team, general) = ws.host_team("Lab"); + ws.host_ok("workspace.set_governance", json!({"governance": "open"})); + ws.directory.set(BOB, "bob", None); + ws.invite_by_name("bob"); + let mut bob = Member::new(BOB, key(11)); + ws.account_ok(&mut bob, json!({})); + let snapshot = ws.snapshot(&mut bob); + assert!(snapshot["teams"] + .as_array() + .unwrap() + .iter() + .any(|t| t["id"] == json!(team))); + assert!(snapshot["channels"] + .as_array() + .unwrap() + .iter() + .any(|c| c["id"] == json!(general))); + ws.directory.set(CAROL, "carol", None); + ws.call_ok(&mut bob, "enrollment.invite", json!({"username": "carol"})); + let mut carol = Member::new(CAROL, key(12)); + assert_eq!( + ws.account_ok(&mut carol, json!({}))["admitted"], + "invitation" + ); + } + + /// The invited branch keeps `auth.join`'s checks: a renamed account, a changed generation + /// (here a lookalike who became a member, which D3 would refuse) and an inviter who lost + /// the authority are refused, and nothing is written. + #[test] + fn the_invited_branch_keeps_the_join_checks() { + let mut ws = named("account-invited-checks"); + ws.directory.set(BOB, "bob", None); + ws.invite_by_name("bob"); + // Renamed before joining. + ws.directory.set(BOB, "robert", None); + let records = ws.journal_records(); + let mut bob = Member::new(BOB, key(11)); + let (code, message) = refused(ws.account(&mut bob, json!({}))); + assert_eq!(code, "account_changed", "{message}"); + assert_eq!(ws.journal_records(), records); + // A lookalike enrolled by token after the invitation. + ws.directory.set(BOB, "bob", None); + ws.enroll(LOOKALIKE, "Bob", 30); + let records = ws.journal_records(); + let (code, _) = refused(ws.account(&mut bob, json!({}))); + assert_eq!(code, "account_changed"); + assert_eq!(ws.journal_records(), records); + // An open member's invitation ends when the workspace is managed again. + let mut ws = named("account-invited-inviter"); + ws.host_ok("workspace.set_governance", json!({"governance": "open"})); + let mut carol = ws.enroll(CAROL, "carol", 12); + ws.directory.set(DAVE, "dave", None); + ws.call_ok(&mut carol, "enrollment.invite", json!({"username": "dave"})); + ws.host_ok("workspace.set_governance", json!({"governance": "managed"})); + let mut dave = Member::new(DAVE, key(13)); + let (code, _) = refused(ws.account(&mut dave, json!({}))); + assert_eq!(code, "not_member"); + } + + /// An expired invitation admits nobody. + #[test] + fn an_expired_invitation_is_not_an_admission() { + let mut ws = named("account-invited-expired"); + ws.directory.set(BOB, "bob", None); + ws.invite_by_name("bob"); + let mut ws = ws.edit_journal(|journal| { + journal.append( + "system", + "test.expire", + vec![set( + &["pending_joins", "71001", "expires_at"], + json!(now() - 1), + )], + ); + }); + let mut bob = Member::new(BOB, key(11)); + let (code, _) = refused(ws.account(&mut bob, json!({}))); + assert_eq!(code, "not_member"); + let status = ws.status(BOB); + assert_eq!(status["role"], "invited"); + assert_eq!(status["expired"], true); + } + + // ── Refusals ────────────────────────────────────────────────────────────────────────── + + /// A stranger, a removed member and a renamed member are refused in AO-2's words, and + /// nothing is written. A stranger costs no account lookup: the branch comes from state. + #[test] + fn strangers_removed_and_renamed_members_are_refused() { + let mut ws = named("account-refusals"); + let records = ws.journal_records(); + let lookups = ws.directory.calls(); + let mut stranger = Member::new(STRANGER, key(70)); + let (code, message) = refused(ws.account(&mut stranger, json!({}))); + assert_eq!(code, "not_member"); + assert_eq!( + message, + "not_member: This account is not a member of baranzini-lab. Ask a member to invite it." + ); + assert_eq!(ws.directory.calls(), lookups, "a stranger costs no lookup"); + assert_eq!(ws.journal_records(), records); + + let bob = ws.enroll(BOB, "bob", 11); + ws.offboard(&bob.principal_id); + let records = ws.journal_records(); + let lookups = ws.directory.calls(); + let mut back = Member::new(BOB, key(71)); + let (code, message) = refused(ws.account(&mut back, json!({}))); + assert_eq!(code, "removed"); + assert_eq!( + message, + "removed: You were removed from baranzini-lab. Ask a member to invite you again." + ); + assert_eq!(ws.directory.calls(), lookups); + assert_eq!(ws.journal_records(), records); + + let carol = ws.enroll(CAROL, "carol", 12); + ws.directory.set(CAROL, "caroline", None); + let records = ws.journal_records(); + let mut renamed = Member::new(CAROL, key(72)); + let (code, message) = refused(ws.account(&mut renamed, json!({}))); + assert_eq!(code, "account_changed"); + assert_eq!( + message, + "account_changed: This server account is no longer named @carol, the name it joined with. Ask the host to remove @carol and invite you again." + ); + assert_eq!(ws.journal_records(), records); + assert!(!carol.principal_id.is_empty()); + } + + /// Another UID cannot add a key to someone else's principal: its own UID decides the + /// branch, and a stranger's UID is not a member whatever key it signs with. + #[test] + fn another_uid_cannot_add_a_device_for_someone_else() { + let mut ws = named("account-other-uid"); + let bob = ws.enroll(BOB, "bob", 11); + let mut posing = Member::new(STRANGER, key(73)); + let (code, _) = refused(ws.account( + &mut posing, + json!({"replaces_device_id": device_id(&bob.key)}), + )); + assert_eq!(code, "not_member"); + // A member signing with a key bound to the host cannot take it over either. + let mut with_hosts_key = Member::new(BOB, key(7)); + let (code, _) = refused(ws.account(&mut with_hosts_key, json!({}))); + assert_eq!(code, "device_conflict"); + } + + /// State first: a member with a bad signature, and a request without one, are refused + /// before any account lookup. + #[test] + fn a_bad_signature_is_refused_before_any_lookup() { + let mut ws = named("account-signature"); + ws.enroll(BOB, "bob", 11); + let lookups = ws.directory.calls(); + let mut bob = Member::new(BOB, key(74)); + // Signed by another key than the one it names. + let (code, _) = refused(signed_raw( + &mut ws.broker, + &mut bob, + "auth.account", + json!({"public_key": key_hex(&key(75))}), + )); + assert_eq!(code, "unauthorized"); + let (code, _) = refused(ws.broker.handle( + BOB, + &mut Connection::new(), + request( + "unsigned", + "auth.account", + json!({"public_key": key_hex(&key(74))}), + ), + )); + assert_eq!(code, "unauthorized"); + assert_eq!(ws.directory.calls(), lookups); + } + + /// A worker credential never reaches the account path. + #[test] + fn a_worker_credential_is_refused() { + let mut ws = named("account-worker"); + let response = ws.broker.handle( + host_uid(), + &mut Connection::new(), + Request { + version: 1, + id: "worker".into(), + method: "auth.account".into(), + params: json!({"public_key": key_hex(&key(76))}), + auth: None, + credential: Some("credential".into()), + }, + ); + let (code, message) = refused(response); + assert_eq!(code, "invalid_request"); + assert_eq!(message, "invalid_request: mixed authentication"); + } + + // ── Quotas ──────────────────────────────────────────────────────────────────────────── + + /// A member past their journal share can still add one computer a day, so they can get + /// reading back; a second within the day is refused. `device.revoke` is never refused for + /// the share. + #[test] + fn the_journal_share_admits_one_account_record_a_day_past_it() { + let mut ws = named("account-share"); + let mut bob = ws.enroll(BOB, "bob", 11); + ws.broker.set_quotas(Quotas { + member_journal_bytes: 1, + ..Quotas::STANDARD + }); + let (code, _) = refused(ws.call(&mut bob, "profile.update", json!({"nickname": "Bobby"}))); + assert_eq!(code, "quota_exceeded"); + let mut laptop = Member::new(BOB, key(80)); + ws.account_ok(&mut laptop, json!({})); + let mut tablet = Member::new(BOB, key(81)); + let (code, message) = refused(ws.account(&mut tablet, json!({}))); + assert_eq!(code, "quota_exceeded", "{message}"); + let revoked = ws.call_ok( + &mut laptop, + "device.revoke", + json!({"fingerprint": fingerprint(&bob.key)}), + ); + assert_eq!(revoked["revoked"], true); + // The host answers to no share. + let mut host_laptop = Member::new(host_uid(), key(82)); + ws.account_ok(&mut host_laptop, json!({})); + let mut host_tablet = Member::new(host_uid(), key(83)); + ws.account_ok(&mut host_tablet, json!({})); + } + + // ── account.status ──────────────────────────────────────────────────────────────────── + + /// `account.status` for every role, from state alone and only about the caller. + #[test] + fn account_status_answers_each_role_from_state_alone() { + let mut ws = Workspace::unbootstrapped("account-status"); + let lookups = ws.directory.calls(); + let host = ws.status(host_uid()); + assert_eq!(host["role"], "host"); + assert_eq!(host["devices"], 0); + assert_eq!(host["others_connected"], 0); + assert!(host.get("username").is_none()); + assert!(host.get("host_username").is_none()); + assert_eq!(host["workspace_name"], Value::Null); + assert_eq!( + ws.directory.calls(), + lookups, + "account.status makes no lookup" + ); + let mut host_member = Member::new(host_uid(), key(7)); + ws.account_ok(&mut host_member, json!({})); + ws.host = host_member; + ws.host_ok("workspace.rename", json!({"name": "baranzini-lab"})); + + let host = ws.status(host_uid()); + assert_eq!(host["role"], "host"); + assert_eq!(host["username"], "alice"); + assert_eq!(host["host_username"], "alice"); + assert_eq!(host["devices"], 1); + assert_eq!(host["workspace_name"], "baranzini-lab"); + + let none = ws.status(STRANGER); + assert_eq!( + none, + json!({"role": "none", "workspace_name": "baranzini-lab", "host_username": "alice"}) + ); + + ws.directory.set(BOB, "bob", None); + ws.invite_by_name("bob"); + let lookups = ws.directory.calls(); + let invited = ws.status(BOB); + assert_eq!(invited["role"], "invited"); + assert_eq!(invited["username"], "bob"); + assert_eq!( + invited["invited_by"], + json!({"username": "alice", "display_name": "alice"}) + ); + assert_eq!(invited["expired"], false); + assert!(invited["expires_at"].as_u64().unwrap() > now()); + assert!(invited.get("devices").is_none()); + assert!(invited.get("others_connected").is_none()); + + let mut bob = Member::new(BOB, key(11)); + ws.account_ok(&mut bob, json!({})); + let lookups_after_join = ws.directory.calls(); + let member = ws.status(BOB); + assert_eq!( + member, + json!({ + "role": "member", "workspace_name": "baranzini-lab", "host_username": "alice", + "username": "bob", "devices": 1, + }) + ); + ws.offboard(&bob.principal_id); + let lookups_after_offboard = ws.directory.calls(); + let removed = ws.status(BOB); + assert_eq!(removed["role"], "removed"); + assert_eq!(removed["username"], "bob"); + assert!(removed.get("devices").is_none()); + assert_eq!(ws.directory.calls(), lookups_after_offboard); + assert!( + lookups_after_join > lookups, + "the join itself looked the account up once" + ); + } + + /// The host's `others_connected` counts live connections from other UIDs, and goes down as + /// they close. Nobody else is told. + #[test] + fn the_host_is_told_how_many_other_connections_are_open() { + let mut ws = Workspace::new("account-others"); + assert_eq!(ws.status(host_uid())["others_connected"], 0); + let mut bob = ws.enroll(BOB, "bob", 11); + // Bob's enrollment connection is still open. + assert_eq!(ws.status(host_uid())["others_connected"], 1); + let mut second = Connection::new(); + ok(account_status(&mut ws.broker, BOB, &mut second)); + let mut carols = Connection::new(); + ok(account_status(&mut ws.broker, CAROL, &mut carols)); + assert_eq!(ws.status(host_uid())["others_connected"], 3); + // The host's own connections never count. + let mut host_extra = Connection::new(); + ok(account_status(&mut ws.broker, host_uid(), &mut host_extra)); + assert_eq!(ws.status(host_uid())["others_connected"], 3); + assert!(ws.status(BOB).get("others_connected").is_none()); + ws.broker.connection_closed(&mut bob.connection); + ws.broker.connection_closed(&mut second); + ws.broker.connection_closed(&mut carols); + assert_eq!(ws.status(host_uid())["others_connected"], 0); + // Closing a connection twice changes nothing. + ws.broker.connection_closed(&mut second); + assert_eq!(ws.status(host_uid())["others_connected"], 0); + ws.broker.connection_closed(&mut host_extra); + } + + // ── device.revoke ───────────────────────────────────────────────────────────────────── + + /// A person revokes one of their own devices by the fingerprint the snapshot shows, the one + /// signing the request included; a fingerprint that is not one of theirs is `invalid_params`. + #[test] + fn a_person_revokes_their_own_devices_by_fingerprint() { + let mut ws = named("account-revoke"); + let mut desktop = ws.enroll(BOB, "bob", 11); + let mut laptop = Member::new(BOB, key(90)); + ws.account_ok(&mut laptop, json!({})); + for wrong in [ + json!({"fingerprint": fingerprint(&ws.host.key)}), + json!({"fingerprint": device_id(&desktop.key)}), + json!({"fingerprint": "not a fingerprint"}), + json!({"fingerprint": 7}), + json!({}), + ] { + let (code, message) = refused(ws.call(&mut laptop, "device.revoke", wrong.clone())); + assert_eq!(code, "invalid_params", "{wrong}: {message}"); + } + // C1: the person's machines are computers in what the refusals say. + let (_, message) = refused(ws.call( + &mut laptop, + "device.revoke", + json!({"fingerprint": fingerprint(&ws.host.key)}), + )); + assert_eq!( + message, + "invalid_params: None of your computers has that fingerprint. Check it in Keys and try again." + ); + let (_, message) = refused(ws.call( + &mut laptop, + "device.revoke", + json!({"fingerprint": "not a fingerprint"}), + )); + assert_eq!( + message, + "invalid_params: fingerprint must be the 16 hex digits of a computer's fingerprint, as Keys shows it" + ); + // Lower case without spaces names the same device. + let typed = fingerprint(&desktop.key).replace(' ', "").to_lowercase(); + let answer = ws.call_ok(&mut laptop, "device.revoke", json!({"fingerprint": typed})); + assert_eq!( + answer, + json!({"revoked": true, "fingerprint": fingerprint(&desktop.key), "this_device": false}) + ); + assert_unknown_device(&mut ws, &mut desktop); + let record = ws.last_record(); + assert_eq!(record["operation"], "device.revoke"); + assert_eq!(record["actor"], json!(desktop.principal_id)); + // Its own device: the answer says so, and the key stops working. + let params = json!({"fingerprint": fingerprint(&laptop.key)}); + let answer = ws.call_ok(&mut laptop, "device.revoke", params); + assert_eq!(answer["this_device"], true); + assert_unknown_device(&mut ws, &mut laptop); + // The person is still a member, with no device: the account adds one again. + assert_eq!(ws.status(BOB)["role"], "member"); + assert_eq!(ws.status(BOB)["devices"], 0); + let mut again = Member::new(BOB, key(91)); + assert_eq!(ws.account_ok(&mut again, json!({}))["admitted"], "device"); + } + + /// `device.revoke` is a person's: an agent's grant cannot reach it. + #[test] + fn an_agent_cannot_revoke_a_device() { + let mut ws = named("account-revoke-agent"); + let mut bob = ws.enroll(BOB, "bob", 11); + let (_, general) = ws.create_team(&mut bob, "Lab"); + let epoch = ws.broker.workspace().policy_epoch; + let credential = ws.call_ok( + &mut bob, + "run.create", + json!({ + "channel_id": general, "source_channels": [], + "provider_policy_id": "private", "personal_mode": "private", + "public_provider": false, "expires_in": 600, + "expected_workspace_policy_epoch": epoch, + "expected_protected_context": true, + "workspace_institution_id": "ucsf", "connection_institution_id": "ucsf", + "provider_affiliation": {"kind": "local"}, + }), + )["credential"] + .as_str() + .unwrap() + .to_owned(); + let response = ws.broker.handle( + BOB, + &mut Connection::new(), + Request { + version: 1, + id: "agent-revoke".into(), + method: "device.revoke".into(), + params: json!({"fingerprint": fingerprint(&bob.key), "idempotency_key": "k"}), + auth: None, + credential: Some(credential), + }, + ); + let (_, message) = refused(response); + assert_eq!(message, "forbidden: worker operation unavailable"); + ws.snapshot(&mut bob); + } + + // ── The capability and the version ──────────────────────────────────────────────────── + + /// `account_identity_v1` is advertised last, right after `join_by_name_v1`. + #[test] + fn the_capability_comes_last() { + let capabilities = Broker::capabilities(); + let n = capabilities.len(); + assert_eq!( + &capabilities[n - 2..], + &["join_by_name_v1", "account_identity_v1"] + ); + } + + /// `hello` signs the capability inside `signature_v2`, and carries `version` outside both + /// signed payloads. Needs a machine identity (Linux). + #[cfg(target_os = "linux")] + #[test] + fn hello_signs_the_capability_and_not_the_version() { + use biorouter_crew::{hello_v1_payload, HelloV2, Mode}; + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + if !std::path::Path::new("/etc/machine-id").exists() { + eprintln!("skipped: hello needs /etc/machine-id"); + return; + } + let mut ws = Workspace::new("account-hello"); + let hello = ok(ws.broker.handle( + BOB, + &mut Connection::new(), + request("hello", "hello", json!({"challenge_nonce": "n-1"})), + )); + assert_eq!(hello["version"], env!("CARGO_PKG_VERSION")); + let capabilities: Vec<&str> = hello["capabilities"] + .as_array() + .unwrap() + .iter() + .map(|c| c.as_str().unwrap()) + .collect(); + assert_eq!(capabilities.last(), Some(&"account_identity_v1")); + let key_hex = hello["workspace_public_key"].as_str().unwrap(); + let key = + VerifyingKey::from_bytes(&hex::decode(key_hex).unwrap().try_into().unwrap()).unwrap(); + let signature = |field: &str| { + Signature::from_slice(&hex::decode(hello[field].as_str().unwrap()).unwrap()).unwrap() + }; + let workspace_id = hello["workspace_id"].as_str().unwrap(); + let node_id = hello["node_id"].as_str().unwrap(); + let host = hello["host_uid"].as_u64().unwrap() as u32; + key.verify( + &hello_v1_payload(workspace_id, host, "n-1", key_hex, node_id), + &signature("signature"), + ) + .expect("v1 does not cover the version"); + let v2 = HelloV2 { + workspace_id, + host_uid: host, + challenge_nonce: "n-1", + workspace_public_key: key_hex, + node_id, + mode: &Mode::Private, + institution_id: Some("ucsf"), + policy_epoch: hello["policy_epoch"].as_u64().unwrap(), + name: None, + capabilities: &capabilities, + }; + key.verify(&v2.signing_payload(), &signature("signature_v2")) + .expect("v2 covers the capabilities and not the version"); + let stripped: Vec<&str> = capabilities[..capabilities.len() - 1].to_vec(); + assert!(key + .verify( + &HelloV2 { + capabilities: &stripped, + ..v2 + } + .signing_payload(), + &signature("signature_v2"), + ) + .is_err()); + ws.host_snapshot(); + } + + // ── The journal ─────────────────────────────────────────────────────────────────────── + + /// The real broker writes the shapes the downgrade test replays: `set` and `remove` patches + /// on principals, devices, pending joins and idempotency results, and a device carrying only + /// the four fields every broker knows. + #[test] + fn the_account_path_writes_only_shapes_an_older_broker_replays() { + let mut ws = Workspace::without_bootstrap_key("account-shapes"); + let mut host = Member::new(host_uid(), key(7)); + ws.account_ok(&mut host, json!({})); + ws.host = host; + ws.directory.set(BOB, "bob", None); + ws.invite_by_name("bob"); + let mut desktop = Member::new(BOB, key(11)); + ws.account_ok(&mut desktop, json!({})); + let mut laptop = Member::new(BOB, key(12)); + ws.account_ok( + &mut laptop, + json!({"replaces_device_id": device_id(&desktop.key)}), + ); + ws.directory.set(host_uid(), "alicia", None); + let mut second = Member::new(host_uid(), key(13)); + ws.account_ok(&mut second, json!({})); + let params = json!({"fingerprint": fingerprint(&second.key)}); + ws.call_ok(&mut second, "device.revoke", params); + let journal = String::from_utf8(ws.journal_bytes()).unwrap(); + let records: Vec = journal + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .filter(|record: &Value| { + matches!( + record["operation"].as_str(), + Some("auth.account" | "device.revoke") + ) + }) + .collect(); + assert_eq!(records.len(), 5); + for record in &records { + assert_ne!(record["actor"], "system"); + assert!(!record["actor"].as_str().unwrap().starts_with("uid:")); + for patch in record["patches"].as_array().unwrap() { + let op = patch["op"].as_str().unwrap(); + let path: Vec<&str> = patch["path"] + .as_array() + .unwrap() + .iter() + .map(|p| p.as_str().unwrap()) + .collect(); + assert!(matches!(op, "set" | "remove"), "{patch}"); + assert!( + matches!( + path[0], + "principals" | "devices" | "pending_joins" | "dedupe" | "sequence" + ), + "{patch}" + ); + if path[0] == "devices" && op == "set" { + let device = patch["value"].as_object().unwrap(); + let mut fields: Vec<&str> = device.keys().map(String::as_str).collect(); + fields.sort(); + assert_eq!( + fields, + ["added_at", "added_via", "principal_id", "public_key"] + ); + assert_eq!(device["added_via"], "account"); + } + } + } + // And it all replays. + let mut ws = ws.reopen(); + ws.host_snapshot(); + assert_eq!(ws.snapshot(&mut laptop)["actor"]["username"], "bob"); + } +} diff --git a/crates/biorouter-crew/tests/delete_contract.rs b/crates/biorouter-crew/tests/delete_contract.rs index f1de02f8f..72d2ae2da 100644 --- a/crates/biorouter-crew/tests/delete_contract.rs +++ b/crates/biorouter-crew/tests/delete_contract.rs @@ -596,6 +596,20 @@ fn deleting_the_workspace_leaves_a_tombstone_that_refuses_everything_but_hello() "auth.join", json!({"public_key": key_hex(&bob.key), "join_id": "x"}), ), + // Amended for account ownership (AO-2, AO-3): the account path's two pre-authentication + // methods are refused behind the same gate. + ( + host_uid(), + "auth.account", + json!({"public_key": key_hex(&ws.host.key)}), + ), + ( + BOB, + "auth.account", + json!({"public_key": key_hex(&bob.key)}), + ), + (host_uid(), "account.status", json!({})), + (BOB, "account.status", json!({})), (BOB, "workspace.snapshot", json!({})), ]; for (uid, method, params) in attempts { diff --git a/crates/biorouter-crew/tests/join_contract.rs b/crates/biorouter-crew/tests/join_contract.rs index 1790702eb..1ce835935 100644 --- a/crates/biorouter-crew/tests/join_contract.rs +++ b/crates/biorouter-crew/tests/join_contract.rs @@ -86,6 +86,46 @@ fn a_journal_with_a_join_added_and_removed_replays_without_the_feature() { ws.host_team("Methods"); } +/// A broker built without `join-by-name` stands for one that predates account ownership +/// (AO-4): it neither advertises `account_identity_v1` nor accepts `auth.account`, +/// `account.status` or `device.revoke`, so a daemon that finds the capability missing falls +/// back to the device-code join, and nothing it sends there is written. +#[cfg(not(feature = "join-by-name"))] +#[test] +fn a_broker_without_the_feature_neither_advertises_nor_accepts_the_account_path() { + let capabilities = biorouter_crew::Broker::capabilities(); + assert!(!capabilities.contains(&"account_identity_v1")); + assert!(!capabilities.contains(&"join_by_name_v1")); + let mut ws = Workspace::new("account-without-feature"); + let journal = ws.journal_bytes(); + let mut laptop = Member::new(host_uid(), key(50)); + let params = json!({"public_key": key_hex(&laptop.key)}); + let (code, message) = refused(signed_raw( + &mut ws.broker, + &mut laptop, + "auth.account", + params, + )); + assert_eq!( + (code.as_str(), message.as_str()), + ("unauthorized", "unauthorized: unknown device") + ); + let (_, message) = refused(ws.broker.handle( + 71_001, + &mut biorouter_crew::Connection::new(), + request("status", "account.status", json!({})), + )); + assert_eq!(message, "unauthorized: signed device required"); + let params = json!({"fingerprint": fingerprint(&ws.host.key)}); + let (_, message) = refused(ws.host_call("device.revoke", params)); + assert_eq!( + message, + "unsupported: operation is not supported by this broker" + ); + assert_eq!(ws.journal_bytes(), journal); + ws.host_snapshot(); +} + #[cfg(feature = "join-by-name")] mod join { use super::*; @@ -675,6 +715,10 @@ mod join { let (code, message) = refused(bob.claim(&mut ws.broker, &join_id)); assert_eq!(code, "code_mismatch"); assert!(!message.contains(&bob.code()) && !message.contains(&other.code())); + assert_eq!( + message, + "code_mismatch: The host hasn't let this computer in. Send the host the code shown on your screen." + ); assert_eq!(ws.journal_bytes(), journal, "a mismatch writes nothing"); assert!(!is_device(&mut ws, BOB, &bob.member.key)); @@ -825,7 +869,11 @@ mod join { let mut bob_laptop = ws.enroll(BOB, "bob", 40); let (code, message) = refused(invite(&mut ws, "bob")); assert_eq!(code, "already_member"); - assert!(message.contains("Add device"), "{message}"); + // C1: it names the desktop's control, Add another computer. + assert_eq!( + message, + "already_member: @bob is already a member. To add another computer for them, choose Add another computer." + ); let invited = ok(ws.host_call( "enrollment.invite", json!({"username": "bob", "add_device": true}), @@ -845,11 +893,15 @@ mod join { ws.snapshot(&mut bob_laptop); // Adding a device to someone who is not a member is refused. ws.directory.set(CAROL, "carol", None); - let (code, _) = refused(ws.host_call( + let (code, message) = refused(ws.host_call( "enrollment.invite", json!({"username": "carol", "add_device": true}), )); assert_eq!(code, "invalid_params"); + assert_eq!( + message, + "invalid_params: @carol is not a member yet. Turn off Add another computer, then invite them." + ); } #[test] diff --git a/crates/biorouter-crew/tests/lifecycle_contract.rs b/crates/biorouter-crew/tests/lifecycle_contract.rs new file mode 100644 index 000000000..19d7856ee --- /dev/null +++ b/crates/biorouter-crew/tests/lifecycle_contract.rs @@ -0,0 +1,664 @@ +//! Server commands addressed by workspace ID, and creating a workspace only on purpose +//! (account ownership, AO-7), plus the bridge's refusal to add or revoke a device from inside a +//! full-access job (the Security position's accident guard). +//! +//! What runs anywhere: an unknown ID is `not_found` and creates nothing, no journal is created +//! without `--bootstrap-key` or `--create`, an empty bootstrap key is stored as the empty +//! string, the restart lock, and the command line's refusals. What needs Linux: a broker that +//! will not stop (`stop_timeout`), a new program that will not start (`start_failed`), the +//! bridge's refusal, and (with `/etc/machine-id`, as a real broker needs) a whole restart. +#![cfg(unix)] + +mod support; + +use biorouter_crew::Broker; +use serde_json::{json, Value}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use support::*; + +const BINARY: &str = env!("CARGO_BIN_EXE_biorouter-crew"); + +/// A home directory with the workspace store in it, as setup leaves one. +fn home(label: &str) -> (TempRoot, PathBuf) { + let home = TempRoot::new(label); + let store = home.path().join(".local/share/biorouter-crew"); + fs::create_dir_all(&store).unwrap(); + for dir in [ + home.path().join(".local"), + home.path().join(".local/share"), + store.clone(), + ] { + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + } + (home, store) +} + +fn directory() -> FakeDirectory { + let directory = FakeDirectory::default(); + directory.set(host_uid(), "alice", Some("Alice Chen")); + directory +} + +/// A stopped workspace in `store/folder`, created with a bootstrap key; its ID. +fn stored(store: &Path, folder: &str) -> String { + let broker = + Broker::open_with_directory(&store.join(folder), &key_hex(&key(7)), directory().boxed()) + .unwrap(); + broker.workspace().id.clone() +} + +/// Every path under `root`, to show nothing was created. +fn paths(root: &Path) -> Vec { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + if fs::symlink_metadata(&path).unwrap().is_dir() { + for child in fs::read_dir(&path).unwrap() { + stack.push(child.unwrap().path()); + } + } + found.push(path); + } + found.sort(); + found +} + +fn refusal(result: anyhow::Result) -> String { + format!("{:#}", result.expect_err("refused")) +} + +fn crew(args: &[&str], home: &Path) -> std::process::Output { + std::process::Command::new(BINARY) + .args(args) + .env("HOME", home) + .output() + .unwrap() +} + +#[test] +fn an_unknown_id_is_not_found_and_nothing_is_created() { + let (home, store) = home("by-id-unknown"); + stored(&store, "lab"); + let before = paths(home.path()); + let unknown = uuid(); + let error = refusal(Broker::start_by_id(home.path(), &unknown)); + assert!(error.starts_with("not_found: "), "{error}"); + assert!(error.contains(&unknown), "{error}"); + let error = refusal(Broker::restart_by_id_with( + home.path(), + &unknown, + Duration::from_secs(1), + )); + assert!(error.starts_with("not_found: "), "{error}"); + let error = refusal(Broker::start_by_id(home.path(), "lab")); + assert!(error.starts_with("invalid_params: "), "{error}"); + let error = refusal(Broker::restart_by_id_with( + home.path(), + "../lab", + Duration::from_secs(1), + )); + assert!(error.starts_with("invalid_params: "), "{error}"); + assert_eq!(paths(home.path()), before, "nothing was created"); + + // An account with no workspace store at all: still not_found, and no store appears. + let bare = TempRoot::new("by-id-bare"); + let error = refusal(Broker::start_by_id(bare.path(), &unknown)); + assert!(error.starts_with("not_found: "), "{error}"); + assert!(!bare.path().join(".local").exists()); +} + +/// A renamed workspace keeps its folder: the ID, never the folder's name, finds it. +#[test] +fn the_id_finds_the_folder_whatever_it_is_called() { + let (home, store) = home("by-id-folder"); + let lab = stored(&store, "first-name"); + let other = stored(&store, "lab"); + // Folders that are not workspaces, or not this account's to use, are passed over. + fs::create_dir(store.join("empty")).unwrap(); + std::os::unix::fs::symlink(store.join("first-name"), store.join("alias")).unwrap(); + // Another restart holds the lock, as the broker makes it: a private file. + let held = { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .mode(0o600) + .open(store.join(".restart-lock")) + .unwrap() + }; + assert_eq!( + unsafe { libc::flock(std::os::fd::AsRawFd::as_raw_fd(&held), libc::LOCK_EX) }, + 0 + ); + // The lock is taken after the folder is found, so it answers busy for a known ID only. + let error = refusal(Broker::restart_by_id_with( + home.path(), + &lab, + Duration::from_secs(1), + )); + assert!(error.starts_with("busy: "), "{error}"); + let error = refusal(Broker::restart_by_id_with( + home.path(), + &uuid(), + Duration::from_secs(1), + )); + assert!(error.starts_with("not_found: "), "{error}"); + drop(held); + assert_ne!(lab, other); +} + +/// Nothing is stopped when there is no installed program to start in its place, and the code +/// says so: `start_failed` is kept for a broker that was stopped and did not start again. +#[test] +fn a_restart_without_an_installed_program_leaves_the_workspace_alone() { + let (home, store) = home("restart-no-program"); + let id = stored(&store, "lab"); + let before = paths(&store.join("lab")); + let error = refusal(Broker::restart_by_id_with( + home.path(), + &id, + Duration::from_secs(1), + )); + assert!(error.starts_with("program_missing: "), "{error}"); + assert!(error.contains(".local/bin/biorouter-crew"), "{error}"); + assert!( + error.ends_with("so the running broker was left as it is."), + "{error}" + ); + assert_eq!(paths(&store.join("lab")), before); +} + +/// A copy of a workspace's folder kept in the store holds the same ID. Neither `start` nor +/// `restart` guesses which folder is the workspace: both refuse, naming the folders, and +/// nothing is started, stopped or written. +#[test] +fn an_id_two_folders_hold_is_refused_and_nothing_is_touched() { + let (home, store) = home("by-id-copies"); + let id = stored(&store, "lab"); + copy_tree(&store.join("lab"), &store.join("backup-lab")); + let before = paths(home.path()); + let start = refusal(Broker::start_by_id(home.path(), &id)); + let restart = refusal(Broker::restart_by_id_with( + home.path(), + &id, + Duration::from_secs(1), + )); + for error in [&start, &restart] { + assert!(error.starts_with("identity_conflict: "), "{error}"); + assert!(error.contains(&id), "{error}"); + assert!(error.contains("(backup-lab, lab)"), "{error}"); + } + assert_eq!(paths(home.path()), before, "nothing was started or written"); + // With the copy moved out, the ID finds its folder again. + fs::remove_dir_all(store.join("backup-lab")).unwrap(); + let error = refusal(Broker::restart_by_id_with( + home.path(), + &id, + Duration::from_secs(1), + )); + assert!(error.starts_with("program_missing: "), "{error}"); +} + +/// The program setup installs, as a script that says why it cannot start and exits. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn install_failing_program(home: &Path) { + let bin = home.join(".local/bin"); + fs::create_dir_all(&bin).unwrap(); + let program = bin.join("biorouter-crew"); + fs::write( + &program, + b"#!/bin/sh\necho 'boom: the new program could not start' >&2\nexit 3\n", + ) + .unwrap(); + fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[test] +fn no_journal_is_created_without_a_key_or_create() { + let parent = TempRoot::new("no-create"); + let empty = parent.path().join("empty"); + fs::create_dir(&empty).unwrap(); + fs::set_permissions(&empty, fs::Permissions::from_mode(0o700)).unwrap(); + let missing = parent.path().join("missing"); + + for root in [&empty, &missing] { + let error = refusal(Broker::start_state_dir(root, "", None, false)); + assert!(error.starts_with("not_found: "), "{error}"); + assert!(error.contains("--create"), "{error}"); + let error = Broker::open_with_directory(root, "", directory().boxed()) + .err() + .expect("no key and no journal"); + assert!(format!("{error:#}").starts_with("not_found: "), "{error:#}"); + } + assert!(!missing.exists(), "not even the directory is created"); + assert!( + fs::read_dir(&empty).unwrap().next().is_none(), + "nothing is written into an empty state directory" + ); + let error = refusal(Broker::start_state_dir( + &parent.path().join("named"), + "", + Some("lab"), + false, + )); + assert!(error.starts_with("not_found: "), "{error}"); + assert!(!parent.path().join("named").exists()); +} + +/// `--create` without a key stores the key as the empty string, never null or absent, so an +/// older broker still replays the journal; the workspace then reopens without a key, and +/// `auth.bootstrap` admits nobody. +#[test] +fn a_workspace_created_without_a_key_stores_it_empty() { + let parent = TempRoot::new("create-empty-key"); + let root = parent.path().join("lab"); + let broker = Broker::create_with_directory(&root, directory().boxed()).unwrap(); + let id = broker.workspace().id.clone(); + let state = broker.state_json(); + assert_eq!(state["bootstrap_key"], json!(""), "{state:#}"); + drop(broker); + let journal = fs::read_to_string(root.join("journal.jsonl")).unwrap(); + assert!(journal.contains("\"bootstrap_key\":\"\""), "{journal}"); + + let mut broker = Broker::open_with_directory(&root, "", directory().boxed()).unwrap(); + assert_eq!( + broker.workspace().id, + id, + "an existing journal opens without a key" + ); + let host_key = key(7); + let public_key = key_hex(&host_key); + let mut host = Member::new(host_uid(), host_key); + let (code, _) = refused(signed_raw( + &mut broker, + &mut host, + "auth.bootstrap", + json!({"public_key": public_key}), + )); + assert_eq!(code, "forbidden"); + drop(broker); + // A key given later never re-creates or re-keys an existing workspace. + let broker = + Broker::open_with_directory(&root, &key_hex(&key(8)), directory().boxed()).unwrap(); + assert_eq!(broker.workspace().id, id); + assert_eq!(broker.state_json()["bootstrap_key"], json!("")); +} + +#[test] +fn the_command_line_addresses_a_workspace_one_way_at_a_time() { + let (home, store) = home("by-id-cli"); + let id = stored(&store, "lab"); + let mixed = crew( + &["restart", "--workspace-id", &id, "--name", "lab"], + home.path(), + ); + assert!(!mixed.status.success()); + assert!( + String::from_utf8_lossy(&mixed.stderr) + .contains("--workspace-id names the workspace by itself"), + "{}", + String::from_utf8_lossy(&mixed.stderr) + ); + let bare = crew(&["restart"], home.path()); + assert!(!bare.status.success()); + assert!(String::from_utf8_lossy(&bare.stderr).contains("--workspace-id required")); + let unknown = crew(&["start", "--workspace-id", &uuid()], home.path()); + assert!(!unknown.status.success()); + assert!( + String::from_utf8_lossy(&unknown.stderr).contains("not_found: "), + "{}", + String::from_utf8_lossy(&unknown.stderr) + ); + let create = crew( + &[ + "status", + "--create", + "--state-dir", + store.join("lab").to_str().unwrap(), + ], + home.path(), + ); + assert!(!create.status.success()); + assert!(String::from_utf8_lossy(&create.stderr).contains("--create applies to serve and start")); + // `start` on a folder that holds no workspace, without --create: refused before anything + // is spawned, and nothing is written. + let empty = store.join("empty"); + let started = crew( + &["start", "--state-dir", empty.to_str().unwrap()], + home.path(), + ); + assert!(!started.status.success()); + assert!( + String::from_utf8_lossy(&started.stderr).contains("not_found: "), + "{}", + String::from_utf8_lossy(&started.stderr) + ); + assert!(!empty.exists()); +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use std::io::{BufRead, BufReader, Read, Write}; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::process::{Child, Command, Stdio}; + use std::sync::{Arc, Mutex}; + use std::time::Instant; + + /// Not a test: the process a restart test stops. It listens at `FAKE_BROKER_SOCKET` and + /// answers every `hello` for `FAKE_BROKER_WORKSPACE`, ignoring SIGTERM when + /// `FAKE_BROKER_IGNORE_TERM` is set. Without the variables it does nothing. + #[test] + #[ignore = "a helper process for the restart tests"] + fn fake_broker_process() { + let Some(socket) = std::env::var_os("FAKE_BROKER_SOCKET") else { + return; + }; + let workspace = std::env::var("FAKE_BROKER_WORKSPACE").unwrap(); + if std::env::var_os("FAKE_BROKER_IGNORE_TERM").is_some() { + unsafe { + libc::signal(libc::SIGTERM, libc::SIG_IGN); + } + } + let listener = UnixListener::bind(socket).unwrap(); + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + let mut writer = stream.try_clone().unwrap(); + let mut line = String::new(); + if BufReader::new(stream).read_line(&mut line).unwrap_or(0) > 0 { + let request: Value = serde_json::from_str(&line).unwrap_or_default(); + let answer = json!({"id": request["id"], "result": { + "workspace_id": workspace, "state": "running", "version": "0.0.1"}}); + let _ = writer.write_all(format!("{answer}\n").as_bytes()); + } + } + } + + /// Start [`fake_broker_process`] as a process of its own at `socket`, wait until it + /// listens, and record it in `state_dir`'s `runtime.json` as `workspace_id`'s broker. + fn running_fake( + state_dir: &Path, + socket: &Path, + workspace_id: &str, + ignore_term: bool, + ) -> Child { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "linux::fake_broker_process", + "--exact", + "--ignored", + "--nocapture", + "--test-threads", + "1", + ]) + .env("FAKE_BROKER_SOCKET", socket) + .env("FAKE_BROKER_WORKSPACE", workspace_id) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if ignore_term { + command.env("FAKE_BROKER_IGNORE_TERM", "1"); + } + let child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + while UnixStream::connect(socket).is_err() { + assert!(Instant::now() < deadline, "the fake broker never listened"); + std::thread::sleep(Duration::from_millis(50)); + } + let info = json!({ + "pid": child.id(), + "socket": socket, + "workspace_id": workspace_id, + "host_uid": host_uid(), + "protocol": 1, + }); + let path = state_dir.join("runtime.json"); + fs::write(&path, serde_json::to_vec(&info).unwrap()).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + child + } + + fn alive(pid: u32) -> bool { + unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } + } + + #[test] + fn a_broker_that_does_not_stop_in_time_is_not_restarted() { + let (home, store) = home("restart-timeout"); + let id = stored(&store, "lab"); + let runtime = TempRoot::short(); + let socket = runtime.path().join("broker.sock"); + let mut child = running_fake(&store.join("lab"), &socket, &id, true); + install_failing_program(home.path()); + let started = Instant::now(); + let error = refusal(Broker::restart_by_id_with( + home.path(), + &id, + Duration::from_secs(2), + )); + assert!(error.starts_with("stop_timeout: "), "{error}"); + assert!(started.elapsed() >= Duration::from_secs(2)); + assert!(alive(child.id()), "nothing was killed"); + // Nothing was started in its place. + assert!(!store.join("lab").join("broker.log").exists()); + child.kill().unwrap(); + child.wait().unwrap(); + } + + #[test] + fn a_new_program_that_does_not_start_is_start_failed_with_the_last_log_line() { + let (home, store) = home("restart-start-failed"); + let id = stored(&store, "lab"); + let runtime = TempRoot::short(); + let socket = runtime.path().join("broker.sock"); + let mut child = running_fake(&store.join("lab"), &socket, &id, false); + install_failing_program(home.path()); + let error = refusal(Broker::restart_by_id_with( + home.path(), + &id, + Duration::from_secs(10), + )); + assert!(error.starts_with("start_failed: "), "{error}"); + assert!( + error.contains("boom: the new program could not start"), + "{error}" + ); + // The old broker was stopped: its process has exited. + let deadline = Instant::now() + Duration::from_secs(5); + while child.try_wait().unwrap().is_none() { + assert!(Instant::now() < deadline, "the old broker is still running"); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// A fake broker in this process that answers `hello` for `workspace_id` and `{ok: true}` + /// to anything else, recording every method it receives. + fn recording_broker( + directory: &Path, + workspace_id: &str, + ) -> (PathBuf, Arc>>) { + let socket = directory.join("broker.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let seen = Arc::new(Mutex::new(Vec::new())); + let record = seen.clone(); + let workspace_id = workspace_id.to_owned(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let (record, workspace_id) = (record.clone(), workspace_id.clone()); + std::thread::spawn(move || { + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + while reader.read_line(&mut line).unwrap_or(0) > 0 { + let request: Value = serde_json::from_str(&line).unwrap(); + let method = request["method"].as_str().unwrap_or_default().to_owned(); + let result = if method == "hello" { + json!({"workspace_id": workspace_id}) + } else { + json!({"ok": true}) + }; + record.lock().unwrap().push(method); + let answer = json!({"id": request["id"], "result": result}); + if writer.write_all(format!("{answer}\n").as_bytes()).is_err() { + return; + } + line.clear(); + } + }); + } + }); + (socket, seen) + } + + /// Run `biorouter-crew bridge` against `socket`, inside a job when `job` names one, send + /// it `methods` in turn and return its answers. + fn bridge( + socket: &Path, + workspace_id: &str, + job: Option<&str>, + methods: &[&str], + ) -> Vec { + let mut command = Command::new(BINARY); + command + .args([ + "bridge", + "--stdio", + "--socket", + socket.to_str().unwrap(), + "--owner-uid", + &host_uid().to_string(), + "--workspace-id", + workspace_id, + ]) + .env_remove("BIOROUTER_CREW_JOB_ID") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(job) = job { + command.env("BIOROUTER_CREW_JOB_ID", job); + } + let mut child = command.spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut answers = Vec::new(); + for (index, method) in methods.iter().enumerate() { + let request = json!({"version": 1, "id": format!("r{index}"), "method": method, + "params": {"public_key": "00"}}); + stdin.write_all(format!("{request}\n").as_bytes()).unwrap(); + let mut line = String::new(); + stdout.read_line(&mut line).unwrap(); + answers.push(serde_json::from_str(&line).unwrap_or_else(|_| { + let mut stderr = String::new(); + let _ = child.stderr.take().unwrap().read_to_string(&mut stderr); + panic!("no answer to {method}: {line:?} {stderr}") + })); + } + drop(stdin); + assert!(child.wait().unwrap().success()); + answers + } + + /// A bridge started inside a full-access job (its environment carries the job's ID, which + /// the job supervisor sets) answers `auth.account` and `device.revoke` itself with + /// `agent_session_refused`, and they never reach the broker; anything else still does. A + /// bridge outside a job forwards them. + #[test] + fn a_bridge_inside_an_agent_job_never_adds_or_revokes_a_device() { + let runtime = TempRoot::short(); + let workspace_id = uuid(); + let (socket, seen) = recording_broker(runtime.path(), &workspace_id); + let methods = ["auth.account", "device.revoke", "workspace.snapshot"]; + + let answers = bridge(&socket, &workspace_id, Some("job-1"), &methods); + for answer in &answers[..2] { + assert_eq!(answer["error"]["code"], "agent_session_refused", "{answer}"); + assert_eq!( + answer["error"]["message"], + "agent_session_refused: An agent's job on the server cannot add or remove your computers. Do this from Biorouter on your computer." + ); + } + assert_eq!(answers[2]["result"]["ok"], true); + assert_eq!( + *seen.lock().unwrap(), + vec!["hello".to_owned(), "workspace.snapshot".to_owned()], + "the refused methods never reached the broker" + ); + + seen.lock().unwrap().clear(); + let answers = bridge(&socket, &workspace_id, None, &methods); + assert!( + answers.iter().all(|answer| answer["result"]["ok"] == true), + "{answers:?}" + ); + assert_eq!( + *seen.lock().unwrap(), + vec![ + "hello".to_owned(), + "auth.account".to_owned(), + "device.revoke".to_owned(), + "workspace.snapshot".to_owned() + ] + ); + } + + /// Start, restart and start again by ID against real brokers: needs an ordinary user and + /// `/etc/machine-id`, as a real broker does. + #[test] + fn restart_runs_the_installed_program_and_says_which_versions() { + if host_uid() == 0 || !Path::new("/etc/machine-id").exists() { + eprintln!("skipped: a real broker needs an ordinary user and /etc/machine-id"); + return; + } + let (home, store) = home("restart-real"); + let bin = home.path().join(".local/bin"); + fs::create_dir_all(&bin).unwrap(); + fs::copy(BINARY, bin.join("biorouter-crew")).unwrap(); + let json_of = |output: std::process::Output| -> Value { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() + }; + let state_dir = store.join("lab"); + let state = state_dir.to_str().unwrap(); + let started = json_of(crew( + &["start", "--state-dir", state, "--create"], + home.path(), + )); + assert_eq!(started["state"], "running", "{started:#}"); + assert_eq!(started["created"], true); + let id = started["workspace_id"].as_str().unwrap().to_owned(); + let runtime: Value = + serde_json::from_slice(&fs::read(state_dir.join("runtime.json")).unwrap()).unwrap(); + assert_eq!(runtime["version"], env!("CARGO_PKG_VERSION")); + + let restarted = json_of(crew(&["restart", "--workspace-id", &id], home.path())); + assert_eq!( + restarted, + json!({"restarted": true, "from_version": env!("CARGO_PKG_VERSION"), + "to_version": env!("CARGO_PKG_VERSION")}) + ); + let listed = Broker::list_workspaces(home.path()); + let lab = listed["workspaces"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["workspace_id"] == id.as_str()) + .unwrap_or_else(|| panic!("{listed:#}")); + assert_eq!(lab["state"], "running"); + assert_eq!(lab["binary_current"], true, "{lab:#}"); + + json_of(crew(&["stop", "--state-dir", state], home.path())); + let again = json_of(crew(&["start", "--workspace-id", &id], home.path())); + assert_eq!(again["state"], "running"); + assert_eq!(again["created"], false); + json_of(crew(&["stop", "--state-dir", state], home.path())); + } +} diff --git a/crates/biorouter-crew/tests/naming_contract.rs b/crates/biorouter-crew/tests/naming_contract.rs index 4afcdf9fd..cd3fce449 100644 --- a/crates/biorouter-crew/tests/naming_contract.rs +++ b/crates/biorouter-crew/tests/naming_contract.rs @@ -562,6 +562,9 @@ fn a_person_sees_their_own_devices_and_nobody_elses() { .join(" "); assert_eq!(fingerprint, expected); assert_eq!(devices[0]["added_via"], "bootstrap"); + // Amended for account ownership (AO-5): each entry also says whether it is the key that + // signed this snapshot request. + assert_eq!(devices[0]["this_device"], true); // A second device for bob, by the legacy add-device path. let second = key(31); @@ -582,7 +585,33 @@ fn a_person_sees_their_own_devices_and_nobody_elses() { assert_eq!(device["added_via"], "token"); assert!(device["added_at"].as_u64().unwrap() >= started); assert!(!contains_machine_id(&device.to_string())); + let mut fields: Vec<&str> = device + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + fields.sort(); + assert_eq!( + fields, + ["added_at", "added_via", "fingerprint", "this_device"] + ); } + let signing: Vec<&Value> = devices + .iter() + .filter(|device| device["this_device"] == true) + .collect(); + assert_eq!(signing.len(), 1, "exactly the key that signed"); + let bob_fingerprint: String = device_id(&bob.key) + .to_uppercase() + .chars() + .take(16) + .collect::>() + .chunks(4) + .map(|group| group.iter().collect::()) + .collect::>() + .join(" "); + assert_eq!(signing[0]["fingerprint"], json!(bob_fingerprint)); assert!( ws.host_snapshot()["principals"] .as_array() diff --git a/crates/biorouter-crew/tests/open_governance_contract.rs b/crates/biorouter-crew/tests/open_governance_contract.rs index bb1fb7a56..86abf7d2b 100644 --- a/crates/biorouter-crew/tests/open_governance_contract.rs +++ b/crates/biorouter-crew/tests/open_governance_contract.rs @@ -756,8 +756,10 @@ fn a_task_without_a_work_folder_is_told_where_to_set_one() { assert_eq!(message, sentence); } -/// `hello` signs its capabilities in order, so the new ones are appended after `presence_v1` -/// and `join_by_name_v1` stays last. Needs a machine identity (Linux). +/// `hello` signs its capabilities in order, so the new ones are appended after `presence_v1`, +/// and with `join-by-name` the last two are `join_by_name_v1` and then `account_identity_v1` +/// (*amended for account ownership, AO-4*: `join_by_name_v1` used to be last). Needs a machine +/// identity (Linux). #[cfg(target_os = "linux")] #[test] fn hello_appends_the_new_capabilities_in_order() { @@ -794,7 +796,13 @@ fn hello_appends_the_new_capabilities_in_order() { assert!(at(pair[0]) < at(pair[1]), "{capabilities:?}"); } if cfg!(feature = "join-by-name") { - assert_eq!(capabilities.last(), Some(&"join_by_name_v1")); + assert_eq!( + &capabilities[capabilities.len() - 2..], + &["join_by_name_v1", "account_identity_v1"] + ); + assert!(at("wide_runs_v1") < at("join_by_name_v1")); + } else { + assert!(!capabilities.contains(&"account_identity_v1")); } } diff --git a/crates/biorouter-crew/tests/quota_contract.rs b/crates/biorouter-crew/tests/quota_contract.rs index 160a9a31e..412b56b53 100644 --- a/crates/biorouter-crew/tests/quota_contract.rs +++ b/crates/biorouter-crew/tests/quota_contract.rs @@ -1862,3 +1862,32 @@ fn the_hosts_snapshot_says_how_full_the_workspace_is_and_a_members_does_not() { snapshot.get("usage") ); } + +#[test] +fn accepted_mutations_store_increasing_commit_sequence_metadata() { + let mut ws = Workspace::new("dedupe-sequence-metadata"); + let mut mallory = ws.enroll(MALLORY, "mallory", 21); + ok(signed_raw( + &mut ws.broker, + &mut mallory, + "profile.update", + json!({"nickname": "First", "idempotency_key": "sequence-first"}), + )); + let first_key = dedupe_key(&mallory.principal_id, "sequence-first"); + let first_sequence = ws.broker.state_json()["dedupe"][&first_key]["sequence"] + .as_u64() + .expect("first accepted mutation records its commit sequence"); + + ok(signed_raw( + &mut ws.broker, + &mut mallory, + "profile.update", + json!({"nickname": "Second", "idempotency_key": "sequence-second"}), + )); + let second_key = dedupe_key(&mallory.principal_id, "sequence-second"); + let second_sequence = ws.broker.state_json()["dedupe"][&second_key]["sequence"] + .as_u64() + .expect("second accepted mutation records its commit sequence"); + + assert!(second_sequence > first_sequence); +} diff --git a/crates/biorouter-crew/tests/support/mod.rs b/crates/biorouter-crew/tests/support/mod.rs index 36b561eec..7f7cc5d77 100644 --- a/crates/biorouter-crew/tests/support/mod.rs +++ b/crates/biorouter-crew/tests/support/mod.rs @@ -268,6 +268,32 @@ pub fn signed_raw( broker.handle(member.uid, &mut member.connection, req) } +/// `auth.account {public_key, ...extra}` signed by `member`'s key (account ownership, AO-2). +/// `extra` may carry `replaces_device_id`. +pub fn auth_account(broker: &mut Broker, member: &mut Member, extra: Value) -> Response { + let mut params = json!({"public_key": key_hex(&member.key)}); + if let (Some(fields), Some(more)) = (params.as_object_mut(), extra.as_object()) { + for (name, value) in more { + fields.insert(name.clone(), value.clone()); + } + } + signed_raw(broker, member, "auth.account", params) +} + +/// `account.status {}`, unsigned, as `uid` on `connection` (AO-3). +pub fn account_status(broker: &mut Broker, uid: u32, connection: &mut Connection) -> Response { + broker.handle( + uid, + connection, + request(&next_id("status"), "account.status", json!({})), + ) +} + +/// The grouped fingerprint the snapshot shows for `key`'s device (`3F2A 9C1E 77B0 D4E1`). +pub fn fingerprint(key: &SigningKey) -> String { + biorouter_crew::invitation::grouped_fingerprint(&device_id(key)) +} + /// The result of a request that must succeed. pub fn ok(response: Response) -> Value { match response.error { @@ -333,6 +359,78 @@ impl Workspace { } } + /// Opened over a [`FakeDirectory`] with the host account `@alice`, and never bootstrapped: + /// no principal holds the host's UID yet, and `host.principal_id` is empty. + pub fn unbootstrapped(label: &str) -> Self { + let root = TempRoot::new(label); + let directory = FakeDirectory::default(); + directory.set(host_uid(), "alice", Some("Alice Chen")); + let host_key = key(7); + let broker = open(root.path(), &host_key, &directory); + Self { + broker, + directory, + host: Member::new(host_uid(), host_key), + root, + } + } + + /// As [`Self::unbootstrapped`], but created with no bootstrap key, as `start --create` + /// without `--bootstrap-key` creates one (AO-7): the key is stored empty. + pub fn without_bootstrap_key(label: &str) -> Self { + let root = TempRoot::new(label); + let directory = FakeDirectory::default(); + directory.set(host_uid(), "alice", Some("Alice Chen")); + let mut broker = Broker::create_with_directory(root.path(), directory.boxed()).unwrap(); + broker.set_runtime_root(&root.path().join("no-sibling-runtimes")); + Self { + broker, + directory, + host: Member::new(host_uid(), key(7)), + root, + } + } + + /// `member` adds its own key with `auth.account` (AO-2). + pub fn account(&mut self, member: &mut Member, extra: Value) -> Response { + auth_account(&mut self.broker, member, extra) + } + + /// As [`Self::account`], which must succeed; records the principal ID on `member`. + pub fn account_ok(&mut self, member: &mut Member, extra: Value) -> Value { + let result = ok(self.account(member, extra)); + member.principal_id = result["principal"]["id"].as_str().unwrap().to_owned(); + result + } + + /// `account.status` as `uid`, on a connection of its own that is then closed. + pub fn status(&mut self, uid: u32) -> Value { + let mut connection = Connection::new(); + let status = ok(account_status(&mut self.broker, uid, &mut connection)); + self.broker.connection_closed(&mut connection); + status + } + + /// The host invites `username` by name (S3a), without Add device. + pub fn invite_by_name(&mut self, username: &str) -> Value { + self.host_ok("enrollment.invite", json!({"username": username})) + } + + /// The number of records in the journal. + pub fn journal_records(&self) -> usize { + String::from_utf8(self.journal_bytes()) + .unwrap() + .lines() + .filter(|line| !line.is_empty()) + .count() + } + + /// The journal's last record. + pub fn last_record(&self) -> Value { + let journal = String::from_utf8(self.journal_bytes()).unwrap(); + serde_json::from_str(journal.lines().rfind(|line| !line.is_empty()).unwrap()).unwrap() + } + /// `method(params)` as the host. pub fn host_call(&mut self, method: &str, params: Value) -> Response { signed(&mut self.broker, &mut self.host, method, params) @@ -584,6 +682,22 @@ pub fn uuid() -> String { uuid::Uuid::new_v4().to_string() } +/// `cp -a source target` for a stopped workspace's folder: directories made owner-only, and +/// regular files copied with their modes. Anything else (a socket, a link) is left behind. +pub fn copy_tree(source: &Path, target: &Path) { + fs::create_dir(target).unwrap(); + fs::set_permissions(target, fs::Permissions::from_mode(0o700)).unwrap(); + for entry in fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + let kind = entry.file_type().unwrap(); + if kind.is_dir() { + copy_tree(&entry.path(), &target.join(entry.file_name())); + } else if kind.is_file() { + fs::copy(entry.path(), target.join(entry.file_name())).unwrap(); + } + } +} + /// Whether `text` contains a UUID or a 64-hex string anywhere. pub fn contains_machine_id(text: &str) -> bool { let bytes = text.as_bytes(); diff --git a/crates/biorouter-crew/tests/workspaces_contract.rs b/crates/biorouter-crew/tests/workspaces_contract.rs new file mode 100644 index 000000000..cd8232014 --- /dev/null +++ b/crates/biorouter-crew/tests/workspaces_contract.rs @@ -0,0 +1,1063 @@ +//! `biorouter-crew workspaces` (account ownership, AO-6): what is on a server for the account +//! that runs it. +//! +//! The hosted half (state directories, deleted and unreadable ones, the folder cap, nothing +//! written) runs on any Unix. Everything that probes a broker needs Linux, since the kernel's +//! peer credentials are what make an answer trustworthy. +//! +//! Tests run as one Unix account, so another account's broker is played by a fake broker of +//! this account while the listing is told it runs as another UID +//! (`Broker::list_workspaces_with`). That covers candidate selection, the ownership checks, +//! decoys, the per-account and total caps, the deadlines and the signature checks. What only a +//! live run with two accounts proves: that the kernel refuses a probe across accounts where a +//! directory or socket is not that account's, and that `/proc//exe` of another account's +//! broker is unreadable (its `binary_current` stays `null`). +#![cfg(unix)] + +mod support; + +use biorouter_crew::Broker; +use serde_json::{json, Value}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use support::*; + +/// A home directory with the workspace store in it, as setup leaves one. +fn home(label: &str) -> (TempRoot, PathBuf) { + let home = TempRoot::new(label); + let store = home.path().join(".local/share/biorouter-crew"); + fs::create_dir_all(&store).unwrap(); + for dir in [ + home.path().join(".local"), + home.path().join(".local/share"), + store.clone(), + ] { + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + } + (home, store) +} + +/// A directory holding `@alice` as the test's own account. +fn alice() -> FakeDirectory { + let directory = FakeDirectory::default(); + directory.set(host_uid(), "alice", Some("Alice Chen")); + directory +} + +/// A workspace hosted in `store/folder`, bootstrapped, named `name`, labelled Private with +/// institution `ucsf`, its runtime directory bound under `runtime_root`, and then stopped. +/// Returns its ID, state directory, socket path and state. +fn hosted( + store: &Path, + folder: &str, + name: &str, + runtime_root: &Path, +) -> (String, PathBuf, PathBuf, Value) { + let dir = store.join(folder); + let directory = alice(); + let host_key = key(7); + let public_key = key_hex(&host_key); + let mut broker = Broker::open_with_directory(&dir, &public_key, directory.boxed()).unwrap(); + broker.set_runtime_root(runtime_root); + let mut host = Member::new(host_uid(), host_key); + ok(signed_raw( + &mut broker, + &mut host, + "auth.bootstrap", + json!({"public_key": public_key}), + )); + ok(signed( + &mut broker, + &mut host, + "workspace.rename", + json!({"name": name}), + )); + ok(signed( + &mut broker, + &mut host, + "policy.set", + json!({"mode": "private", "institution_id": "ucsf"}), + )); + let socket = broker.prepare_runtime("test-node").unwrap(); + let id = broker.workspace().id.clone(); + let state = broker.state_json(); + drop(broker); + (id, dir, socket, state) +} + +/// The workspace signing key a journal's state holds. +fn signing_key_of(state: &Value) -> ed25519_dalek::SigningKey { + let secret: [u8; 32] = hex::decode(state["workspace_signing_key"].as_str().unwrap()) + .unwrap() + .try_into() + .unwrap(); + ed25519_dalek::SigningKey::from_bytes(&secret) +} + +/// The workspace public key a journal's state holds, hex. +fn public_key_of(state: &Value) -> String { + hex::encode(signing_key_of(state).verifying_key().to_bytes()) +} + +fn entries(answer: &Value) -> &Vec { + answer["workspaces"].as_array().expect("a workspaces list") +} + +fn entry<'a>(answer: &'a Value, workspace_id: &str) -> &'a Value { + entries(answer) + .iter() + .find(|entry| entry["workspace_id"] == workspace_id) + .unwrap_or_else(|| panic!("{workspace_id} missing from {answer:#}")) +} + +/// Every path under `root` with its size and mode, to show a listing wrote nothing. +fn tree(root: &Path) -> Vec<(PathBuf, u64, u32)> { + let mut found = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let metadata = fs::symlink_metadata(&path).unwrap(); + found.push((path.clone(), metadata.len(), metadata.permissions().mode())); + if metadata.is_dir() { + for child in fs::read_dir(&path).unwrap() { + stack.push(child.unwrap().path()); + } + } + } + found.sort(); + found +} + +/// Serializes the tests that use `/tmp` itself as the runtime root, where an invitation's +/// socket path must live. +static TMP: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A runtime directory of this account directly under `/tmp`, removed on drop. +struct TmpRuntime(PathBuf); +impl TmpRuntime { + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] + fn new(prefix: &str) -> Self { + let tail: String = uuid() + .replace('-', "") + .chars() + .take(32 - prefix.len()) + .collect(); + let path = Path::new("/tmp").join(format!("crew-{}-{prefix}{tail}", host_uid())); + Self(path) + } +} +impl Drop for TmpRuntime { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn hosted_workspaces_are_listed_stopped_deleted_or_unknown_and_nothing_is_written() { + let _tmp = TMP.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let (home, store) = home("ws-hosted"); + let tmp = Path::new("/tmp"); + let (lab, lab_dir, lab_socket, lab_state) = hosted(&store, "lab", "lab", tmp); + let _lab_runtime = TmpRuntime(lab_socket.parent().unwrap().to_path_buf()); + let (notes, notes_dir, notes_socket, _) = hosted(&store, "notes", "notes", tmp); + let _notes_runtime = TmpRuntime(notes_socket.parent().unwrap().to_path_buf()); + // An erase a crash interrupted: its marker names the workspace. + fs::write(notes_dir.join("DELETED"), notes.as_bytes()).unwrap(); + fs::set_permissions(notes_dir.join("DELETED"), fs::Permissions::from_mode(0o600)).unwrap(); + // A journal that does not replay, and a folder other accounts may read. + let broken = store.join("broken"); + fs::create_dir(&broken).unwrap(); + fs::set_permissions(&broken, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write( + broken.join("journal.jsonl"), + b"{\"version\":2,\"oops\":true}\n", + ) + .unwrap(); + fs::set_permissions( + broken.join("journal.jsonl"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let open = store.join("open"); + fs::create_dir(&open).unwrap(); + fs::write(open.join("journal.jsonl"), b"").unwrap(); + fs::set_permissions(&open, fs::Permissions::from_mode(0o755)).unwrap(); + // Neither a folder without a journal nor the restart lock is a workspace. + fs::create_dir(store.join("empty")).unwrap(); + fs::write(store.join(".restart-lock"), b"").unwrap(); + + let before = tree(home.path()); + let answer = Broker::list_workspaces_with(home.path(), tmp, host_uid(), &alice()); + assert_eq!(tree(home.path()), before, "the listing changes nothing"); + + assert_eq!( + answer["account"], + json!({"uid": host_uid(), "username": "alice"}) + ); + assert_eq!(answer["truncated"], false); + let lab_entry = entry(&answer, &lab); + let public_key = public_key_of(&lab_state); + assert_eq!(lab_entry["role"], "host"); + assert_eq!(lab_entry["state"], "stopped"); + assert_eq!(lab_entry["name"], "lab"); + assert_eq!(lab_entry["mode"], "private"); + assert_eq!(lab_entry["institution_id"], "ucsf"); + assert_eq!(lab_entry["host_uid"], host_uid()); + assert_eq!(lab_entry["host_username"], "alice"); + assert_eq!(lab_entry["socket_path"], json!(lab_socket)); + assert_eq!(lab_entry["state_dir"], json!(lab_dir)); + assert_eq!(lab_entry["found_by"], "state_dir"); + assert!(lab_entry["version"].is_null()); + assert!(lab_entry["binary_current"].is_null()); + assert!(lab_entry["others_connected"].is_null()); + assert_eq!( + lab_entry["workspace_key_fingerprint"], + json!(digest(&hex::decode(&public_key).unwrap())) + ); + let invitation = biorouter_crew::invitation::parse(lab_entry["invitation"].as_str().unwrap()) + .unwrap() + .invitation; + assert_eq!(invitation.workspace_id, lab); + assert_eq!(invitation.workspace_public_key, public_key); + assert_eq!(invitation.socket_path, lab_socket.to_str().unwrap()); + assert_eq!(invitation.owner_uid, host_uid()); + assert_eq!(invitation.workspace_name.as_deref(), Some("lab")); + assert_eq!(invitation.host_username.as_deref(), Some("alice")); + assert_eq!( + invitation.ssh_host, None, + "the server cannot know how people reach it" + ); + + let deleted = entry(&answer, ¬es); + assert_eq!(deleted["state"], "deleted"); + assert!(deleted["invitation"].is_null()); + + let unknown: Vec<&Value> = entries(&answer) + .iter() + .filter(|entry| entry["state"] == "unknown") + .collect(); + assert_eq!(unknown.len(), 2, "{answer:#}"); + for (dir, code) in [(&broken, "journal_corrupt"), (&open, "unsafe_storage")] { + let found = unknown + .iter() + .find(|entry| entry["state_dir"] == json!(dir)) + .unwrap_or_else(|| panic!("{} missing: {answer:#}", dir.display())); + assert!( + found["error"].as_str().unwrap().starts_with(code), + "{found:#}" + ); + assert!(found["invitation"].is_null()); + } + let listed_dirs: Vec<&Value> = entries(&answer) + .iter() + .map(|entry| &entry["state_dir"]) + .collect(); + assert!(!listed_dirs.contains(&&json!(store.join("empty")))); + assert!( + serde_json::to_vec(&answer).unwrap().len() <= 48 * 1024, + "the answer stays within 48 KiB" + ); +} + +#[test] +fn more_than_sixty_four_hosted_folders_leave_the_answer_truncated() { + let (home, store) = home("ws-cap"); + let runtime = TempRoot::short(); + let directory = alice(); + for index in 0..66 { + let dir = store.join(format!("w{index:02}")); + drop(Broker::open_with_directory(&dir, &key_hex(&key(7)), directory.boxed()).unwrap()); + } + let answer = Broker::list_workspaces_with(home.path(), runtime.path(), host_uid(), &alice()); + let hosted = entries(&answer) + .iter() + .filter(|entry| entry["found_by"] == "state_dir") + .count(); + assert_eq!(hosted, 64, "{answer:#}"); + assert_eq!(answer["truncated"], true); + assert!(serde_json::to_vec(&answer).unwrap().len() <= 48 * 1024); +} + +/// A copy of a workspace's folder kept in the store holds the same ID. Neither folder is +/// listed as the workspace: each is `unknown` with the sentence `start` and `restart` refuse +/// that ID with, so a stopped copy never stands in for the workspace. Another workspace in +/// the same store is listed as before. +#[test] +fn a_copy_of_a_workspaces_folder_is_listed_as_a_conflict() { + let (home, store) = home("ws-copies"); + let runtime = TempRoot::short(); + let (id, ..) = hosted(&store, "lab", "lab", runtime.path()); + let (notes, ..) = hosted(&store, "notes", "notes", runtime.path()); + copy_tree(&store.join("lab"), &store.join("backup-lab")); + let answer = Broker::list_workspaces_with(home.path(), runtime.path(), host_uid(), &alice()); + let copies: Vec<&Value> = entries(&answer) + .iter() + .filter(|entry| entry["workspace_id"] == json!(id)) + .collect(); + assert_eq!(copies.len(), 2, "{answer:#}"); + for copy in copies { + assert_eq!(copy["state"], "unknown", "{answer:#}"); + let error = copy["error"].as_str().unwrap(); + assert!(error.starts_with("identity_conflict: "), "{error}"); + assert!(error.contains("(backup-lab, lab)"), "{error}"); + assert!(copy["invitation"].is_null()); + } + assert_eq!(entry(&answer, ¬es)["state"], "stopped"); + let start = format!("{:#}", Broker::start_by_id(home.path(), &id).unwrap_err()); + let listed = entries(&answer) + .iter() + .find(|entry| entry["workspace_id"] == json!(id)) + .unwrap(); + assert_eq!(listed["error"], json!(start), "one sentence for both"); +} + +#[test] +fn an_account_that_cannot_be_resolved_is_listed_without_a_name() { + let (home, store) = home("ws-noname"); + let runtime = TempRoot::short(); + let (id, ..) = hosted(&store, "lab", "lab", runtime.path()); + let answer = Broker::list_workspaces_with( + home.path(), + runtime.path(), + host_uid(), + &FakeDirectory::default(), + ); + assert_eq!( + answer["account"], + json!({"uid": host_uid(), "username": null}) + ); + let lab = entry(&answer, &id); + assert_eq!(lab["state"], "stopped"); + assert!(lab["host_username"].is_null()); + assert!( + lab["invitation"].is_null(), + "a line needs the host's name: {lab:#}" + ); +} + +/// The command itself: one line of JSON, whatever is there. +#[test] +fn the_command_prints_one_line_of_json() { + let (home, _store) = home("ws-command"); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_biorouter-crew")) + .args(["workspaces", "--output-format", "json"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let text = String::from_utf8(output.stdout).unwrap(); + assert_eq!(text.trim_end().lines().count(), 1, "{text}"); + let answer: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(answer["account"]["uid"], host_uid()); + for count in ["skipped", "unsupported"] { + assert!(answer[count].is_u64(), "{count}: {answer:#}"); + } + assert!(answer["truncated"].is_boolean(), "{answer:#}"); + assert!(answer["workspaces"].is_array()); + let refused = std::process::Command::new(env!("CARGO_BIN_EXE_biorouter-crew")) + .args(["workspaces", "--output-format", "text"]) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(!refused.status.success()); +} + +#[cfg(target_os = "linux")] +mod linux { + use super::*; + use biorouter_crew::{hello_v1_payload, HelloV2, Mode}; + use ed25519_dalek::{Signer, SigningKey}; + use std::io::{BufRead, BufReader, Write}; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::time::{Duration, Instant}; + + /// How a fake broker behaves. (A test fixture: the size of its variants does not matter.) + #[derive(Clone)] + #[allow(clippy::large_enum_variant)] + enum Behaviour { + /// Answer `hello` (signed with `key` over the nonce it is sent, unless `wrong_nonce`) + /// and `account.status` with `status`. + Answer { + key: SigningKey, + workspace_id: String, + host_uid: u32, + name: Option, + capabilities: Vec<&'static str>, + status: Value, + version: Option<&'static str>, + wrong_nonce: bool, + }, + /// Read the request and never answer. + Stall, + /// Answer one byte at a time, slowly, forever. + Drip, + } + + fn capabilities(account: bool) -> Vec<&'static str> { + let mut list = vec!["human_chat", "signed_devices", "join_by_name_v1"]; + if account { + list.push("account_identity_v1"); + } + list + } + + fn member_broker(role: &str) -> Behaviour { + Behaviour::Answer { + key: SigningKey::from_bytes(&rand_seed()), + workspace_id: uuid(), + host_uid: host_uid(), + name: Some("lab".into()), + capabilities: capabilities(true), + status: json!({"role": role, "workspace_name": "lab", "host_username": "mallory"}), + version: Some("1.94.1"), + wrong_nonce: false, + } + } + + fn rand_seed() -> [u8; 32] { + let id = uuid().replace('-', ""); + let mut seed = [0u8; 32]; + seed[..16].copy_from_slice(&hex::decode(&id).unwrap()); + seed[16..].copy_from_slice(&hex::decode(&id).unwrap()); + seed + } + + fn hello_answer(behaviour: &Behaviour, nonce: &str) -> Value { + let Behaviour::Answer { + key, + workspace_id, + host_uid, + name, + capabilities, + version, + wrong_nonce, + .. + } = behaviour + else { + unreachable!() + }; + let public_key = hex::encode(key.verifying_key().to_bytes()); + let signed_nonce = if *wrong_nonce { "another-nonce" } else { nonce }; + let v1 = key.sign(&hello_v1_payload( + workspace_id, + *host_uid, + signed_nonce, + &public_key, + "test-node", + )); + let v2 = key.sign( + &HelloV2 { + workspace_id, + host_uid: *host_uid, + challenge_nonce: signed_nonce, + workspace_public_key: &public_key, + node_id: "test-node", + mode: &Mode::Private, + institution_id: Some("ucsf"), + policy_epoch: 3, + name: name.as_deref(), + capabilities, + } + .signing_payload(), + ); + let mut hello = json!({ + "protocol": 1, "workspace_id": workspace_id, "host_uid": host_uid, + "mode": "private", "institution_id": "ucsf", "policy_epoch": 3, "name": name, + "workspace_public_key": public_key, "node_id": "test-node", + "workspace_key_fingerprint": digest(&key.verifying_key().to_bytes()), + "challenge_nonce": nonce, "signature": hex::encode(v1.to_bytes()), + "signature_v2": hex::encode(v2.to_bytes()), "capabilities": capabilities, + "state": "running", + }); + if let Some(version) = version { + hello["version"] = json!(version); + } + hello + } + + fn serve_one(stream: UnixStream, behaviour: Behaviour) { + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + let request: Value = serde_json::from_str(&line).unwrap_or_default(); + match &behaviour { + Behaviour::Stall => { + std::thread::sleep(Duration::from_secs(30)); + return; + } + Behaviour::Drip => { + for _ in 0..150 { + if writer.write_all(b" ").is_err() { + return; + } + std::thread::sleep(Duration::from_millis(200)); + } + return; + } + Behaviour::Answer { status, .. } => { + let result = match request["method"].as_str() { + Some("hello") => hello_answer( + &behaviour, + request["params"]["challenge_nonce"].as_str().unwrap_or(""), + ), + Some("account.status") => status.clone(), + _ => json!({}), + }; + let answer = json!({"id": request["id"], "result": result}); + if writer.write_all(format!("{answer}\n").as_bytes()).is_err() { + return; + } + } + } + } + } + + /// A fake broker listening at `directory/broker.sock` (the directory is made private). + fn fake_broker(directory: &Path, behaviour: Behaviour) -> PathBuf { + fs::create_dir(directory).unwrap(); + fs::set_permissions(directory, fs::Permissions::from_mode(0o711)).unwrap(); + let socket = directory.join("broker.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let behaviour = behaviour.clone(); + std::thread::spawn(move || serve_one(stream, behaviour)); + } + }); + socket + } + + /// The name of one of this account's runtime directories: `crew--` and 32 hex digits + /// starting with `prefix`. + fn runtime_name(prefix: &str) -> String { + let tail: String = uuid() + .replace('-', "") + .chars() + .take(32 - prefix.len()) + .collect(); + format!("crew-{}-{prefix}{tail}", host_uid()) + } + + fn workspace_id_of(behaviour: &Behaviour) -> String { + match behaviour { + Behaviour::Answer { workspace_id, .. } => workspace_id.clone(), + _ => unreachable!(), + } + } + + /// The listing as another account sees this one's brokers: the test account's runtime + /// directories stand in for another account's. + fn as_another_account(home: &Path, runtime_root: &Path) -> Value { + Broker::list_workspaces_with(home, runtime_root, host_uid() + 1, &alice()) + } + + #[test] + fn another_accounts_workspaces_are_listed_by_role_with_the_host_named_locally() { + let (home, _store) = home("ws-scan-roles"); + let runtime = TempRoot::short(); + let mut ids = Vec::new(); + for (index, role) in ["member", "invited", "removed", "none"].iter().enumerate() { + let behaviour = member_broker(role); + ids.push(workspace_id_of(&behaviour)); + fake_broker( + &runtime.path().join(runtime_name(&format!("{index}"))), + behaviour, + ); + } + let answer = as_another_account(home.path(), runtime.path()); + assert_eq!(answer["skipped"], 0, "{answer:#}"); + assert_eq!(answer["unsupported"], 0); + assert_eq!(entries(&answer).len(), 3, "{answer:#}"); + for (id, role) in ids.iter().zip(["member", "invited", "removed"]) { + let found = entry(&answer, id); + assert_eq!(found["role"], role); + assert_eq!(found["found_by"], "scan"); + assert_eq!(found["host_uid"], host_uid()); + // From resolving the socket's owner, never from the broker's answer ("mallory"). + assert_eq!(found["host_username"], "alice"); + assert_eq!(found["name"], "lab"); + assert_eq!(found["state"], "running"); + assert_eq!(found["version"], "1.94.1"); + assert!(found["binary_current"].is_null()); + assert!(found["state_dir"].is_null()); + assert!(found["others_connected"].is_null()); + } + } + + #[test] + fn four_brokers_per_account_are_probed_and_the_rest_are_counted() { + let (home, _store) = home("ws-scan-cap"); + let runtime = TempRoot::short(); + for index in 0..6 { + fake_broker( + &runtime.path().join(runtime_name(&format!("{index}"))), + member_broker("member"), + ); + } + let answer = as_another_account(home.path(), runtime.path()); + assert_eq!(entries(&answer).len(), 4, "{answer:#}"); + assert_eq!(answer["skipped"], 2); + } + + #[test] + fn a_stalled_or_dripping_broker_cannot_hold_the_scan_past_its_deadline() { + let (home, _store) = home("ws-scan-deadline"); + let runtime = TempRoot::short(); + fake_broker(&runtime.path().join(runtime_name("0")), Behaviour::Stall); + fake_broker(&runtime.path().join(runtime_name("1")), Behaviour::Drip); + let good = member_broker("member"); + let good_id = workspace_id_of(&good); + fake_broker(&runtime.path().join(runtime_name("2")), good); + let started = Instant::now(); + let answer = as_another_account(home.path(), runtime.path()); + let elapsed = started.elapsed(); + assert!(elapsed < Duration::from_secs(3), "took {elapsed:?}"); + assert_eq!(answer["skipped"], 2, "{answer:#}"); + assert_eq!(entry(&answer, &good_id)["role"], "member"); + } + + #[test] + fn decoys_take_no_slot_and_are_not_counted() { + let (home, _store) = home("ws-scan-decoys"); + let runtime = TempRoot::short(); + // The real broker sorts after every decoy named for the same account. + let good = member_broker("member"); + let good_id = workspace_id_of(&good); + let real = runtime.path().join(runtime_name("f")); + fake_broker(&real, good); + let decoy = |prefix: &str| runtime.path().join(runtime_name(prefix)); + // A plain file, a link to the real directory, a directory without a socket, a + // directory others can write into holding a live socket, and a file named broker.sock. + fs::write(decoy("0"), b"").unwrap(); + std::os::unix::fs::symlink(&real, decoy("1")).unwrap(); + fs::create_dir(decoy("2")).unwrap(); + let writable = decoy("3"); + fake_broker(&writable, member_broker("member")); + fs::set_permissions(&writable, fs::Permissions::from_mode(0o777)).unwrap(); + let fake_socket = decoy("4"); + fs::create_dir(&fake_socket).unwrap(); + fs::write(fake_socket.join("broker.sock"), b"").unwrap(); + // A directory named for another UID that this account owns, and a name with the UID + // written with a leading zero. + let hex: String = "5".repeat(32); + fake_broker( + &runtime + .path() + .join(format!("crew-{}-{hex}", host_uid() + 7)), + member_broker("member"), + ); + fake_broker( + &runtime.path().join(format!("crew-0{}-{hex}", host_uid())), + member_broker("member"), + ); + let answer = as_another_account(home.path(), runtime.path()); + assert_eq!(entries(&answer).len(), 1, "{answer:#}"); + assert_eq!(entry(&answer, &good_id)["role"], "member"); + assert_eq!(answer["skipped"], 0); + assert_eq!(answer["unsupported"], 0); + } + + #[test] + fn an_older_broker_is_counted_and_a_forged_answer_is_skipped() { + let (home, _store) = home("ws-scan-forged"); + let runtime = TempRoot::short(); + let Behaviour::Answer { key, status, .. } = member_broker("member") else { + unreachable!() + }; + let older = Behaviour::Answer { + key: key.clone(), + workspace_id: uuid(), + host_uid: host_uid(), + name: Some("old-lab".into()), + capabilities: capabilities(false), + status: status.clone(), + version: None, + wrong_nonce: false, + }; + let replayed = Behaviour::Answer { + key: key.clone(), + workspace_id: uuid(), + host_uid: host_uid(), + name: Some("replayed".into()), + capabilities: capabilities(true), + status: status.clone(), + version: None, + wrong_nonce: true, + }; + let not_its_host = Behaviour::Answer { + key, + workspace_id: uuid(), + host_uid: host_uid() + 9, + name: Some("elsewhere".into()), + capabilities: capabilities(true), + status, + version: None, + wrong_nonce: false, + }; + fake_broker(&runtime.path().join(runtime_name("0")), older); + fake_broker(&runtime.path().join(runtime_name("1")), replayed); + fake_broker(&runtime.path().join(runtime_name("2")), not_its_host); + let answer = as_another_account(home.path(), runtime.path()); + assert!(entries(&answer).is_empty(), "{answer:#}"); + assert_eq!(answer["unsupported"], 1); + assert_eq!(answer["skipped"], 2); + } + + /// Another account's brokers cannot fill the answer: a signed name larger than the + /// workspace-name rule allows, or an unsigned version longer than 64 bytes, does not check + /// out and is counted in `skipped`. Its well-formed neighbour, whose name sorts after the + /// oversized ones, is still listed. + #[test] + fn a_broker_repeating_oversized_fields_is_skipped() { + let (home, _store) = home("ws-scan-oversized"); + let runtime = TempRoot::short(); + let oversized = |name: String, version: Option<&'static str>| Behaviour::Answer { + key: SigningKey::from_bytes(&rand_seed()), + workspace_id: uuid(), + host_uid: host_uid(), + name: Some(name), + capabilities: capabilities(true), + status: json!({"role": "member"}), + version, + wrong_nonce: false, + }; + let long_version: &'static str = Box::leak("9".repeat(4 * 1024).into_boxed_str()); + let big = "a".repeat(14 * 1024); + for (index, behaviour) in [ + oversized(big.clone(), Some("1.94.1")), + oversized(format!("{big}b"), Some("1.94.1")), + oversized("aaa-lab".into(), Some(long_version)), + ] + .into_iter() + .enumerate() + { + fake_broker( + &runtime.path().join(runtime_name(&format!("{index}"))), + behaviour, + ); + } + let good = member_broker("member"); + let good_id = workspace_id_of(&good); + fake_broker(&runtime.path().join(runtime_name("3")), good); + let answer = as_another_account(home.path(), runtime.path()); + assert_eq!(entries(&answer).len(), 1, "{answer:#}"); + assert_eq!(entry(&answer, &good_id)["name"], "lab"); + assert_eq!(answer["skipped"], 3); + assert_eq!(answer["truncated"], false); + } + + /// Two brokers answering with one workspace ID are two entries, never one: each keeps its + /// own socket and key fingerprint (and `host_uid`), so a reader can tell them apart and + /// refuse to open the name when two hosts claim it. + #[test] + fn brokers_sharing_a_workspace_id_are_listed_apart() { + let (home, _store) = home("ws-scan-shared-id"); + let runtime = TempRoot::short(); + let shared = uuid(); + let mut keys = Vec::new(); + let mut sockets = Vec::new(); + for index in 0..2 { + let key = SigningKey::from_bytes(&rand_seed()); + keys.push(digest(&key.verifying_key().to_bytes())); + sockets.push(fake_broker( + &runtime.path().join(runtime_name(&format!("{index}"))), + Behaviour::Answer { + key, + workspace_id: shared.clone(), + host_uid: host_uid(), + name: Some("baranzini-lab".into()), + capabilities: capabilities(true), + status: json!({"role": "member"}), + version: Some("1.94.1"), + wrong_nonce: false, + }, + )); + } + let answer = as_another_account(home.path(), runtime.path()); + let listed: Vec<&Value> = entries(&answer) + .iter() + .filter(|entry| entry["workspace_id"] == json!(shared)) + .collect(); + assert_eq!(listed.len(), 2, "{answer:#}"); + let mut seen_keys: Vec = listed + .iter() + .map(|entry| entry["workspace_key_fingerprint"].clone()) + .collect(); + seen_keys.sort_by_key(Value::to_string); + let mut expected: Vec = keys.iter().map(|key| json!(key)).collect(); + expected.sort_by_key(Value::to_string); + assert_eq!(seen_keys, expected); + let mut seen_sockets: Vec = listed + .iter() + .map(|entry| entry["socket_path"].clone()) + .collect(); + seen_sockets.sort_by_key(Value::to_string); + let mut expected: Vec = sockets.iter().map(|socket| json!(socket)).collect(); + expected.sort_by_key(Value::to_string); + assert_eq!(seen_sockets, expected); + for entry in listed { + assert_eq!(entry["host_uid"], host_uid()); + } + } + + /// A member's entry carries the line the ordinary join path opens, pointing at the socket + /// the broker answered at. + #[test] + fn a_found_workspace_carries_its_invitation_line() { + let _tmp = TMP.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let (home, _store) = home("ws-scan-line"); + let behaviour = member_broker("member"); + let id = workspace_id_of(&behaviour); + let directory = TmpRuntime::new("0000"); + let socket = fake_broker(&directory.0, behaviour); + let answer = as_another_account(home.path(), Path::new("/tmp")); + let found = entry(&answer, &id); + let line = biorouter_crew::invitation::parse(found["invitation"].as_str().unwrap()) + .unwrap() + .invitation; + assert_eq!(line.workspace_id, id); + assert_eq!(line.socket_path, socket.to_str().unwrap()); + assert_eq!(line.owner_uid, host_uid()); + assert_eq!(line.host_username.as_deref(), Some("alice")); + assert_eq!(line.workspace_name.as_deref(), Some("lab")); + assert_eq!(line.ssh_host, None); + } + + /// The runtime descriptor a running broker of the store's workspace in `dir` leaves. + fn write_runtime(dir: &Path, socket: &Path, workspace_id: &str, key: &SigningKey) { + let info = json!({ + "pid": std::process::id(), + "socket": socket, + "workspace_id": workspace_id, + "host_uid": host_uid(), + "protocol": 1, + "node_id": "test-node", + "workspace_public_key": hex::encode(key.verifying_key().to_bytes()), + "version": "1.94.0", + }); + let path = dir.join("runtime.json"); + fs::write(&path, serde_json::to_vec(&info).unwrap()).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + + #[test] + fn a_running_hosted_workspace_is_described_by_its_broker() { + let (home, store) = home("ws-running"); + let runtime = TempRoot::short(); + let (id, dir, ..) = hosted(&store, "lab", "lab", runtime.path()); + let key = SigningKey::from_bytes(&rand_seed()); + let behaviour = Behaviour::Answer { + key: key.clone(), + workspace_id: id.clone(), + host_uid: host_uid(), + name: Some("lab".into()), + capabilities: capabilities(true), + status: json!({"role": "host", "workspace_name": "lab", "others_connected": 2}), + version: None, + wrong_nonce: false, + }; + let socket = fake_broker(&runtime.path().join("live"), behaviour); + write_runtime(&dir, &socket, &id, &key); + // The installed program is this test's own executable, which the listener runs. + let bin = home.path().join(".local/bin"); + fs::create_dir_all(&bin).unwrap(); + std::os::unix::fs::symlink(std::env::current_exe().unwrap(), bin.join("biorouter-crew")) + .unwrap(); + + let answer = + Broker::list_workspaces_with(home.path(), runtime.path(), host_uid(), &alice()); + let lab = entry(&answer, &id); + assert_eq!(lab["state"], "running", "{answer:#}"); + assert_eq!(lab["found_by"], "state_dir"); + assert_eq!(lab["state_dir"], json!(dir)); + assert_eq!(lab["socket_path"], json!(socket)); + // An older broker's hello says no version; its runtime.json may. + assert_eq!(lab["version"], "1.94.0"); + assert_eq!(lab["binary_current"], true); + assert_eq!(lab["others_connected"], 2); + assert_eq!(lab["host_username"], "alice"); + + // A newer copy installed over it: the running broker no longer runs the installed file. + fs::remove_file(bin.join("biorouter-crew")).unwrap(); + fs::copy(std::env::current_exe().unwrap(), bin.join("biorouter-crew")).unwrap(); + let answer = + Broker::list_workspaces_with(home.path(), runtime.path(), host_uid(), &alice()); + assert_eq!(entry(&answer, &id)["binary_current"], false); + } + + #[test] + fn a_broker_answering_for_another_workspace_is_unknown_and_a_gone_one_is_stopped() { + let (home, store) = home("ws-mismatch"); + let runtime = TempRoot::short(); + let (id, dir, ..) = hosted(&store, "lab", "lab", runtime.path()); + let key = SigningKey::from_bytes(&rand_seed()); + let impostor = Behaviour::Answer { + key: key.clone(), + workspace_id: uuid(), + host_uid: host_uid(), + name: Some("lab".into()), + capabilities: capabilities(true), + status: json!({"role": "host", "others_connected": 0}), + version: None, + wrong_nonce: false, + }; + let socket = fake_broker(&runtime.path().join("live"), impostor); + write_runtime(&dir, &socket, &id, &key); + let answer = + Broker::list_workspaces_with(home.path(), runtime.path(), host_uid(), &alice()); + let lab = entry(&answer, &id); + assert_eq!(lab["state"], "unknown", "{answer:#}"); + assert!(lab["error"] + .as_str() + .unwrap() + .starts_with("identity_mismatch")); + + // Nothing listens at the recorded socket any more: the journal says what it is. + fs::remove_file(&socket).unwrap(); + let answer = + Broker::list_workspaces_with(home.path(), runtime.path(), host_uid(), &alice()); + assert_eq!(entry(&answer, &id)["state"], "stopped", "{answer:#}"); + } + + #[test] + fn a_workspace_started_from_a_custom_state_dir_is_found_by_its_runtime_directory() { + let (home, store) = home("ws-custom"); + let runtime = TempRoot::short(); + let custom = Behaviour::Answer { + key: SigningKey::from_bytes(&rand_seed()), + workspace_id: uuid(), + host_uid: host_uid(), + name: Some("scratch".into()), + capabilities: capabilities(true), + status: json!({"role": "host", "others_connected": 1}), + version: Some("1.94.1"), + wrong_nonce: false, + }; + let custom_id = workspace_id_of(&custom); + fake_broker(&runtime.path().join(runtime_name("1")), custom); + // A workspace of the store whose runtime.json went missing while its broker runs: the + // same ID, host and key. + let (stored_id, stored_dir, _, stored_state) = hosted(&store, "lab", "lab", runtime.path()); + fake_broker( + &runtime.path().join(runtime_name("2")), + Behaviour::Answer { + key: signing_key_of(&stored_state), + workspace_id: stored_id.clone(), + host_uid: host_uid(), + name: Some("lab".into()), + capabilities: capabilities(false), + status: Value::Null, + version: None, + wrong_nonce: false, + }, + ); + // A socket nothing listens on any more is passed over. + let stale = runtime.path().join(runtime_name("3")); + fs::create_dir(&stale).unwrap(); + drop(UnixListener::bind(stale.join("broker.sock")).unwrap()); + + let answer = + Broker::list_workspaces_with(home.path(), runtime.path(), host_uid(), &alice()); + let found = entry(&answer, &custom_id); + assert_eq!(found["found_by"], "runtime", "{answer:#}"); + assert!(found["state_dir"].is_null()); + assert_eq!(found["state"], "running"); + assert_eq!(found["role"], "host"); + assert_eq!(found["others_connected"], 1); + let stored = entry(&answer, &stored_id); + assert_eq!(stored["state"], "running", "{answer:#}"); + assert_eq!(stored["state_dir"], json!(stored_dir)); + assert!( + stored["others_connected"].is_null(), + "no account_identity_v1" + ); + assert_eq!(entries(&answer).len(), 2, "{answer:#}"); + assert_eq!(answer["skipped"], 0); + } + + /// A running broker that answers with a folder's workspace ID but another key is not that + /// workspace: both are listed, each with its own key, and the folder stays stopped. + #[test] + fn a_runtime_broker_with_a_folders_id_but_another_key_is_listed_apart() { + let (home, store) = home("ws-custom-other-key"); + let runtime = TempRoot::short(); + let (id, dir, _, state) = hosted(&store, "lab", "lab", runtime.path()); + let other_key = SigningKey::from_bytes(&rand_seed()); + let socket = fake_broker( + &runtime.path().join(runtime_name("2")), + Behaviour::Answer { + key: other_key.clone(), + workspace_id: id.clone(), + host_uid: host_uid(), + name: Some("lab".into()), + capabilities: capabilities(true), + status: json!({"role": "host", "others_connected": 0}), + version: Some("1.94.1"), + wrong_nonce: false, + }, + ); + let answer = + Broker::list_workspaces_with(home.path(), runtime.path(), host_uid(), &alice()); + let listed: Vec<&Value> = entries(&answer) + .iter() + .filter(|entry| entry["workspace_id"] == json!(id)) + .collect(); + assert_eq!(listed.len(), 2, "{answer:#}"); + let folder = listed + .iter() + .find(|entry| entry["found_by"] == "state_dir") + .unwrap(); + assert_eq!(folder["state"], "stopped", "{answer:#}"); + assert_eq!(folder["state_dir"], json!(dir)); + assert_eq!( + folder["workspace_key_fingerprint"], + json!(digest(&hex::decode(public_key_of(&state)).unwrap())) + ); + let running = listed + .iter() + .find(|entry| entry["found_by"] == "runtime") + .unwrap(); + assert_eq!(running["state"], "running"); + assert_eq!(running["socket_path"], json!(socket)); + assert_eq!( + running["workspace_key_fingerprint"], + json!(digest(&other_key.verifying_key().to_bytes())) + ); + } + + /// A probe never reads more than 16 KiB of an answer. + #[test] + fn an_oversized_answer_is_skipped() { + let (home, _store) = home("ws-scan-large"); + let runtime = TempRoot::short(); + let directory = runtime.path().join(runtime_name("0")); + fs::create_dir(&directory).unwrap(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o711)).unwrap(); + let listener = UnixListener::bind(directory.join("broker.sock")).unwrap(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { return }; + std::thread::spawn(move || { + let mut line = String::new(); + let _ = BufReader::new(stream.try_clone().unwrap()).read_line(&mut line); + let _ = stream.write_all(&vec![b'x'; 64 * 1024]); + std::thread::sleep(Duration::from_secs(5)); + }); + } + }); + let started = Instant::now(); + let answer = as_another_account(home.path(), runtime.path()); + assert!(started.elapsed() < Duration::from_secs(3)); + assert_eq!(answer["skipped"], 1, "{answer:#}"); + assert!(entries(&answer).is_empty()); + } +} diff --git a/crates/biorouter-server/src/commands/agent.rs b/crates/biorouter-server/src/commands/agent.rs index 55e95c185..49281a030 100644 --- a/crates/biorouter-server/src/commands/agent.rs +++ b/crates/biorouter-server/src/commands/agent.rs @@ -440,6 +440,70 @@ fn declared_public_url(serve_ui: Option<&str>) -> Result crew.sweep_removed_connections().await, + Err(error) => tracing::debug!(%error, "Crew is unavailable; nothing to sweep"), + } + }); +} + +/// `app` with the web interface attached when this daemon was asked to serve one from the +/// folder `serve_ui` names, unchanged otherwise. The caller adds it after `check_token`, so the +/// shell and the static bundle sit outside that middleware (see `run`). +async fn attach_web_ui( + app: axum::Router, + serve_ui: Option<&str>, + secret_key: &str, + public_url: Option<&crate::routes::PublicUrl>, +) -> Result { + let Some(dir) = serve_ui else { + return Ok(app); + }; + let web_dir = std::path::PathBuf::from(dir); + // A browser token is minted by whoever launched this daemon -- + // `biorouter serve` -- and refused outright for a non-loopback bind + // there, so its absence here means a loopback bind whose launcher + // chose not to require one. + let browser_token = std::env::var("BIOROUTER_BROWSER_TOKEN").ok(); + // Issue #56, QA 2026-09-10 (SD-10): the interface this daemon serves is + // the operator's, and SD-1 pins the provider every session here runs + // on — so that provider's tier is the reach the listing and + // knowledge-base gates give a request carrying the served document's + // cookie. Without a token there is no such cookie, and the interface + // cannot be told from any other local caller, so it gets none. + if let Some(token) = browser_token.as_deref().filter(|t| !t.is_empty()) { + let capability = served_operator_capability().await; + info!( + ?capability, + "the served interface is given the configured provider's tier on listings \ + and knowledge bases" + ); + biorouter_server::auth::install_served_operator(token.to_string(), capability); + } + let ui = crate::routes::web_ui::WebUi::new( + &web_dir, + secret_key, + browser_token, + public_url.map(crate::routes::PublicUrl::path), + ) + .map_err(|e| { + anyhow::anyhow!( + "could not read the web interface at {}: {e}", + web_dir.display() + ) + })?; + info!("serving the web interface from {}", web_dir.display()); + Ok(crate::routes::web_ui::attach(app, web_dir, ui)) +} + pub async fn run(exit_with_parent: Option) -> Result<()> { crate::logging::setup_logging(Some("biorouterd"))?; biorouter_mcp::app_folder::report_once(); @@ -524,12 +588,16 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { None }; let app_state = state::AppState::new().await?; + spawn_crew_sweep(); // BR-71: publish the daemon's platform services to the `biorouter` crate so // the workspace extension's tools can reach the turn lock, the detached turn // runner and (Slice 2) the GUI bridge. Without this the tools degrade to // their headless behaviour even inside the daemon. crate::workspace::services::install_workspace_services(app_state.clone()); + // AO-12: a chat's person tools (`crew__read`, `crew__change`) run through the same + // functions as the Crew routes. Without this the `crew` extension does not offer them. + crate::routes::crew_tools::install(app_state.clone()); let cors = CorsLayer::new() .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _| { @@ -567,46 +635,13 @@ pub async fn run(exit_with_parent: Option) -> Result<()> { // `routes::web_ui` for what gates them instead, and // `docs/deployment/serve-architecture.md` for why the daemon serves them at // all rather than a separate binary proxying to it. - let app = match settings.serve_ui.as_deref() { - Some(dir) => { - let web_dir = std::path::PathBuf::from(dir); - // A browser token is minted by whoever launched this daemon -- - // `biorouter serve` -- and refused outright for a non-loopback bind - // there, so its absence here means a loopback bind whose launcher - // chose not to require one. - let browser_token = std::env::var("BIOROUTER_BROWSER_TOKEN").ok(); - // Issue #56, QA 2026-09-10 (SD-10): the interface this daemon serves is - // the operator's, and SD-1 pins the provider every session here runs - // on — so that provider's tier is the reach the listing and - // knowledge-base gates give a request carrying the served document's - // cookie. Without a token there is no such cookie, and the interface - // cannot be told from any other local caller, so it gets none. - if let Some(token) = browser_token.as_deref().filter(|t| !t.is_empty()) { - let capability = served_operator_capability().await; - info!( - ?capability, - "the served interface is given the configured provider's tier on listings \ - and knowledge bases" - ); - biorouter_server::auth::install_served_operator(token.to_string(), capability); - } - let ui = crate::routes::web_ui::WebUi::new( - &web_dir, - &secret_key, - browser_token, - public_url.as_ref().map(crate::routes::PublicUrl::path), - ) - .map_err(|e| { - anyhow::anyhow!( - "could not read the web interface at {}: {e}", - web_dir.display() - ) - })?; - info!("serving the web interface from {}", web_dir.display()); - crate::routes::web_ui::attach(app, web_dir, ui) - } - None => app, - }; + let app = attach_web_ui( + app, + settings.serve_ui.as_deref(), + &secret_key, + public_url.as_ref(), + ) + .await?; let app = app.layer(response_compression()); diff --git a/crates/biorouter-server/src/openapi.rs b/crates/biorouter-server/src/openapi.rs index 4977f0b2e..bcb0d12d3 100644 --- a/crates/biorouter-server/src/openapi.rs +++ b/crates/biorouter-server/src/openapi.rs @@ -25,10 +25,11 @@ use biorouter::config::declarative_providers::{ DeclarativeProviderConfig, LoadedProvider, ProviderEngine, }; use biorouter::conversation::message::{ - ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent, - MessageMetadata, MessageProvenance, ProvenanceKind, RedactedThinkingContent, SecretDestination, - SecretKeyRequest, SystemNotificationContent, SystemNotificationType, ThinkingContent, - TokenState, ToolConfirmationRequest, ToolRequest, ToolResponse, + ActionRequired, ActionRequiredData, CrewSignInTarget, CrewStepRequest, CrewVaultAction, + FrontendToolRequest, Message, MessageContent, MessageMetadata, MessageProvenance, + ProvenanceKind, RedactedThinkingContent, SecretDestination, SecretKeyRequest, + SystemNotificationContent, SystemNotificationType, ThinkingContent, TokenState, + ToolConfirmationRequest, ToolRequest, ToolResponse, }; use biorouter::conversation::tool_preview::{ToolPreview, ToolPreviewLine, ToolPreviewLineKind}; use biorouter::permission::tool_risk::ToolRisk; @@ -367,6 +368,7 @@ impl utoipa::Modify for ApiKeySecurity { modifiers(&ApiKeySecurity), paths( super::routes::crew::list_connections, + super::routes::crew::get_connection, super::routes::crew::prepare_device, super::routes::crew::save_connection, super::routes::crew::update_connection, @@ -385,6 +387,7 @@ impl utoipa::Modify for ApiKeySecurity { super::routes::crew::host_start_state, super::routes::crew::host_start_cancel, super::routes::crew_setup::artifact, + super::routes::crew_setup::jobs, super::routes::crew_setup::start, super::routes::crew_setup::status, super::routes::crew_setup::cancel, @@ -398,6 +401,9 @@ impl utoipa::Modify for ApiKeySecurity { super::routes::crew_authentication::invitation, super::routes::crew_authentication::join_status, super::routes::crew_authentication::join, + super::routes::crew_authentication::rejoin, + super::routes::crew_authentication::account_status, + super::routes::crew_authentication::acknowledge_devices, super::routes::crew_transfers::register_file, super::routes::crew_transfers::confirm_file, super::routes::crew_transfers::discard_file, @@ -415,6 +421,10 @@ impl utoipa::Modify for ApiKeySecurity { super::routes::crew_profile::grants, super::routes::crew_profile::context, super::routes::crew_profile::revoke, + super::routes::crew_profile::revoke_read, + super::routes::crew_changes::crew_changes, + super::routes::crew_host_keys::read_key, + super::routes::crew_host_keys::trust_key, super::routes::status::status, super::routes::status::system_info, super::routes::status::diagnostics, @@ -471,6 +481,7 @@ impl utoipa::Modify for ApiKeySecurity { super::routes::folder_access::list_folder_access, super::routes::folder_access::revoke_folder_access, super::routes::action_required::submit_secrets, + super::routes::action_required::dismiss_crew_step, super::routes::catalog::catalog_changes, super::routes::session_meta::session_changes, super::routes::catalog::catalog_revision, @@ -659,6 +670,7 @@ impl utoipa::Modify for ApiKeySecurity { super::routes::crew_authentication::FromInvitationResponse, super::routes::crew_authentication::InvitationSummary, super::routes::crew_authentication::JoinClaimed, + super::routes::crew_authentication::AcknowledgeDevicesRequest, biorouter::crew::ClusterMode, biorouter::crew::authentication::InvitationAdvanced, biorouter::crew::authentication::InvitationPreview, @@ -669,6 +681,16 @@ impl utoipa::Modify for ApiKeySecurity { biorouter::crew::authentication::JoinState, biorouter::crew::authentication::JoinPerson, biorouter::crew::authentication::LoginMismatch, + // Account ownership (AO-3, AO-9, AO-10, AO-12): `rejoin` and `account` answer these, + // and a connection view lists `NewDevice`; each names the others by `$ref`. + biorouter::crew::authentication::AccountStatus, + biorouter::crew::authentication::AccountJoined, + biorouter::crew::authentication::AccountRole, + biorouter::crew::authentication::AccountAdmission, + biorouter::crew::authentication::EvictedDevice, + biorouter::crew::NewDevice, + super::routes::crew::wire::CrewPersonReadView, + super::routes::crew::wire::CrewSetupJobs, // "Start it for me" (D-HOST): the request, the run and what it names by `$ref`. biorouter::crew::HostStartRequest, biorouter::crew::HostStartStatus, @@ -690,6 +712,14 @@ impl utoipa::Modify for ApiKeySecurity { biorouter::crew::SetupOutcome, biorouter::crew::SetupError, biorouter::crew::ContextDraft, + // Crew's change feed (WI-15): kinds and IDs only. + biorouter::crew::changes::CrewChangesDelta, + biorouter::crew::changes::CrewChange, + // Trusting an unknown host key the person verified (D2). + super::routes::crew_host_keys::HostKeyReadRequest, + super::routes::crew_host_keys::HostKeyTrustRequest, + biorouter::crew::host_keys::HostKeyRead, + biorouter::crew::host_keys::HostKeyTrusted, super::routes::crew_setup::SetupSignIn, super::routes::crew_transfers::Resume, biorouter::security::computer_use::ComputerUseStatus, @@ -737,6 +767,8 @@ impl utoipa::Modify for ApiKeySecurity { super::routes::folder_access::RevokeFolderAccessRequest, biorouter::folder_access::FolderGrantView, super::routes::action_required::SubmitSecretsRequest, + super::routes::action_required::CrewStepResponseRequest, + super::routes::action_required::CrewStepOutcome, biorouter::catalog::CatalogDelta, biorouter::session_meta::SessionMetaDelta, biorouter::session_meta::SessionMetaChanged, @@ -897,6 +929,10 @@ impl utoipa::Modify for ApiKeySecurity { // type — the failure is loud, but only at `npm run generate-api`. SecretKeyRequest, SecretDestination, + // Referenced by `ActionRequiredData::CrewStep`, for the same reason as the two above. + CrewStepRequest, + CrewSignInTarget, + CrewVaultAction, ToolPreview, ToolPreviewLine, ToolPreviewLineKind, diff --git a/crates/biorouter-server/src/routes/action_required.rs b/crates/biorouter-server/src/routes/action_required.rs index 8dba2341d..d9bf33ee7 100644 --- a/crates/biorouter-server/src/routes/action_required.rs +++ b/crates/biorouter-server/src/routes/action_required.rs @@ -551,6 +551,76 @@ async fn complete_elicitation( } } +/// What a person did with a Crew step card. Only dismissing it: the step itself (signing in, +/// unlocking the vault, trusting a key) is done in the person's own window, and the call that +/// parked the card reads the daemon to learn whether it was. +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum CrewStepOutcome { + Dismiss, +} + +/// Dismissing a Crew step card (`POST /action-required/crew-step`). +#[derive(Debug, Deserialize, Serialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct CrewStepResponseRequest { + /// The card's ID, as the `crewStep` message carries it. + id: String, + /// The chat the card is in. + session_id: String, + outcome: CrewStepOutcome, +} + +/// Dismiss a Crew step card. +/// +/// The call that parked the card is released as cancelled and says the step was not done. A +/// dismissal grants nothing and can never mean the step was done, because a Crew step accepts +/// no outcome but cancelled (`UserActionRequest::CrewStep`), so it needs no proof beyond what +/// reaching the chat needs. It names a chat, so it asks `session_reach` first, as every route +/// that names one does: a private chat's card is out of reach to a caller holding only the +/// daemon secret, and an unknown chat is refused the same way, so the answer discloses nothing +/// about which chats exist. It reaches only a Crew step card in the session posted, never an +/// approval or a credential card, so anything else answers 404 as an unknown ID does. +#[utoipa::path( + post, + path = "/action-required/crew-step", + request_body = CrewStepResponseRequest, + responses( + (status = 200, description = "Dismissed: the call that parked the card was released", body = Value), + (status = 401, description = "Unauthorized - invalid secret key"), + (status = 403, description = "Refused by a privacy boundary: the named chat is private (or absent, and a caller out of reach is told the same thing for both) and the request carried neither the user-action proof nor a private caller (body = plain text)"), + (status = 404, description = "No Crew step card with that ID is waiting in that chat", body = Value), + ) +)] +pub async fn dismiss_crew_step( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> axum::response::Response { + use axum::response::IntoResponse; + use biorouter::pending_user_action::{PendingUserActions, ResolveOutcome as Resolved}; + // The gate first, before the registry is read: a 404 for a private chat's card would tell + // a caller out of reach that the chat exists. + if let Err(refusal) = + super::session_reach::session_reach(state.session_manager(), &request.session_id, &headers) + .await + { + return refusal.into_response(); + } + let CrewStepOutcome::Dismiss = request.outcome; + match PendingUserActions::global().dismiss_crew_step(&request.session_id, &request.id) { + Resolved::Delivered => Json(serde_json::json!({ "status": "dismissed" })).into_response(), + _ => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "status": "unknown", + "error": "No Crew step is waiting with that ID in this chat.", + })), + ) + .into_response(), + } +} + pub fn routes(state: Arc) -> Router { Router::new() .route( @@ -562,6 +632,7 @@ pub fn routes(state: Arc) -> Router { post(confirm_tool_action), ) .route("/action-required/secrets", post(submit_secrets)) + .route("/action-required/crew-step", post(dismiss_crew_step)) .with_state(state) } @@ -819,6 +890,184 @@ mod tests { format!("{prefix}-{}-{sequence}", std::process::id()) } + fn dismiss(id: &str, session_id: &str) -> Request { + dismiss_with_user_action(id, session_id, None) + } + + fn dismiss_with_user_action( + id: &str, + session_id: &str, + user_action: Option<&str>, + ) -> Request { + let mut builder = Request::builder() + .uri("/action-required/crew-step") + .method("POST") + .header("content-type", "application/json") + .header("x-secret-key", "test-secret"); + if let Some(proof) = user_action { + builder = builder.header("X-User-Action", proof); + } + builder + .body(Body::from( + serde_json::json!({"id": id, "session_id": session_id, "outcome": "dismiss"}) + .to_string(), + )) + .unwrap() + } + + fn parked_crew_step(session_id: &str) -> biorouter::pending_user_action::PendingUserAction { + use biorouter::conversation::message::{CrewStepRequest, CrewVaultAction}; + use biorouter::pending_user_action::{PendingUserActions, UserActionRequest}; + PendingUserActions::global().park( + Some(session_id), + None, + UserActionRequest::CrewStep(CrewStepRequest::Vault { + action: CrewVaultAction::Unlock, + }), + ) + } + + /// A chat in the store: public as a new chat is, or raised to private. + async fn stored_chat(state: &AppState, private: bool) -> String { + let session = state + .session_manager() + .create_session( + std::env::temp_dir(), + "Synthetic Crew step dismissal".to_string(), + biorouter::session::SessionType::User, + ) + .await + .unwrap(); + if private { + state + .session_manager() + .update(&session.id) + .raise_privacy( + biorouter::privacy::SessionClassification::Private, + "synthetic-crew-step", + ) + .apply() + .await + .unwrap(); + } + session.id + } + + async fn body_text(response: axum::response::Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + } + + /// Dismissing a Crew step card in a public chat releases its call as cancelled, from the + /// chat that shows it, with only the daemon secret: a dismissal grants nothing, and a + /// public chat is in every caller's reach. Naming another chat reaches nothing. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn dismissing_a_crew_step_releases_it_as_cancelled() { + use crate::routes::session::diverge_tests::install_test_user_action_key; + use biorouter::pending_user_action::UserActionOutcome; + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let session = stored_chat(&state, false).await; + let other = stored_chat(&state, false).await; + let parked = parked_crew_step(&session); + let id = parked.id().to_string(); + let app = routes(state); + + let foreign = app.clone().oneshot(dismiss(&id, &other)).await.unwrap(); + assert_eq!(foreign.status(), StatusCode::NOT_FOUND); + + let response = app.oneshot(dismiss(&id, &session)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["status"], "dismissed"); + assert_eq!( + parked.wait(std::time::Duration::from_secs(5), None).await, + UserActionOutcome::Cancelled + ); + } + + /// A private chat's card is out of reach to a caller holding only the daemon secret, or + /// a key that is not the person's: refused with `session_reach`'s own words, and left + /// waiting. The person's proof dismisses it. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn a_private_chats_crew_step_is_dismissed_only_with_the_persons_proof() { + use crate::routes::session::diverge_tests::{ + install_test_user_action_key, TEST_USER_ACTION_KEY, + }; + use crate::routes::session_reach::SESSION_OUT_OF_REACH; + use biorouter::pending_user_action::{PendingUserActions, UserActionOutcome}; + install_test_user_action_key(); + let state = AppState::new().await.unwrap(); + let session = stored_chat(&state, true).await; + let parked = parked_crew_step(&session); + let id = parked.id().to_string(); + let app = routes(state); + + for proof in [None, Some("not-the-user-key")] { + let refused = app + .clone() + .oneshot(dismiss_with_user_action(&id, &session, proof)) + .await + .unwrap(); + assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{proof:?}"); + assert_eq!(body_text(refused).await, SESSION_OUT_OF_REACH); + assert!(PendingUserActions::global().is_pending(&id)); + } + + let response = app + .oneshot(dismiss_with_user_action( + &id, + &session, + Some(TEST_USER_ACTION_KEY), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["status"], "dismissed"); + assert_eq!( + parked.wait(std::time::Duration::from_secs(5), None).await, + UserActionOutcome::Cancelled + ); + } + + /// An unknown ID, or a card that is not a Crew step, is a 404 to a caller that reaches + /// the chat: this door can never cancel an approval. A chat that is not in the store is + /// refused to a caller out of reach exactly as a private one is, so the answer never + /// says which chats exist. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn dismissing_an_unknown_or_other_card_is_not_found() { + use crate::routes::session::diverge_tests::{ + install_test_user_action_key, TEST_USER_ACTION_KEY, + }; + use crate::routes::session_reach::SESSION_OUT_OF_REACH; + use biorouter::pending_user_action::PendingUserActions; + install_test_user_action_key(); + let session = unique_session("crew-step-unknown"); + let app = routes(AppState::new().await.unwrap()); + let unproven = app + .clone() + .oneshot(dismiss("no-such-card", &session)) + .await + .unwrap(); + assert_eq!(unproven.status(), StatusCode::FORBIDDEN); + assert_eq!(body_text(unproven).await, SESSION_OUT_OF_REACH); + + let proven = + |id: &str| dismiss_with_user_action(id, &session, Some(TEST_USER_ACTION_KEY)); + let response = app.clone().oneshot(proven("no-such-card")).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(body_json(response).await["status"], "unknown"); + + let approval = parked_approval(&session); + let response = app.oneshot(proven(approval.id())).await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(PendingUserActions::global().is_pending(approval.id())); + } + #[tokio::test(flavor = "multi_thread")] async fn a_foreign_confirmation_is_unknown_and_the_owner_can_still_answer() { use biorouter::pending_user_action::{PendingUserActions, UserActionOutcome}; diff --git a/crates/biorouter-server/src/routes/agent.rs b/crates/biorouter-server/src/routes/agent.rs index bb44d3dad..1461d46e5 100644 --- a/crates/biorouter-server/src/routes/agent.rs +++ b/crates/biorouter-server/src/routes/agent.rs @@ -1703,37 +1703,8 @@ async fn permission_editor_tools( .await? }; - if session_id.is_empty() { - let Some(extension_name) = extension_name.as_deref() else { - return Ok(Json(Vec::new())); - }; - let Some(extension) = biorouter::config::get_all_extensions() - .into_iter() - .find(|entry| entry.enabled && entry.config.key() == extension_name) - else { - return Ok(Json(Vec::new())); - }; - - agent - .remove_extension(extension_name) - .await - .map_err(|error| { - warn!(extension = extension_name, %error, "Failed to refresh permission settings extension"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - tokio::time::timeout( - PERMISSION_SETTINGS_LOAD_TIMEOUT, - agent.add_extension(extension.config), - ) - .await - .map_err(|_| { - warn!(extension = extension_name, "Timed out loading extension for permission settings"); - StatusCode::REQUEST_TIMEOUT - })? - .map_err(|error| { - warn!(extension = extension_name, %error, "Failed to load extension for permission settings"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + if session_id.is_empty() && !load_settings_extension(&agent, extension_name.as_deref()).await? { + return Ok(Json(Vec::new())); } let permission_manager = agent.config.permission_manager.clone(); @@ -1751,9 +1722,23 @@ async fn permission_editor_tools( // which loads one globally enabled extension purely so its tools can be // listed here. Gate E is about the model's context; this is the user's own // administration surface. Do not swap this for `list_tools`. - let mut tools: Vec = agent + let mut listed = agent .list_tools_for_permission_settings(&session_id, extension_name) - .await + .await; + // AO-12: Crew's tools are not a privacy tier's to hide but the chat's own standing, and a + // chat is offered only the Crew tools it can use (a granted chat its two worker tools, any + // other chat the person tools it may use). So a chat's listing shows exactly those, by the + // rule the model's list follows, never a Crew tool the chat is refused. The settings page's + // global listing (`session_id` empty) names no chat and is left as it is. + if !session_id.is_empty() { + listed = biorouter::agents::crew_extension::offered_crew_tools( + &agent.extension_manager, + &session_id, + listed, + ) + .await; + } + let mut tools: Vec = listed .into_iter() .map(|tool| { let permission = permission_manager @@ -1794,6 +1779,46 @@ async fn permission_editor_tools( Ok(Json(tools)) } +/// For the settings page's global listing (an empty `session_id`): load the one globally +/// enabled extension it names into `agent` afresh, so its tools can be listed. `false` when it +/// names none, or no enabled extension has that name, and then the listing is empty. +async fn load_settings_extension( + agent: &biorouter::agents::Agent, + extension_name: Option<&str>, +) -> Result { + let Some(extension_name) = extension_name else { + return Ok(false); + }; + let Some(extension) = biorouter::config::get_all_extensions() + .into_iter() + .find(|entry| entry.enabled && entry.config.key() == extension_name) + else { + return Ok(false); + }; + + agent + .remove_extension(extension_name) + .await + .map_err(|error| { + warn!(extension = extension_name, %error, "Failed to refresh permission settings extension"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + tokio::time::timeout( + PERMISSION_SETTINGS_LOAD_TIMEOUT, + agent.add_extension(extension.config), + ) + .await + .map_err(|_| { + warn!(extension = extension_name, "Timed out loading extension for permission settings"); + StatusCode::REQUEST_TIMEOUT + })? + .map_err(|error| { + warn!(extension = extension_name, %error, "Failed to load extension for permission settings"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(true) +} + #[utoipa::path( get, path = "/agent/callable_tool_count", @@ -3427,6 +3452,22 @@ fn direct_crew_tool_refusal(tool_name: &str) -> Option { }) } +/// What a direct call to a Crew person tool answers (account ownership AO-12). +pub(crate) const DIRECT_PERSON_TOOL_REFUSAL: &str = "crew__read and crew__change act as you, so they run only in a chat's own turn, where their approval cards can be shown. Use the Crew view or biorouter crew instead."; + +/// `crew__read` and `crew__change` act as the person, and every change and the first content +/// read wait on a card only a chat's turn can show. This route has no turn and no surface for +/// a card (a card parked beneath it is declined at once), and it dispatches as Public and +/// enforced, so it is refused here, whatever the chat, before anything is dispatched. +fn direct_person_tool_refusal(tool_name: &str) -> Option { + biorouter::crew::tool_host::PersonTool::from_name(tool_name).map(|_| CallToolResponse { + content: vec![Content::text(DIRECT_PERSON_TOOL_REFUSAL)], + structured_content: None, + is_error: true, + _meta: None, + }) +} + async fn dispatch_direct_tool( agent: &biorouter::agents::Agent, session_id: &str, @@ -3496,6 +3537,9 @@ async fn call_tool( headers: HeaderMap, Json(payload): Json, ) -> Result, StatusCode> { + if let Some(refusal) = direct_person_tool_refusal(&payload.name) { + return Ok(Json(refusal)); + } let crew = biorouter::crew::manager().map_err(|_| StatusCode::FORBIDDEN)?; if crew.is_scoped_session(&payload.session_id).await { crate::routes::session_reach::session_reach( @@ -6870,6 +6914,37 @@ mod gate_c_call_tool_tests { assert!(helper.contains(".dispatch_tool_call(")); assert!(reach < refusal && refusal < dispatch); } + + /// AO-12: the two person tools act as the person and wait on cards only a chat's turn can + /// show, so this route refuses them in plain words for every chat, before it reads the + /// chat's standing or dispatches anything. The worker tools keep their own refusal. + #[test] + fn a_direct_person_tool_call_is_refused_for_every_chat() { + for tool in ["crew__read", "crew__change"] { + let refused = super::direct_person_tool_refusal(tool).expect("refused"); + assert!(refused.is_error); + assert_eq!(text_of(&refused), super::DIRECT_PERSON_TOOL_REFUSAL); + } + for other in [ + "crew__request", + "crew__connections", + "todo__write", + "crew__", + ] { + assert!( + super::direct_person_tool_refusal(other).is_none(), + "{other}" + ); + } + let source = include_str!("agent.rs"); + let handler = crate::routes::body_of(source, "async fn call_tool"); + let refusal = handler + .find("direct_person_tool_refusal(&payload.name)") + .expect("the person-tool refusal"); + let standing = handler.find("is_scoped_session(").expect("standing"); + let dispatch = handler.find("dispatch_direct_tool(").expect("dispatch"); + assert!(refusal < standing && standing < dispatch); + } } #[cfg(test)] diff --git a/crates/biorouter-server/src/routes/crew.rs b/crates/biorouter-server/src/routes/crew.rs index 880541288..45b788bb4 100644 --- a/crates/biorouter-server/src/routes/crew.rs +++ b/crates/biorouter-server/src/routes/crew.rs @@ -48,6 +48,11 @@ const MAX_QUEUED_RUN_PROJECTIONS: usize = 256; const RUN_PROJECTION_DRAIN_TIMEOUT: Duration = Duration::from_secs(50); static RUN_SLOTS: LazyLock> = LazyLock::new(|| Arc::new(Semaphore::new(MAX_CONCURRENT_RUNS))); +/// A task start refused because [`RUN_SLOTS`] has no slot left. +const RUN_SLOTS_FULL: &str = + "Four Crew tasks are already active on this computer. Finish or cancel one first."; +/// A cancel naming a task this computer's ledger does not hold on that connection. +const TASK_NOT_OWNED: &str = "This task is not owned by this computer and connection."; static LEDGERS: LazyLock>>> = LazyLock::new(Mutex::default); struct RunLedger { path: PathBuf, @@ -253,6 +258,12 @@ impl CrewRouteError { } } + /// The refusal's code and sentence, for a caller that words it itself (a chat's person + /// tools, `routes::crew_tools`). + pub(crate) fn code_and_sentence(&self) -> (&str, &str) { + (&self.code, &self.error) + } + /// Add a field beside `code` and `error`, which it can never replace. fn with(mut self, key: &str, value: impl Serialize) -> Self { if key != "code" && key != "error" { @@ -377,7 +388,7 @@ fn require_person(headers: &HeaderMap) -> Result<(), CrewRouteError> { /// `biorouter::crew::server_label`), and `last_error_code`, which types `last_error` when the /// daemon has a code for it (Q3-12: `crew_membership_ended`), so a client never has to match /// the sentence. -async fn connection_view( +pub(crate) async fn connection_view( connection: &biorouter::crew::Connection, ) -> anyhow::Result { let crew = manager()?; @@ -387,6 +398,10 @@ async fn connection_view( let mut view = saved_connection_view(connection, last_error_code, server_label, server_storage); view.server_access = crew.server_access(&connection.id); view.workspace_deleted = crew.workspace_deleted(&connection.id); + // AO-10. Only a connection removed meanwhile is refused here; it reports nothing new. + view.new_devices = crew.new_devices(&connection.id).await.unwrap_or_default(); + view.account_identity = crew.account_identity(&connection.id); + view.server_version = crew.server_version(&connection.id); Ok(view) } @@ -405,6 +420,9 @@ fn saved_connection_view( server_storage, server_access: None, workspace_deleted: false, + new_devices: Vec::new(), + account_identity: None, + server_version: None, } } @@ -413,7 +431,7 @@ fn saved_connection_view( operation_id = "crew_list_connections", path = "/crew/connections", responses( - (status = 200, description = "Every connection saved on this computer, each with `server_label`, the person's own name for its server (their SSH alias when one maps to the address, else the host; display only), and `last_error_code` when the daemon has a code for `last_error`: `crew_membership_ended` (the workspace refused this computer or its person as no longer a member, so the daemon stops dialling it), an SSH failure's code or `crew_workspace_identity_mismatch`. `server_storage` says when a connected workspace's server has stopped saving changes (`code` `storage_full` or `storage_failed`, `since` when); `null` while it saves or when that is not known. `server_access` says what agents can do on the server as the last connect found (whether full access is available, whether commands can be confined, the bridge's version, account and home); `null` before the first connect. `workspace_deleted` is `true` once the workspace answered `crew_workspace_deleted`: its host deleted it, so the person can only remove it from this computer", body = CrewConnectionList), + (status = 200, description = "Every connection saved on this computer, each with `server_label`, the person's own name for its server (their SSH alias when one maps to the address, else the host; display only), and `last_error_code` when the daemon has a code for `last_error`: `crew_membership_ended` (the workspace refused this computer or its person as no longer a member, so the daemon stops dialling it), an SSH failure's code or `crew_workspace_identity_mismatch`. `server_storage` says when a connected workspace's server has stopped saving changes (`code` `storage_full` or `storage_failed`, `since` when); `null` while it saves or when that is not known. `server_access` says what agents can do on the server as the last connect found (whether full access is available, whether commands can be confined, the bridge's version, account and home); `null` before the first connect. `workspace_deleted` is `true` once the workspace answered `crew_workspace_deleted`: its host deleted it, so the person can only remove it from this computer. `new_devices` lists devices of the person's account added since this computer last saw them, with how and when each was added, until `POST /crew/connections/{id}/devices/acknowledge`. `account_identity` is `true` when the workspace's server takes joining by account (any computer that signs in to the server as the account can open the workspace again, so removing the host's last computer no longer ends the host controls), `false` for an older server, and `null` while not connected. `server_version` is the workspace broker's version as its last verified `hello` said (unsigned, display only), `null` while not connected or from an older broker that does not say", body = CrewConnectionList), (status = 400, description = "`crew_request_refused`: Crew's saved settings could not be read", body = CrewError), (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError) ), @@ -429,6 +447,34 @@ pub async fn list_connections(headers: HeaderMap) -> CrewResult, +) -> CrewResult { + require_person(&headers)?; + let connection = manager()?.connection(&id).await.map_err(|_| { + CrewRouteError::new( + StatusCode::NOT_FOUND, + "crew_connection_not_found", + "This computer has no saved Crew connection with that ID.", + ) + })?; + Ok(Json(connection_view(&connection).await?)) +} + #[utoipa::path( post, operation_id = "crew_prepare_device", @@ -880,7 +926,7 @@ fn empty_object() -> Value { (status = 413, description = "`crew_request_invalid`: the body is larger than the route takes", body = CrewError), (status = 415, description = "`crew_request_invalid`: the body is not sent as `application/json`", body = CrewError), (status = 422, description = "`crew_request_invalid`: a body with a missing, mistyped or unknown field; `detail` says which", body = CrewError), - (status = 409, description = "`crew_not_connected`: the connection is down and nothing is dialling it again, so connect it (signing in if asked) and try again; `workspace` names it. Nothing was sent. `crew_workspace_deleted`: the workspace's host deleted it, so it answers nothing else and the connection can only be removed from this computer; `workspace` names it", body = CrewError), + (status = 409, description = "`crew_not_connected`: the connection is down and nothing is dialling it again, so connect it (signing in if asked) and try again; `workspace` names it. Nothing was sent. `crew_workspace_deleted`: the workspace's host deleted it, so it answers nothing else and the connection can only be removed from this computer; `workspace` names it. `crew_device_key_missing`: this computer no longer holds its device key for the workspace, so nothing was sent; `crew_device_not_recognized`: the workspace no longer recognizes this computer's device while the account is still its host or a member. For both, `POST /crew/connections/{id}/rejoin` adds this computer again; `workspace` names it", body = CrewError), (status = 503, description = "The request did not go through: `crew_not_sent` (nothing reached the workspace; `ssh_code` when an SSH failure caused it), `crew_outcome_unknown` (the bridge was lost after the request was written, so whether the workspace applied it is not known; retry with `request_id`, its idempotency key, to apply it at most once) or `crew_reconnecting` (Biorouter is dialling the workspace again; `workspace` names it)", body = CrewError) ), tag = "Crew" @@ -992,12 +1038,23 @@ fn check_run_channels( ) } -fn run_policy(body: &StartRunRequest) -> biorouter::crew::RunPolicy { +/// Where a task's request came from, as the run's policy carries it. The route starts a task for +/// the person and says nothing more; a chat's `tasks_ask` (`routes::crew_tools`) starts one for +/// a chat, which a Private chat marks `restricted`, with its affiliations, so a public model is +/// refused as a grant to that chat would be (AO-12). +#[derive(Clone, Default)] +pub(crate) struct RunOrigin { + pub(crate) restricted: bool, + pub(crate) institution_ids: std::collections::BTreeSet, +} + +fn run_policy(body: &StartRunRequest, origin: &RunOrigin) -> biorouter::crew::RunPolicy { biorouter::crew::RunPolicy { + origin_restricted: origin.restricted, expected_mode: body.expected_mode, expected_policy_epoch: body.expected_policy_epoch, expected_workspace_policy_epoch: body.expected_workspace_policy_epoch, - ..Default::default() + origin_institution_ids: origin.institution_ids.clone(), } } @@ -1027,6 +1084,17 @@ pub async fn start_run( CrewJson(body): CrewJson, ) -> CrewResult { require_person(&headers)?; + start_owned_run(state, id, body, RunOrigin::default()).await +} + +/// [`start_run`] once a person's proof is checked: the route's whole start, shared with a +/// chat's `tasks_ask`, which passes the chat as the task's `origin`. +pub(crate) async fn start_owned_run( + state: Arc, + id: String, + body: StartRunRequest, + origin: RunOrigin, +) -> CrewResult { require_valid( body.posting_grant, "Grant this task permission to publish in the selected channel.", @@ -1070,11 +1138,9 @@ pub async fn start_run( "Crew task history has reached its 4096-request safety limit", )?; } - let permit = Arc::clone(&RUN_SLOTS).try_acquire_owned().map_err(|_| { - anyhow::anyhow!( - "Four Crew tasks are already active on this device. Finish or cancel one first." - ) - })?; + let permit = Arc::clone(&RUN_SLOTS) + .try_acquire_owned() + .map_err(|_| anyhow::anyhow!(RUN_SLOTS_FULL))?; let provider = biorouter::providers::create( &body.provider, ModelConfig::new(&body.model).map_err(anyhow::Error::from)?, @@ -1087,7 +1153,7 @@ pub async fn start_run( &body.channel_id, &body.context_channels, provider.as_ref(), - &run_policy(&body), + &run_policy(&body, &origin), ) .await { @@ -1113,6 +1179,7 @@ pub async fn start_run( state, id, body, + origin, ledger.clone(), request_key, provider, @@ -1332,10 +1399,12 @@ async fn record_admission_affiliation( Ok(()) } +#[allow(clippy::too_many_arguments)] async fn launch_run( state: Arc, id: String, body: StartRunRequest, + origin: RunOrigin, ledger: Arc, request_key: String, provider: Arc, @@ -1345,7 +1414,7 @@ async fn launch_run( let agent = state.agent_manager.new_scoped_agent(); agent.ensure_crew_compatible()?; let crew = manager()?; - let policy = run_policy(&body); + let policy = run_policy(&body, &origin); let admission = match crew .begin_run_with_policy( &session_id, @@ -2159,13 +2228,18 @@ pub async fn cancel_run( Path((id, run_id)): Path<(String, String)>, ) -> CrewResult { require_person(&headers)?; + cancel_owned_task(&id, &run_id).await +} + +/// [`cancel_run`] once a person's proof is checked, shared with a chat's `tasks_cancel`. +pub(crate) async fn cancel_owned_task(id: &str, run_id: &str) -> CrewResult { // No run this daemon started has any other shape (RENDERER-2). require_valid( - biorouter::crew::is_run_id(&run_id), + biorouter::crew::is_run_id(run_id), "Crew run IDs contain only letters, digits, hyphens and underscores", )?; let ledger = run_ledger().await?; - cancellation_response(cancel_owned_run(&ledger, &id, &run_id).await?) + cancellation_response(cancel_owned_run(&ledger, id, run_id).await?) } /// What stopping one of this device's owned tasks achieved. @@ -2324,7 +2398,7 @@ async fn reserve_cancellation( .runs .get_mut(run_id) .filter(|run| run.view.connection_id == connection_id) - .ok_or_else(|| anyhow::anyhow!("This task is not owned by this device and connection."))?; + .ok_or_else(|| anyhow::anyhow!(TASK_NOT_OWNED))?; if matches!( run.view.status.as_str(), "completed" | "failed" | "cancelled" @@ -2573,7 +2647,9 @@ pub fn routes(state: Arc) -> Router { ) .route( "/crew/connections/{id}", - axum::routing::patch(update_connection).delete(remove_connection), + get(get_connection) + .patch(update_connection) + .delete(remove_connection), ) .route("/crew/connections/{id}/connect", post(connect)) .route("/crew/connections/{id}/disconnect", post(disconnect)) @@ -2619,7 +2695,7 @@ mod tests { reserve_cancellation, run_with_deadline, settle_confirmed_revocations, transition_run_status, CancelReservation, LedgerState, OwnedCancellation, OwnedRun, RunLedger, RunProjection, RunStatusUpdate, RunView, StartRunRequest, - COMPLETED_REVOCATION_UNCONFIRMED, MAX_QUEUED_RUN_PROJECTIONS, + COMPLETED_REVOCATION_UNCONFIRMED, MAX_QUEUED_RUN_PROJECTIONS, RUN_SLOTS_FULL, }; use biorouter::agents::AgentEvent; use biorouter::conversation::message::Message; @@ -3062,6 +3138,27 @@ mod tests { assert_eq!(final_reason(&ledger, &view).await, "complete"); } + /// C1: a cancel for a task this computer's ledger does not hold on that connection, and a + /// start with no task slot left, call the person's machine "this computer". + #[tokio::test] + async fn task_refusals_call_the_persons_machine_this_computer() { + let (_temp, ledger, _view) = ledger_fixture("running", false).await; + for (connection, run) in [("connection-2", "run-1"), ("connection-1", "run-unknown")] { + let refused = reserve_cancellation(&ledger, connection, run) + .await + .err() + .expect("a task this computer does not own is refused"); + assert_eq!( + refused.to_string(), + "This task is not owned by this computer and connection." + ); + } + assert_eq!( + RUN_SLOTS_FULL, + "Four Crew tasks are already active on this computer. Finish or cancel one first." + ); + } + #[tokio::test] async fn failed_revocation_is_durable_and_retry_can_confirm_cancellation() { let (_temp, ledger, view) = ledger_fixture("running", false).await; diff --git a/crates/biorouter-server/src/routes/crew/openapi_contract_tests.rs b/crates/biorouter-server/src/routes/crew/openapi_contract_tests.rs index b78fa81b1..f8e9bece8 100644 --- a/crates/biorouter-server/src/routes/crew/openapi_contract_tests.rs +++ b/crates/biorouter-server/src/routes/crew/openapi_contract_tests.rs @@ -93,7 +93,13 @@ fn crew_contract_violations(spec: &Value) -> Vec { } for (status, response) in responses { let content = response["content"].as_object(); - if status.starts_with('2') { + if status == "204" { + // No Content: the status is the whole answer, and a body would be one a + // client reads that the wire never carries. + if content.is_some_and(|content| !content.is_empty()) { + violations.push(format!("{at}: its 204 names a body")); + } + } else if status.starts_with('2') { match content { Some(content) if !content.is_empty() => { for (media, body) in content { @@ -180,6 +186,16 @@ fn the_contract_check_finds_what_the_old_crew_spec_did_wrong() { }}}, "403": {"description": "", "content": {"application/json": {"schema": {}}}} } + }}, + "/crew/connections/{id}/devices/acknowledge": {"post": { + "operationId": "crew_connection_acknowledge_devices", + "parameters": [{"name": "id", "in": "path"}], + "responses": { + "204": {"content": {"application/json": {"schema": {}}}}, + "403": {"description": "No proof", "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/CrewError"} + }}} + } }} }, "components": {"schemas": {}} @@ -196,6 +212,7 @@ fn the_contract_check_finds_what_the_old_crew_spec_did_wrong() { "403 is not a CrewError", "403 does not say why", "#/components/schemas/Gone names no schema", + "its 204 names a body", ] { assert!(found.contains(expected), "missed {expected:?} in:\n{found}"); } @@ -273,10 +290,14 @@ fn crew_codes(source: &str) -> BTreeSet { .collect() } -/// Quoted `crew_*` words in the Crew sources that are not `/crew` codes. -const NOT_CREW_CODES: [(&str, &str); 7] = [ - ("crew__", "the prefix of the Crew tools' names"), - ("crew__request", "a Crew tool's name"), +/// The prefix of the Crew tools' names (`crew__request`, `crew__read`, `crew__change`, ...). +/// A code is `crew_` and one word, never two underscores, so no quoted word with this prefix is +/// a code. +const CREW_TOOL_PREFIX: &str = "crew__"; + +/// Quoted `crew_*` words in the Crew sources that are not `/crew` codes, beside the tools' +/// names ([`CREW_TOOL_PREFIX`]). +const NOT_CREW_CODES: [(&str, &str); 5] = [ ( "crew_run_stopped", "the code a task's own chat reads when the task stops", @@ -334,6 +355,7 @@ fn every_code_a_crew_source_answers_is_a_declared_crew_error_code() { let text = std::fs::read_to_string(source).expect("a Crew source"); codes.extend(crew_codes(&text)); } + codes.retain(|word| !word.starts_with(CREW_TOOL_PREFIX)); for (word, _) in NOT_CREW_CODES { codes.remove(word); } @@ -425,6 +447,56 @@ fn every_field_a_crew_refusal_carries_is_one_crew_error_declares() { ); } +/// The words a description quotes in backticks. +fn quoted(text: &str) -> BTreeSet<&str> { + text.split('`').skip(1).step_by(2).collect() +} + +/// AO-9: `crew_account_unsupported` chooses its words by `is_host`, with `server_login` for the +/// host and `host_username` for anyone else. `host` is another refusal's field (an SSH +/// failure's hop), so a client written from a description that named it found nothing and +/// could not tell the host's words from a member's. +#[test] +fn the_account_unsupported_refusal_is_described_by_the_fields_it_carries() { + let spec = spec(); + let mut described = 0; + for (path, operations) in spec["paths"].as_object().expect("paths") { + for method in METHODS { + let Some(responses) = operations[method]["responses"].as_object() else { + continue; + }; + // A refusal's own answer: a setup job's status reports the code inside its + // `error`, beside the job's fields. + for (status, response) in responses.iter().filter(|(s, _)| s.starts_with('4')) { + let text = response["description"].as_str().unwrap_or_default(); + let Some((_, after)) = text.split_once("`crew_account_unsupported`") else { + continue; + }; + // What the description says of this refusal: up to the parenthesis that + // names its fields. + let said = after.split_once(')').map_or(after, |(said, _)| said); + let fields = quoted(said); + // A description that only names the code says nothing of its fields. + if fields.is_empty() { + continue; + } + described += 1; + let at = format!("{} {path} {status}", method.to_uppercase()); + for field in ["is_host", "server_login", "host_username"] { + assert!( + fields.contains(field), + "{at} does not name `{field}`: {said}" + ); + } + assert!(!fields.contains("host"), "{at} names `host`: {said}"); + } + } + } + // A check that reads nothing would agree with one that finds nothing: join, rejoin and + // account each describe it. + assert!(described >= 3, "only {described} descriptions were read"); +} + #[test] fn the_institution_refusal_schema_names_what_the_core_sends() { let details = biorouter::crew::institution_refusal_details("model", None, None, None); diff --git a/crates/biorouter-server/src/routes/crew/wire.rs b/crates/biorouter-server/src/routes/crew/wire.rs index 69875c7d0..fce019a96 100644 --- a/crates/biorouter-server/src/routes/crew/wire.rs +++ b/crates/biorouter-server/src/routes/crew/wire.rs @@ -15,7 +15,9 @@ use axum::response::{IntoResponse, Response}; use axum::Json; use biorouter::crew::authentication::InvitationMissing; use biorouter::crew::observation::RunView; -use biorouter::crew::{ClusterMode, Connection, GrantRow, ServerAccess, ServerStorage}; +use biorouter::crew::{ + ClusterMode, Connection, GrantRow, NewDevice, ServerAccess, ServerStorage, SetupStatus, +}; use biorouter_server::crew::transfers::Receipt; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -86,7 +88,10 @@ where /// `last_error_code`, and a host-start run's `error.code`. Each route's responses say which it /// answers and when. A client branches on these, never on a refusal's words, so the generated /// client names every one; `openapi_contract_tests` fails on a code a Crew source answers that -/// is missing here, and on one listed here that nothing answers any more. +/// is missing here, and on one listed here that nothing answers any more. A chat's Crew tools +/// also give the model `crew_ssh_settings_refused`, with the check's own sentence, when Crew's +/// SSH settings check refuses the route to a server (a jump host without +/// `StrictHostKeyChecking yes`, say); no route answers it. #[derive(Clone, Copy, Debug, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] #[allow( @@ -113,11 +118,16 @@ pub enum CrewErrorCode { CrewSshHostKeyChanged, CrewSshUnreachable, CrewSshFailed, + CrewSshSettingsRefused, CrewBridgeMissing, CrewBrokerNotRunning, CrewHandoffFailed, CrewWorkspaceIdentityMismatch, CrewWorkspaceDeleted, + // This computer's device and the account it signs in as (account ownership AO-9). + CrewDeviceKeyMissing, + CrewDeviceNotRecognized, + CrewAccountUnsupported, // A request the workspace was sent. CrewNotSent, CrewOutcomeUnknown, @@ -143,6 +153,9 @@ pub enum CrewErrorCode { CrewGrantOtherConnection, CrewGrantReplaced, CrewGrantEnded, + // A chat's consent to read a workspace as the person (AO-12). + CrewReadConsentRequired, + CrewNotFound, // The credential vault. CrewProfileRefused, // Files and transfers. @@ -170,6 +183,7 @@ pub enum CrewErrorCode { CrewJoinDeviceConflict, CrewJoinIdentityConflict, CrewJoinRefused, + CrewJoinRemoved, // Starting Crew on a server for its host. CrewHostSetupUnknown, CrewHostSetupUsed, @@ -188,6 +202,7 @@ pub enum CrewErrorCode { CrewSetupStartFailed, CrewSetupChanged, CrewHostStorageUnsafe, + CrewHostNameInUse, CrewServerProbeUnsupported, CrewInstallArtifactUnavailable, CrewInstallUnsupportedPlatform, @@ -221,6 +236,19 @@ pub struct CrewError { /// The SSH hop an SSH failure concerns, a jump host's included. #[serde(skip_serializing_if = "Option::is_none")] pub host: Option, + /// `crew_account_unsupported`: whether this account hosts the workspace, which decides the + /// sentence: the host is told to update Crew with `server_login`, anyone else to ask + /// `host_username`. + #[serde(skip_serializing_if = "Option::is_none")] + pub is_host: Option, + /// `crew_account_unsupported` said to the host: the SSH login that updates Crew on the + /// server (`biorouter crew setup --server {server_login}`). + #[serde(skip_serializing_if = "Option::is_none")] + pub server_login: Option, + /// `crew_account_unsupported` said to anyone but the host: the host's username, `null` when + /// it could not be learned. + #[serde(skip_serializing_if = "Option::is_none")] + pub host_username: Option, /// The workspace's own refusal code, when `code` is `crew_request_refused` because the /// workspace refused (`name_taken`, `forbidden`, `response_too_large`, ...). #[serde(skip_serializing_if = "Option::is_none")] @@ -354,6 +382,24 @@ pub struct CrewConnectionView { /// The workspace's host deleted it: its server answered `crew_workspace_deleted`, so the /// person can only remove the connection from this computer. pub workspace_deleted: bool, + /// Devices of the person's account added since this computer last saw the account's + /// devices, with how and when each was added, until the person acknowledges them + /// (`POST /crew/connections/{id}/devices/acknowledge`; account ownership AO-10). Read from + /// the workspace's signed snapshots; empty before the first one. + pub new_devices: Vec, + /// Whether the workspace's verified `hello` advertises `account_identity_v1` under its v2 + /// signature (AO-9): `true` means any computer that signs in to the server as the account + /// can open the workspace again, so removing the host's last computer no longer ends the + /// host controls. `false` for an older server; `null` while this computer holds no verified + /// `hello` (not connected). Display and the removal guard only: the workspace decides + /// every join. + pub account_identity: Option, + /// The workspace broker's package version, as its last verified `hello` said (AO-4), so a + /// client can tell when the server runs an older Crew than the one this build carries. + /// `null` while this computer holds no verified `hello` (not connected) and for an older + /// broker that does not say, when a client falls back to `account_identity`. Display only: + /// the server's word, unsigned. + pub server_version: Option, } /// `GET /crew/connections`. @@ -496,8 +542,33 @@ pub struct CrewGrantList { /// Earlier grants whose revocation the workspace has not confirmed yet (F3). The daemon /// keeps asking by itself. pub replaced_grants: Vec, + /// The chats the person let read this connection's workspace as them (account ownership + /// AO-12). Until a chat has one, its person tools answer `crew_read_consent_required`; + /// `DELETE /crew/sessions/{session_id}/reads/{connection_id}` takes one back. + pub person_reads: Vec, } +/// One chat's consent to read a connection's workspace as the person, as +/// `GET /crew/connections/{id}/grants` lists it. Display only: whether a chat may read is +/// decided on the chat as it is now, never on this row. +#[derive(Debug, Serialize, ToSchema)] +pub struct CrewPersonReadView { + /// The chat the consent was given in. + pub session_id: String, + /// The saved connection whose workspace it may read. + pub connection_id: String, + /// When the person gave it, in seconds since the Unix epoch. + pub granted_at: u64, + /// The chat's title; `null` when the chat is gone or untitled. + pub session_name: Option, +} + +/// `GET /crew/setup`: this computer's setup jobs, those under way and those finished in the +/// last 15 minutes, the newest first. A bare array on the wire. +#[derive(Debug, Serialize, ToSchema)] +#[serde(transparent)] +pub struct CrewSetupJobs(pub Vec); + /// `POST /crew/connections/{id}/sessions/{session_id}/revoke`: a grant stopped here and /// confirmed by the workspace. One the workspace did not confirm is refused instead /// (`crew_revocation_unconfirmed`). diff --git a/crates/biorouter-server/src/routes/crew_authentication.rs b/crates/biorouter-server/src/routes/crew_authentication.rs index 899e068b5..762d482a3 100644 --- a/crates/biorouter-server/src/routes/crew_authentication.rs +++ b/crates/biorouter-server/src/routes/crew_authentication.rs @@ -11,7 +11,14 @@ //! - `GET /crew/connections/{id}/invitation`: the invitation message a host sends. //! - `GET /crew/connections/{id}/join`: where this computer stands in joining, with the device //! code this daemon computed itself. -//! - `POST /crew/connections/{id}/join`: claim the join once the host approved the code. +//! - `POST /crew/connections/{id}/join`: claim the join once the host approved the code, or, +//! on a server that takes joining by account, join as the account (account ownership AO-9). +//! - `POST /crew/connections/{id}/rejoin`: add this computer again, with a new device key in +//! place of the saved one (AO-9). +//! - `GET /crew/connections/{id}/account`: what the workspace knows about the account this +//! computer signs in to the server as (AO-3). +//! - `POST /crew/connections/{id}/devices/acknowledge`: the person has seen these new devices +//! of the account, by fingerprint (AO-10). //! //! Every admission route asks for proof that a person acted, with the codes and words of //! `routes::crew`'s person gate. They name a saved connection, never a chat, so no chat reach @@ -21,7 +28,7 @@ use axum::{ extract::{ rejection::{JsonRejection, QueryRejection}, ws::{Message, WebSocket, WebSocketUpgrade}, - Path, Query, + DefaultBodyLimit, Path, Query, }, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, @@ -29,9 +36,10 @@ use axum::{ Json, Router, }; use biorouter::crew::authentication::{ - self, AuthenticationSession, HandoffFailed, InvitationAdvanced, InvitationOutcome, - InvitationOverrides, InvitationPreview, InvitationRefusal, InvitationRefused, InvitationText, - JoinPerson, JoinRefused, JoinState, JoinStatus, TerminalEvent, + self, AccountJoined, AccountStatus, AuthenticationSession, HandoffFailed, InvitationAdvanced, + InvitationOutcome, InvitationOverrides, InvitationPreview, InvitationRefusal, + InvitationRefused, InvitationText, JoinPerson, JoinRefused, JoinState, JoinStatus, + TerminalEvent, }; use biorouter::crew::{ manager, ClusterMode, Connection, CrewManager, CrewRefusal, SshFailure, WorkspaceIdentityError, @@ -368,16 +376,24 @@ pub const INVITATION_FAILED: &str = "Biorouter couldn't build an invitation for pub const JOIN_STATUS_FAILED: &str = "Biorouter couldn't check this computer's join with the workspace. Try again. If it keeps failing, the Biorouter log has the details."; /// What `POST /crew/connections/{id}/join` answers for a failure the core did not type. pub const JOIN_FAILED: &str = "Biorouter couldn't finish joining the workspace. Try again. If it keeps failing, the Biorouter log has the details."; +/// `crew_request_refused`'s sentence when adding this computer again fails for a reason the +/// daemon cannot type. +pub const REJOIN_FAILED: &str = "Biorouter couldn't add this computer to the workspace again. Try again. If it keeps failing, the Biorouter log has the details."; +/// `crew_request_refused`'s sentence when the workspace could not be asked about the account. +pub const ACCOUNT_STATUS_FAILED: &str = "Biorouter couldn't ask the workspace about your account. Try again. If it keeps failing, the Biorouter log has the details."; +/// `crew_request_refused`'s sentence when the person's acknowledgement of new computers on their +/// account could not be saved. +pub const ACKNOWLEDGE_FAILED: &str = "Biorouter couldn't save that you have seen these computers. Try again. If it keeps failing, the Biorouter log has the details."; /// A refusal from an admission route: `{code, error}` and any typed fields beside them. Every /// refusal carries a code, so a client can tell a refusal from a daemon without the route. #[derive(Debug)] pub struct AdmissionRefusal { - status: StatusCode, - code: String, - error: String, + pub(crate) status: StatusCode, + pub(crate) code: String, + pub(crate) error: String, /// A list, empty and unallocated for most refusals, so the error stays small. - fields: Vec<(String, Value)>, + pub(crate) fields: Vec<(String, Value)>, } impl AdmissionRefusal { @@ -496,7 +512,7 @@ fn require_connected(connection: &Connection) -> Result<(), AdmissionRefusal> { /// words are unauthenticated, and the join screen shows a daemon's words verbatim beside the /// device code, so a process in the joiner's bridge path could otherwise write "your code is /// …" on it. The text goes to the log, escaped, and never into the answer, not even `detail`. -async fn core_refusal( +pub(crate) async fn core_refusal( crew: &CrewManager, connection_id: Option<&str>, error: anyhow::Error, @@ -686,7 +702,7 @@ pub struct InvitationSummary { } impl InvitationSummary { - fn new(preview: InvitationPreview, pasted: &str) -> Self { + pub(crate) fn new(preview: InvitationPreview, pasted: &str) -> Self { // The core parsed this text a moment ago; parse it again only for the hints its // summary folds into the save plan. A paste the codec now refuses cannot happen, and // would only leave the hints out. @@ -829,11 +845,11 @@ pub async fn invitation( path = "/crew/connections/{id}/join", params(("id" = String, Path, description = "The joiner's saved Crew connection")), responses( - (status = 200, description = "Where this computer stands in joining. `code` is computed here from the saved device key and the pinned workspace key, never read from the workspace's answer. `unsupported` when the workspace's server can't join by invitation", body = JoinStatus), + (status = 200, description = "Where this computer stands in joining. `code` is computed here from the saved device key and the pinned workspace key, never read from the workspace's answer, and is never shown against a server that takes joining by account. `unsupported` when the workspace's server can't join by invitation. On a server that takes joining by account (`account_identity_v1`): `ready` when the account may join with this computer's key and no code (it is invited, or it is the host or a member and the workspace does not know this computer), which `POST` on this route then does; `expired` for an invitation that ran out; `joined` when this computer's key is a member's device; `removed` when the workspace removed the account; `not_invited` otherwise. `ready` never joins by itself", body = JoinStatus), (status = 400, description = "A typed connection failure (`crew_ssh_*`, `crew_bridge_missing`, `crew_broker_not_running`, `crew_workspace_identity_mismatch`), `crew_credential_store_unavailable` or `crew_credential_store_refused` when the device key cannot be read, or `crew_request_refused` with a fixed sentence for any other failure. The workspace's own words, which are unauthenticated, go to the log and never into the answer", body = CrewError), (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), (status = 404, description = "`crew_connection_not_found`: no saved connection has that ID", body = CrewError), - (status = 409, description = "`crew_not_connected`: connect first", body = CrewError), + (status = 409, description = "`crew_not_connected`: connect first; `crew_device_key_missing`: this computer no longer holds its device key for the workspace, so only adding it again (`POST /crew/connections/{id}/rejoin`) helps; `workspace` names it", body = CrewError), (status = 503, description = "The workspace could not be asked: `crew_not_sent` (nothing reached it; `ssh_code` when an SSH failure caused it) or `crew_reconnecting` (Biorouter is dialling it again; `workspace` names it)", body = CrewError) ), tag = "Crew" @@ -884,11 +900,11 @@ impl JoinClaimed { path = "/crew/connections/{id}/join", params(("id" = String, Path, description = "The joiner's saved Crew connection")), responses( - (status = 200, description = "This computer is a member. Idempotent: a member answers this without asking the workspace again", body = JoinClaimed), + (status = 200, description = "This computer is a member. Idempotent: a member answers this without asking the workspace again. On `ready` (a server that takes joining by account) this joins as the account the connection signs in to the server as, with no code", body = JoinClaimed), (status = 400, description = "A typed connection failure (`crew_ssh_*`, `crew_bridge_missing`, `crew_broker_not_running`, `crew_workspace_identity_mismatch`), a failed sign-in handoff (`crew_handoff_failed`), `crew_credential_store_unavailable` or `crew_credential_store_refused` when the device key cannot be read, or `crew_request_refused` with a fixed sentence for any other failure. The workspace's own words, which are unauthenticated, go to the log and never into the answer", body = CrewError), (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), (status = 404, description = "`crew_connection_not_found`: no saved connection has that ID", body = CrewError), - (status = 409, description = "`crew_not_connected`, or a typed join refusal: `crew_join_unsupported`, `crew_join_not_approved`, `crew_join_code_mismatch`, `crew_join_not_invited`, `crew_join_expired`, `crew_join_replaced`, `crew_join_account_changed`, `crew_join_device_conflict`, `crew_join_identity_conflict` or `crew_join_refused`", body = CrewError), + (status = 409, description = "`crew_not_connected`, or a typed join refusal: `crew_join_unsupported`, `crew_join_not_approved`, `crew_join_code_mismatch`, `crew_join_not_invited`, `crew_join_expired`, `crew_join_replaced`, `crew_join_account_changed`, `crew_join_device_conflict`, `crew_join_identity_conflict`, `crew_join_removed` (the workspace removed this account: \"You were removed from {workspace}. Ask a member to invite you again.\") or `crew_join_refused`. Joining by account also answers `crew_account_unsupported` (the server's Crew is older; `is_host` chooses the words: `true` with `server_login` for the host, `false` with `host_username` for anyone else) and `crew_device_key_missing` (this computer no longer holds its key; add it again with `POST /crew/connections/{id}/rejoin`)", body = CrewError), (status = 503, description = "The claim did not go through: `crew_not_sent` (nothing reached the workspace; `ssh_code` when an SSH failure caused it), `crew_outcome_unknown` (whether the workspace applied it is not known; claiming again is safe) or `crew_reconnecting` (Biorouter is dialling it again; `workspace` names it)", body = CrewError) ), tag = "Crew" @@ -906,6 +922,129 @@ pub async fn join( } } +/// Add this computer again (account ownership AO-9): give the saved connection a new device key +/// in place of the one it holds, keeping its ID, so chats granted through it can be granted +/// again. For a computer whose key is gone (`crew_device_key_missing`) or that the workspace no +/// longer recognizes (`crew_device_not_recognized`). The workspace decides: it admits the key +/// only for its host or a member signing in to the server as their own account. +/// +/// ⚠ **Device identity; needs human review.** The key is replaced only when the workspace +/// accepts the new one; a refusal keeps the old key. +#[utoipa::path( + post, + operation_id = "crew_connection_rejoin", + path = "/crew/connections/{id}/rejoin", + params(("id" = String, Path, description = "The saved Crew connection to add this computer to again")), + responses( + (status = 200, description = "This computer is a device of the account again, under a new key. The connection keeps its ID; its grants ended and can be granted again. `status.status` is always `joined`; `role` is `host` or `member`; `evicted_device` names a device the workspace removed to keep the account within its device limit", body = AccountJoined), + (status = 400, description = "A typed connection failure (`crew_ssh_*`, `crew_bridge_missing`, `crew_broker_not_running`, `crew_workspace_identity_mismatch`), `crew_credential_store_unavailable` or `crew_credential_store_refused` when the new key cannot be saved, or `crew_request_refused` with a fixed sentence for any other failure. The workspace's own words go to the log and never into the answer", body = CrewError), + (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), + (status = 404, description = "`crew_connection_not_found`: no saved connection has that ID", body = CrewError), + (status = 409, description = "`crew_not_connected`: connect first. `crew_account_unsupported`: the server's Crew is older and cannot add a computer by account; nothing was sent (`workspace`, and `is_host`: `true` with `server_login` for the host, `false` with `host_username` for anyone else). A typed refusal of the workspace, which keeps the old key: `crew_join_removed` (the workspace removed this account), `crew_join_not_invited` (this account is not a member), `crew_join_account_changed`, `crew_join_device_conflict`, `crew_join_identity_conflict` or `crew_join_refused`", body = CrewError), + (status = 503, description = "The request did not go through: `crew_not_sent` (nothing reached the workspace; `ssh_code` when an SSH failure caused it), `crew_outcome_unknown` (whether the workspace took the new key is not known; adding this computer again sends the same key, which the workspace answers as a retry) or `crew_reconnecting` (Biorouter is dialling it again; `workspace` names it)", body = CrewError) + ), + tag = "Crew" +)] +pub async fn rejoin( + headers: HeaderMap, + Path(id): Path, +) -> Result, AdmissionRefusal> { + require_person(&headers)?; + let crew = crew()?; + require_connected(&saved_connection(&crew, &id).await?)?; + match crew.rejoin(&id).await { + Ok(joined) => Ok(Json(joined)), + Err(error) => Err(core_refusal(&crew, Some(&id), error, REJOIN_FAILED).await), + } +} + +/// What the connection's workspace knows about the account this computer signs in to the server +/// as (`account.status`, account ownership AO-3). Unsigned and answered about that account +/// alone: it chooses which card to show and which words to use, and the workspace still decides +/// every join. +#[utoipa::path( + get, + operation_id = "crew_connection_account", + path = "/crew/connections/{id}/account", + params(("id" = String, Path, description = "The saved Crew connection")), + responses( + (status = 200, description = "The account's `role` in the workspace (`host`, `member`, `invited`, `removed` or `none`) and what that role carries: its `username`, the host's `host_username`, `invited_by` and `expires_at` (`expired`) for an invitation, how many `devices` it has, and, for the host only, `others_connected`, how many other accounts are connected now. `workspace_name` is the signed `hello`'s where there is one", body = AccountStatus), + (status = 400, description = "A typed connection failure (`crew_ssh_*`, `crew_bridge_missing`, `crew_broker_not_running`, `crew_workspace_identity_mismatch`), or `crew_request_refused` with a fixed sentence for any other failure. The workspace's own words go to the log and never into the answer", body = CrewError), + (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), + (status = 404, description = "`crew_connection_not_found`: no saved connection has that ID", body = CrewError), + (status = 409, description = "`crew_not_connected`: connect first. `crew_account_unsupported`: the server's Crew is older and does not answer about accounts; nothing was sent (`workspace`, and `is_host`: `true` with `server_login` for the host, `false` with `host_username` for anyone else)", body = CrewError), + (status = 503, description = "The workspace could not be asked: `crew_not_sent` (nothing reached it; `ssh_code` when an SSH failure caused it) or `crew_reconnecting` (Biorouter is dialling it again; `workspace` names it)", body = CrewError) + ), + tag = "Crew" +)] +pub async fn account_status( + headers: HeaderMap, + Path(id): Path, +) -> Result, AdmissionRefusal> { + require_person(&headers)?; + let crew = crew()?; + require_connected(&saved_connection(&crew, &id).await?)?; + match crew.account_status(&id).await { + Ok(status) => Ok(Json(status)), + Err(error) => Err(core_refusal(&crew, Some(&id), error, ACCOUNT_STATUS_FAILED).await), + } +} + +/// The largest body `POST /crew/connections/{id}/devices/acknowledge` takes. The workspace keeps +/// at most 32 devices per account and this computer reads at most 64 from a snapshot, each a +/// 19-byte fingerprint, so a list the person was shown fits many times over. +const ACKNOWLEDGE_BODY_LIMIT: usize = 8 * 1024; + +/// `POST /crew/connections/{id}/devices/acknowledge`: the new devices the person was shown. +#[derive(Debug, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct AcknowledgeDevicesRequest { + /// The fingerprints of the devices the person was shown, as `new_devices` gave them + /// (`3F2A 9C1E 77B0 D4E1`). Only these stop being reported: a device the workspace listed + /// after they were shown stays in `new_devices` until it is acknowledged in turn. + pub fingerprints: Vec, +} + +/// The person has seen these new devices of their account on this connection (account +/// ownership AO-10): stop reporting them in `new_devices`, on every Biorouter process of this +/// profile. Only the devices the request names are acknowledged, so a device added between +/// showing the notice and acknowledging it is still reported. Asks the workspace nothing, so it +/// works while the connection is down. +#[utoipa::path( + post, + operation_id = "crew_connection_acknowledge_devices", + path = "/crew/connections/{id}/devices/acknowledge", + params(("id" = String, Path, description = "The saved Crew connection")), + request_body = AcknowledgeDevicesRequest, + responses( + (status = 204, description = "Acknowledged: the devices `fingerprints` names leave the connection's `new_devices`, and any other device stays there until it is acknowledged in turn. A fingerprint `new_devices` does not list changes nothing, so acknowledging again is safe"), + (status = 400, description = "`crew_request_invalid` for a body that is not JSON, or `crew_request_refused` with a fixed sentence: the acknowledgement could not be saved", body = CrewError), + (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), + (status = 404, description = "`crew_connection_not_found`: no saved connection has that ID", body = CrewError), + (status = 409, description = "`crew_registry_unreadable`: Crew's saved settings on this computer can't be read by this build, so nothing was changed; `detail` holds the reader's own words, for support", body = CrewError), + (status = 413, description = "`crew_request_invalid`: the body is larger than the route takes", body = CrewError), + (status = 415, description = "`crew_request_invalid`: the body is not sent as `application/json`", body = CrewError), + (status = 422, description = "`crew_request_invalid`: a body with a missing, mistyped or unknown field; `detail` says which", body = CrewError) + ), + tag = "Crew" +)] +pub async fn acknowledge_devices( + headers: HeaderMap, + Path(id): Path, + body: Result, JsonRejection>, +) -> Result { + require_person(&headers)?; + let Json(request) = + body.map_err(|rejection| unreadable_request(rejection.status(), rejection.body_text()))?; + let crew = crew()?; + saved_connection(&crew, &id).await?; + match crew.acknowledge_devices(&id, &request.fingerprints).await { + Ok(()) => Ok(StatusCode::NO_CONTENT), + // Not a request to the workspace, so a connection that is down is no reason to refuse. + Err(error) => Err(core_refusal(&crew, None, error, ACKNOWLEDGE_FAILED).await), + } +} + pub fn routes() -> Router { Router::new() .route("/crew/connections/{id}/authentication", post(prepare)) @@ -914,6 +1053,12 @@ pub fn routes() -> Router { .route("/crew/connections/from-invitation", post(from_invitation)) .route("/crew/connections/{id}/invitation", get(invitation)) .route("/crew/connections/{id}/join", get(join_status).post(join)) + .route("/crew/connections/{id}/rejoin", post(rejoin)) + .route("/crew/connections/{id}/account", get(account_status)) + .route( + "/crew/connections/{id}/devices/acknowledge", + post(acknowledge_devices).layer(DefaultBodyLimit::max(ACKNOWLEDGE_BODY_LIMIT)), + ) } #[cfg(test)] @@ -1129,6 +1274,16 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + /// C1: the new computers the person was shown are "computers" in the sentence a failed + /// acknowledgement answers. + #[test] + fn a_failed_acknowledgement_names_computers() { + assert_eq!( + ACKNOWLEDGE_FAILED, + "Biorouter couldn't save that you have seen these computers. Try again. If it keeps failing, the Biorouter log has the details." + ); + } + /// A workspace refusal the core passes through untyped (the transport's text, carrying the /// broker's whole envelope) answers each route's fixed sentence, with none of the /// workspace's words anywhere in the body: not in `error`, not in `detail`, not under a diff --git a/crates/biorouter-server/src/routes/crew_changes.rs b/crates/biorouter-server/src/routes/crew_changes.rs new file mode 100644 index 000000000..722f98a9f --- /dev/null +++ b/crates/biorouter-server/src/routes/crew_changes.rs @@ -0,0 +1,628 @@ +//! `GET /crew/changes`, and the layer that announces the Crew routes' own changes on it (chat +//! parity, WI-15). +//! +//! A Crew view reads this computer's Crew state when it opens and after its own actions. The +//! feed (`biorouter::crew::changes`) is how it learns that something changed anywhere else: a +//! chat's `crew__change`, a setup job moving, another window, `biorouter crew` in a terminal. The +//! last two reach the daemon through the routes below, so [`announcing`] wraps them: a request +//! that changed something and succeeded is announced with the IDs its path names. +//! +//! The route answers IDs only (see the feed's module docs), and only to a person: the IDs it +//! names (saved connections, chats) are otherwise read through routes that need the person's +//! proof, so a caller holding just the daemon's secret learns nothing here it could not before. + +use std::time::Duration; + +use axum::body::{Body, Bytes}; +use axum::extract::{MatchedPath, Query, Request}; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use biorouter::crew::changes::{self, ChangeIds, CrewChangesDelta}; +use biorouter_server::auth::{user_action_proof, UserActionProof}; +use serde::Deserialize; +use utoipa::IntoParams; + +use super::crew::wire::CrewError; +use super::crew_authentication::{ + no_human_authority, HUMAN_AUTHORITY_UNAVAILABLE_CODE, USER_ACTION_REQUIRED_CODE, +}; + +/// The longest a poll waits before answering with nothing new. Under the 30 seconds most +/// clients and proxies give up at. +const MAX_WAIT: Duration = Duration::from_secs(25); + +/// The route that sends one protocol request to a workspace: a change only when it carries an +/// idempotency key (`request_id`), which every client sends with a change and never with a read. +const WORKSPACE_REQUEST: &str = "/crew/connections/{id}/request"; +/// The most of a workspace request's body read to find its `request_id`: the route's own limit. +const WORKSPACE_REQUEST_LIMIT: usize = 2 * 1024 * 1024; +/// The workspace changes nothing on this feed shows: a read marker, which an open channel sends +/// by itself as messages arrive. Announced, each would have every open Crew view read its lists +/// again for every message. Unread counts reach the other windows through their own observation. +const UNSHOWN_WORKSPACE_CHANGES: [&str; 1] = ["channel.read"]; + +/// The Crew routes that take a POST but change nothing: watching a workspace, resolving names, +/// planning a sign-in and previewing an image. They are asked often, and a change announced for +/// each would have every open Crew view read everything again for nothing. +const READS_BY_POST: [&str; 4] = [ + "/crew/connections/{id}/observe", + "/crew/resolve", + "/crew/connections/{id}/auth-plan", + "/crew/transfers/preview", +]; + +/// The kind a change made through a route is announced with, when the route does what one +/// `crew__change` action does: a client reacts to `rejoin` the same way wherever it came from. +/// Any other change a route makes is announced as `route`. +const ROUTE_KINDS: [(&str, &str, &str); 21] = [ + ("POST", "/crew/connections/{id}/rejoin", "rejoin"), + ("POST", "/crew/connections/{id}/join", "join"), + ("POST", "/crew/connections/{id}/connect", "connect"), + ("POST", "/crew/connections/{id}/disconnect", "disconnect"), + ("PATCH", "/crew/connections/{id}", "connections_update"), + ("DELETE", "/crew/connections/{id}", "connections_remove"), + ( + "POST", + "/crew/connections/{id}/workspace/delete", + "workspace_delete", + ), + ( + "POST", + "/crew/connections/{id}/sessions/{session_id}/revoke", + "grants_revoke", + ), + ( + "DELETE", + "/crew/sessions/{session_id}/reads/{connection_id}", + "grants_revoke", + ), + ( + "POST", + "/crew/connections/{id}/devices/acknowledge", + "profile_devices_acknowledge", + ), + ( + "POST", + "/crew/connections/{id}/context/draft", + "context_draft", + ), + ("POST", "/crew/connections/{id}/runs", "tasks_ask"), + ( + "POST", + "/crew/connections/{id}/runs/{run_id}/cancel", + "tasks_cancel", + ), + ("POST", "/crew/setup", "setup"), + ("DELETE", "/crew/setup/{job_id}", "setup_cancel"), + ("POST", "/crew/transfers/{id}/pause", "files_pause"), + ("POST", "/crew/transfers/{id}/resume", "files_resume"), + ("DELETE", "/crew/transfers/{id}", "files_forget"), + ("POST", "/crew/credentials/unlock", "credentials_unlock"), + ("POST", "/crew/credentials/init", "credentials_init"), + ("POST", "/crew/credentials/lock", "credentials_lock"), +]; +/// The kind of a route's change that no `crew__change` action matches. +const ROUTE_KIND: &str = "route"; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct CrewChangesQuery { + /// The last revision this reader saw. Absent: answer where the feed stands now, at once and + /// with no changes, which is how a new reader learns where to start. + #[serde(default)] + since: Option, + /// How long to wait for a change, in milliseconds, at most 25 000 (the default). `0` + /// answers at once. + #[serde(default)] + timeout_ms: Option, +} + +/// The person gate's refusal, with the Crew routes' own codes and words. +fn refusal(code: &str, error: &str) -> Response { + let body = CrewError { + code: code.to_owned(), + error: error.to_owned(), + ..CrewError::default() + }; + (StatusCode::FORBIDDEN, Json(body)).into_response() +} + +/// What changed in Crew on this computer after `since`: IDs and kinds only. +#[utoipa::path( + get, + operation_id = "crew_changes", + path = "/crew/changes", + params(CrewChangesQuery), + responses( + (status = 200, description = "The changes after `since`, by kind and ID only: a `crew__change` that ran, a change a Crew route made, or a setup job's new state. With no `since`, where the feed stands now and no changes. `truncated` means the reader fell behind or the daemon restarted: read everything again. A change names no name and no content; read what changed through the route that shows it", body = CrewChangesDelta), + (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError) + ), + tag = "Crew" +)] +pub async fn crew_changes( + headers: HeaderMap, + Query(query): Query, +) -> Result, Response> { + match user_action_proof(&headers) { + UserActionProof::Proven => {} + UserActionProof::Unproven => { + return Err(refusal( + USER_ACTION_REQUIRED_CODE, + "Only a person using the Biorouter desktop app or the biorouter crew command can do this. Agent tools use their separate task grant.", + )) + } + UserActionProof::NoKeyInstalled => { + return Err(refusal( + HUMAN_AUTHORITY_UNAVAILABLE_CODE, + no_human_authority("This daemon cannot verify human Crew actions. Start it from the Biorouter desktop app or with biorouter crew daemon start."), + )) + } + } + let feed = changes::feed(); + let Some(since) = query.since else { + return Ok(Json(feed.current())); + }; + let wait = query + .timeout_ms + .map_or(MAX_WAIT, Duration::from_millis) + .min(MAX_WAIT); + Ok(Json(feed.wait(since, wait).await)) +} + +pub fn routes() -> Router { + Router::new().route("/crew/changes", get(crew_changes)) +} + +/// `router` with every change its routes make announced on Crew's change feed +/// ([`announce_changes`]). Layered per route, so only a request that reached one of them counts. +pub fn announcing(router: Router) -> Router { + router.route_layer(axum::middleware::from_fn(announce_changes)) +} + +/// Announce a request that changed something and succeeded: a POST, PUT, PATCH or DELETE to one +/// of the Crew routes, answered 2xx, except the reads that take a POST ([`READS_BY_POST`]), a +/// workspace request without an idempotency key ([`WORKSPACE_REQUEST`]) and a read marker +/// ([`UNSHOWN_WORKSPACE_CHANGES`]). The answer itself is passed on untouched. +pub async fn announce_changes(request: Request, next: Next) -> Response { + let Some(template) = request + .extensions() + .get::() + .map(|path| path.as_str().to_owned()) + else { + return next.run(request).await; + }; + let method = request.method().clone(); + if !changes_by_method(&method) || READS_BY_POST.contains(&template.as_str()) { + return next.run(request).await; + } + let ids = path_ids(&template, request.uri().path()); + let (request, changes_something) = if template == WORKSPACE_REQUEST { + carries_request_id(request).await + } else { + (request, true) + }; + let response = next.run(request).await; + if changes_something && response.status().is_success() { + changes::publish(route_kind(&method, &template), ids); + } + response +} + +fn changes_by_method(method: &Method) -> bool { + [Method::POST, Method::PUT, Method::PATCH, Method::DELETE].contains(method) +} + +fn route_kind(method: &Method, template: &str) -> &'static str { + ROUTE_KINDS + .iter() + .find(|(verb, path, _)| method.as_str() == *verb && template == *path) + .map_or(ROUTE_KIND, |(_, _, kind)| kind) +} + +/// The IDs a request's path names, by its route's parameters. `{id}` is a saved connection's +/// only under `/crew/connections/`; elsewhere it is a transfer's or a sign-in's, and not +/// announced. Each is checked to look like an ID when it is published. +fn path_ids(template: &str, path: &str) -> ChangeIds { + let mut ids = ChangeIds::default(); + let connection_route = template.starts_with("/crew/connections/{id}"); + for (part, value) in template.split('/').zip(path.split('/')) { + let slot = match part { + "{id}" if connection_route => &mut ids.connection_id, + "{connection_id}" => &mut ids.connection_id, + "{session_id}" => &mut ids.session_id, + "{run_id}" => &mut ids.run_id, + "{job_id}" => &mut ids.job_id, + _ => continue, + }; + *slot = Some(value.to_owned()); + } + ids +} + +/// Whether a workspace request is a change this feed announces: it carries an idempotency key +/// (`request_id`) and is not a read marker ([`UNSHOWN_WORKSPACE_CHANGES`]). Answers the request to +/// pass on with it. The body is read only when it says how long it is and is within the route's +/// own limit; otherwise the request is passed on as it came and counted as a change, which the +/// route's answer then decides. +async fn carries_request_id(request: Request) -> (Request, bool) { + let declared = request + .headers() + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + if declared.is_none_or(|length| length > WORKSPACE_REQUEST_LIMIT) { + return (request, true); + } + let (parts, body) = request.into_parts(); + let bytes = match axum::body::to_bytes(body, WORKSPACE_REQUEST_LIMIT).await { + Ok(bytes) => bytes, + // A body that could not be read is refused by the route too: nothing changed. + Err(_) => Bytes::new(), + }; + let shown = serde_json::from_slice::(&bytes) + .ok() + .is_some_and(|body| { + let keyed = body + .get("request_id") + .and_then(serde_json::Value::as_str) + .is_some_and(|key| !key.is_empty()); + let method = body.get("method").and_then(serde_json::Value::as_str); + keyed && !method.is_some_and(|method| UNSHOWN_WORKSPACE_CHANGES.contains(&method)) + }); + (Request::from_parts(parts, Body::from(bytes)), shown) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::session::diverge_tests::{ + install_test_user_action_key, TEST_USER_ACTION_KEY, + }; + use axum::http::StatusCode; + use axum::routing::{delete, patch, post}; + use biorouter::crew::changes::{feed, CrewChange}; + use serde_json::{json, Value}; + use tower::ServiceExt; + + async fn ok() -> StatusCode { + StatusCode::OK + } + + async fn refused() -> StatusCode { + StatusCode::CONFLICT + } + + async fn echo(body: Bytes) -> Bytes { + body + } + + /// The real Crew paths, each answered by a stand-in, behind the real layer. + fn crew_like() -> Router { + announcing( + Router::new() + .route("/crew/connections", get(ok).post(ok)) + .route("/crew/connections/{id}", patch(ok).delete(ok)) + .route("/crew/connections/{id}/rejoin", post(ok)) + .route("/crew/connections/{id}/connect", post(refused)) + .route("/crew/connections/{id}/observe", post(ok)) + .route(WORKSPACE_REQUEST, post(echo)) + .route( + "/crew/sessions/{session_id}/reads/{connection_id}", + delete(ok), + ) + .route("/crew/transfers/{id}", delete(ok)), + ) + .merge(routes()) + } + + fn send(method: &str, uri: &str, body: Option) -> Request { + let builder = axum::http::Request::builder() + .method(method) + .uri(uri) + .header("X-User-Action", TEST_USER_ACTION_KEY); + match body { + Some(body) => { + let text = body.to_string(); + builder + .header("content-type", "application/json") + .header("content-length", text.len()) + .body(Body::from(text)) + .unwrap() + } + None => builder.body(Body::empty()).unwrap(), + } + } + + /// What the feed holds after `from` for the connection `connection`. + fn announced(from: u64, connection: &str) -> Vec { + feed() + .since(from) + .changes + .into_iter() + .filter(|change| change.connection_id.as_deref() == Some(connection)) + .collect() + } + + fn unique() -> String { + format!("c-{}", uuid::Uuid::new_v4()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_change_a_route_made_is_announced_and_a_read_is_not() { + let app = crew_like(); + let connection = unique(); + let from = feed().revision(); + for (method, path, status) in [ + ("GET", "/crew/connections".to_owned(), StatusCode::OK), + ( + "POST", + format!("/crew/connections/{connection}/observe"), + StatusCode::OK, + ), + // Refused: nothing changed. + ( + "POST", + format!("/crew/connections/{connection}/connect"), + StatusCode::CONFLICT, + ), + ] { + let response = app + .clone() + .oneshot(send(method, &path, None)) + .await + .unwrap(); + assert_eq!(response.status(), status, "{method} {path}"); + } + assert!(announced(from, &connection).is_empty()); + + let response = app + .clone() + .oneshot(send( + "POST", + &format!("/crew/connections/{connection}/rejoin"), + None, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response = app + .clone() + .oneshot(send( + "DELETE", + &format!("/crew/connections/{connection}"), + None, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let kinds: Vec = announced(from, &connection) + .into_iter() + .map(|change| change.kind) + .collect(); + assert_eq!(kinds, ["rejoin", "connections_remove"]); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_route_s_ids_come_from_its_path() { + let app = crew_like(); + let connection = unique(); + let from = feed().revision(); + let response = app + .clone() + .oneshot(send( + "DELETE", + &format!("/crew/sessions/20261106_7/reads/{connection}"), + None, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let changes = announced(from, &connection); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].kind, "grants_revoke"); + assert_eq!(changes[0].session_id.as_deref(), Some("20261106_7")); + // A transfer's `{id}` is not a connection's. + let transfer = unique(); + let from = feed().revision(); + app.clone() + .oneshot(send("DELETE", &format!("/crew/transfers/{transfer}"), None)) + .await + .unwrap(); + let since = feed().since(from); + assert!(since + .changes + .iter() + .any(|change| change.kind == "files_forget")); + assert!(announced(from, &transfer).is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_workspace_request_is_a_change_only_with_its_idempotency_key() { + let app = crew_like(); + let connection = unique(); + let path = format!("/crew/connections/{connection}/request"); + let from = feed().revision(); + let read = json!({"method": "workspace.snapshot", "params": {}, "request_id": null}); + let response = app + .clone() + .oneshot(send("POST", &path, Some(read.clone()))) + .await + .unwrap(); + // The route reads the body the layer read: it is passed on whole. + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(serde_json::from_slice::(&body).unwrap(), read); + assert!(announced(from, &connection).is_empty()); + + // A read marker changes nothing the feed shows, though it carries a key. + let marker = json!({"method": "channel.read", "params": {"channel_id": "c", "sequence": "9"}, "request_id": "k-0"}); + app.clone() + .oneshot(send("POST", &path, Some(marker))) + .await + .unwrap(); + assert!(announced(from, &connection).is_empty()); + + let write = + json!({"method": "channel.create", "params": {"name": "x"}, "request_id": "k-1"}); + app.clone() + .oneshot(send("POST", &path, Some(write))) + .await + .unwrap(); + let changes = announced(from, &connection); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].kind, ROUTE_KIND); + } + + async fn poll(app: &Router, query: &str, proof: bool) -> (StatusCode, Value) { + let mut request = axum::http::Request::builder().uri(format!("/crew/changes{query}")); + if proof { + request = request.header("X-User-Action", TEST_USER_ACTION_KEY); + } + let response = app + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_poll_answers_early_on_a_change_and_at_its_timeout_otherwise() { + install_test_user_action_key(); + let app = routes(); + let (status, baseline) = poll(&app, "", true).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(baseline["changes"], json!([])); + let since = baseline["revision"].as_u64().unwrap(); + + // Behind already: answered at once, whatever the timeout. + let connection = unique(); + changes::publish( + "channels_create", + ChangeIds { + connection_id: Some(connection.clone()), + ..ChangeIds::default() + }, + ); + let started = std::time::Instant::now(); + let (_, delta) = poll(&app, &format!("?since={since}&timeout_ms=20000"), true).await; + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(delta["changes"] + .as_array() + .unwrap() + .iter() + .any(|change| change["connection_id"] == json!(connection))); + + // A parked poll ends when a change lands. + let current = delta["revision"].as_u64().unwrap(); + let parked = { + let app = app.clone(); + tokio::spawn(async move { + poll(&app, &format!("?since={current}&timeout_ms=20000"), true).await + }) + }; + tokio::time::sleep(Duration::from_millis(50)).await; + changes::publish("rejoin", ChangeIds::default()); + let (_, woke) = tokio::time::timeout(Duration::from_secs(5), parked) + .await + .expect("the poll ended when the change landed") + .unwrap(); + assert!(woke["revision"].as_u64().unwrap() > current); + + // Nothing lands: answered at its timeout, with nothing. + let quiet = feed().revision(); + let started = std::time::Instant::now(); + let (status, idle) = poll(&app, &format!("?since={quiet}&timeout_ms=40"), true).await; + let waited = started.elapsed(); + assert_eq!(status, StatusCode::OK); + assert!(waited < MAX_WAIT); + // The feed is the process's own, and another test may announce meanwhile: then the poll + // ends early, with only what came after. With nothing new, it waited out its timeout. + let listed = idle["changes"].as_array().unwrap(); + assert!(listed + .iter() + .all(|change| change["revision"].as_u64().unwrap() > quiet)); + assert!( + !listed.is_empty() || waited >= Duration::from_millis(30), + "answered after {waited:?} with nothing new" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn the_feed_answers_only_a_person_and_names_ids_only() { + install_test_user_action_key(); + let app = routes(); + let (status, refusal) = poll(&app, "", false).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(refusal["code"], json!(USER_ACTION_REQUIRED_CODE)); + + // A private chat's change: its IDs, and no name it was given. + let from = feed().revision(); + changes::publish( + "grants_revoke", + ChangeIds { + connection_id: Some("Lab notes".into()), + session_id: Some("20261106_9".into()), + ..ChangeIds::default() + }, + ); + let (status, delta) = poll(&app, &format!("?since={from}&timeout_ms=0"), true).await; + assert_eq!(status, StatusCode::OK); + let change = delta["changes"] + .as_array() + .unwrap() + .iter() + .find(|change| change["session_id"] == json!("20261106_9")) + .expect("the change is listed") + .clone(); + assert_eq!(change["connection_id"], Value::Null); + let mut keys: Vec<&str> = change + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "channel_id", + "connection_id", + "job_id", + "kind", + "revision", + "run_id", + "session_id" + ] + ); + } + + #[test] + fn every_route_kind_is_a_change_action_on_a_real_route() { + let spec: Value = serde_json::from_str(&crate::openapi::generate_schema()).unwrap(); + for (method, path, kind) in ROUTE_KINDS { + assert!( + biorouter::crew::tool_host::CHANGE_ACTIONS.contains(&kind), + "{kind} is not a crew__change action" + ); + assert!( + !spec["paths"][path][method.to_lowercase()].is_null(), + "{method} {path} is not a Crew route" + ); + } + for path in READS_BY_POST.into_iter().chain([WORKSPACE_REQUEST]) { + assert!( + !spec["paths"][path]["post"].is_null(), + "POST {path} is not a Crew route" + ); + } + assert!(!spec["paths"]["/crew/changes"]["get"].is_null()); + } +} diff --git a/crates/biorouter-server/src/routes/crew_host_keys.rs b/crates/biorouter-server/src/routes/crew_host_keys.rs new file mode 100644 index 000000000..f1a926f60 --- /dev/null +++ b/crates/biorouter-server/src/routes/crew_host_keys.rs @@ -0,0 +1,255 @@ +//! Trusting a server's host key the person verified (the owner's D2 ruling on AO-12): the +//! "Trust this key" button of the Crew view's trust panes, of a chat's host-key card and of a +//! terminal chat on the shared daemon. +//! +//! - `POST /crew/host-keys/read`: the key this computer reads from a server, straight from +//! here, for a pane whose refusal printed no fingerprint (OpenSSH prints none for an unknown +//! key under strict checking). +//! - `POST /crew/host-keys/trust`: add the server's key to the known hosts file, only when no +//! key is known for it and only when the key the daemon reads again has the fingerprint the +//! person was shown. A changed key is always refused. +//! +//! Both ask for proof that a person acted, with the codes and words of `routes::crew`'s person +//! gate, and no Crew tool action reaches them: a model can never trust a key. Everything they +//! do is `biorouter::crew::host_keys`; nothing a request carries becomes command text beyond a +//! checked host name, port and login. +use axum::{ + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +use biorouter::crew::host_keys::{ + self, HostKeyRead, HostKeyRefused, HostKeyTarget, HostKeyTrusted, +}; +use biorouter_server::auth::{user_action_proof, UserActionProof}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::crew::wire::CrewJson; +use super::crew_authentication::{ + no_human_authority, HUMAN_AUTHORITY_UNAVAILABLE_CODE, REQUEST_INVALID_CODE, + REQUEST_REFUSED_CODE, USER_ACTION_REQUIRED_CODE, +}; + +/// A host-key route's refusal: `{code, error}`, as every Crew refusal is. +pub struct HostKeyRouteError { + status: StatusCode, + code: String, + error: String, +} + +impl HostKeyRouteError { + fn new(status: StatusCode, code: impl Into, error: impl Into) -> Self { + Self { + status, + code: code.into(), + error: error.into(), + } + } +} + +impl IntoResponse for HostKeyRouteError { + fn into_response(self) -> Response { + ( + self.status, + Json(json!({"code": self.code, "error": self.error})), + ) + .into_response() + } +} + +impl From for HostKeyRouteError { + /// A refusal the host-key module typed keeps its code and sentence; anything else is + /// `crew_request_refused` with a fixed sentence. + fn from(error: anyhow::Error) -> Self { + let Some(refused) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + else { + return Self::new( + StatusCode::CONFLICT, + REQUEST_REFUSED_CODE, + "Crew couldn't trust that host key. Add it in a terminal of your own.", + ); + }; + let status = match refused.code { + host_keys::INVALID => StatusCode::BAD_REQUEST, + host_keys::UNREACHABLE => StatusCode::BAD_GATEWAY, + _ => StatusCode::CONFLICT, + }; + Self::new(status, refused.code, refused.message.clone()) + } +} + +/// Proof that a person asked, with the person gate's codes and words. +fn person(headers: &HeaderMap) -> Result<(), HostKeyRouteError> { + match user_action_proof(headers) { + UserActionProof::Proven => Ok(()), + UserActionProof::Unproven => Err(HostKeyRouteError::new( + StatusCode::FORBIDDEN, + USER_ACTION_REQUIRED_CODE, + "Only a person using the Biorouter desktop app or the biorouter crew command can do this. Agent tools use their separate task grant.", + )), + UserActionProof::NoKeyInstalled => Err(HostKeyRouteError::new( + StatusCode::FORBIDDEN, + HUMAN_AUTHORITY_UNAVAILABLE_CODE, + no_human_authority("This daemon cannot verify human Crew actions. Start it from the Biorouter desktop app or with biorouter crew daemon start."), + )), + } +} + +/// Read `body` as a `T`, refused as a request the route does not take. +fn read_body(body: Value) -> Result { + serde_json::from_value(body).map_err(|_| { + HostKeyRouteError::new( + StatusCode::BAD_REQUEST, + REQUEST_INVALID_CODE, + "Send the server's host name and port, and nothing else.", + ) + }) +} + +/// Which server's key to read. +#[derive(Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct HostKeyReadRequest { + /// The host as OpenSSH looked the key up by, without brackets or port. + host: String, + port: u16, + /// The SSH login the refused step used (`you@lab-server`), so its own SSH settings are + /// checked for a jump host or another name. + #[serde(default)] + login: Option, +} + +/// Which server's key to trust, and the fingerprint the person was shown and verified. +#[derive(Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct HostKeyTrustRequest { + /// The host as OpenSSH looked the key up by, without brackets or port. + host: String, + port: u16, + /// The SSH login the refused step used (`you@lab-server`). + #[serde(default)] + login: Option, + /// `SHA256:…`, as the person was shown it. + fingerprint: String, +} + +/// Read a server's host key straight from this computer. +#[utoipa::path( + post, + operation_id = "crew_host_keys_read", + path = "/crew/host-keys/read", + request_body = HostKeyReadRequest, + responses( + (status = 200, description = "The key this computer read from the server, straight from here with `ssh-keyscan`, the first of the types ssh prefers. Shown to the person to compare with the fingerprint their IT team gave them; nothing is written", body = HostKeyRead), + (status = 400, description = "`crew_request_invalid`: a body that is not this route's, or a host, port or login that is not a plain name", body = CrewError), + (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), + (status = 409, description = "`crew_request_refused`: the person's SSH settings reach the server through a jump host, a ProxyCommand or under a HostKeyAlias, or a saved Crew connection to it has a jump host (a key read from here could be another machine's), or the SSH settings or the saved connections could not be read", body = CrewError), + (status = 502, description = "`crew_ssh_unreachable`: the server gave this computer no host key", body = CrewError) + ), + tag = "Crew" +)] +pub async fn read_key( + headers: HeaderMap, + CrewJson(body): CrewJson, +) -> Result, HostKeyRouteError> { + person(&headers)?; + let request: HostKeyReadRequest = read_body(body)?; + let target = HostKeyTarget { + host: request.host, + port: request.port, + login: request.login, + }; + Ok(Json(host_keys::read(target).await?)) +} + +/// Trust a server's unknown host key the person verified. +#[utoipa::path( + post, + operation_id = "crew_host_keys_trust", + path = "/crew/host-keys/trust", + request_body = HostKeyTrustRequest, + responses( + (status = 200, description = "Trusted: the daemon read the server's key again, found the one with this fingerprint, and added it to the first `UserKnownHostsFile` `ssh -G` names (hashed when `HashKnownHosts` is set), under the name OpenSSH looks it up by. `already_trusted` when that file already held exactly this key and nothing else, so nothing was written. A chat waiting on the host-key card sees the key and goes on", body = HostKeyTrusted), + (status = 400, description = "`crew_request_invalid`: a body that is not this route's, a host, port or login that is not a plain name, or a fingerprint that is not `SHA256:` and its digest", body = CrewError), + (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), + (status = 409, description = "`crew_ssh_host_key_changed`: the known hosts files already hold a key for this server (another key, a revoked one or a certificate authority: Crew never replaces one), or the key the server gives now does not have this fingerprint. Nothing was written. `crew_request_refused`: the SSH settings reach the server another way (a saved Crew connection's jump host included), could not be read, or name no known hosts file Crew can write; or a known hosts file could not be read, so a key already on file could not be ruled out", body = CrewError), + (status = 502, description = "`crew_ssh_unreachable`: the server gave this computer no host key", body = CrewError) + ), + tag = "Crew" +)] +pub async fn trust_key( + headers: HeaderMap, + CrewJson(body): CrewJson, +) -> Result, HostKeyRouteError> { + person(&headers)?; + let request: HostKeyTrustRequest = read_body(body)?; + let target = HostKeyTarget { + host: request.host, + port: request.port, + login: request.login, + }; + Ok(Json(host_keys::trust(target, &request.fingerprint).await?)) +} + +pub fn routes() -> Router { + Router::new() + .route("/crew/host-keys/read", post(read_key)) + .route("/crew/host-keys/trust", post(trust_key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn answer(refusal: HostKeyRouteError) -> (StatusCode, Value) { + let response = refusal.into_response(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("refusal body"); + (status, serde_json::from_slice(&bytes).expect("JSON")) + } + + /// A typed refusal keeps its code and sentence, with the status its code takes; anything + /// else says a fixed sentence, never the error's own words. + #[tokio::test] + async fn a_refusal_keeps_its_code_and_says_nothing_else() { + for (code, status) in [ + (host_keys::CHANGED, StatusCode::CONFLICT), + (host_keys::UNREACHABLE, StatusCode::BAD_GATEWAY), + (host_keys::REFUSED, StatusCode::CONFLICT), + (host_keys::INVALID, StatusCode::BAD_REQUEST), + ] { + let error = anyhow::Error::new(HostKeyRefused { + code, + message: "Said.".into(), + }); + let (got, body) = answer(HostKeyRouteError::from(error)).await; + assert_eq!(got, status, "{code}"); + assert_eq!(body, json!({"code": code, "error": "Said."})); + } + let (got, body) = answer(HostKeyRouteError::from(anyhow::anyhow!( + "/home/you/.ssh/known_hosts: os error 13" + ))) + .await; + assert_eq!(got, StatusCode::CONFLICT); + assert_eq!(body["code"], REQUEST_REFUSED_CODE); + assert!(!body["error"].as_str().unwrap().contains("os error")); + } + + #[test] + fn a_body_the_route_does_not_take_is_refused() { + for body in [ + json!({"host": "h", "port": 22, "fingerprint": "SHA256:x", "key": "ssh-ed25519 AAAA"}), + json!({"host": "h", "fingerprint": "SHA256:x"}), + json!({"host": "h", "port": 70000, "fingerprint": "SHA256:x"}), + ] { + assert!(read_body::(body).is_err()); + } + assert!(read_body::(json!({"host": "h", "port": 22})).is_ok()); + } +} diff --git a/crates/biorouter-server/src/routes/crew_observation.rs b/crates/biorouter-server/src/routes/crew_observation.rs index fed98d84d..aaa564255 100644 --- a/crates/biorouter-server/src/routes/crew_observation.rs +++ b/crates/biorouter-server/src/routes/crew_observation.rs @@ -57,6 +57,19 @@ fn broker_code(error: &anyhow::Error) -> Option { .map(str::to_owned) } fn observation_error_code(error: &anyhow::Error) -> String { + // AO-9: a lost device key and a device the workspace no longer recognizes are typed as a + // context on top of the underlying error, so the outermost message is the refusal's + // sentence, never a broker envelope. The frame carries the refusal's own code, which every + // client answers with Add this computer again rather than as a dropped connection. + if let Some(refusal) = biorouter::crew::CrewRefusal::find(error).filter(|refusal| { + matches!( + refusal.code(), + biorouter::crew::refusal::DEVICE_KEY_MISSING + | biorouter::crew::refusal::DEVICE_NOT_RECOGNIZED + ) + }) { + return refusal.code().into(); + } if let Some(code) = broker_code(error) { return code; } @@ -103,6 +116,11 @@ fn observation_error_text(code: &str) -> &'static str { "principal_revoked" => "You're no longer a member of this workspace", "privacy_denied" => "The workspace's privacy rules don't allow this view", "workspace_deleted" => "The host deleted this workspace", + // AO-9: the daemon's own sentences for the same refusals, without the workspace's name, + // which the terminal already says. Each client offers its own way to add the computer + // again. + "crew_device_key_missing" => "This computer no longer has its key for this workspace", + "crew_device_not_recognized" => "This workspace no longer recognizes this computer", _ => "Live updates stopped", } } @@ -656,7 +674,7 @@ fn observe_refusal(status: StatusCode, code: &str, error: &str) -> (StatusCode, params(("id" = String, Path, description = "The saved connection")), request_body = ObserveRequest, responses( - (status = 200, description = "Bounded NDJSON room events (one schema instance per line). An `error` frame ends the stream: its `code` is the workspace's refusal code or one of `policy_changed`, `scope_changed`, `channel_access_changed`, `stale_cursor`, `human_authority_required`, `observer_capacity_reached`, `response_too_large` and `observation_refused`, and `clear` asks the client to drop what the observation showed", body = ObserveEvent, content_type = "application/x-ndjson"), + (status = 200, description = "Bounded NDJSON room events (one schema instance per line). An `error` frame ends the stream: its `code` is the workspace's refusal code or one of `policy_changed`, `scope_changed`, `channel_access_changed`, `stale_cursor`, `human_authority_required`, `observer_capacity_reached`, `response_too_large` and `observation_refused`, or `crew_device_key_missing` (this computer no longer holds its key) or `crew_device_not_recognized` (the workspace no longer recognizes this computer while it still lists the account), which adding this computer again with `POST /crew/connections/{id}/rejoin` repairs. `clear` asks the client to drop what the observation showed", body = ObserveEvent, content_type = "application/x-ndjson"), (status = 400, description = "`crew_request_invalid`: a body that is not JSON, or a channel or cursor that is empty, longer than 128 bytes, or a cursor without its channel", body = CrewError), (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), (status = 404, description = "`crew_connection_not_found`: no saved connection has that ID", body = CrewError), @@ -1680,6 +1698,122 @@ mod tests { ); } + /// A broker that refuses every read with the error `refusal` builds, as the core's signed + /// path answers once it has typed the refusal (`device_signer`, `recognize_device`). + struct RefusingSource { + refusal: fn() -> anyhow::Error, + } + + #[async_trait::async_trait] + impl ObservationSource for RefusingSource { + async fn binding(&self, _connection: &str) -> Result { + Ok(json!({})) + } + async fn read(&self, _connection: &str, _method: &str, _params: Value) -> Result { + Err((self.refusal)()) + } + async fn runs(&self, _connection: &str) -> Result> { + Ok(Vec::new()) + } + fn capabilities(&self, _connection: &str) -> Result> { + Ok(Vec::new()) + } + } + + /// The frame a person's observation of `channel` ends with when every read is refused with + /// `refusal`. + async fn ending_frame(refusal: fn() -> anyhow::Error) -> Value { + use futures::StreamExt; + crate::routes::session::diverge_tests::install_test_user_action_key(); + let mut headers = HeaderMap::new(); + headers.insert( + "X-User-Action", + crate::routes::session::diverge_tests::TEST_USER_ACTION_KEY + .parse() + .unwrap(), + ); + let observer = Observer::new( + headers, + "connection".into(), + ObserveRequest { + channel_id: Some("c-general".into()), + after: None, + initial: Initial::Latest, + }, + json!({}), + Arc::new(RefusingSource { refusal }), + None, + ); + let mut stream = Box::pin(observation_stream(observer)); + let frame = tokio::time::timeout(Duration::from_secs(10), stream.next()) + .await + .expect("a frame") + .expect("the stream ended without a frame") + .unwrap(); + serde_json::from_slice(&frame).unwrap() + } + + /// AO-9: a lost device key and a device the workspace no longer recognizes end the live view + /// with their own codes, which the clients answer with Add this computer again. The core + /// types both as a context over the underlying error, so the outermost message is the + /// refusal's sentence and not a broker envelope; they used to leave as + /// `observation_refused`, which reads as a dropped connection. + #[tokio::test] + async fn a_lost_or_unrecognized_device_ends_the_view_with_its_own_code() { + fn key_missing() -> anyhow::Error { + anyhow::anyhow!("No Crew credential is saved for device:connection").context( + biorouter::crew::CrewRefusal::new( + biorouter::crew::refusal::DEVICE_KEY_MISSING, + "This computer no longer has its key for lab. Choose Add this computer again to make a new one.", + ) + .status(409), + ) + } + fn not_recognized() -> anyhow::Error { + anyhow::anyhow!( + "Crew broker refused request: {}", + json!({"code": "unauthorized", "message": "unauthorized: unknown device"}) + ) + .context( + biorouter::crew::CrewRefusal::new( + biorouter::crew::refusal::DEVICE_NOT_RECOGNIZED, + "lab no longer recognizes this computer. Choose Add this computer again while you are still a member.", + ) + .status(409), + ) + } + for (refusal, code, sentence) in [ + ( + key_missing as fn() -> anyhow::Error, + "crew_device_key_missing", + "This computer no longer has its key for this workspace", + ), + ( + not_recognized, + "crew_device_not_recognized", + "This workspace no longer recognizes this computer", + ), + ] { + let frame = ending_frame(refusal).await; + assert_eq!(frame["type"], "error", "{frame}"); + assert_eq!(frame["code"], code, "{frame}"); + assert_eq!(frame["error"], sentence, "{frame}"); + assert_eq!(frame["clear"], true, "{frame}"); + assert_eq!(observation_error_code(&refusal()), code); + } + // The broker's own code for an unknown device the core did not type stays as it was. + let untyped = anyhow::anyhow!( + "Crew broker refused request: {}", + json!({"code": "unauthorized", "message": "unauthorized: unknown device"}) + ); + assert_eq!(observation_error_code(&untyped), "unauthorized"); + // Any other typed refusal keeps the codes the frame always carried. + let other = anyhow::anyhow!("observer_capacity_reached").context( + biorouter::crew::CrewRefusal::new(biorouter::crew::refusal::NOT_CONNECTED, "down"), + ); + assert_eq!(observation_error_code(&other), "observation_refused"); + } + #[test] fn broker_code_accepts_only_the_exact_refusal_envelope() { let error = diff --git a/crates/biorouter-server/src/routes/crew_profile.rs b/crates/biorouter-server/src/routes/crew_profile.rs index f891097fa..c39474329 100644 --- a/crates/biorouter-server/src/routes/crew_profile.rs +++ b/crates/biorouter-server/src/routes/crew_profile.rs @@ -1,5 +1,6 @@ use super::crew::wire::{ - CrewGrantKind, CrewGrantList, CrewGrantView, CrewJson, CrewRevocation, CrewWorkspaceAnswer, + CrewGrantKind, CrewGrantList, CrewGrantView, CrewJson, CrewPersonReadView, CrewRevocation, + CrewWorkspaceAnswer, }; use super::crew::{cancel_owned_session, owned_run_views, OwnedCancellation}; use crate::state::AppState; @@ -7,7 +8,7 @@ use axum::{ extract::{DefaultBodyLimit, Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - routing::{get, post}, + routing::{delete, get, post}, Json, Router, }; use biorouter::crew::{ @@ -45,14 +46,20 @@ pub const REVOCATION_NOT_SAVED: &str = "crew_revocation_not_saved"; pub const REVOCATION_UNCONFIRMED: &str = "crew_revocation_unconfirmed"; /// The text of a [`REVOCATION_UNCONFIRMED`] refusal (RV-D1). The daemon asks the workspace /// again by itself whenever the connection is back (F3), so it asks nothing more of the person. -pub const REVOCATION_UNCONFIRMED_MESSAGE: &str = "Stopped on this device. The workspace hasn't confirmed yet; Biorouter confirms it by itself when the connection is back."; +pub const REVOCATION_UNCONFIRMED_MESSAGE: &str = "Stopped on this computer. The workspace hasn't confirmed yet; Biorouter confirms it by itself when the connection is back."; +/// A chat held no consent to read the connection's workspace as the person (AO-12). +pub const NOT_FOUND: &str = "crew_not_found"; +/// [`NOT_FOUND`]'s sentence for a read consent. +pub const NO_READ_CONSENT: &str = "This chat has no permission to read that workspace as you."; +/// [`REVOCATION_NOT_SAVED`]'s sentence for a read consent taken back on this computer only. +pub const READ_CONSENT_NOT_SAVED: &str = "Stopped on this computer, but Biorouter couldn't save the change, so the chat could read again after a restart. Try again."; /// A refusal: `{code, error}` plus any fields a route adds beside them (`detail`, …). pub struct Refusal { - status: StatusCode, - code: &'static str, - error: String, - fields: Map, + pub(crate) status: StatusCode, + pub(crate) code: &'static str, + pub(crate) error: String, + pub(crate) fields: Map, } impl Refusal { fn new(status: StatusCode, code: &'static str, error: impl Into) -> Self { @@ -241,13 +248,18 @@ pub async fn lock(headers: HeaderMap) -> Result { /// `replaced_grants` rows (earlier grants kept until the workspace confirms their revocation, /// F3) get the same `kind` and `expires_at`, and a `null` `session_name`: the id may now name a /// different conversation, whose title would mislabel the grant. +/// +/// `person_reads` lists the chats the person let read this connection's workspace as them +/// (account ownership AO-12), named as the grants are. Like the grants, they are listed to the +/// person alone, whose proof this route asks for; whether a chat may read is decided on the +/// chat as it is now, never on this list. #[utoipa::path( get, operation_id = "crew_profile_grants", path = "/crew/connections/{id}/grants", params(("id" = String, Path, description = "The saved connection")), responses( - (status = 200, description = "The chats and tasks holding a grant on this connection, and the earlier grants whose revocation the workspace has not confirmed yet", body = CrewGrantList), + (status = 200, description = "The chats and tasks holding a grant on this connection, the earlier grants whose revocation the workspace has not confirmed yet, and `person_reads`: the chats the person let read the workspace as them, each with `session_id`, `connection_id`, `granted_at` and `session_name`", body = CrewGrantList), (status = 400, description = "`crew_profile_refused`: no saved connection has that ID, or the grants could not be read", body = CrewError), (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError) ), @@ -259,7 +271,16 @@ pub async fn grants( Path(id): Path, ) -> Result { person(&headers)?; - let listed = manager()?.session_grant_rows(&id).await?; + grant_list(&state, &id).await +} + +/// `GET /crew/connections/{id}/grants` after its proof check: the one listing the route and a +/// chat's `crew__read grants_list` (account ownership AO-12) both answer. The chat tool filters +/// the rows to the chats its caller may list; the route lists them all to the person. +pub(crate) async fn grant_list(state: &AppState, id: &str) -> Result { + let id = id.to_owned(); + let crew = manager()?; + let listed = crew.session_grant_rows(&id).await?; let tasks: Option> = match owned_run_views(&id).await { Ok(views) => Some( views @@ -285,7 +306,7 @@ pub async fn grants( for grant in listed.grants { grants.push(CrewGrantView { kind: kind(&grant), - session_name: session_name(&state, &grant.session_id).await, + session_name: session_name(state, &grant.session_id).await, grant, }); } @@ -298,9 +319,27 @@ pub async fn grants( grant, }) .collect(); + let mut person_reads = Vec::new(); + for row in crew.person_read_rows().await { + if row.consent.connection_id != id { + continue; + } + person_reads.push(CrewPersonReadView { + session_name: consent_session_name( + state, + &row.session_id, + row.consent.session_incarnation, + ) + .await, + session_id: row.session_id, + connection_id: row.consent.connection_id, + granted_at: row.consent.granted_at, + }); + } Ok(Json(CrewGrantList { grants, replaced_grants, + person_reads, })) } /// The conversation's title, or `None` when it cannot be read or has none. @@ -316,6 +355,57 @@ async fn session_name(state: &AppState, session: &str) -> Option { .map(|found| found.name) .filter(|name| !name.trim().is_empty()) } +/// A read consent's chat title: only while the chat holding `session` is the one the consent +/// was given in (SCOPE-BIND), since a reissued id names another chat, whose title would +/// mislabel the consent. `None` when the chat is gone, untitled or another. +async fn consent_session_name(state: &AppState, session: &str, incarnation: i64) -> Option { + match state.session_manager().session_incarnation(session).await { + Ok(Some(now)) if now == incarnation => session_name(state, session).await, + _ => None, + } +} + +/// Take back a chat's consent to read a connection's workspace as the person (account +/// ownership AO-12). Like every stop it holds on this computer even when it cannot be saved. +#[utoipa::path( + delete, + operation_id = "crew_profile_revoke_read", + path = "/crew/sessions/{session_id}/reads/{connection_id}", + params( + ("session_id" = String, Path, description = "The chat that may read the workspace"), + ("connection_id" = String, Path, description = "The saved connection whose workspace it may read") + ), + responses( + (status = 204, description = "Taken back: the chat's reads of the workspace as the person answer `crew_read_consent_required` until the person allows them again"), + (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), + (status = 404, description = "`crew_not_found`: the chat held no consent to read that connection's workspace", body = CrewError), + (status = 500, description = "`crew_revocation_not_saved`: stopped on this computer, but the change could not be saved, so the chat could read again after a restart. Retry", body = CrewError) + ), + tag = "Crew" +)] +pub async fn revoke_read( + headers: HeaderMap, + Path((session, connection)): Path<(String, String)>, +) -> std::result::Result { + person(&headers)?; + match manager()?.revoke_person_read(&session, &connection).await { + Ok(true) => Ok(StatusCode::NO_CONTENT), + Ok(false) => Err(Refusal::new( + StatusCode::NOT_FOUND, + NOT_FOUND, + NO_READ_CONSENT, + )), + Err(error) => { + tracing::warn!(%error, "a Crew read consent was taken back on this computer only"); + Err(Refusal::new( + StatusCode::INTERNAL_SERVER_ERROR, + REVOCATION_NOT_SAVED, + READ_CONSENT_NOT_SAVED, + ) + .with("detail", error.to_string())) + } + } +} /// The context a chat's grant reads, as the workspace lists it (`context.manifest`). #[utoipa::path( @@ -392,6 +482,13 @@ pub async fn revoke( Path((id, session)): Path<(String, String)>, ) -> Result { person(&headers)?; + revoke_grant(id, session).await +} + +/// `POST /crew/connections/{id}/sessions/{session_id}/revoke` after its proof check: the one +/// revocation the route and a chat's `crew__change grants_revoke` (account ownership AO-12) +/// both run, the chat's only after the person allowed it on its card. +pub(crate) async fn revoke_grant(id: String, session: String) -> Result { let crew = manager()?; let Some(metadata) = crew.run_metadata(&session).await else { return Err(grant_not_found()); @@ -571,6 +668,10 @@ pub fn routes(state: Arc) -> Router { "/crew/connections/{id}/sessions/{session_id}/revoke", post(revoke), ) + .route( + "/crew/sessions/{session_id}/reads/{connection_id}", + delete(revoke_read), + ) .layer(DefaultBodyLimit::max(8 * 1024)) .with_state(state) } @@ -672,6 +773,11 @@ mod tests { assert_eq!(refusal.status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(refusal.code, REVOCATION_UNCONFIRMED); assert_eq!(refusal.error, REVOCATION_UNCONFIRMED_MESSAGE); + // C1: the person's machine is "this computer". + assert_eq!( + refusal.error, + "Stopped on this computer. The workspace hasn't confirmed yet; Biorouter confirms it by itself when the connection is back." + ); assert_eq!(refusal.fields["detail"], json!(detail)); assert_eq!(refusal.fields["stopped_on_this_device"], json!(true)); assert_eq!(refusal.fields["remote_revocation_confirmed"], json!(false)); @@ -707,7 +813,7 @@ mod tests { async fn a_revoke_that_did_not_land_says_why() { let (_root, crew) = registry_with_one_grant(); let error = - || anyhow::anyhow!("Couldn't save the revocation on this device; retry to finish it"); + || anyhow::anyhow!("Couldn't save the revocation on this computer; retry to finish it"); let gone = not_revoked(&crew, "session-gone", "run-current", error()).await; assert_eq!( @@ -728,7 +834,7 @@ mod tests { ); assert_eq!( unsaved.error, - "Couldn't save the revocation on this device; retry to finish it" + "Couldn't save the revocation on this computer; retry to finish it" ); } diff --git a/crates/biorouter-server/src/routes/crew_setup.rs b/crates/biorouter-server/src/routes/crew_setup.rs index 16c58a317..4f0793dbc 100644 --- a/crates/biorouter-server/src/routes/crew_setup.rs +++ b/crates/biorouter-server/src/routes/crew_setup.rs @@ -2,7 +2,10 @@ //! //! - `GET /crew/setup/artifact`: the Crew server program this daemon would install. //! - `POST /crew/setup`: start a setup job (check, install, host or member) for a saved -//! connection or this computer's pending host setup. +//! connection, this computer's pending host setup, or a server named by its SSH login +//! (account ownership AO-8). +//! - `GET /crew/setup`: this computer's jobs, the newest first, so the Crew view can show one a +//! chat started. //! - `GET` and `DELETE /crew/setup/{job_id}`: where a job stands, and stopping it. //! - `POST /crew/setup/{job_id}/authentication`: the sign-in a job waits for, whose terminal is //! the ordinary `GET /crew/authentication/{id}/terminal`. @@ -21,14 +24,14 @@ use axum::{ }; use biorouter::crew::authentication::AuthenticationSession; use biorouter::crew::{ - cancel_setup, manager, prepare_setup_sign_in, setup_artifact, setup_status, ContextDraft, - CrewRefusal, SetupArtifact, SetupRefused, SetupRequest, SetupStatus, + cancel_setup, manager, prepare_setup_sign_in, setup_artifact, setup_jobs, setup_status, + ContextDraft, CrewRefusal, SetupArtifact, SetupRefused, SetupRequest, SetupStatus, }; use biorouter_server::auth::{user_action_proof, UserActionProof}; use serde::Deserialize; use serde_json::{json, Value}; -use super::crew::wire::{CrewCancelled, CrewJson}; +use super::crew::wire::{CrewCancelled, CrewJson, CrewSetupJobs}; use super::crew_authentication::{ no_human_authority, HUMAN_AUTHORITY_UNAVAILABLE_CODE, REQUEST_INVALID_CODE, REQUEST_REFUSED_CODE, USER_ACTION_REQUIRED_CODE, @@ -145,22 +148,23 @@ pub async fn artifact(headers: HeaderMap) -> SetupResult { Ok(Json(setup_artifact()?)) } -/// Set Crew up on a server (OW-9). +/// Set Crew up on a server (OW-9, account ownership AO-8). /// /// Check the server, sign in when it asks for a password or a code, install this app's Crew -/// program in the account, then start the workspace (`host`) or connect (`member`). The body -/// names no command: every step is a fixed script. +/// program in the account, list the workspaces there and update the ones the account hosts, +/// then start the workspace (`host`) or connect (`member`). The body names no command: every +/// step is a fixed script. #[utoipa::path( post, operation_id = "crew_setup_start", path = "/crew/setup", request_body = SetupRequest, responses( - (status = 200, description = "The job, started (or the one already under way for the same server and intent): poll `GET /crew/setup/{job_id}`", body = SetupStatus), - (status = 400, description = "`crew_request_invalid`: a body that is not JSON, a field the route does not take, a workspace name, login or route outside what a connection allows, a `host` job without a pending host setup and a name, or a `member` job without a saved connection; `crew_request_refused` for an SSH configuration the preflight refuses", body = CrewError), + (status = 200, description = "The job, started, or the one already under way for this server when its intent, SSH route and every choice match this request's (any other request for the server is `crew_setup_busy`): poll `GET /crew/setup/{job_id}`. A `check` job runs check, sign-in, `workspaces`, done; an `install` job adds install and `restart`; a `host` job adds the start and, with `finish`, `save`, `join` and `configure`. An installed Crew older than this app's is replaced unless `keep_older`; `replace_existing` only covers one that does not run or name its version, and a newer one is never replaced. `restart_workspaces` (`true`: restart every outdated hosted workspace even with others connected; `false`: only report them in `outcome.outdated`), `restart_workspace_ids` (hosted workspaces, by ID, the person agreed to restart even with others connected, whatever `restart_workspaces` says) and `start_workspaces` (stopped hosted workspaces, by ID) choose what the `restart` stage does. A `host` job whose account already hosts a workspace of that name on the server fails with `crew_host_name_in_use` before anything is started or restarted", body = SetupStatus), + (status = 400, description = "`crew_request_invalid`: a body that is not JSON, a field the route does not take, a workspace name, login or route outside what a connection allows, a `host` job without a pending host setup and a name, a `member` job without a saved connection, a `host` or `member` job on a `server` target, `start_workspaces` or `restart_workspace_ids` on a `check` job, or an ID in either that is not a workspace ID, or `finish` without `mode`, on a job that does not host, or for a Private workspace without `institution_id`; `crew_request_refused` for an SSH configuration the preflight refuses", body = CrewError), (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), (status = 404, description = "`crew_connection_not_found`: no saved connection has that ID", body = CrewError), - (status = 409, description = "`crew_host_setup_unknown`: no pending host setup with that ID on this computer; `crew_host_setup_used`: it already has a saved connection; `crew_setup_busy`: another kind of job is under way for this server, or too many jobs at once", body = CrewError), + (status = 409, description = "`crew_host_setup_unknown`: no pending host setup (or, for a `server` target, prepared device) with that ID on this computer; `crew_host_setup_used`: it already has a saved connection; `crew_setup_busy`: a job is under way for this server whose intent, SSH route or any choice differs from this request's, or too many jobs are under way at once", body = CrewError), (status = 413, description = "`crew_request_invalid`: the body is larger than the route takes", body = CrewError), (status = 415, description = "`crew_request_invalid`: the body is not sent as `application/json`", body = CrewError) ), @@ -178,6 +182,23 @@ pub async fn start( Ok(Json(manager()?.start_setup(request).await?)) } +/// This computer's setup jobs (account ownership AO-8), so the Crew view can show one a chat or +/// another window started. +#[utoipa::path( + get, + operation_id = "crew_setup_jobs", + path = "/crew/setup", + responses( + (status = 200, description = "Every job under way on this computer and every one that finished in the last 15 minutes, the newest first, each as `GET /crew/setup/{job_id}` answers it. A bare array, empty when there is none", body = CrewSetupJobs), + (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError) + ), + tag = "Crew" +)] +pub async fn jobs(headers: HeaderMap) -> SetupResult { + person(&headers)?; + Ok(Json(CrewSetupJobs(setup_jobs()))) +} + /// Where a setup job stands. #[utoipa::path( get, @@ -185,7 +206,7 @@ pub async fn start( path = "/crew/setup/{job_id}", params(("job_id" = String, Path, description = "The job `POST /crew/setup` answered")), responses( - (status = 200, description = "`state` is `running`, `needs_sign_in` (open `POST /crew/setup/{job_id}/authentication`), `finished` (`outcome`), `failed` or `cancelled` (`error`: a typed code and a sentence, such as `crew_setup_cancelled`, `crew_setup_timed_out`, `crew_setup_sign_in_expired`, `crew_setup_start_failed` (`detail` is what Crew printed), `crew_host_storage_unsafe` (other accounts can write to a folder above where the workspace is kept; `detail` lists them, and starting again with `fix_storage_permissions` fixes the account's own), `crew_setup_changed`, `crew_install_checksum_mismatch` or an SSH code)", body = SetupStatus), + (status = 200, description = "`state` is `running`, `needs_sign_in` (open `POST /crew/setup/{job_id}/authentication`), `finished` (`outcome`), `failed` or `cancelled` (`error`: a typed code and a sentence, such as `crew_setup_cancelled`, `crew_setup_timed_out`, `crew_setup_sign_in_expired`, `crew_setup_start_failed` (`detail` is what Crew printed), `crew_host_storage_unsafe` (other accounts can write to a folder above where the workspace is kept; `detail` lists them, and starting again with `fix_storage_permissions` fixes the account's own), `crew_host_name_in_use` (the account already hosts a workspace of that name on the server: \"You already host {name} on {server}. Open it on this computer instead.\" when it runs, and \"You already host {name} on {server}. It is stopped: start it with biorouter crew setup --server {login} --start {name}, then open it.\" when it is stopped), `crew_setup_changed`, `crew_install_checksum_mismatch` or an SSH code; after a `finish` saved the connection, a join refusal such as `crew_join_removed`, or `crew_account_unsupported`, with `outcome.connection_id` and `outcome.steps_left` saying what is left). `workspaces` lists the workspaces on the server for this account, each with its `role`, `state`, host and `this_computer` (`connected`, `saved` or `not_saved`), with `workspaces_scan` counting what the listing skipped and `workspaces_note` saying why there is no listing (an older Crew, a failure or a timeout, which never stops the job), and `workspaces_unlisted` saying it as a code: `not_installed`, `too_old` (an `install` job replaces the Crew in the account and lists them), `failed` or `timed_out`. `outcome.restarted`, `outcome.outdated` (with `reason` `others_connected`, `running_newer`, `not_running_installed` or `custom_state_dir`) and `outcome.restart_failed` say what the `restart` stage did; a failed restart fails only that stage. Each `restart_failed` entry has a `code` and a sentence. A refusal made before anything was stopped (such as `program_missing`, `busy`, `identity_conflict`, `identity_mismatch`, `workspace_deleted` or `unsupported`) says the workspace was not restarted and still runs the older Crew, and has no `command`; `identity_conflict` shows Crew's own sentence naming the folders that keep the workspace. Only Crew's `start_failed` says the workspace stopped and did not start again; where the workspace may be stopped, `command` starts it by hand", body = SetupStatus), (status = 403, description = "No proof that a person asked: `crew_user_action_required`, or `crew_human_authority_unavailable` on a daemon that holds no user-action key", body = CrewError), (status = 404, description = "`crew_setup_not_found`: no such job on this computer", body = CrewError) ), @@ -286,7 +307,7 @@ pub async fn context_draft( pub fn routes() -> Router { Router::new() - .route("/crew/setup", post(start)) + .route("/crew/setup", get(jobs).post(start)) .route("/crew/setup/artifact", get(artifact)) .route("/crew/setup/{job_id}", get(status).delete(cancel)) .route("/crew/setup/{job_id}/authentication", post(authentication)) @@ -353,6 +374,82 @@ mod tests { } } + /// `GET /crew/setup` (AO-8) lists this computer's jobs to a person only, as a bare array: + /// a job a chat started shows a person's sign-in prompt, so it is not for an agent. + #[tokio::test] + async fn the_job_list_is_a_persons_and_a_bare_array() { + match jobs(HeaderMap::new()).await { + Err(refusal) => proofless(refusal).await, + Ok(_) => panic!("listed setup jobs without proof of a person"), + } + let listed = serde_json::to_value(CrewSetupJobs(setup_jobs())).unwrap(); + assert!(listed.is_array(), "{listed}"); + let (_, schema) = ::schema(); + let schema = serde_json::to_value(schema).unwrap(); + assert_eq!(schema["type"], "array", "{schema}"); + assert_eq!( + schema["items"]["$ref"], "#/components/schemas/SetupStatus", + "{schema}" + ); + } + + /// What the OpenAPI text says a route answers with `status`. + fn described(method: &str, status: &str) -> String { + let item = serde_json::to_value(P::path_item(None)).unwrap(); + item[method]["responses"][status]["description"] + .as_str() + .unwrap_or_else(|| panic!("no {status} for {method}: {item}")) + .to_owned() + } + + /// AO-8: a request shares a running job only when it asks for exactly the same (intent, + /// SSH route and every choice), else `crew_setup_busy`; and a restart refused before + /// anything was stopped is described as leaving the workspace on the older Crew. + #[test] + fn the_setup_routes_say_when_a_job_is_shared_and_what_a_failed_restart_left() { + let started = described::<__path_start>("post", "200"); + assert!( + started.contains("when its intent, SSH route and every choice match") + && started.contains("is `crew_setup_busy`"), + "{started}" + ); + assert!(!started.contains("same server and intent"), "{started}"); + let busy = described::<__path_start>("post", "409"); + assert!( + busy.contains("`crew_setup_busy`: a job is under way for this server whose intent, SSH route or any choice differs"), + "{busy}" + ); + assert!(!busy.contains("another kind of job"), "{busy}"); + let status = described::<__path_status>("get", "200"); + assert!( + status + .contains("was not restarted and still runs the older Crew, and has no `command`") + && status.contains("Only Crew's `start_failed` says the workspace stopped"), + "{status}" + ); + // A listing a too old Crew could not give says so as a code a surface acts on. + assert!( + status.contains("`workspaces_unlisted` saying it as a code") + && status.contains( + "`too_old` (an `install` job replaces the Crew in the account and lists them)" + ), + "{status}" + ); + } + + /// AO-8: the job's status carries why the workspaces were not listed as a code, beside its + /// sentence, in the schema the desktop reads. + #[test] + fn the_setup_status_names_why_nothing_was_listed() { + let (_, schema) = ::schema(); + let schema = serde_json::to_value(schema).unwrap(); + let unlisted = &schema["properties"]["workspaces_unlisted"]; + let text = unlisted.to_string(); + for code in ["not_installed", "too_old", "failed", "timed_out"] { + assert!(text.contains(&format!("\"{code}\"")), "{code}: {unlisted}"); + } + } + #[tokio::test] async fn a_setup_refusal_keeps_its_status_and_code() { let refused = anyhow::Error::new(SetupRefused { diff --git a/crates/biorouter-server/src/routes/crew_tools/catalog.rs b/crates/biorouter-server/src/routes/crew_tools/catalog.rs new file mode 100644 index 000000000..b2a39f816 --- /dev/null +++ b/crates/biorouter-server/src/routes/crew_tools/catalog.rs @@ -0,0 +1,1126 @@ +//! Every action of the two person tools (account ownership AO-12, "Names"): its parameters, +//! an example, and the function that runs it. `crew__read describe` serves the first two, so a +//! chat's own tool schemas stay small, and [`check_params`] holds every call to the same +//! parameters, so what `describe` says is what a call is checked against. +//! +//! Every action AO-12 lists runs. An action whose `run` is `None` (one added to the plumbing's +//! lists before it has a runner here) answers [`NOT_YET`]. + +use biorouter::crew::tool_host::PersonTool; +use serde_json::{json, Map, Value}; + +use super::{channels, messages, more, setup, signin, tasks, vault, workspace, Run}; + +/// What a parameter holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kind { + /// A string. + Text, + /// `true` or `false`. + Flag, + /// A whole number, zero or more. + Number, + /// A list of strings. + Texts, + /// One of these words. + Choice(&'static [&'static str]), +} + +/// One parameter of an action. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Param { + pub(crate) name: &'static str, + pub(crate) kind: Kind, + pub(crate) required: bool, + pub(crate) about: &'static str, +} + +/// One action of `crew__read` or `crew__change`. +pub(crate) struct Action { + pub(crate) tool: PersonTool, + pub(crate) name: &'static str, + /// One sentence: what it does. + pub(crate) summary: &'static str, + pub(crate) params: &'static [Param], + /// An example `params` object, as JSON text. + pub(crate) example: &'static str, + /// The action, or `None` while it is not available in a chat yet. + pub(crate) run: Option, +} + +/// Said for an action listed in AO-12 that a chat cannot run yet. +pub(crate) const NOT_YET: &str = + "That action isn't available in a chat yet. Use the Crew view or biorouter crew for it."; + +const fn p(name: &'static str, kind: Kind, about: &'static str) -> Param { + Param { + name, + kind, + required: false, + about, + } +} + +const fn req(name: &'static str, kind: Kind, about: &'static str) -> Param { + Param { + name, + kind, + required: true, + about, + } +} + +use Kind::{Choice, Flag, Number, Text, Texts}; + +const CONNECTION: Param = p( + "connection", + Text, + "The saved connection: its name, SSH login or ID. Omit it when only one is saved.", +); +const SERVER: Param = p( + "server", + Text, + "The server's SSH login, like you@hpc.example.edu.", +); +const SERVER_REQUIRED: Param = req( + "server", + Text, + "The server's SSH login, like you@hpc.example.edu.", +); +const PORT: Param = p("port", Number, "The SSH port, when it is not 22."); +const IDENTITY_FILE: Param = p( + "identity_file", + Text, + "The SSH key file on this computer to sign in with.", +); +const PROXY_JUMP: Param = p( + "proxy_jump", + Text, + "A jump host to reach the server through.", +); +const AGENT_ACCESS: Param = p( + "agent_access", + Choice(&["off", "folder", "full"]), + "What agents may do on the server: off, folder (a confined work folder) or full (run as the person).", +); +const JOB_ID: Param = req( + "job_id", + Text, + "The setup job's ID, from the action that started it.", +); +const MODE: Param = req( + "mode", + Choice(&["private", "public"]), + "The privacy: private or public.", +); +const INSTITUTION: Param = p( + "institution_id", + Text, + "The institution, like ucsf. A private workspace needs one.", +); +const CHANNEL: Param = req("channel", Text, "The channel: #name, team/name or its ID."); +const TEAM_OPTIONAL: Param = p("team", Text, "The team: its name or ID."); +const TEAM: Param = req("team", Text, "The team: its name or ID."); +const MEMBER: Param = req("member", Text, "The person: @username or their ID."); +const USERNAME: Param = req( + "username", + Text, + "The person's account on the server, like @bob.", +); +const NAME: Param = req("name", Text, "The new name."); +const TRANSFER_ID: Param = req( + "transfer_id", + Text, + "The transfer's ID, from files_share, files_download or files_pending.", +); + +/// Every action, reads first, in AO-12's order. +pub(crate) static ACTIONS: &[Action] = &[ + // ----------------------------------------------------------------------------- crew__read + Action { + tool: PersonTool::Read, + name: "describe", + summary: "Return an action's parameters and an example.", + params: &[req("action", Text, "The action to describe, like setup or members.")], + example: r#"{"action":"setup_check"}"#, + run: Some(setup::describe), + }, + Action { + tool: PersonTool::Read, + name: "status", + summary: "List this computer's saved Crew connections and how each stands.", + params: &[], + example: "{}", + run: Some(setup::status), + }, + Action { + tool: PersonTool::Read, + name: "credentials_status", + summary: "Whether Crew's vault on this computer is set up and whether it is locked.", + params: &[], + example: "{}", + run: Some(vault::credentials_status), + }, + Action { + tool: PersonTool::Read, + name: "connections_show", + summary: "Show one saved connection: its server, privacy, agent access and new computers.", + params: &[CONNECTION], + example: r#"{"connection":"baranzini-lab"}"#, + run: Some(setup::connections_show), + }, + Action { + tool: PersonTool::Read, + name: "connections_preview", + summary: "Read an invitation the person pasted, before anything is saved.", + params: &[ + req("invitation", Text, "The invitation text the host sent, with its brcrew1: line."), + p("username", Text, "The person's account on the server, when it is not the one invited."), + p("mode", Choice(&["private", "public"]), "How this computer would treat the workspace."), + INSTITUTION, + ], + example: r#"{"invitation":"brcrew1:..."}"#, + run: Some(setup::connections_preview), + }, + Action { + tool: PersonTool::Read, + name: "connections_invitation", + summary: "The invitation message for a person to join the workspace, to send again (the host gets this).", + params: &[ + CONNECTION, + p("invitee", Text, "The invited person's account on the server, like @bob. The message then names them."), + ], + example: r#"{"invitee":"@bob"}"#, + run: Some(more::connections_invitation), + }, + Action { + tool: PersonTool::Read, + name: "setup_check", + summary: "Check a server and the Crew program in the person's account there, and list the workspaces it hosts or belongs to. Changes nothing on the server; a server no saved connection names needs the person's approval, because it signs in as them.", + params: &[SERVER, CONNECTION, PORT, IDENTITY_FILE, PROXY_JUMP], + example: r#"{"server":"you@hpc.example.edu"}"#, + run: Some(setup::setup_check), + }, + Action { + tool: PersonTool::Read, + name: "setup_status", + summary: "Where a setup job stands: its stage, what it found and what it did.", + params: &[JOB_ID], + example: r#"{"job_id":"5b1f..."}"#, + run: Some(setup::setup_status), + }, + Action { + tool: PersonTool::Read, + name: "setup_list", + summary: "The setup jobs on this computer, started from any chat, Crew or a terminal: each one's ID, state, stage and server.", + params: &[], + example: "{}", + run: Some(more::setup_list), + }, + Action { + tool: PersonTool::Read, + name: "workspace_show", + summary: "The workspace as the person sees it: its teams, channels, people and settings.", + params: &[CONNECTION], + example: r#"{"connection":"baranzini-lab"}"#, + run: Some(workspace::workspace_show), + }, + Action { + tool: PersonTool::Read, + name: "members", + summary: "The workspace's members, with the host first.", + params: &[CONNECTION], + example: r#"{"connection":"baranzini-lab"}"#, + run: Some(workspace::members), + }, + Action { + tool: PersonTool::Read, + name: "enroll_pending", + summary: "Who is waiting to join the workspace (the host sees this).", + params: &[CONNECTION], + example: "{}", + run: Some(workspace::enroll_pending), + }, + Action { + tool: PersonTool::Read, + name: "invites_list", + summary: "The person's pending invitations to teams and channels.", + params: &[CONNECTION], + example: "{}", + run: Some(workspace::invites_list), + }, + Action { + tool: PersonTool::Read, + name: "teams_list", + summary: "The teams the person is in.", + params: &[CONNECTION], + example: "{}", + run: Some(channels::teams_list), + }, + Action { + tool: PersonTool::Read, + name: "channels_list", + summary: "The person's channels, and in an open workspace the ones they have not joined.", + params: &[CONNECTION, TEAM_OPTIONAL], + example: r#"{"team":"analysis-lab"}"#, + run: Some(channels::channels_list), + }, + Action { + tool: PersonTool::Read, + name: "history", + summary: "A channel's messages, newest last; with after, only newer ones (watch).", + params: &[ + CONNECTION, + CHANNEL, + p("after", Text, "A message's sequence token: only messages after it."), + p("before", Text, "A message's sequence token: only messages before it."), + p("limit", Number, "At most this many messages."), + ], + example: r##"{"channel":"#methods","limit":20}"##, + run: Some(messages::history), + }, + Action { + tool: PersonTool::Read, + name: "search", + summary: "Search one channel's messages.", + params: &[ + CONNECTION, + CHANNEL, + req("query", Text, "The words to look for."), + p("limit", Number, "At most this many messages."), + p("after", Text, "A message's sequence token: only messages after it."), + ], + example: r##"{"channel":"#methods","query":"batch effect"}"##, + run: Some(messages::search), + }, + Action { + tool: PersonTool::Read, + name: "files_list", + summary: "The files shared in a channel, newest first.", + params: &[ + CONNECTION, + CHANNEL, + p("limit", Number, "At most this many files."), + ], + example: r##"{"channel":"#methods"}"##, + run: Some(messages::files_list), + }, + Action { + tool: PersonTool::Read, + name: "files_status", + summary: "Where a file transfer stands.", + params: &[req("transfer_id", Text, "The transfer's ID, from files_share or files_download.")], + example: r#"{"transfer_id":"..."}"#, + run: Some(messages::files_status), + }, + Action { + tool: PersonTool::Read, + name: "files_pending", + summary: "This computer's file transfers through a connection, finished or not, and how each stands.", + params: &[CONNECTION], + example: "{}", + run: Some(more::files_pending), + }, + Action { + tool: PersonTool::Read, + name: "context_show", + summary: "The workspace's context document, which members wrote about the server.", + params: &[CONNECTION], + example: "{}", + run: Some(messages::context_show), + }, + Action { + tool: PersonTool::Read, + name: "privacy_show", + summary: "The privacy in force: the connection's own setting, the workspace's, and each channel's.", + params: &[CONNECTION], + example: "{}", + run: Some(workspace::privacy_show), + }, + Action { + tool: PersonTool::Read, + name: "profile_show", + summary: "The person's own profile in the workspace.", + params: &[CONNECTION], + example: "{}", + run: Some(workspace::profile_show), + }, + Action { + tool: PersonTool::Read, + name: "profile_devices", + summary: "The computers enrolled as the person, which one is this computer, and which are new.", + params: &[CONNECTION], + example: "{}", + run: Some(workspace::profile_devices), + }, + Action { + tool: PersonTool::Read, + name: "defaults_show", + summary: "The model Crew tasks start on, and the default model for new chats.", + params: &[], + example: "{}", + run: Some(setup::defaults_show), + }, + Action { + tool: PersonTool::Read, + name: "tasks_list", + summary: "The person's agent tasks on this connection and how each stands.", + params: &[CONNECTION], + example: "{}", + run: Some(tasks::tasks_list), + }, + Action { + tool: PersonTool::Read, + name: "tasks_watch", + summary: "A task's progress since a point, waiting up to 20 seconds for news.", + params: &[ + CONNECTION, + req("task", Text, "The task's ID."), + p("after", Text, "Only progress after this token."), + p("wait_seconds", Number, "How long to wait for news, at most 20."), + ], + example: r#"{"task":"run-...","wait_seconds":10}"#, + run: Some(tasks::tasks_watch), + }, + Action { + tool: PersonTool::Read, + name: "grants_list", + summary: "The chats and tasks with Crew access through a connection, and the chats the person let read it as them.", + params: &[CONNECTION], + example: "{}", + run: Some(workspace::grants_list), + }, + // --------------------------------------------------------------------------- crew__change + Action { + tool: PersonTool::Change, + name: "setup", + summary: "Install or update Crew in the person's account on a server, restart the workspaces they host there on it, and for a saved connection, connect it.", + params: &[ + SERVER, + CONNECTION, + PORT, + IDENTITY_FILE, + PROXY_JUMP, + AGENT_ACCESS, + p("keep_older", Flag, "Keep an older Crew program instead of replacing it."), + p("replace_existing", Flag, "Replace a Crew program that does not run or name its version."), + p("restart_workspaces", Flag, "true restarts the hosted workspaces even with others connected; false only reports them."), + p("restart_workspace_ids", Texts, "IDs of hosted workspaces to restart even with others connected, from setup_check, whatever restart_workspaces says."), + p("start_workspaces", Texts, "IDs of stopped workspaces the person hosts there to start, from setup_check."), + ], + example: r#"{"server":"you@hpc.example.edu"}"#, + run: Some(setup::setup), + }, + Action { + tool: PersonTool::Change, + name: "setup_cancel", + summary: "Stop a setup job.", + params: &[JOB_ID], + example: r#"{"job_id":"5b1f..."}"#, + run: Some(setup::setup_cancel), + }, + Action { + tool: PersonTool::Change, + name: "host", + summary: "Host a new workspace on a server as the person, and save and join it here.", + params: &[ + SERVER_REQUIRED, + req("name", Text, "The workspace's name, like lab."), + MODE, + INSTITUTION, + AGENT_ACCESS, + PORT, + IDENTITY_FILE, + PROXY_JUMP, + p("fix_storage_permissions", Flag, "Take other accounts' write access off the person's folders above where the workspace is kept, when Crew refuses to start there."), + ], + example: r#"{"server":"you@hpc.example.edu","name":"lab","mode":"private","institution_id":"ucsf"}"#, + run: Some(setup::host), + }, + Action { + tool: PersonTool::Change, + name: "open", + summary: "Open a workspace a setup_check job found on the server, as the person: save it here and join it.", + params: &[ + JOB_ID, + req("workspace", Text, "The workspace's name or ID, as setup_check listed it."), + p("host", Text, "The host's username, when two hosts have a workspace of that name."), + AGENT_ACCESS, + p("local_name", Text, "This computer's name for the connection."), + ], + example: r#"{"job_id":"5b1f...","workspace":"baranzini-lab"}"#, + run: Some(setup::open), + }, + Action { + tool: PersonTool::Change, + name: "join", + summary: "Join a workspace: from an invitation the person pasted, or a saved connection that is ready to join. From an invitation it can also take another SSH login, port, key file or jump host, a work folder and agent access.", + params: &[ + CONNECTION, + p("invitation", Text, "The invitation text the host sent, with its brcrew1: line."), + p("username", Text, "The person's account on the server, when it is not the one invited."), + p("mode", Choice(&["private", "public"]), "How this computer treats the workspace."), + INSTITUTION, + p("local_name", Text, "This computer's name for the connection."), + p("ssh_target", Text, "With an invitation: a login from the person's own SSH settings to sign in with instead, like hpc or bob@hpc.example.edu. The invitation's port and jump host then don't apply."), + p("port", Number, "With an invitation: the SSH port, when it is not the invitation's."), + p("identity_file", Text, "With an invitation: the SSH key file on this computer to sign in with."), + p("proxy_jump", Text, "With an invitation: a jump host to reach the server through, or an empty text for none, even when the invitation suggests one."), + p("folder", Text, "With an invitation: the agents' work folder's full path on the server, for agent_access folder or full."), + p("agent_access", Choice(&["off", "folder", "full"]), "With an invitation: what agents may do on the server: off, folder (a confined work folder) or full (run as the person)."), + ], + example: r#"{"invitation":"brcrew1:...","port":2222,"proxy_jump":"gateway.example.edu","agent_access":"folder"}"#, + run: Some(setup::join), + }, + Action { + tool: PersonTool::Change, + name: "rejoin", + summary: "Add this computer to a workspace again with a new key, as the account on the server.", + params: &[CONNECTION], + example: r#"{"connection":"baranzini-lab"}"#, + run: Some(setup::rejoin), + }, + Action { + tool: PersonTool::Change, + name: "connect", + summary: "Connect a saved connection to its workspace.", + params: &[CONNECTION], + example: r#"{"connection":"baranzini-lab"}"#, + run: Some(setup::connect), + }, + Action { + tool: PersonTool::Change, + name: "disconnect", + summary: "Disconnect a saved connection.", + params: &[CONNECTION], + example: r#"{"connection":"baranzini-lab"}"#, + run: Some(setup::disconnect), + }, + Action { + tool: PersonTool::Change, + name: "auth", + summary: "Sign the person in to a setup job's server or a saved connection. They type into a Sign in window the chat opens, never into the chat.", + params: &[ + p("job_id", Text, "A setup job waiting for the person to sign in, from the action that started it or setup_list."), + CONNECTION, + ], + example: r#"{"job_id":"5b1f..."}"#, + run: Some(signin::auth), + }, + Action { + tool: PersonTool::Change, + name: "credentials_unlock", + summary: "Unlock Crew's vault on this computer. The person types the passphrase in a secure prompt the chat opens, never into the chat.", + params: &[], + example: "{}", + run: Some(vault::credentials_unlock), + }, + Action { + tool: PersonTool::Change, + name: "credentials_init", + summary: "Set up Crew's vault on this computer. The person chooses the passphrase in a secure prompt the chat opens, never in the chat.", + params: &[], + example: "{}", + run: Some(vault::credentials_init), + }, + Action { + tool: PersonTool::Change, + name: "credentials_lock", + summary: "Lock Crew's vault on this computer. Crew tasks and chats that use it stop until it is unlocked.", + params: &[], + example: "{}", + run: Some(vault::credentials_lock), + }, + Action { + tool: PersonTool::Change, + name: "connections_remove", + summary: "Remove a saved connection and its key from this computer.", + params: &[CONNECTION], + example: r#"{"connection":"baranzini-lab"}"#, + run: Some(setup::connections_remove), + }, + Action { + tool: PersonTool::Change, + name: "connections_update", + summary: "Change a saved connection's name or SSH route.", + params: &[ + CONNECTION, + p("name", Text, "This computer's new name for the connection."), + p("ssh_target", Text, "A new SSH login for the same server."), + PORT, + IDENTITY_FILE, + PROXY_JUMP, + ], + example: r#"{"connection":"baranzini-lab","name":"lab"}"#, + run: Some(setup::connections_update), + }, + Action { + tool: PersonTool::Change, + name: "connections_set_access", + summary: "What agents may do on the server through a connection: off, folder or full.", + params: &[ + CONNECTION, + req("access", Choice(&["off", "folder", "full"]), "off, folder (a confined work folder) or full (run as the person)."), + p("folder", Text, "The work folder's full path on the server, for folder or full."), + ], + example: r#"{"access":"folder","folder":"/home/you/crew-work/lab"}"#, + run: Some(setup::connections_set_access), + }, + Action { + tool: PersonTool::Change, + name: "workspace_rename", + summary: "Rename the workspace for everyone.", + params: &[CONNECTION, NAME], + example: r#"{"name":"lab-2"}"#, + run: Some(workspace::workspace_rename), + }, + Action { + tool: PersonTool::Change, + name: "workspace_delete", + summary: "Delete the workspace on its server for everyone (its host only), and remove it here.", + params: &[CONNECTION], + example: r#"{"connection":"old-lab"}"#, + run: Some(workspace::workspace_delete), + }, + Action { + tool: PersonTool::Change, + name: "workspace_governance", + summary: "Choose who manages teams, channels and people: open (every member) or managed.", + params: &[ + CONNECTION, + req("governance", Choice(&["open", "managed"]), "open or managed."), + ], + example: r#"{"governance":"open"}"#, + run: Some(workspace::workspace_governance), + }, + Action { + tool: PersonTool::Change, + name: "context_draft", + summary: "Draft the workspace's context document from what the server says about itself, signing in as the person. Save it with context_set.", + params: &[CONNECTION], + example: "{}", + run: Some(more::context_draft), + }, + Action { + tool: PersonTool::Change, + name: "context_set", + summary: "Replace the workspace's context document, which every member's agent reads.", + params: &[CONNECTION, req("text", Text, "The whole new document, in Markdown, up to 64 KB.")], + example: r##"{"text":"# Server\nRun jobs with sbatch."}"##, + run: Some(workspace::context_set), + }, + Action { + tool: PersonTool::Change, + name: "enroll_invite", + summary: "Invite a person on the server to join the workspace by their account.", + params: &[ + CONNECTION, + USERNAME, + p("add_device", Flag, "Invite an existing member to add another computer."), + ], + example: r#"{"username":"@bob"}"#, + run: Some(workspace::enroll_invite), + }, + Action { + tool: PersonTool::Change, + name: "enroll_cancel", + summary: "Cancel a person's invitation to join.", + params: &[CONNECTION, USERNAME], + example: r#"{"username":"@bob"}"#, + run: Some(workspace::enroll_cancel), + }, + Action { + tool: PersonTool::Change, + name: "enroll_approve", + summary: "Let in a person who joins from an older Biorouter, with the code they sent.", + params: &[ + CONNECTION, + USERNAME, + req("code", Text, "The device code the person sent, like ABCD-EFGH-JKMN-PQRS."), + p("replace", Flag, "Replace a code saved before."), + ], + example: r#"{"username":"@bob","code":"ABCD-EFGH-JKMN-PQRS"}"#, + run: Some(workspace::enroll_approve), + }, + Action { + tool: PersonTool::Change, + name: "enroll_revoke", + summary: "Remove a person from the workspace for everyone: their membership, computers and agent grants.", + params: &[CONNECTION, MEMBER], + example: r#"{"member":"@bob"}"#, + run: Some(workspace::enroll_revoke), + }, + Action { + tool: PersonTool::Change, + name: "members_add", + summary: "Add a member to a team or to channels.", + params: &[ + CONNECTION, + MEMBER, + TEAM_OPTIONAL, + p("channels", Texts, "Channels to add them to."), + ], + example: r##"{"member":"@bob","team":"analysis-lab","channels":["#methods"]}"##, + run: Some(channels::members_add), + }, + Action { + tool: PersonTool::Change, + name: "teams_create", + summary: "Create a team.", + params: &[CONNECTION, req("name", Text, "The team's name.")], + example: r#"{"name":"analysis-lab"}"#, + run: Some(channels::teams_create), + }, + Action { + tool: PersonTool::Change, + name: "teams_rename", + summary: "Rename a team.", + params: &[CONNECTION, TEAM, NAME], + example: r#"{"team":"analysis-lab","name":"analysis"}"#, + run: Some(channels::teams_rename), + }, + Action { + tool: PersonTool::Change, + name: "teams_join", + summary: "Join a team in an open workspace.", + params: &[CONNECTION, TEAM], + example: r#"{"team":"analysis-lab"}"#, + run: Some(channels::teams_join), + }, + Action { + tool: PersonTool::Change, + name: "teams_delete", + summary: "Delete a team for everyone.", + params: &[CONNECTION, TEAM], + example: r#"{"team":"analysis-lab"}"#, + run: Some(channels::teams_delete), + }, + Action { + tool: PersonTool::Change, + name: "channels_create", + summary: "Create a channel in a team. It is restricted unless classification says public_safe.", + params: &[ + CONNECTION, + req("name", Text, "The channel's name."), + p("team", Text, "The team: its name or ID. Omit it when the person is in one team."), + p( + "classification", + Choice(&["restricted", "public_safe"]), + "restricted (the default: public models never read it) or public_safe.", + ), + ], + example: r#"{"name":"methods","team":"analysis-lab"}"#, + run: Some(channels::channels_create), + }, + Action { + tool: PersonTool::Change, + name: "channels_rename", + summary: "Rename a channel.", + params: &[CONNECTION, CHANNEL, NAME], + example: r##"{"channel":"#methods","name":"methods-2"}"##, + run: Some(channels::channels_rename), + }, + Action { + tool: PersonTool::Change, + name: "channels_archive", + summary: "Archive a channel for everyone.", + params: &[CONNECTION, CHANNEL], + example: r##"{"channel":"#methods"}"##, + run: Some(channels::channels_archive), + }, + Action { + tool: PersonTool::Change, + name: "channels_join", + summary: "Join a channel in an open workspace.", + params: &[CONNECTION, CHANNEL], + example: r##"{"channel":"#methods"}"##, + run: Some(channels::channels_join), + }, + Action { + tool: PersonTool::Change, + name: "channels_delete", + summary: "Delete a channel and its messages for everyone.", + params: &[CONNECTION, CHANNEL], + example: r##"{"channel":"#methods"}"##, + run: Some(channels::channels_delete), + }, + Action { + tool: PersonTool::Change, + name: "channels_read", + summary: "Mark a channel read.", + params: &[CONNECTION, CHANNEL], + example: r##"{"channel":"#methods"}"##, + run: Some(channels::channels_read), + }, + Action { + tool: PersonTool::Change, + name: "invites_send", + summary: "Invite a member to a team or a channel.", + params: &[ + CONNECTION, + MEMBER, + TEAM_OPTIONAL, + p("channel", Text, "The channel: #name, team/name or its ID."), + ], + example: r##"{"member":"@bob","channel":"#methods"}"##, + run: Some(channels::invites_send), + }, + Action { + tool: PersonTool::Change, + name: "invites_accept", + summary: "Accept one of the person's invitations to a team or channel.", + params: &[CONNECTION, p("invitation", Text, "The team or channel it is for, or its ID.")], + example: r##"{"invitation":"#methods"}"##, + run: Some(channels::invites_accept), + }, + Action { + tool: PersonTool::Change, + name: "ownership_offer", + summary: "Offer a channel's ownership to another member.", + params: &[CONNECTION, CHANNEL, req("successor", Text, "The member: @username.")], + example: r##"{"channel":"#methods","successor":"@bob"}"##, + run: Some(channels::ownership_offer), + }, + Action { + tool: PersonTool::Change, + name: "ownership_accept", + summary: "Accept a channel's ownership offered to the person.", + params: &[CONNECTION, CHANNEL], + example: r##"{"channel":"#methods"}"##, + run: Some(channels::ownership_accept), + }, + Action { + tool: PersonTool::Change, + name: "remove_member", + summary: "Remove a member from a channel.", + params: &[ + CONNECTION, + CHANNEL, + MEMBER, + p("former", Flag, "The member left the workspace."), + ], + example: r##"{"channel":"#methods","member":"@bob"}"##, + run: Some(channels::remove_member), + }, + Action { + tool: PersonTool::Change, + name: "profile_set", + summary: "Change the person's own profile: their nickname or avatar.", + params: &[ + CONNECTION, + p("nickname", Text, "The name people see."), + p("avatar", Text, "The avatar: an emoji or a short text."), + ], + example: r#"{"nickname":"Sam"}"#, + run: Some(workspace::profile_set), + }, + Action { + tool: PersonTool::Change, + name: "profile_revoke_device", + summary: "Remove one of the person's own computers from the workspace, by its fingerprint, or this computer.", + params: &[ + CONNECTION, + p("fingerprint", Text, "The computer's fingerprint, as profile_devices lists it."), + p("this_device", Flag, "Remove this computer."), + ], + example: r#"{"fingerprint":"3F2A 9C1E 77B0 D4E1"}"#, + run: Some(workspace::profile_revoke_device), + }, + Action { + tool: PersonTool::Change, + name: "profile_devices_acknowledge", + summary: "Mark the new computers added as the person as seen, so Crew stops pointing them out.", + params: &[ + CONNECTION, + req("fingerprints", Texts, "The new computers' fingerprints, as profile_devices lists them."), + ], + example: r#"{"fingerprints":["3F2A 9C1E 77B0 D4E1"]}"#, + run: Some(more::profile_devices_acknowledge), + }, + Action { + tool: PersonTool::Change, + name: "send", + summary: "Post one message in a channel as the person, with files from this computer and references to files on the server when asked.", + params: &[ + CONNECTION, + CHANNEL, + p("text", Text, "The message. It may be left out when the message carries files or references."), + p("attachments", Texts, "Files on this computer to share in the message, each by its full path, at most 10."), + p("references", Texts, "Files on the server to reference in the message, each by its full path, at most 10."), + ], + example: r##"{"channel":"#methods","text":"The run finished.","attachments":["~/results/plot.png","~/results/counts.csv"]}"##, + run: Some(messages::send_message), + }, + Action { + tool: PersonTool::Change, + name: "files_share", + summary: "Share a file from this computer in a channel.", + params: &[ + CONNECTION, + CHANNEL, + req("path", Text, "The file's path on this computer."), + p("text", Text, "A message to post with it."), + ], + example: r##"{"channel":"#methods","path":"~/results/plot.png"}"##, + run: Some(messages::files_share), + }, + Action { + tool: PersonTool::Change, + name: "files_download", + summary: "Download a file shared in a channel to this computer.", + params: &[ + CONNECTION, + req("file", Text, "The shared file's ID, or its name with channel."), + p("channel", Text, "The channel it was shared in, when file is a name."), + p("destination", Text, "Where to save it on this computer: a file, or a folder. Your Downloads folder when omitted."), + ], + example: r##"{"file":"plot.png","channel":"#methods","destination":"~/Downloads"}"##, + run: Some(messages::files_download), + }, + Action { + tool: PersonTool::Change, + name: "files_reference", + summary: "Post a reference to a file on the server in a channel.", + params: &[ + CONNECTION, + CHANNEL, + req("path", Text, "The file's path on the server."), + p("text", Text, "A message to post with it."), + ], + example: r##"{"channel":"#methods","path":"/data/lab/results.csv"}"##, + run: Some(messages::files_reference), + }, + Action { + tool: PersonTool::Change, + name: "files_pause", + summary: "Pause a file transfer.", + params: &[TRANSFER_ID], + example: r#"{"transfer_id":"..."}"#, + run: Some(more::files_pause), + }, + Action { + tool: PersonTool::Change, + name: "files_resume", + summary: "Resume a paused or stopped file transfer from where it stopped.", + params: &[ + TRANSFER_ID, + req("path", Text, "The file's path on this computer: the file being shared, or where the download goes."), + ], + example: r#"{"transfer_id":"...","path":"~/results/plot.png"}"#, + run: Some(more::files_resume), + }, + Action { + tool: PersonTool::Change, + name: "files_forget", + summary: "Forget a transfer that is not under way. For an unfinished download, its partial file is deleted too.", + params: &[TRANSFER_ID], + example: r#"{"transfer_id":"..."}"#, + run: Some(more::files_forget), + }, + Action { + tool: PersonTool::Change, + name: "tasks_ask", + summary: "Start an agent task in a channel, with its own Crew access.", + params: &[ + CONNECTION, + CHANNEL, + req("prompt", Text, "What to ask the agent."), + p("context_channels", Texts, "Other channels it may read."), + p("provider", Text, "The model's provider."), + p("model", Text, "The model."), + ], + example: r##"{"channel":"#methods","prompt":"Summarize this week's runs."}"##, + run: Some(tasks::tasks_ask), + }, + Action { + tool: PersonTool::Change, + name: "tasks_cancel", + summary: "Stop an agent task.", + params: &[CONNECTION, req("task", Text, "The task's ID.")], + example: r#"{"task":"run-..."}"#, + run: Some(tasks::tasks_cancel), + }, + Action { + tool: PersonTool::Change, + name: "grants_revoke", + summary: "Take back a chat's Crew access through a connection, or with reads, its permission to read the workspace as the person.", + params: &[ + CONNECTION, + req("session", Text, "The chat's ID, as grants_list lists it."), + p("reads", Flag, "Take back the chat's permission to read the workspace as the person instead."), + ], + example: r#"{"session":"20260924_2","reads":true}"#, + run: Some(workspace::grants_revoke), + }, + Action { + tool: PersonTool::Change, + name: "privacy_set_personal", + summary: "How this computer treats the workspace: private or public.", + params: &[CONNECTION, MODE, INSTITUTION], + example: r#"{"mode":"private","institution_id":"ucsf"}"#, + run: Some(workspace::privacy_set_personal), + }, + Action { + tool: PersonTool::Change, + name: "privacy_set_workspace", + summary: "The workspace's privacy for everyone (its host only): private, or public allowed.", + params: &[CONNECTION, MODE, INSTITUTION], + example: r#"{"mode":"private","institution_id":"ucsf"}"#, + run: Some(workspace::privacy_set_workspace), + }, + Action { + tool: PersonTool::Change, + name: "defaults_set", + summary: "The model Crew tasks start on from now on.", + params: &[ + req("provider", Text, "The model's provider, like versa_azure."), + req("model", Text, "The model, like gpt-5.5."), + ], + example: r#"{"provider":"versa_azure","model":"gpt-5.5"}"#, + run: Some(setup::defaults_set), + }, + Action { + tool: PersonTool::Change, + name: "defaults_clear", + summary: "Crew tasks go back to the default model for new chats.", + params: &[], + example: "{}", + run: Some(setup::defaults_clear), + }, +]; + +/// The action `name` of `tool`. +pub(crate) fn find(tool: PersonTool, name: &str) -> Option<&'static Action> { + ACTIONS + .iter() + .find(|action| action.tool == tool && action.name == name) +} + +/// The action named `name` in either tool, for `describe`. +pub(crate) fn find_any(name: &str) -> Option<&'static Action> { + ACTIONS.iter().find(|action| action.name == name) +} + +/// Whether the table and the plumbing's two lists name exactly the same actions, in order. +#[cfg(test)] +pub(crate) fn matches_the_tools() -> bool { + let reads: Vec<&str> = ACTIONS + .iter() + .filter(|action| action.tool == PersonTool::Read) + .map(|action| action.name) + .collect(); + let changes: Vec<&str> = ACTIONS + .iter() + .filter(|action| action.tool == PersonTool::Change) + .map(|action| action.name) + .collect(); + reads == biorouter::crew::tool_host::READ_ACTIONS + && changes == biorouter::crew::tool_host::CHANGE_ACTIONS +} + +fn kind_schema(kind: Kind) -> Value { + match kind { + Text => json!({"type": "string"}), + Flag => json!({"type": "boolean"}), + Number => json!({"type": "integer", "minimum": 0}), + Texts => json!({"type": "array", "items": {"type": "string"}}), + Choice(words) => json!({"type": "string", "enum": words}), + } +} + +/// The action's parameters as a JSON schema. +pub(crate) fn schema(action: &Action) -> Value { + let mut properties = Map::new(); + for param in action.params { + let mut schema = kind_schema(param.kind); + schema["description"] = json!(param.about); + properties.insert(param.name.to_owned(), schema); + } + let required: Vec<&str> = action + .params + .iter() + .filter(|param| param.required) + .map(|param| param.name) + .collect(); + json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false, + }) +} + +/// What `crew__read describe` answers for `action`. +pub(crate) fn describe(action: &Action) -> Value { + let example: Value = serde_json::from_str(action.example).unwrap_or_else(|_| json!({})); + json!({ + "action": action.name, + "tool": action.tool.name(), + "summary": action.summary, + "params": schema(action), + "example": {"action": action.name, "params": example}, + "available": action.run.is_some(), + "approval": match action.tool { + PersonTool::Read => "none, except the first read of a workspace's content in this chat, which asks the person once", + PersonTool::Change => "every call waits for the person to allow it once", + }, + }) +} + +fn kind_fits(kind: Kind, value: &Value) -> bool { + match kind { + Text => value.is_string(), + Flag => value.is_boolean(), + Number => value.is_u64(), + Texts => value + .as_array() + .is_some_and(|items| items.len() <= 64 && items.iter().all(Value::is_string)), + Choice(words) => value.as_str().is_some_and(|word| words.contains(&word)), + } +} + +fn kind_words(kind: Kind) -> String { + match kind { + Text => "text".into(), + Flag => "true or false".into(), + Number => "a whole number".into(), + Texts => "a list of texts".into(), + Choice(words) => format!("one of {}", words.join(", ")), + } +} + +/// Check `params` against `action`'s parameters: no unknown names, every required one there, +/// each of its kind. `null` counts as absent. The sentence names the parameter and how to see +/// them all. +pub(crate) fn check_params(action: &Action, params: &Value) -> Result<(), String> { + let Some(given) = params.as_object() else { + return Err("params must be an object of the action's parameters.".into()); + }; + let help = format!( + "Get its parameters with crew__read action describe, params {{\"action\": \"{}\"}}.", + action.name + ); + for (name, value) in given { + let Some(param) = action.params.iter().find(|param| param.name == name) else { + return Err(format!("{} takes no parameter {name}. {help}", action.name)); + }; + if !value.is_null() && !kind_fits(param.kind, value) { + return Err(format!( + "{} needs {name} as {}. {help}", + action.name, + kind_words(param.kind) + )); + } + if let Some(text) = value.as_str() { + if text.len() > 256 * 1024 { + return Err(format!("{name} is too long.")); + } + } + } + for param in action.params.iter().filter(|param| param.required) { + if given.get(param.name).is_none_or(Value::is_null) { + return Err(format!("{} needs {}. {help}", action.name, param.name)); + } + } + Ok(()) +} diff --git a/crates/biorouter-server/src/routes/crew_tools/channels.rs b/crates/biorouter-server/src/routes/crew_tools/channels.rs new file mode 100644 index 000000000..fb25da9fe --- /dev/null +++ b/crates/biorouter-server/src/routes/crew_tools/channels.rs @@ -0,0 +1,1078 @@ +//! The person tools' team, channel and membership actions (AO-12): what `biorouter crew teams`, +//! `channels`, `members add`, `invites`, `ownership` and `remove-member` do. +//! +//! The two reads (`teams_list`, `channels_list`) go through [`Ctx::read_content`]: the call's +//! privacy scope, the person's read consent, the snapshot narrowed to what the call may see, +//! then the raise. Every change resolves its targets in the person's own snapshot, parks one +//! card naming them, and then sends exactly those IDs, with `expected_username` and +//! `expected_name` guards where the broker takes them, through the request `biorouter crew` +//! sends. + +use anyhow::Result; +use futures::future::BoxFuture; +use serde_json::{json, Value}; + +use super::places::{ + channel_phrase, crossing, item, names_readable, place, public_safe, resolve, send, yours, Place, +}; +use super::{naming, said, tool_error, Card, ContentScope, Ctx, Resolved}; +use crate::routes::crew::names::SelectorKind; + +// ------------------------------------------------------------------------------------- reads + +/// `biorouter crew teams list`: the person's teams, and in an open workspace the ones they may +/// join. +pub(super) fn teams_list(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + ctx.read_content(|visible, _| { + let mut answer = json!({"teams": visible.get("teams").cloned().unwrap_or(json!([]))}); + if let Some(joinable) = visible.get("joinable_teams") { + answer["joinable_teams"] = joinable.clone(); + } + if let Some(total) = visible["totals"]["teams"].as_u64() { + answer["total"] = json!(total); + } + Ok(answer) + }) + .await + }) +} + +/// `biorouter crew channels list [--team T]`: the person's channels with their unread counts, +/// and in an open workspace the ones they may join. A public model sees no protected channel, +/// and of the channels it could join only the public-safe ones; the rest are counted. +pub(super) fn channels_list(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let team_text = ctx.text("team"); + ctx.read_content(|visible, scope| { + let team = team_text + .as_deref() + .map(|text| { + Ctx::resolve(visible, SelectorKind::Team, text) + .map_err(|error| naming(error, scope)) + }) + .transpose()?; + let in_team = |channel: &Value| { + team.as_ref() + .is_none_or(|team| channel["team_id"].as_str() == Some(team.id.as_str())) + }; + let channels: Vec = visible["channels"] + .as_array() + .into_iter() + .flatten() + .filter(|channel| in_team(channel)) + .map(|channel| { + let mut channel = channel.clone(); + let unread = channel["id"] + .as_str() + .and_then(|id| visible["unread"].get(id)) + .cloned(); + if let Some(unread) = unread { + channel["unread"] = unread; + } + channel + }) + .collect(); + let mut answer = json!({"channels": channels}); + // `visible` is already narrowed (`public_snapshot`): to a public model, only the + // public-safe channels the person may join are left, and the rest are counted. + if let Some(joinable) = visible["joinable_channels"].as_array() { + let kept: Vec<&Value> = + joinable.iter().filter(|channel| in_team(channel)).collect(); + answer["joinable_channels"] = json!(kept); + } + if scope.public_only { + let withheld = ["channels", "joinable_channels"] + .iter() + .filter_map(|key| visible["withheld"][*key].as_u64()) + .sum::(); + answer["withheld"] = json!({"channels": withheld}); + } + Ok(answer) + }) + .await + }) +} + +// ----------------------------------------------------------------------------------- helpers + +/// A forgiving comparison key: lower case, a leading `#` dropped, and every run of spaces or +/// punctuation one `-` (`Analysis Lab` is `analysis-lab`), as `biorouter crew` matches what a +/// person may join. +fn loose_key(text: &str) -> String { + let lowered = text.trim().trim_start_matches('#').to_lowercase(); + let mut key = String::with_capacity(lowered.len()); + let mut gap = false; + for c in lowered.chars() { + if c.is_alphanumeric() { + if gap && !key.is_empty() { + key.push('-'); + } + gap = false; + key.push(c); + } else { + gap = true; + } + } + key +} + +fn names_of(item: &Value) -> Vec { + ["name", "display_name", "handle"] + .iter() + .filter_map(|field| item[*field].as_str()) + .map(loose_key) + .collect() +} + +/// What the person may join in an open workspace (OW-2): the snapshot's `joinable_teams` or +/// `joinable_channels`, matched as `biorouter crew teams join` matches them. A channel may be +/// named `team/channel`. +fn joinable<'a>(ctx: &Ctx, place: &'a Place, team: bool, text: &str) -> Result<&'a Value> { + let snapshot = &place.snapshot; + let (key, noun) = if team { + ("joinable_teams", "team") + } else { + ("joinable_channels", "channel") + }; + let Some(candidates) = snapshot[key].as_array() else { + return Err(said( + None, + if team { + "Only an open workspace lets members join teams themselves. Ask the team's creator or the host to add you." + } else { + "Only an open workspace lets members join channels themselves. Ask the channel's owner or the host to add you." + }, + )); + }; + let literal = uuid::Uuid::parse_str(text.trim()).is_ok(); + let matches: Vec<&Value> = if literal { + candidates + .iter() + .filter(|item| item["id"].as_str() == Some(text.trim())) + .collect() + } else { + let (team_part, name) = match (team, text.split_once('/')) { + (false, Some((team_part, name))) => (Some(team_part), name), + _ => (None, text), + }; + candidates + .iter() + .filter(|item| names_of(item).contains(&loose_key(name))) + .filter(|item| { + team_part.is_none_or(|wanted| { + item["team_id"] + .as_str() + .and_then(|id| { + super::places::item(snapshot, "teams", id) + .or_else(|| super::places::item(snapshot, "joinable_teams", id)) + }) + .is_some_and(|found| names_of(found).contains(&loose_key(wanted))) + }) + }) + .collect() + }; + match (matches.as_slice(), names_readable(ctx, place)) { + ([only], _) => Ok(*only), + ([], _) => Err(said( + Some("unknown_name"), + format!("No {noun} you can join is named {text}."), + )), + (several, Some(scope)) => Err(naming( + said( + Some("ambiguous_name"), + format!( + "More than one {noun} you can join matches {text}: {}. Name its team too, like analysis-lab/methods.", + several + .iter() + .map(|item| item["name"].as_str().unwrap_or_default().to_owned()) + .collect::>() + .join("; ") + ), + ), + scope, + )), + (_, None) => Err(said( + Some("ambiguous_name"), + format!("More than one {noun} you can join matches {text}. Name it exactly, or by its team too."), + )), + } +} + +/// The name the workspace stores a team or channel under, which `expected_name` sends so the +/// broker refuses a deletion of something renamed since the card named it (OW-4). +fn stored_name(place: &Place, key: &str, id: &str) -> Option { + item(&place.snapshot, key, id) + .and_then(|entry| entry["name"].as_str()) + .map(|name| name.trim_start_matches('#').to_owned()) + .filter(|name| !name.is_empty()) +} + +/// A person's canonical username, from the resolver or the snapshot, never guessed. +fn username_of(place: &Place, person: &Resolved) -> Result { + if let Some(username) = &person.username { + return Ok(username.clone()); + } + place.snapshot["principals"] + .as_array() + .into_iter() + .chain(place.snapshot["former_principals"].as_array()) + .flatten() + .find(|principal| principal["id"].as_str() == Some(person.id.as_str())) + .and_then(|principal| principal["username"].as_str()) + .map(str::to_owned) + .ok_or_else(|| { + said( + None, + "Name the person as @username; that ID isn't a member of this workspace.", + ) + }) +} + +/// `#a`, `#a and #b`, `#a, #b and #c`. +fn and_list(items: &[String]) -> String { + match items { + [] => String::new(), + [only] => only.clone(), + [rest @ .., last] => format!("{} and {last}", rest.join(", ")), + } +} + +/// A channel selector confined to `team` when both are names: `methods` with team `Lab` is +/// `Lab/methods`, so a same-named channel of another team is never chosen. +fn channel_in_team(team: Option<&str>, channel: &str) -> String { + match team.map(str::trim) { + Some(team) + if !team.is_empty() + && !team.contains('/') + && !channel.contains('/') + && uuid::Uuid::parse_str(team).is_err() + && uuid::Uuid::parse_str(channel.trim()).is_err() => + { + format!("{team}/{}", channel.trim()) + } + _ => channel.to_owned(), + } +} + +// ----------------------------------------------------------------------------------- teams + +/// `biorouter crew teams create NAME`. +pub(super) fn teams_create(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let name = ctx.required("name")?; + let place = place(ctx).await?; + let mut card = place + .card(|here| format!("Create the team {name} in {here} as you")) + .show("name", name.clone()); + if let Some(line) = crossing(ctx, &place, false).await { + card = card.line(line); + } + ctx.approve(card).await?; + let result = send( + ctx, + &place.connection, + "team.create", + json!({"name": name}), + 0, + ) + .await?; + Ok(place.answer(json!({ + "created": true, + "team_id": result["id"], + "name": result["name"], + "general_channel_id": result["general_channel_id"], + }))) + }) +} + +/// `biorouter crew teams rename TEAM NAME`. +pub(super) fn teams_rename(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("team")?; + let name = ctx.required("name")?; + let place = place(ctx).await?; + let team = resolve(ctx, &place, SelectorKind::Team, &typed)?; + let mut card = place + .card(|here| { + format!( + "Rename the team {} in {here} to {name}, for everyone", + team.label + ) + }) + .show("team", team.label.clone()) + .show("name", name.clone()); + if let Some(line) = crossing(ctx, &place, false).await { + card = card.line(line); + } + ctx.approve(card).await?; + let result = send( + ctx, + &place.connection, + "team.rename", + json!({"team_id": team.id, "name": name}), + 0, + ) + .await?; + Ok(place.answer(json!({ + "renamed": true, + "team_id": team.id, + "name": result["display_name"].as_str().or_else(|| result["name"].as_str()).unwrap_or(&name), + }))) + }) +} + +/// `biorouter crew teams join TEAM` (OW-2): join a team, with its `#general`, in an open +/// workspace. +pub(super) fn teams_join(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("team")?; + let place = place(ctx).await?; + let found = joinable(ctx, &place, true, &typed)?; + let id = found["id"].as_str().unwrap_or_default().to_owned(); + let label = found["display_name"] + .as_str() + .or_else(|| found["name"].as_str()) + .unwrap_or("the team") + .to_owned(); + ctx.approve( + place + .card(|here| format!("Join the team {label} in {here}, with its #general, as you")) + .show("team", label.clone()), + ) + .await?; + send( + ctx, + &place.connection, + "team.join", + json!({"team_id": id}), + 0, + ) + .await?; + Ok(place.answer(json!({"joined": true, "team_id": id}))) + }) +} + +/// `biorouter crew teams delete TEAM` (OW-4): every channel of the team goes with it, its +/// `#general` among them. Guarded by the name the card showed. +pub(super) fn teams_delete(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("team")?; + let place = place(ctx).await?; + let team = resolve(ctx, &place, SelectorKind::Team, &typed)?; + let expected = stored_name(&place, "teams", &team.id); + ctx.approve( + place + .card(|here| { + format!( + "Delete the team {} in {here} for everyone; this cannot be undone", + team.label + ) + }) + .line("All its channels, #general among them, and their messages and files are removed from the workspace.") + .show("team", team.label.clone()), + ) + .await?; + let mut params = json!({"team_id": team.id}); + if let Some(name) = expected { + params["expected_name"] = json!(name); + } + let result = send(ctx, &place.connection, "team.delete", params, 0).await?; + Ok(place + .answer(json!({"deleted": true, "team_id": team.id, "channels": result["channels"]}))) + }) +} + +// -------------------------------------------------------------------------------- channels + +/// `biorouter crew channels create NAME --team T [--classification C]`. A channel is +/// restricted unless the call says public-safe, as the command makes it. +pub(super) fn channels_create(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let name = ctx.required("name")?; + let classification = ctx + .text("classification") + .unwrap_or_else(|| "restricted".to_owned()); + let place = place(ctx).await?; + let team = match ctx.text("team") { + Some(typed) => resolve(ctx, &place, SelectorKind::Team, &typed)?, + None => { + let teams = place.snapshot["teams"] + .as_array() + .cloned() + .unwrap_or_default(); + let [only] = teams.as_slice() else { + return Err(said(None, "Name the team to create the channel in.")); + }; + Resolved { + id: only["id"].as_str().unwrap_or_default().to_owned(), + label: only["display_name"] + .as_str() + .or_else(|| only["name"].as_str()) + .unwrap_or("your team") + .to_owned(), + username: None, + } + } + }; + let shown = format!("#{}", name.trim().trim_start_matches('#')); + let public = classification == "public_safe"; + let mut card = place + .card(|here| format!("Create {shown} ({}) in {here} as you", team.label)) + .line(if public { + "It is public-safe: where the workspace allows Public, public models may read it." + } else { + "It is restricted: public models never read it." + }) + .show("name", shown.clone()) + .show("team", team.label.clone()) + .show("classification", classification.clone()); + if let Some(line) = crossing(ctx, &place, public).await { + card = card.line(line); + } + ctx.approve(card).await?; + let result = send( + ctx, + &place.connection, + "channel.create", + json!({"name": name, "team_id": team.id, "classification": classification}), + 0, + ) + .await?; + Ok(place.answer(json!({ + "created": true, + "channel_id": result["id"], + "team_id": team.id, + "name": result["name"], + "classification": result["classification"], + }))) + }) +} + +/// `biorouter crew channels rename CHANNEL NAME`. +pub(super) fn channels_rename(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("channel")?; + let name = ctx.required("name")?; + let place = place(ctx).await?; + let channel = resolve(ctx, &place, SelectorKind::Channel, &typed)?; + let shown = format!("#{}", name.trim().trim_start_matches('#')); + let mut card = place + .card(|here| { + format!( + "Rename {} in {here} to {shown}, for everyone", + place.channel(&channel) + ) + }) + .show("channel", channel.label.clone()) + .show("name", shown); + if let Some(line) = crossing(ctx, &place, public_safe(&place, &channel.id)).await { + card = card.line(line); + } + ctx.approve(card).await?; + let result = send( + ctx, + &place.connection, + "channel.rename", + json!({"channel_id": channel.id, "name": name}), + 0, + ) + .await?; + Ok( + place + .answer(json!({"renamed": true, "channel_id": channel.id, "name": result["name"]})), + ) + }) +} + +/// `biorouter crew channels archive CHANNEL`: nobody can post in it after this. +pub(super) fn channels_archive(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("channel")?; + let place = place(ctx).await?; + let channel = resolve(ctx, &place, SelectorKind::Channel, &typed)?; + ctx.approve( + place + .card(|here| { + format!( + "Archive {} in {here} for everyone; this cannot be undone", + place.channel(&channel) + ) + }) + .line("Nobody can post in it after this.") + .show("channel", channel.label.clone()), + ) + .await?; + send( + ctx, + &place.connection, + "channel.archive", + json!({"channel_id": channel.id}), + 0, + ) + .await?; + Ok(place.answer(json!({"archived": true, "channel_id": channel.id}))) + }) +} + +/// `biorouter crew channels join CHANNEL` (OW-2): a channel of one of the person's teams, in +/// an open workspace. +pub(super) fn channels_join(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("channel")?; + let place = place(ctx).await?; + let found = joinable(ctx, &place, false, &typed)?; + let id = found["id"].as_str().unwrap_or_default().to_owned(); + let mut label = format!("#{}", found["name"].as_str().unwrap_or_default()); + if let Some(team) = found["team_id"] + .as_str() + .and_then(|team| item(&place.snapshot, "teams", team)) + .and_then(|team| { + team["display_name"] + .as_str() + .or_else(|| team["name"].as_str()) + }) + { + label = format!("{label} ({team})"); + } + ctx.approve( + place + .card(|here| format!("Join {label} in {here} as you")) + .show("channel", label.clone()), + ) + .await?; + send( + ctx, + &place.connection, + "channel.join", + json!({"channel_id": id}), + 0, + ) + .await?; + Ok(place.answer(json!({"joined": true, "channel_id": id}))) + }) +} + +/// `biorouter crew channels delete CHANNEL` (OW-4): the channel goes for everyone, with its +/// messages and files. A team's `#general` goes only with its team. +pub(super) fn channels_delete(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("channel")?; + let place = place(ctx).await?; + let channel = resolve(ctx, &place, SelectorKind::Channel, &typed)?; + let phrase = channel_phrase(&place.snapshot, &channel); + let general_of = place.snapshot["teams"] + .as_array() + .into_iter() + .flatten() + .any(|team| team["general_channel_id"].as_str() == Some(channel.id.as_str())); + if general_of { + // The phrase names the channel's team: only to a call that may be shown names + // from this workspace (AO-12), and only as content returns. + return Err(match names_readable(ctx, &place) { + Some(scope) => naming( + said( + None, + format!("{phrase} belongs to its team. Delete the team instead, with crew__change action teams_delete."), + ), + scope, + ), + None => said( + None, + "That channel belongs to its team. Delete the team instead, with crew__change action teams_delete.", + ), + }); + } + let expected = stored_name(&place, "channels", &channel.id); + ctx.approve( + place + .card(|here| { + format!( + "Delete {} in {here} for everyone; this cannot be undone", + place.channel(&channel) + ) + }) + .line("Its messages and files are removed from the workspace.") + .show("channel", channel.label.clone()), + ) + .await?; + let mut params = json!({"channel_id": channel.id}); + if let Some(name) = expected { + params["expected_name"] = json!(name); + } + send(ctx, &place.connection, "channel.delete", params, 0).await?; + Ok(place.answer(json!({"deleted": true, "channel_id": channel.id}))) + }) +} + +/// `biorouter crew channels mark-read CHANNEL`: read up to the newest message. +pub(super) fn channels_read(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("channel")?; + let place = place(ctx).await?; + let channel = resolve(ctx, &place, SelectorKind::Channel, &typed)?; + yours(&place.snapshot, &channel, &typed)?; + let newest = ctx + .crew()? + .human_request( + &place.connection.id, + "messages.history", + json!({"channel_id": channel.id, "latest": true, "limit": 1}), + None, + ) + .await?; + let Some(cursor) = newest["cursor"].as_str().map(str::to_owned) else { + return Ok(place.answer(json!({"read": false, "channel_id": channel.id, + "note": "It has no messages to mark read."}))); + }; + ctx.approve( + place + .card(|here| format!("Mark {} in {here} read", place.channel(&channel))) + .show("channel", channel.label.clone()), + ) + .await?; + send( + ctx, + &place.connection, + "channel.read", + json!({"channel_id": channel.id, "sequence": cursor}), + 0, + ) + .await?; + Ok(place.answer(json!({"read": true, "channel_id": channel.id}))) + }) +} + +// ------------------------------------------------------------------------------ membership + +/// `biorouter crew members add @bob --team T [--channel C ...]` or `--channel C ...`: direct +/// add, guarded by the username the card named. Several channels are added one at a time, each +/// with its own idempotency key; a refusal part way says what was already added. +pub(super) fn members_add(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("member")?; + let team_text = ctx.text("team"); + let channel_texts = ctx.texts("channels"); + if team_text.is_none() && channel_texts.is_empty() { + return Err(said(None, "Say where to add them: a team, or channels.")); + } + let place = place(ctx).await?; + let person = resolve(ctx, &place, SelectorKind::Person, &typed)?; + let username = username_of(&place, &person)?; + let team = team_text + .as_deref() + .map(|text| resolve(ctx, &place, SelectorKind::Team, text)) + .transpose()?; + let mut channels = Vec::with_capacity(channel_texts.len()); + for text in &channel_texts { + channels.push(resolve( + ctx, + &place, + SelectorKind::Channel, + &channel_in_team(team_text.as_deref(), text), + )?); + } + ctx.approve(members_add_card(&place, &person, team.as_ref(), &channels)) + .await?; + if let Some(team) = &team { + let channel_ids: Vec<&str> = + channels.iter().map(|channel| channel.id.as_str()).collect(); + let result = send( + ctx, + &place.connection, + "team.add_member", + json!({ + "team_id": team.id, + "principal_id": person.id, + "expected_username": username, + "channel_ids": channel_ids, + }), + 0, + ) + .await?; + return Ok(place.answer(json!({ + "added": true, + "principal_id": person.id, + "team_id": team.id, + "added_channels": result["added_channels"], + }))); + } + let mut added = Vec::new(); + for (step, channel) in channels.iter().enumerate() { + let answer = send( + ctx, + &place.connection, + "channel.add_member", + json!({ + "channel_id": channel.id, + "principal_id": person.id, + "expected_username": username, + }), + step, + ) + .await; + match answer { + Ok(result) => { + if result["already_member"].as_bool() != Some(true) { + added.push(channel.id.clone()); + } + } + Err(error) if step > 0 => { + let reason = tool_error(error, ctx.text("connection")).await; + return Err(stopped_adding( + names_readable(ctx, &place), + &person, + &channels, + step, + &reason, + )); + } + Err(error) => return Err(error), + } + } + Ok( + place + .answer(json!({"added": true, "principal_id": person.id, "added_channels": added})), + ) + }) +} + +/// The card a direct add parks: `person` joining `team`, `channels`, or the team and those of +/// its channels, by the names resolved in the person's snapshot. +fn members_add_card( + place: &Place, + person: &Resolved, + team: Option<&Resolved>, + channels: &[Resolved], +) -> Card { + let channel_names: Vec = channels + .iter() + .map(|channel| channel.label.clone()) + .collect(); + let mut card = place + .card(|here| match (team, channel_names.is_empty()) { + (Some(team), true) => { + format!("Add {} to the team {} in {here}", person.label, team.label) + } + (Some(team), false) => format!( + "Add {} to the team {} and to {} in {here}", + person.label, + team.label, + and_list(&channel_names) + ), + (None, _) => format!( + "Add {} to {} in {here}", + person.label, + and_list(&channel_names) + ), + }) + .line("They join at once; nothing waits for them to accept.") + .show("member", person.label.clone()); + if let Some(team) = team { + card = card.show("team", team.label.clone()); + } + if !channel_names.is_empty() { + card = card.show("channels", json!(channel_names)); + } + card +} + +/// What a direct add says when `channels[stopped]` refused it after the ones before it were +/// added. By name only to a call that may be shown names from the workspace (`names`), and +/// then only as content returns; to any other call, by count, so a resolved person's name or a +/// channel's name typed as its ID never reaches a model that may not read them (AO-12). +pub(super) fn stopped_adding( + names: Option<&ContentScope>, + person: &Resolved, + channels: &[Resolved], + stopped: usize, + reason: &anyhow::Error, +) -> anyhow::Error { + let Some(scope) = names else { + return said( + None, + format!( + "Added them to {stopped} of the {} channels, then one was refused: {reason}", + channels.len() + ), + ); + }; + let done: Vec = channels[..stopped] + .iter() + .map(|channel| channel.label.clone()) + .collect(); + naming( + said( + None, + format!( + "Added {} to {}, then stopped at {}: {reason}", + person.label, + and_list(&done), + channels[stopped].label + ), + ), + scope, + ) +} + +/// `biorouter crew invites create @bob --team T` or `--channel C`. +pub(super) fn invites_send(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("member")?; + let (kind, selector, text) = match (ctx.text("team"), ctx.text("channel")) { + (Some(team), None) => ("team", SelectorKind::Team, team), + (None, Some(channel)) => ("channel", SelectorKind::Channel, channel), + _ => return Err(said(None, "Invite them to one place: a team or a channel.")), + }; + let place = place(ctx).await?; + let person = resolve(ctx, &place, SelectorKind::Person, &typed)?; + let target = resolve(ctx, &place, selector, &text)?; + let label = if kind == "channel" { + place.channel(&target) + } else { + format!("the team {}", target.label) + }; + ctx.approve( + place + .card(|here| format!("Invite {} to {label} in {here}", person.label)) + .line("They join when they accept it.") + .show("member", person.label.clone()) + .show(kind, target.label.clone()), + ) + .await?; + let mut params = json!({"kind": kind, "target_id": target.id, "principal_id": person.id}); + if let Some(username) = &person.username { + params["expected_username"] = json!(username); + } + let result = send(ctx, &place.connection, "invitation.create", params, 0).await?; + let mut answer = json!({ + "invited": true, + "invitation_id": result["id"], + "kind": kind, + "target_id": target.id, + "principal_id": person.id, + }); + if kind == "channel" { + answer["channel_id"] = json!(target.id); + } + Ok(place.answer(answer)) + }) +} + +/// "Analysis Lab", or "#methods in Analysis Lab", as an invitation names its target. +fn invitation_label(invitation: &Value) -> String { + let target = invitation["target_name"] + .as_str() + .filter(|name| !name.is_empty()); + let team = invitation["team_name"] + .as_str() + .filter(|name| !name.is_empty()); + match (invitation["kind"].as_str(), target, team) { + (Some("channel"), Some(channel), Some(team)) => format!("#{channel} ({team})"), + (Some("channel"), Some(channel), None) => format!("#{channel}"), + (Some("channel"), None, _) => "a channel".to_owned(), + (_, Some(team), _) => team.to_owned(), + (_, None, _) => "a team".to_owned(), + } +} + +fn invitation_matches(invitation: &Value, text: &str) -> bool { + if invitation["id"].as_str() == Some(text) { + return true; + } + let target = invitation["target_name"].as_str().map(loose_key); + let team = invitation["team_name"].as_str().map(loose_key); + match (invitation["kind"].as_str(), text.split_once('/')) { + (Some("channel"), Some((team_part, channel_part))) => { + team.as_deref() == Some(loose_key(team_part).as_str()) + && target.as_deref() == Some(loose_key(channel_part).as_str()) + } + (_, Some(_)) => false, + (_, None) => target.as_deref() == Some(loose_key(text).as_str()), + } +} + +/// `biorouter crew invites accept [NAME|ID]`: one of the person's own invitations that has not +/// expired; with none named, the only one. +pub(super) fn invites_accept(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.text("invitation"); + let place = place(ctx).await?; + let actor = place.snapshot["actor"]["id"].as_str(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()); + let pending: Vec<&Value> = place.snapshot["invitations"] + .as_array() + .into_iter() + .flatten() + .filter(|invitation| actor.is_some() && invitation["principal_id"].as_str() == actor) + .filter(|invitation| { + invitation["expired"].as_bool() != Some(true) + && invitation["expires_at"] + .as_u64() + .is_none_or(|expires| expires > now) + }) + .collect(); + let matching: Vec<&Value> = match &typed { + None => pending.clone(), + Some(text) => pending + .iter() + .copied() + .filter(|invitation| invitation_matches(invitation, text)) + .collect(), + }; + let chosen = match (matching.as_slice(), &typed) { + ([one], _) => *one, + ([], None) => return Err(said(None, "You have no pending invitations.")), + ([], Some(text)) => { + return Err(said( + Some("unknown_name"), + format!("You have no pending invitation to {text}. List them with crew__read action invites_list."), + )) + } + (several, _) => { + return Err(match names_readable(ctx, &place) { + Some(scope) => naming( + said( + Some("ambiguous_name"), + format!( + "Name the invitation to accept: {}.", + several + .iter() + .map(|invitation| invitation_label(invitation)) + .collect::>() + .join("; ") + ), + ), + scope, + ), + None => said( + Some("ambiguous_name"), + "You have more than one pending invitation. Name the one to accept.", + ), + }) + } + }; + let id = chosen["id"].as_str().unwrap_or_default().to_owned(); + let label = invitation_label(chosen); + ctx.approve( + place + .card(|here| format!("Accept the invitation to {label} in {here}")) + .show("invitation", label.clone()), + ) + .await?; + send( + ctx, + &place.connection, + "invitation.accept", + json!({"invitation_id": id}), + 0, + ) + .await?; + let mut answer = json!({"accepted": true, "invitation_id": id, "kind": chosen["kind"]}); + // The channel it lets the person into, from their own invitation: the chat's row opens + // Crew there (WI-16). + if let (Some("channel"), Some(channel)) = + (chosen["kind"].as_str(), chosen["target_id"].as_str()) + { + answer["channel_id"] = json!(channel); + } + Ok(place.answer(answer)) + }) +} + +/// `biorouter crew ownership offer CHANNEL @successor`. +pub(super) fn ownership_offer(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let channel_text = ctx.required("channel")?; + let successor_text = ctx.required("successor")?; + let place = place(ctx).await?; + let channel = resolve(ctx, &place, SelectorKind::Channel, &channel_text)?; + let successor = resolve(ctx, &place, SelectorKind::Person, &successor_text)?; + ctx.approve( + place + .card(|here| { + format!( + "Offer ownership of {} in {here} to {}", + place.channel(&channel), + successor.label + ) + }) + .line("When they accept, they own it and you leave the channel.") + .show("channel", channel.label.clone()) + .show("successor", successor.label.clone()), + ) + .await?; + let mut params = json!({"channel_id": channel.id, "successor_id": successor.id}); + if let Some(username) = &successor.username { + params["expected_username"] = json!(username); + } + send(ctx, &place.connection, "channel.transfer", params, 0).await?; + Ok(place.answer( + json!({"offered": true, "channel_id": channel.id, "successor_id": successor.id}), + )) + }) +} + +/// `biorouter crew ownership accept CHANNEL`. +pub(super) fn ownership_accept(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let typed = ctx.required("channel")?; + let place = place(ctx).await?; + let channel = resolve(ctx, &place, SelectorKind::Channel, &typed)?; + ctx.approve( + place + .card(|here| format!("Take ownership of {} in {here}", place.channel(&channel))) + .show("channel", channel.label.clone()), + ) + .await?; + send( + ctx, + &place.connection, + "transfer.accept", + json!({"channel_id": channel.id}), + 0, + ) + .await?; + Ok(place.answer(json!({"owner": true, "channel_id": channel.id}))) + }) +} + +/// `biorouter crew remove-member CHANNEL @bob [--former]`, guarded by the username the card +/// named. +pub(super) fn remove_member(ctx: &Ctx) -> BoxFuture<'_, Result> { + Box::pin(async move { + let channel_text = ctx.required("channel")?; + let member_text = ctx.required("member")?; + let kind = if ctx.flag("former") == Some(true) { + SelectorKind::FormerPerson + } else { + SelectorKind::Person + }; + let place = place(ctx).await?; + let channel = resolve(ctx, &place, SelectorKind::Channel, &channel_text)?; + let person = resolve(ctx, &place, kind, &member_text)?; + ctx.approve( + place + .card(|here| { + format!( + "Remove {} from {} in {here} for everyone; this cannot be undone", + person.label, + place.channel(&channel) + ) + }) + .line("They lose access to its messages and files.") + .show("channel", channel.label.clone()) + .show("member", person.label.clone()), + ) + .await?; + let mut params = json!({"channel_id": channel.id, "principal_id": person.id}); + if let Some(username) = &person.username { + params["expected_username"] = json!(username); + } + send(ctx, &place.connection, "membership.revoke", params, 0).await?; + Ok(place + .answer(json!({"removed": true, "channel_id": channel.id, "principal_id": person.id}))) + }) +} diff --git a/crates/biorouter-server/src/routes/crew_tools/hostkey.rs b/crates/biorouter-server/src/routes/crew_tools/hostkey.rs new file mode 100644 index 000000000..1c8984b96 --- /dev/null +++ b/crates/biorouter-server/src/routes/crew_tools/hostkey.rs @@ -0,0 +1,1308 @@ +//! A server's host key from a chat (the owner's D1, D2 and D6 rulings on AO-12, WI-2): the step a +//! host-key refusal names, the fingerprints the daemon read, and the host-key card a chat shows. +//! +//! **A model never accepts a host key.** Crew forces `StrictHostKeyChecking yes` on every hop, +//! so a server whose key this computer does not know is refused until the person checks the key +//! with their server's IT team and either adds it in a terminal of their own or, for an unknown +//! key the card shows a fingerprint for, chooses Trust this key on the card (the owner's D2 +//! ruling: `POST /crew/host-keys/trust`, with the person's proof, which no tool action reaches; +//! the daemon reads the key again and writes it only when its fingerprint is the one shown). The +//! card is [`CrewStepRequest::HostKey`], and every field on it comes from the daemon: +//! +//! * the refusal's own words: a refused action's [`SshFailure`] (`detail`, which the transport +//! summarizes with every fingerprint first, and `host`, the name OpenSSH looked the key up by), +//! or a failed setup job's [`biorouter::crew::SetupError`] (whose `detail` is OpenSSH's raw +//! words), read by [`fingerprints`] the way the desktop's `hostKeyFingerprints` and the +//! transport read them; +//! * for an unknown key whose refusal printed no fingerprint (OpenSSH prints none under strict +//! checking), the key the daemon reads itself from the same host and port with `ssh-keyscan` +//! ([`KeyRefusal::read_offered`]), and only when the refused hop is the route's destination +//! reached straight from this computer ([`reached_directly`]): no jump host, no ProxyCommand, +//! no HostKeyAlias. Behind a jump host a scan from here could reach another machine, so none is +//! made, the card shows no fingerprint, and any key the person adds for the host counts. A key +//! the daemon read is never called the one the server offered: the model, the job's detail and +//! the refusal say this computer read it ([`READ_HERE`]). +//! +//! Never anything from the model's parameters, which can name a server but not its key. +//! +//! What happens next: +//! +//! * **An unknown key** parks the card and waits ([`KEY_WAIT`], Stop or a dismissal) while the +//! daemon reads the known hosts files each second (`ssh-keygen -F`, [`key_added`]) for the key +//! the card shows: the `UserKnownHostsFile` and `GlobalKnownHostsFile` lists `ssh -G` resolves +//! for the step's login ([`biorouter::crew::host_keys::known_hosts_files`]), the first of +//! which is where Trust this key writes. Once the person has added it, the card is withdrawn +//! and the step is tried again, once ([`try_again`]): the action runs as the model called it, +//! and the approval the person gave earlier in this call is used once more, for the identical +//! card only, while an approval card would still have been answerable (`APPROVAL_TTL`). Any +//! other card the retry needs is asked (`retrying_after_a_host_key` and +//! `allowed_for_this_retry` in the parent module). When the call was `auth` and the key +//! stopped the setup job it signed the person in to, the job is what is tried again +//! ([`restart_job`]). +//! * **A changed key** never waits and never offers a fix. Its card (the details for IT) is +//! published and released at once, and the call answers with [`CHANGED_STEP`]. +//! * **No card can be shown** (`biorouter serve`, ACP, `POST /agent/call_tool`): the refusal is +//! said in words, as before. +//! +//! The model is told the fingerprint (it is not a secret) and [`UNKNOWN_STEP`] or +//! [`CHANGED_STEP`], which forbid it to add, scan or accept a key itself. In Fully Automatic mode +//! criterion 7 of `security::sensitive_ops` backs that: an agent shell that would trust a key asks +//! first. + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use biorouter::conversation::message::CrewStepRequest; +use biorouter::crew::authentication::{HandoffFailed, InvitationRefused, JoinRefused}; +use biorouter::crew::host_keys::{self, Resolved}; +use biorouter::crew::{ + Connection, SetupIntent, SetupRefused, SetupRequest, SetupState, SetupStatus, SshFailure, + SshFailureKind, +}; +use futures::future::BoxFuture; +use serde_json::{json, Value}; + +use super::{ + catalog, job_answer, job_target, remember_job_target, said, tool_error, Ctx, Said, StepEnd, +}; + +/// The code of a refusal on a host key this computer does not know yet. +const UNKNOWN: &str = "crew_ssh_host_key_unknown"; +/// The code of a refusal on a host key that differs from the one this computer trusts. +const CHANGED: &str = "crew_ssh_host_key_changed"; +/// How long an unknown key's card waits for the person to add the key (capped by `park_step`). +const KEY_WAIT: Duration = Duration::from_secs(600); +/// How often the known hosts files are read while the card waits. +const KEY_POLL: Duration = Duration::from_secs(1); +/// The key types `ssh-keyscan -t` is asked for, as OpenSSH names them in "No ED25519 host key is +/// known for …". +const KEY_TYPES: &[&str] = &["ed25519", "ecdsa", "rsa", "dsa"]; + +/// The step an unknown key's refusal names for the model. +pub(crate) const UNKNOWN_STEP: &str = "Do not add, scan or accept the key yourself (no ssh-keyscan, no StrictHostKeyChecking=accept-new or no, no edits to known_hosts); Crew never accepts a host key for the person. They check the fingerprint with their server's IT team, then choose Trust this key on the card or add the key in a terminal of their own; then try again."; +/// [`UNKNOWN_STEP`] where no card can open for the call (`biorouter serve`, ACP, +/// `POST /agent/call_tool`): the person adds the key themselves. +pub(crate) const UNKNOWN_STEP_NO_CARD: &str = "Do not add, scan or accept the key yourself (no ssh-keyscan, no StrictHostKeyChecking=accept-new or no, no edits to known_hosts); Crew never accepts a host key for the person. They check the fingerprint with their server's IT team and add the key in a terminal of their own, or trust it in Crew in the desktop app; then try again."; +/// The step a changed key's refusal names for the model: the IT wording, with no fix. +pub(crate) const CHANGED_STEP: &str = "Do not add, scan, remove or accept a key yourself (no ssh-keyscan, no ssh-keygen -R, no StrictHostKeyChecking=accept-new or no, no edits to known_hosts). The server's host key changed since this computer trusted it, so nothing connects until the person's server IT team confirms the change."; +/// Said when a setup job a sign-in let go on stopped on a host key, the person added the key, +/// and the job can't be started again from here: only a check is started again by itself, since +/// its whole request is the server it checks; any other job's choices were the person's, on the +/// card of the action that started it. +pub(crate) const RESTART_STEP: &str = "The key is in the known hosts file now, but this setup can't be started again from here. Start it again with the action that started it; the person approves it again."; +/// How a refusal's detail labels a key the daemon read itself with `ssh-keyscan` +/// ([`KeyRefusal::detail_with_offered`]). Never the transport's "Offered host key fingerprint": +/// no server offered it on the refused connection; this computer asked for it. +const READ_HERE: &str = "Host key fingerprint read by this computer:"; + +tokio::task_local! { + /// Set while the step a host-key card waited for is tried again, so that try is the only + /// one: a second refusal is said, not carded. + static TRYING_AGAIN: (); +} + +/// The step a host-key refusal names for the model ([`super::next_step`]), or `None` when `code` +/// is not a host key's. +pub(super) fn next_step(code: &str, _connection: Option<&str>) -> Option { + step_for(code, card_can_open()) +} + +/// [`next_step`], where `card` says whether a host-key card can open in this process. +fn step_for(code: &str, card: bool) -> Option { + match code { + UNKNOWN if card => Some(UNKNOWN_STEP.to_owned()), + UNKNOWN => Some(UNKNOWN_STEP_NO_CARD.to_owned()), + CHANGED => Some(CHANGED_STEP.to_owned()), + _ => None, + } +} + +/// Whether a host-key card could open for the call being answered: a person's proof can be had +/// in this process (not `biorouter serve`), and the call has a surface a person watches, as +/// [`Ctx::park_step`] asks before it parks one. Otherwise a step would promise a card's Trust +/// this key that nobody can see. +fn card_can_open() -> bool { + biorouter::pending_user_action::user_proof_available() + && !biorouter::user_surface::no_human_surface() +} + +/// `text`, the sentence the model is told for a refusal or a failed job's step, with the +/// fingerprints the daemon read added. `code` is the refusal's code; `fields` are the daemon's +/// own words beside it: an admission refusal's fields (`AdmissionRefusal::fields`), or a failed +/// setup job's `detail` and `host`. Called by [`super::tool_error`] and [`super::job_answer`]. +/// Only a fingerprint-shaped value and a host-shaped name are ever repeated, never the words +/// around them. +pub(super) fn enrich(text: String, code: Option<&str>, fields: &[(String, Value)]) -> String { + let changed = match code { + Some(UNKNOWN) => false, + Some(CHANGED) => true, + _ => return text, + }; + let field = |key: &str| { + fields + .iter() + .find(|(name, _)| name == key) + .and_then(|(_, value)| value.as_str()) + }; + let detail = field("detail").unwrap_or_default(); + let (offered, known) = fingerprints(detail); + let host = field("host") + .and_then(plain_lookup) + .or_else(|| named_host(changed, detail)); + match key_sentence( + changed, + host.as_deref(), + (offered.as_deref(), read_by_this_computer(detail)), + known.as_deref(), + ) { + Some(sentence) => format!("{text} {sentence}"), + None => text, + } +} + +/// One sentence with the fingerprints, for the model. `offered` is the fingerprint and whether +/// this computer read it itself ([`READ_HERE`]), which is then said as that, never as the key +/// the server offered. +fn key_sentence( + changed: bool, + host: Option<&str>, + (offered, read_here): (Option<&str>, bool), + known: Option<&str>, +) -> Option { + let whose = host.map_or_else(|| "The server's".to_owned(), |host| format!("{host}'s")); + Some(match (changed, offered, known) { + (_, None, None) => return None, + (false, Some(offered), _) if read_here => { + let whose = host.map_or_else(|| "the server's".to_owned(), |host| format!("{host}'s")); + format!("This computer read {whose} host key fingerprint as {offered}.") + } + (false, Some(offered), _) => format!("{whose} host key fingerprint is {offered}."), + (true, Some(offered), Some(known)) => { + format!("{whose} host key fingerprint is now {offered}; this computer trusted {known}.") + } + (true, Some(offered), None) => format!("{whose} host key fingerprint is now {offered}."), + (_, None, Some(known)) => format!("This computer trusted the host key {known}."), + }) +} + +/// Called by [`Ctx::wait_for_job`] with every status it reads of the job it waits for, after +/// [`super::signin::on_status`]. A job that failed on an unknown host key parks the card, waits +/// for the person, and answers with the job tried again once they added the key; a changed key +/// shows its card and lets the wait answer as before. `None` lets the wait go on. +pub(super) async fn on_status(ctx: &Ctx, status: &SetupStatus) -> Option { + if !matches!(status.state, SetupState::Failed) { + return None; + } + let error = status.error.as_ref()?; + let mut refusal = KeyRefusal::read( + &error.code, + error.detail.as_deref(), + error.host.as_deref(), + &status.server.login, + )?; + if refusal.changed { + show_changed(ctx, &refusal); + return None; + } + if trying_again() { + return None; + } + refusal + .read_offered(Route::of_job(&status.job_id).await.as_ref()) + .await; + let mut seen = status.clone(); + if let Some(error) = seen.error.as_mut() { + error.detail = refusal.detail_with_offered(); + } + match wait_for_key(ctx, &refusal).await { + Some(StepEnd::Withdrawn) => Some(match try_again(ctx, Some(&seen)).await { + Ok(answer) => answer, + Err(error) => { + let refused = tool_error(error, ctx.named_connection()).await.to_string(); + let mut answer = job_answer(&seen, false); + answer["tried_again"] = json!(refused); + answer + } + }), + _ => Some(job_answer(&seen, false)), + } +} + +/// Called with an action's error before it becomes the refusal the model reads +/// ([`super::tool_error`]). A refusal on an unknown host key parks the card, waits for the +/// person, and answers with the action tried again once they added the key; a changed key shows +/// its card. `Err` passes the error (this one, or the same refusal with the key the daemon read) +/// on to be worded. +pub(super) async fn on_refusal(ctx: &Ctx, error: anyhow::Error) -> Result { + let Some(failure) = host_key_failure(&error).cloned() else { + return Err(error); + }; + let route = refused_route(ctx).await; + let login = route + .as_ref() + .map(|route| route.login.clone()) + .unwrap_or_default(); + let Some(mut refusal) = KeyRefusal::read( + failure.api_code(), + failure.detail.as_deref(), + failure.host.as_deref(), + &login, + ) else { + return Err(error); + }; + if refusal.changed { + show_changed(ctx, &refusal); + return Err(error); + } + if trying_again() { + return Err(error); + } + refusal.read_offered(route.as_ref()).await; + let error = if refusal.scanned { + anyhow::Error::new(SshFailure { + detail: refusal.detail_with_offered(), + ..failure + }) + } else { + error + }; + match wait_for_key(ctx, &refusal).await { + Some(StepEnd::Withdrawn) => try_again(ctx, None).await, + _ => Err(error), + } +} + +/// The host-key refusal `error` carries, when it is worded as one: an [`SshFailure`] on an +/// unknown or changed key that no refusal of another kind wraps, in the order +/// `routes::crew_authentication::core_refusal` classifies them (an action's own words, a setup +/// refusal, an invitation's, a join's and a handoff's come first). +fn host_key_failure(error: &anyhow::Error) -> Option<&SshFailure> { + let worded_otherwise = error.chain().any(|cause| { + cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + }); + if worded_otherwise { + return None; + } + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .filter(|failure| { + matches!( + failure.kind, + SshFailureKind::HostKeyUnknown | SshFailureKind::HostKeyChanged + ) + }) +} + +/// The route the refused call took, as the daemon saved it: the saved connection the call +/// named, whose login the card names. `None` otherwise (the model's own words never reach a +/// card): the card then names the host alone, and no key is read ([`reached_directly`]). +async fn refused_route(ctx: &Ctx) -> Option { + ctx.named_connection()?; + let connection = ctx.connection().await.ok()?; + Some(Route::of(&connection)) +} + +/// A step's SSH route, as the daemon saved or started it: its login, its port and its jump +/// hosts. What [`reached_directly`] checks before the daemon reads a key itself. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Route { + login: String, + port: Option, + proxy_jump: Option, +} + +impl Route { + fn of(connection: &Connection) -> Self { + Self { + login: connection.ssh_target.clone(), + port: connection.port, + proxy_jump: connection.proxy_jump.clone(), + } + } + + /// The route of the setup job `job_id`, as this host started it ([`job_target`]): its saved + /// connection's, or the login, port and jump hosts it was started with. `None` for a job + /// started elsewhere (Crew, a terminal), whose route this host never saw. + async fn of_job(job_id: &str) -> Option { + let target = job_target(job_id)?; + if target["kind"] == "connection" { + let id = target["connection_id"].as_str()?; + let connection = biorouter::crew::manager().ok()?.connection(id).await.ok()?; + return Some(Self::of(&connection)); + } + Some(Self { + login: target["ssh_target"].as_str()?.to_owned(), + port: target["port"] + .as_u64() + .and_then(|port| u16::try_from(port).ok()), + proxy_jump: target["proxy_jump"].as_str().map(str::to_owned), + }) + } + + /// Whether the route names a jump host. + fn jumps(&self) -> bool { + self.proxy_jump + .as_deref() + .map(str::trim) + .is_some_and(|jump| !jump.is_empty() && jump != "none") + } +} + +/// Whether the hop OpenSSH refused is `route`'s destination, reached straight from this +/// computer, so that `ssh-keyscan` to the name and port the refusal gave reads the very server +/// the step reached. It is not when the route or the person's SSH configuration (`ssh -G`, read +/// as the transport reads it) names a jump host or a ProxyCommand, when a HostKeyAlias makes the +/// name the key is filed under differ from the one connected to, or when the name and port +/// `ssh -G` resolves are not the ones OpenSSH looked the key up by. Anything that can't be read +/// counts as not direct. +async fn reached_directly(route: &Route, refusal: &KeyRefusal) -> bool { + if route.jumps() || route.login.is_empty() || route.login.starts_with('-') { + return false; + } + host_keys::resolve(&route.login, route.port) + .await + .is_some_and(|resolved| resolved_directly(&resolved, refusal)) +} + +/// [`reached_directly`]'s reading of what `ssh -G` resolved for the route. +fn resolved_directly(resolved: &Resolved, refusal: &KeyRefusal) -> bool { + resolved.direct + && resolved + .hostname + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(&refusal.host)) + && resolved.port == Some(refusal.port) +} + +/// Whether this is the one time a step a host-key card waited for is tried again. +fn trying_again() -> bool { + TRYING_AGAIN.try_with(|_| ()).is_ok() +} + +/// Run the call's step again, once, after the person added the key the card waited for. A +/// second host-key refusal is said, not carded. +/// +/// The step is the call's action, as the model called it: the approval the person gave for the +/// identical card in this call stands for it (`Ctx::approve`), and any other card is asked. But +/// when the call is `auth` and the key stopped the setup `job` it signed the person in to, the +/// step is that job, and running `auth` again would only find a job that waits for nothing: the +/// job is started again instead ([`restart_job`]). +async fn try_again(ctx: &Ctx, job: Option<&SetupStatus>) -> Result { + if let Some(job) = job.filter(|_| ctx.action.name == "auth") { + return TRYING_AGAIN.scope((), restart_job(ctx, job)).await; + } + let Some(run) = ctx.action.run else { + return Err(said(None, catalog::NOT_YET)); + }; + TRYING_AGAIN.scope((), run(ctx)).await +} + +/// Start the setup job `job` again, as it was started, and wait for it as `auth` waits for a +/// job. Only a check this host started is started again by itself ([`restart_request`]); any +/// other job answers as it stands, with [`RESTART_STEP`]. Boxed: the wait reaches +/// [`on_status`] again, which is how a second refusal is said rather than carded. +fn restart_job<'a>(ctx: &'a Ctx, job: &'a SetupStatus) -> BoxFuture<'a, Result> { + Box::pin(async move { + let Some((request, target)) = restart_request(job) else { + let mut answer = job_answer(job, false); + answer["next_step"] = json!(RESTART_STEP); + return Ok(answer); + }; + let started = ctx.crew()?.start_setup(request).await?; + remember_job_target(&started.job_id, target); + Ok(ctx.wait_for_job(&started.job_id).await) + }) +} + +/// The request that starts `job` again, and its target: a check (`intent` check) whose target +/// this host recorded when it started it (`job_target`). A check's whole request is its target, +/// so it is started again exactly as before. `None` for any other job, whose choices (what to +/// restart, the agent access) only the action that started it, and its card, carried. +fn restart_request(job: &SetupStatus) -> Option<(SetupRequest, Value)> { + if job.intent != SetupIntent::Check { + return None; + } + let target = job_target(&job.job_id)?; + let request = serde_json::from_value(json!({"target": target, "intent": "check"})).ok()?; + Some((request, target)) +} + +/// A changed key's card: published for the person (the details for IT) and released at once, +/// since nothing waits on it and nothing can fix it from a chat. No card where none can be shown. +fn show_changed(ctx: &Ctx, refusal: &KeyRefusal) { + drop(ctx.park_step(refusal.card(), KEY_WAIT)); +} + +/// Park an unknown key's card and wait for it to end: withdrawn once the known hosts files hold +/// the key the card shows (added by the person, in a terminal of their own or with the card's +/// Trust this key), or dismissed, stopped or out of time. `None` when no card could be shown. +async fn wait_for_key(ctx: &Ctx, refusal: &KeyRefusal) -> Option { + let step = ctx.park_step(refusal.card(), KEY_WAIT)?; + let withdraw = step.withdrawer(); + let files = refusal.known_hosts_files().await; + let end = step.end(); + tokio::pin!(end); + loop { + tokio::select! { + how = &mut end => return Some(how), + () = tokio::time::sleep(KEY_POLL) => { + if key_added(refusal, &files).await { + withdraw.cancel(); + } + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// The refusal, as the daemon read it +// --------------------------------------------------------------------------------------------- + +/// A host-key refusal as the daemon read it: everything the card shows comes from here. +#[derive(Clone, Debug, PartialEq, Eq)] +struct KeyRefusal { + changed: bool, + /// The name the known-hosts file holds the key under, as OpenSSH looked it up: `host`, or + /// `[host]:port` off port 22. + lookup: String, + host: String, + port: u16, + /// The SSH login the step used, when the call named one. + login: String, + offered: Option, + known: Option, + /// The key type an unknown key's refusal names (`ed25519`), so the scan reads that key. + key_type: Option, + /// OpenSSH's words, as the daemon kept them. + detail: Option, + /// `offered` is the key the daemon read itself, not one the refusal printed. + scanned: bool, +} + +impl KeyRefusal { + /// A refusal from its code and the daemon's words: `detail`, `host` (OpenSSH's name for + /// the hop) and `login`. `None` for any other code, or when no host can be named. + fn read(code: &str, detail: Option<&str>, host: Option<&str>, login: &str) -> Option { + let changed = match code { + UNKNOWN => false, + CHANGED => true, + _ => return None, + }; + let text = detail.unwrap_or_default(); + let lookup = host + .and_then(plain_lookup) + .or_else(|| named_host(changed, text)) + .or_else(|| plain_lookup(login.rsplit_once('@').map_or(login, |(_, host)| host)))?; + let (host, port) = split_lookup(&lookup)?; + let (offered, known) = fingerprints(text); + Some(Self { + changed, + lookup, + host, + port, + login: login.to_owned(), + offered, + known, + key_type: if changed { None } else { key_type(text) }, + detail: detail.map(str::to_owned), + scanned: read_by_this_computer(text), + }) + } + + /// The card: the daemon's values, nothing else. + fn card(&self) -> CrewStepRequest { + CrewStepRequest::HostKey { + host: self.host.clone(), + port: self.port, + login: if self.login.is_empty() { + self.host.clone() + } else { + self.login.clone() + }, + offered: self.offered.clone(), + known: self.known.clone(), + changed: self.changed, + } + } + + /// For an unknown key whose refusal printed no fingerprint: the key the host gives this + /// computer now, read by the daemon with `ssh-keyscan` from the host and port OpenSSH named, + /// of the type it named, and only when the refused hop is `route`'s destination reached + /// straight from here ([`reached_directly`]). Nothing when the route is not known or not + /// direct, the type is not one `ssh-keyscan` reads, or the scan finds no key: the card then + /// shows no fingerprint, and any key the person adds for the host counts ([`key_added`]). + async fn read_offered(&mut self, route: Option<&Route>) { + if self.changed || self.offered.is_some() { + return; + } + let Some(key_type) = self.key_type.clone() else { + return; + }; + let Some(route) = route else { + return; + }; + if !reached_directly(route, self).await { + return; + } + let port = self.port.to_string(); + let mut command = host_keys::key_tool("ssh-keyscan"); + command + .args(["-T", "5", "-t", key_type.as_str(), "-p", port.as_str()]) + .arg(&self.host); + let Some((_, output)) = host_keys::run_tool(command).await else { + return; + }; + let offered = output + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .find_map(|line| { + let mut words = line.split_whitespace(); + let (_host, _type, blob) = (words.next()?, words.next()?, words.next()?); + host_keys::fingerprint_of(blob) + }); + if offered.is_some() { + self.offered = offered; + self.scanned = true; + } + } + + /// The known hosts files the refused step's ssh reads ([`host_keys::known_hosts_files`]): + /// the `UserKnownHostsFile` and `GlobalKnownHostsFile` lists `ssh -G` resolves for its + /// login (the host, when the call named none), as Crew's ssh resolves them. A key the + /// card's Trust this key adds goes to the first of them. + async fn known_hosts_files(&self) -> Vec { + let name = if self.login.is_empty() { + self.host.as_str() + } else { + self.login.as_str() + }; + host_keys::known_hosts_files(name, None).await + } + + /// The daemon's words with the key it read itself first, labelled as read by this computer + /// ([`READ_HERE`]), never as the key the server offered, so every reader of the refusal + /// finds it ([`fingerprints`]) and says what it is ([`enrich`]). + fn detail_with_offered(&self) -> Option { + match (&self.offered, self.scanned, &self.detail) { + (Some(offered), true, Some(detail)) => { + Some(format!("{READ_HERE} {offered}\n\n{detail}")) + } + (Some(offered), true, None) => Some(format!("{READ_HERE} {offered}")), + _ => self.detail.clone(), + } + } +} + +/// A name OpenSSH looks a key up by, when it is only that: letters, digits and `.-_:[]`, at +/// most 255 bytes, not starting with `-`. Anything else is not repeated anywhere. +fn plain_lookup(text: &str) -> Option { + let text = text.trim().trim_end_matches(['.', ',', ';']); + (!text.is_empty() + && text.len() <= 255 + && !text.starts_with('-') + && text + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-:[]".contains(&b))) + .then(|| text.to_owned()) +} + +/// `[host]:port` as its host and port; a plain name is on port 22. +fn split_lookup(lookup: &str) -> Option<(String, u16)> { + let Some(inner) = lookup.strip_prefix('[') else { + return Some((lookup.to_owned(), 22)); + }; + let (host, port) = inner.split_once("]:")?; + let port = port.parse().ok()?; + (!host.is_empty() && !host.contains(['[', ']'])).then(|| (host.to_owned(), port)) +} + +/// The host OpenSSH's deciding line names: `No ED25519 host key is known for H and …`, or +/// `Host key for H has changed …`. The last such line wins, as the transport reads it. +fn named_host(changed: bool, text: &str) -> Option { + text.lines().rev().find_map(|line| { + let lower = line.to_ascii_lowercase(); + let needle = if changed { + if !lower.contains(" has changed") { + return None; + } + "host key for " + } else { + " host key is known for " + }; + let at = lower.find(needle)? + needle.len(); + plain_lookup(line.get(at..)?.split_whitespace().next()?) + }) +} + +/// The key type an unknown key's refusal names (`No ED25519 host key is known for …`), as +/// `ssh-keyscan -t` takes it. +fn key_type(text: &str) -> Option { + text.lines().find_map(|line| { + let lower = line.to_ascii_lowercase(); + let (before, _) = lower.split_once(" host key is known for ")?; + let named = before.trim().strip_prefix("no ")?.trim(); + KEY_TYPES + .iter() + .find(|known| **known == named) + .map(|known| (*known).to_owned()) + }) +} + +use host_keys::is_fingerprint; + +/// Every SHA-256 fingerprint in `line`, as the transport finds them. +fn sha256_values(line: &str) -> Vec { + const MARKER: &str = "SHA256:"; + line.match_indices(MARKER) + .filter_map(|(at, _)| { + let digest: String = line + .get(at + MARKER.len()..)? + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=')) + .collect(); + let value = format!("{MARKER}{digest}"); + is_fingerprint(&value).then_some(value) + }) + .collect() +} + +/// The offered and the known fingerprint in a refusal's detail: the transport's summary lines +/// first (`Offered host key fingerprint: …`, exactly as the desktop's `hostKeyFingerprints` +/// reads them), else OpenSSH's raw words, labelled by the words around each fingerprint as the +/// transport labels them before it summarizes. An unlabelled fingerprint stands for the offered +/// one, as on the desktop. +fn fingerprints(detail: &str) -> (Option, Option) { + let summary = summary_fingerprints(detail); + if summary != (None, None) { + return summary; + } + raw_fingerprints(detail) +} + +fn summary_fingerprints(detail: &str) -> (Option, Option) { + let summary = Summary::of(detail); + ( + summary.offered.or(summary.unlabeled).or(summary.read_here), + summary.known, + ) +} + +/// Whether the fingerprint [`fingerprints`] reads from `detail` is one this computer read itself +/// ([`READ_HERE`]): the summary carries no other. +fn read_by_this_computer(detail: &str) -> bool { + let summary = Summary::of(detail); + summary.offered.is_none() && summary.unlabeled.is_none() && summary.read_here.is_some() +} + +/// The fingerprints of a refusal's summary lines, each under its label. +#[derive(Default)] +struct Summary { + offered: Option, + known: Option, + unlabeled: Option, + read_here: Option, +} + +impl Summary { + fn of(detail: &str) -> Self { + const OFFERED: [&str; 2] = [ + "New host key fingerprint (offered by the server):", + "Offered host key fingerprint:", + ]; + const KNOWN: &str = "Previously known host key fingerprint:"; + const UNLABELED: &str = "Host key fingerprint:"; + let mut summary = Self::default(); + for line in detail.lines().map(str::trim) { + let value = |label: &str| { + line.strip_prefix(label) + .map(str::trim) + .filter(|value| is_fingerprint(value)) + .map(str::to_owned) + }; + if let Some(found) = OFFERED.iter().find_map(|label| value(label)) { + summary.offered.get_or_insert(found); + } else if let Some(found) = value(KNOWN) { + summary.known.get_or_insert(found); + } else if let Some(found) = value(READ_HERE) { + summary.read_here.get_or_insert(found); + } else if let Some(found) = value(UNLABELED) { + summary.unlabeled.get_or_insert(found); + } + } + summary + } +} + +fn raw_fingerprints(text: &str) -> (Option, Option) { + const OFFERED: [&str; 4] = [ + "sent by the remote host", + "key fingerprint is", + "server host key", + "offered", + ]; + const KNOWN: [&str; 4] = ["known_hosts", "known host", "previously", "expected"]; + let lines: Vec<&str> = text.lines().collect(); + let (mut offered, mut known, mut unlabeled) = (None, None, None); + for (index, line) in lines.iter().enumerate() { + let previous = index.checked_sub(1).map_or("", |at| lines[at]); + let context = format!("{previous} {line}").to_lowercase(); + for value in sha256_values(line) { + if OFFERED.iter().any(|needle| context.contains(needle)) { + offered.get_or_insert(value); + } else if KNOWN.iter().any(|needle| context.contains(needle)) { + known.get_or_insert(value); + } else { + unlabeled.get_or_insert(value); + } + } + } + (offered.or(unlabeled), known) +} + +// --------------------------------------------------------------------------------------------- +// The known hosts files +// --------------------------------------------------------------------------------------------- + +/// Whether `files` now hold the key the card shows for the refusal's host: the fingerprint the +/// card shows, or, where it shows none, any key the person added for the host. A revoked key or +/// a certificate authority does not count. +async fn key_added(refusal: &KeyRefusal, files: &[PathBuf]) -> bool { + // File by file: one that can't be read shows no key, and the others still count. + let mut listed: Vec = Vec::new(); + for file in files { + let entries = host_keys::listed(&refusal.lookup, std::slice::from_ref(file)).await; + listed.extend( + entries + .unwrap_or_default() + .into_iter() + .filter(|entry| entry.marker == host_keys::Marker::Plain) + .map(|entry| entry.fingerprint), + ); + } + match &refusal.offered { + Some(offered) => listed.iter().any(|found| found == offered), + None => !listed.is_empty(), + } +} + +#[cfg(test)] +mod tests { + use super::super::test_support::test_ctx; + use super::*; + use biorouter::action_required_manager::ActionRequiredManager; + use biorouter::conversation::message::{ActionRequiredData, MessageContent}; + use biorouter::crew::tool_host::PersonTool; + + /// A key OpenSSH printed as `ssh-ed25519 `, and `ssh-keygen -l`'s fingerprint of it. + const BLOB: &str = "AAAAC3NzaC1lZDI1NTE5AAAAIDVMyBQ3psSwstgc8+Mce5VHIZN5GFuu3xE6WV8pfFkD"; + const BLOB_FINGERPRINT: &str = "SHA256:XA01Ou0eChdPDyZiadGxCZ/g+7RnODImHcZEtuRmV8g"; + const OFFERED: &str = "SHA256:TmV3S2V5TmV3S2V5TmV3S2V5TmV3S2V5TmV3S2V5TmU"; + const KNOWN: &str = "SHA256:Wm9vZm9vZm9vZm9vZm9vZm9vZm9vZm9vZm9vZm9vZm8"; + + fn changed_banner() -> String { + [ + "@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @", + "The fingerprint for the ED25519 key sent by the remote host is", + &format!("{OFFERED}."), + "Offending ED25519 key in /Users/alice/.ssh/known_hosts:3", + "Host key for hpc.example.edu has changed and you have requested strict checking.", + "Host key verification failed.", + ] + .join("\n") + } + + fn failure(kind: SshFailureKind, detail: &str, host: Option<&str>) -> SshFailure { + SshFailure { + kind, + code: "ssh_eof".into(), + status: "exit_255".into(), + description: "SSH exited".into(), + detail: Some(detail.into()), + host: host.map(str::to_owned), + outcome_unknown: false, + } + } + + #[test] + fn each_host_key_refusal_names_its_own_step() { + let unknown = step_for(UNKNOWN, true).unwrap(); + assert!( + unknown.starts_with("Do not add, scan or accept the key yourself"), + "{unknown}" + ); + assert!(unknown.contains("no ssh-keyscan"), "{unknown}"); + assert!(unknown.contains("never accepts a host key"), "{unknown}"); + assert!(unknown.contains("Trust this key on the card"), "{unknown}"); + assert!(unknown.contains("then try again"), "{unknown}"); + // Where no card can open, no step promises one. + let no_card = step_for(UNKNOWN, false).unwrap(); + assert!(!no_card.contains("on the card"), "{no_card}"); + assert!(no_card.contains("in a terminal of their own"), "{no_card}"); + assert!(no_card.contains("then try again"), "{no_card}"); + let changed = step_for(CHANGED, true).unwrap(); + assert!(changed.contains("IT team confirms the change"), "{changed}"); + assert!(changed.contains("no ssh-keygen -R"), "{changed}"); + assert!(!changed.contains("try again"), "{changed}"); + assert!( + !changed.to_lowercase().contains("trust this key"), + "{changed}" + ); + assert_eq!(step_for(CHANGED, false), Some(changed.clone())); + assert!(next_step("crew_ssh_auth_required", None).is_none()); + for text in [unknown, no_card, changed, RESTART_STEP.to_owned()] { + assert!(!text.contains(['\u{2014}', '\u{2013}']), "{text}"); + } + } + + /// Outside any call with a person at it (a unit test is one), the step never promises a card. + #[test] + fn a_step_said_where_nobody_watches_promises_no_card() { + let said = next_step(UNKNOWN, None).unwrap(); + assert_eq!( + said, + step_for(UNKNOWN, card_can_open()).unwrap(), + "next_step asks whether a card can open" + ); + } + + /// The transport's summary, a changed key's raw banner and an unlabelled fingerprint are + /// each read; a digest too short to be one, or words around a fingerprint, are not. + #[test] + fn fingerprints_are_read_from_the_daemon_s_words_only() { + assert_eq!( + fingerprints(&format!( + "Offered host key fingerprint: {OFFERED}\nPreviously known host key fingerprint: {KNOWN}\n\nraw words" + )), + (Some(OFFERED.into()), Some(KNOWN.into())) + ); + assert_eq!( + fingerprints(&format!( + "New host key fingerprint (offered by the server): {OFFERED}" + )), + (Some(OFFERED.into()), None) + ); + assert_eq!( + fingerprints(&changed_banner()), + (Some(OFFERED.into()), None) + ); + assert_eq!( + fingerprints(&format!("Host key fingerprint: {OFFERED}")), + (Some(OFFERED.into()), None) + ); + assert_eq!( + fingerprints( + "Offered host key fingerprint: SHA256:short\nNo ED25519 host key is known for h" + ), + (None, None) + ); + assert_eq!( + fingerprints(&format!("Offered host key fingerprint: {OFFERED} ` + `` ); - await tab.waitForSelector('button.br-transcript-row-head'); + await Promise.race([ + tab.waitForSelector('button.br-transcript-row-head'), + renderingFailed.then((error) => { + throw error; + }), + ]); for (const head of await tab.$$('button.br-transcript-row-head')) await head.click(); await tab.waitForTimeout(500); for (const more of await tab.$$('button.br-tool-more')) await more.click(); diff --git a/ui/desktop/src/hooks/chatStreamStore.crewStep.test.ts b/ui/desktop/src/hooks/chatStreamStore.crewStep.test.ts new file mode 100644 index 000000000..dcf45efad --- /dev/null +++ b/ui/desktop/src/hooks/chatStreamStore.crewStep.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChatStreamRegistry } from './chatStreamStore'; +import { ChatState } from '../types/chatState'; +import type { Message, MessageEvent, Session, TokenState } from '../api'; +import { reply, resumeAgent } from '../api'; +import { + cardEndingOf, + resetStreamedCrewStepsForTests, + wasStreamedCrewStep, +} from '../components/crew/chat/streamedCrewSteps'; + +vi.mock('../api', async () => ({ + cancelTurn: vi.fn(), + editMessage: vi.fn(), + getSession: vi.fn(async () => ({ data: null })), + interrupt: vi.fn(), + listSessions: vi.fn(async () => ({ data: { sessions: [] } })), + reply: vi.fn(), + resumeAgent: vi.fn(), + updateFromSession: vi.fn(async () => ({ data: {} })), + updateSessionUserWorkflowValues: vi.fn(async () => ({ data: {} })), +})); +vi.mock('../utils/continuationLease', () => ({ + abandonContinuationLease: vi.fn(async () => undefined), + getContinuationOwnerId: vi.fn(() => 'test-window-owner'), + recoverContinuationGroup: vi.fn(), +})); +vi.mock('../utils/userAction', async (importOriginal) => ({ + ...(await importOriginal()), + userActionHeaders: async () => ({ 'X-User-Action': 'test-key' }), +})); + +const tokenState: TokenState = { + accumulatedInputTokens: 0, + accumulatedOutputTokens: 0, + accumulatedTotalTokens: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, +}; + +/** A Crew step card as the daemon publishes one: user-only, and never stored. */ +function stepCard(messageId: string, cardId: string): Message { + return { + id: messageId, + role: 'assistant', + created: 2, + metadata: { userVisible: true, agentVisible: false }, + content: [ + { + type: 'actionRequired', + data: { + actionType: 'crewStep', + id: cardId, + step: { + kind: 'signIn', + target: { kind: 'setup', jobId: 'job-1' }, + server: 'hpc.example.edu', + login: 'you@hpc.example.edu', + }, + }, + }, + ], + } as Message; +} + +function session(id: string, conversation: Message[]): Session { + return { + id, + name: `Session ${id}`, + working_dir: '/tmp', + conversation, + message_count: conversation.length, + total_tokens: 0, + created_at: '', + updated_at: '', + extension_data: {}, + user_set_name: false, + } as Session; +} + +beforeEach(() => { + vi.useFakeTimers(); + resetStreamedCrewStepsForTests(); + vi.mocked(resumeAgent).mockReset(); + vi.mocked(reply).mockReset(); + Object.assign(window, { + electron: { + openExternal: vi.fn(async () => undefined), + showNotification: vi.fn(), + logInfo: vi.fn(), + }, + }); +}); + +describe('the chat stream and Crew step cards', () => { + it('notes a card it streamed, and never one a stored conversation carried', async () => { + const sid = 'crew-step-streamed-vs-stored'; + // A card in the stored rows (an imported session, a forged file): the daemon never stores one. + vi.mocked(resumeAgent).mockResolvedValue({ + data: { session: session(sid, [stepCard('stored-row', 'card-from-file')]) }, + } as never); + vi.mocked(reply).mockResolvedValue({ + stream: (async function* () { + yield { + type: 'Message', + message: stepCard('live-row', 'card-from-daemon'), + token_state: tokenState, + } as MessageEvent; + yield { type: 'Finish', reason: 'done', token_state: tokenState } as MessageEvent; + })(), + } as never); + + const registry = new ChatStreamRegistry(); + await registry.getController(sid).handleSubmit('set up Crew on hpc'); + + expect(wasStreamedCrewStep(sid, 'card-from-daemon')).toBe(true); + expect(wasStreamedCrewStep(sid, 'card-from-file')).toBe(false); + // Kept per chat: the same card ID in another chat is not this one. + expect(wasStreamedCrewStep('another-chat', 'card-from-daemon')).toBe(false); + }); +}); + +/** A stream the test feeds one frame at a time. */ +function controlledStream() { + const events: MessageEvent[] = []; + let wake: (() => void) | null = null; + let closed = false; + async function* stream() { + while (!closed || events.length > 0) { + if (events.length === 0) { + await new Promise((resolve) => { + wake = resolve; + }); + } + const event = events.shift(); + if (event) yield event; + } + } + return { + stream: stream(), + push(event: MessageEvent) { + events.push(event); + wake?.(); + wake = null; + }, + close() { + closed = true; + wake?.(); + wake = null; + }, + }; +} + +async function settle() { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + await Promise.resolve(); +} + +/** The daemon's WI-17 frame: a card's ID and how it ended, nothing else. */ +function ended(id: string, ending: string): MessageEvent { + return { type: 'ActionRequiredEnded', id, ending } as unknown as MessageEvent; +} + +function changedKeyNotice(messageId: string, cardId: string): Message { + const message = stepCard(messageId, cardId); + (message.content[0] as { data: { step: unknown } }).data.step = { + kind: 'hostKey', + host: 'hpc.example.edu', + port: 22, + login: 'you', + offered: null, + known: null, + changed: true, + }; + return message; +} + +describe('the life of a Crew step card on the chat stream (WI-17)', () => { + it('waits for the person while a step waits, and stops the moment the daemon ends it', async () => { + const sid = 'crew-step-waiting'; + vi.mocked(resumeAgent).mockResolvedValue({ data: { session: session(sid, []) } } as never); + const feed = controlledStream(); + vi.mocked(reply).mockResolvedValue({ stream: feed.stream } as never); + + const registry = new ChatStreamRegistry(); + const controller = registry.getController(sid); + const submitted = controller.handleSubmit('sign me in on hpc'); + await settle(); + + feed.push({ type: 'Message', message: stepCard('row-1', 'card-1'), token_state: tokenState }); + await settle(); + // (f): a chat parked on a Crew step is waiting for the person, not streaming. + expect(controller.getSnapshot().chatState).toBe(ChatState.WaitingForUserInput); + expect(cardEndingOf(sid, 'card-1')).toBeUndefined(); + + // Another chat's card ending, and an ID this chat never drew, leave it waiting. + feed.push(ended('card-elsewhere', 'dismissed')); + await settle(); + expect(controller.getSnapshot().chatState).toBe(ChatState.WaitingForUserInput); + + feed.push(ended('card-1', 'withdrawn')); + await settle(); + expect(cardEndingOf(sid, 'card-1')).toBe('withdrawn'); + expect(controller.getSnapshot().chatState).toBe(ChatState.Streaming); + + feed.push({ type: 'Finish', reason: 'done', token_state: tokenState } as MessageEvent); + feed.close(); + await submitted; + }); + + it('notes an end that came before its card, and an unknown ending as withdrawn', async () => { + const sid = 'crew-step-early-end'; + vi.mocked(resumeAgent).mockResolvedValue({ data: { session: session(sid, []) } } as never); + vi.mocked(reply).mockResolvedValue({ + stream: (async function* () { + yield ended('card-early', 'a word from a newer daemon'); + yield { type: 'Finish', reason: 'done', token_state: tokenState } as MessageEvent; + })(), + } as never); + await new ChatStreamRegistry().getController(sid).handleSubmit('go'); + expect(cardEndingOf(sid, 'card-early')).toBe('withdrawn'); + expect(cardEndingOf('another-chat', 'card-early')).toBeUndefined(); + }); + + it('does not wait on a changed host key, which is a notice', async () => { + const sid = 'crew-step-changed-key'; + vi.mocked(resumeAgent).mockResolvedValue({ data: { session: session(sid, []) } } as never); + const feed = controlledStream(); + vi.mocked(reply).mockResolvedValue({ stream: feed.stream } as never); + const controller = new ChatStreamRegistry().getController(sid); + const submitted = controller.handleSubmit('connect to hpc'); + await settle(); + feed.push({ + type: 'Message', + message: changedKeyNotice('row-2', 'card-2'), + token_state: tokenState, + }); + await settle(); + expect(controller.getSnapshot().chatState).not.toBe(ChatState.WaitingForUserInput); + feed.push({ type: 'Finish', reason: 'done', token_state: tokenState } as MessageEvent); + feed.close(); + await submitted; + }); + + it('draws a card handed over again once, and again after a resync replaced the view', async () => { + const sid = 'crew-step-handed-again'; + vi.mocked(resumeAgent).mockResolvedValue({ data: { session: session(sid, []) } } as never); + const text: Message = { + id: 'prose', + role: 'assistant', + created: 3, + metadata: { userVisible: true, agentVisible: true }, + content: [{ type: 'text', text: 'Waiting for you to sign in.' }], + } as Message; + vi.mocked(reply).mockResolvedValue({ + stream: (async function* () { + yield { type: 'Message', message: stepCard('row-a', 'card-x'), token_state: tokenState }; + yield { type: 'Message', message: text, token_state: tokenState }; + // The daemon hands the waiting card over again (after a lag resync of its own log). + yield { type: 'Message', message: stepCard('row-b', 'card-x'), token_state: tokenState }; + yield { type: 'Finish', reason: 'done', token_state: tokenState } as MessageEvent; + })(), + } as never); + const controller = new ChatStreamRegistry().getController(sid); + await controller.handleSubmit('go'); + const cards = controller + .getSnapshot() + .messages.filter((message) => message.content.some((c) => c.type === 'actionRequired')); + expect(cards.map((message) => message.id)).toEqual(['row-a']); + }); + + it('asks the daemon to say when each card ends', async () => { + const sid = 'crew-step-header'; + vi.mocked(resumeAgent).mockResolvedValue({ data: { session: session(sid, []) } } as never); + vi.mocked(reply).mockResolvedValue({ + stream: (async function* () { + yield { type: 'Finish', reason: 'done', token_state: tokenState } as MessageEvent; + })(), + } as never); + await new ChatStreamRegistry().getController(sid).handleSubmit('go'); + const headers = vi.mocked(reply).mock.calls[0][0]?.headers as Record; + expect(headers['X-Card-Endings']).toBe('1'); + // The person's proof still rides with it. + expect(headers['X-User-Action']).toBe('test-key'); + }); +}); diff --git a/ui/desktop/src/hooks/chatStreamStore.test.ts b/ui/desktop/src/hooks/chatStreamStore.test.ts index e4bec42cf..2668ab1e4 100644 --- a/ui/desktop/src/hooks/chatStreamStore.test.ts +++ b/ui/desktop/src/hooks/chatStreamStore.test.ts @@ -285,7 +285,10 @@ describe('ChatStreamRegistry', () => { // every other session-addressing route, so the renderer losing this header // would make every private chat unusable — which is the failure this pins. expect(reply).toHaveBeenCalledWith( - expect.objectContaining({ headers: { 'X-User-Action': 'test-key' } }) + expect.objectContaining({ + // With WI-17's ask to hear when each card ends, which every stream request makes. + headers: { 'X-User-Action': 'test-key', 'X-Card-Endings': '1' }, + }) ); expect(controller.getSnapshot().session?.name).toBe('Apple Watch News'); window.removeEventListener('message-stream-finished', onFinished); diff --git a/ui/desktop/src/hooks/chatStreamStore.tsx b/ui/desktop/src/hooks/chatStreamStore.tsx index eb18d94d8..15e8a2124 100644 --- a/ui/desktop/src/hooks/chatStreamStore.tsx +++ b/ui/desktop/src/hooks/chatStreamStore.tsx @@ -43,11 +43,13 @@ import { subscribeSessionRowChanges, } from '../utils/sessionRowSync'; import { isReadOnlySubagentChat } from '../components/subagent/subagentReadOnly'; +import { noteCardEnded, noteStreamedCrewStep } from '../components/crew/chat/streamedCrewSteps'; import { mergeStopRecord } from '../components/conversation/turnStoppedNotice'; import { isBrowserSurface } from '../utils/surface'; import { createUserMessage, getCompactingMessage, + getCrewStepContent, getElicitationContent, getSecretRequestContent, getThinkingMessage, @@ -180,6 +182,95 @@ type TurnStateEvent = { active_turn_id: string | null; }; +/** + * WI-17: the daemon's word that a card this chat drew stopped waiting: its ID and how it ended, + * never a value. Read off the frame rather than the generated union, so it parses whether or + * not this build's client knows the frame yet. + */ +type ActionRequiredEndedEvent = { + type: 'ActionRequiredEnded'; + id: string; + ending: string; +}; + +function actionRequiredEndedEvent(event: MessageEvent): ActionRequiredEndedEvent | null { + const candidate = event as unknown as Partial; + return candidate.type === 'ActionRequiredEnded' && typeof candidate.id === 'string' + ? (candidate as ActionRequiredEndedEvent) + : null; +} + +/** + * The headers of a request for a chat's stream (`/reply`, the observer): the person's proof, + * and WI-17's ask to be told when each card ends (the daemon's `CARD_ENDINGS_HEADER`). The ask + * is a header because a client that predates the frame must never be sent one. Synchronous on + * purpose, so a stream request is made in the same tick it was before. + */ +function withCardEndings(proof: Record): Record { + return { ...proof, 'X-Card-Endings': '1' }; +} + +/** + * Whether `message` carries a card that waits for the person (WI-17's (f)): an approval, a + * question, a credential card or a Crew step. A changed host key's Crew step is a notice: it + * waits for nothing, so the chat is not waiting on it. + */ +function waitsForThePerson(message: Message): boolean { + if ( + getToolConfirmationContent(message) !== undefined || + getElicitationContent(message) !== undefined || + getSecretRequestContent(message) !== undefined + ) { + return true; + } + const step = getCrewStepContent(message)?.data; + if (!step || step.actionType !== 'crewStep') return false; + const asked = step.step as { kind?: string; changed?: boolean } | undefined; + return !(asked?.kind === 'hostKey' && asked.changed === true); +} + +/** The ID of the card `message` carries when it waits for the person (`waitsForThePerson`). */ +function waitingCardId(message: Message): string | undefined { + if (!waitsForThePerson(message)) return undefined; + return cardIdOf(message); +} + +/** The ID of the first card `message` carries, of any kind. */ +function cardIdOf(message: Message): string | undefined { + for (const content of message.content) { + if (content.type !== 'actionRequired') continue; + const id = (content.data as { id?: unknown }).id; + if (typeof id === 'string') return id; + } + return undefined; +} + +/** The kinds of card the daemon never stores, which only a stream can draw. */ +const DECISION_CARDS = new Set(['toolConfirmation', 'secretRequest', 'crewStep']); + +/** The ID of the decision card `message` carries ({@link DECISION_CARDS}), if any. */ +function decisionCardIdOf(message: Message): string | undefined { + for (const content of message.content) { + if (content.type !== 'actionRequired') continue; + const data = content.data as { actionType?: unknown; id?: unknown }; + if (typeof data.actionType === 'string' && DECISION_CARDS.has(data.actionType)) { + return typeof data.id === 'string' ? data.id : undefined; + } + } + return undefined; +} + +/** + * Whether the transcript already draws the decision card `message` carries (WI-17). The daemon + * hands a window the cards still waiting again after a resync from storage, which held none of + * them, and a window that kept its view (no resync reached it) must not draw a second copy. An + * elicitation and its answer share an ID by design, so they are not decision cards here. + */ +function alreadyDrawsCard(messages: Message[], message: Message): boolean { + const id = decisionCardIdOf(message); + return id !== undefined && messages.some((held) => decisionCardIdOf(held) === id); +} + function turnStartedEvent(event: MessageEvent): TurnStartedEvent | null { const candidate = event as unknown as Partial; return candidate.type === 'TurnStarted' && typeof candidate.turn_id === 'string' @@ -908,6 +999,12 @@ class ChatStreamController { * turn its numbering belongs to. */ private activeTurnId: string | null = null; + /** + * WI-17: the cards this stream drew that wait for the person and have not ended. When the + * daemon says the last of them ended, the chat stops showing as waiting at once rather than + * at its next frame. + */ + private waitingCards = new Set(); /** * Highest per-turn `seq` already applied to the snapshot, for `seqTurnId`. * `-1` means "nothing from this turn has been rendered yet", which is the @@ -1554,13 +1651,19 @@ class ChatStreamController { // real card arrives as `actionRequired` with `actionType: 'toolConfirmation'` // — which is exactly what the shared helper tests, so use it rather than // spelling the shape a fourth time. - const hasToolConfirmation = getToolConfirmationContent(msg) !== undefined; - const hasElicitation = getElicitationContent(msg) !== undefined; - // Issue #117. A credential card parks the turn the same way, and the chat - // must say so — the install is genuinely waiting on the person. - const hasSecretRequest = getSecretRequestContent(msg) !== undefined; + // + // Issue #117: a credential card parks the turn the same way, and the chat + // must say so — the install is genuinely waiting on the person. So does a + // Crew step (WI-17), which showed as streaming until this said otherwise. + const waitingCard = waitingCardId(msg); + if (waitingCard !== undefined) this.waitingCards.add(waitingCard); + for (const content of msg.content) { + // An approval the agent's own loop raised carries its tool call's ID, and + // the call's response means it was answered. + if (content.type === 'toolResponse') this.waitingCards.delete(content.id); + } const derivedChatState = - hasToolConfirmation || hasElicitation || hasSecretRequest + waitingCard !== undefined ? ChatState.WaitingForUserInput : getCompactingMessage(msg) ? ChatState.Compacting @@ -2440,6 +2543,24 @@ class ChatStreamController { }; } + /** + * WI-17: the daemon said a card ended. Noted for the card to draw its ended state at once + * (`streamedCrewSteps.ts`), whatever kind of card it names and whether or not its card has + * arrived yet; and when it was the last card this chat waited on, the chat stops showing as + * waiting for the person. + */ + private applyCardEnded(ended: ActionRequiredEndedEvent): void { + noteCardEnded(this.sessionId, ended.id, ended.ending); + const waited = this.waitingCards.delete(ended.id); + if ( + waited && + this.waitingCards.size === 0 && + this.snapshot.chatState === ChatState.WaitingForUserInput + ) { + this.setChatState(ChatState.Streaming); + } + } + private finishCurrentStream = async (error?: ChatTurnErrorData): Promise => { // Sampled at the very top, and once. It reads `activeTurnId`, which // `retireActiveTurn()` below clears — so a check made afterwards answers @@ -2454,6 +2575,8 @@ class ChatStreamController { // The turn is over: any skeleton whose authoritative request never arrived // (cancel, provider abort, a dropped block) must not linger. this.clearPendingToolCalls(); + // No card of a turn outlives it. + this.waitingCards.clear(); this.abortController = null; // Retire the turn's attach handle and its sequence numbering together. Both // outliving the turn is the same bug in two shapes: a stale handle sends a @@ -2725,6 +2848,11 @@ class ChatStreamController { isReplayFrame(event) ); if (!msg) break; + if (alreadyDrawsCard(currentMessages, msg)) break; + // A Crew step card is never stored, so a streamed one is the only kind the + // daemon can be waiting on: only it may draw buttons (`streamedCrewSteps.ts`). + // Noted before the snapshot swap, so its first render already knows. + noteStreamedCrewStep(this.sessionId, msg); currentMessages = pushMessage(currentMessages, msg); // #22 — one snapshot swap (state + tokens + transcript + skeleton // cleanup) per streamed event, not three. @@ -2829,8 +2957,13 @@ class ChatStreamController { case 'Notification': this.updateNotifications(event as NotificationEvent); break; - default: + default: { + // WI-17. Matched here rather than as a case so it parses whether or not this + // build's generated client knows the frame. + const ended = actionRequiredEndedEvent(event); + if (ended) this.applyCardEnded(ended); break; + } } } @@ -3151,7 +3284,7 @@ class ChatStreamController { // the stream id first would leave a window in which this reattach claims the // turn with no stream on it. The guard is re-run afterwards because the // await is a window in which a submit or a Stop can arrive. - const headers = await userActionHeaders(); + const headers = withCardEndings(await userActionHeaders()); if (this.activeStreamId !== streamId) return false; this.reattachesThisTurn += 1; @@ -3288,7 +3421,7 @@ class ChatStreamController { // `stopObserving()` arriving in that window would abort the OLD // socket and then be overwritten by a fresh un-aborted one — // leaving `hasLiveTurn()` true for a loop that is being torn down. - headers: await userActionHeaders(), + headers: withCardEndings(await userActionHeaders()), path: { session_id: this.sessionId }, throwOnError: true, signal: socket.signal, @@ -3605,7 +3738,7 @@ class ChatStreamController { // person: `submitPreparedMessage` sends the proof here and this did not, // which made every mid-turn reload of a private chat a 403 that the SSE // client turns into an empty stream. See `userActionHeaders`. - headers: await userActionHeaders(), + headers: withCardEndings(await userActionHeaders()), body: buildAttachRequest( this.sessionId, turnId, @@ -3930,7 +4063,7 @@ class ChatStreamController { // the user's surface, so every call it makes is a user act; the model // reaching this route over HTTP is precisely the caller the header // separates out. - headers: await userActionHeaders(), + headers: withCardEndings(await userActionHeaders()), body: { session_id: this.sessionId, user_message: newMessage, diff --git a/ui/desktop/src/hooks/chatStreamStore.userAction.test.tsx b/ui/desktop/src/hooks/chatStreamStore.userAction.test.tsx index 1efd5cd2a..714eae0c6 100644 --- a/ui/desktop/src/hooks/chatStreamStore.userAction.test.tsx +++ b/ui/desktop/src/hooks/chatStreamStore.userAction.test.tsx @@ -91,13 +91,24 @@ async function* streamOf(...frames: MessageEvent[]) { for (const frame of frames) yield frame; } -/** Every `headers` object the store handed a gated route, in call order. */ -function gatedCallHeaders(): Array | undefined> { +/** Every `headers` object the store handed a gated route, in call order, as sent. */ +function sentHeaders(): Array | undefined> { return [...mocks.reply.mock.calls, ...mocks.observeSessionEvents.mock.calls].map( (call) => (call[0] as { headers?: Record }).headers ); } +/** + * {@link sentHeaders} without WI-17's ask to hear when each card ends, which every stream + * request makes whatever its proof (pinned on its own below): what is left is the proof alone. + */ +function gatedCallHeaders(): Array> { + return sentHeaders().map((headers) => { + const { 'X-Card-Endings': _endings, ...proof } = headers ?? {}; + return proof; + }); +} + /** * A fresh session id per test. The transcript LRU in `utils/sessionNameSync` is * module-level and keyed by session id, so a reused id sends `loadSession` down @@ -220,4 +231,24 @@ describe('gated routes carry the user-action proof', () => { expect(gatedCallHeaders()).toEqual([{}]); }); + + it('asks every stream to say when each card ends, with or without a proof (WI-17)', async () => { + const sid = `ua-endings-${++sessionSeq}`; + mocks.resumeAgent.mockResolvedValue({ data: { session: session(sid) } }); + mocks.reply.mockResolvedValue({ stream: streamOf(finishFrame) }); + mocks.observeSessionEvents.mockResolvedValue({ stream: streamOf(finishFrame) }); + + const controller = new ChatStreamRegistry().getController(sid); + await controller.loadSession(); + await controller.attachToTurn('turn-9'); + const observer = new ChatStreamRegistry().getController(`${sid}-observer`); + const loop = observer.observeSession(); + await vi.waitFor(() => expect(mocks.observeSessionEvents).toHaveBeenCalled()); + observer.stopObserving(); + await loop; + + const sent = sentHeaders(); + expect(sent.length).toBeGreaterThanOrEqual(2); + for (const headers of sent) expect(headers?.['X-Card-Endings']).toBe('1'); + }); }); diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index 4eb5e2e6e..76ac8c808 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -152,6 +152,21 @@ export function getSecretRequestContent( ); } +/** + * A Crew step card (the owner's D1 ruling on AO-12): a step only the person can + * take, opened by a chat. Like a credential card it has no response message + * type: the step happens in the person's own window, and the card can only be + * dismissed (`POST /action-required/crew-step`). + */ +export function getCrewStepContent( + message: Message +): (ActionRequired & { type: 'actionRequired' }) | undefined { + return message.content.find( + (content): content is ActionRequired & { type: 'actionRequired' } => + content.type === 'actionRequired' && content.data.actionType === 'crewStep' + ); +} + export function hasCompletedToolCalls(message: Message): boolean { const toolRequests = getToolRequests(message); return toolRequests.length > 0;