diff --git a/.github/workflows/app-ffi-release.yml b/.github/workflows/app-ffi-release.yml index 693dde419dec..0e0abb971521 100644 --- a/.github/workflows/app-ffi-release.yml +++ b/.github/workflows/app-ffi-release.yml @@ -41,6 +41,7 @@ on: paths: - "cmux-tui/crates/cmux-app-ffi/**" - "cmux-tui/crates/cmux-bookmarks-core/**" + - "cmux-tui/crates/cmux-cloud-config/**" - "cmux-tui/crates/cmux-cloud-start-core/**" - "cmux-tui/crates/cmux-focus-core/**" - "cmux-tui/crates/cmux-imessage/**" diff --git a/.github/workflows/cmux-vm-clients.yml b/.github/workflows/cmux-vm-clients.yml index 945ce171b03f..ba25056251cc 100644 --- a/.github/workflows/cmux-vm-clients.yml +++ b/.github/workflows/cmux-vm-clients.yml @@ -16,6 +16,8 @@ on: paths: - workers/cmux-vm/openapi.json - workers/cmux-vm/cli/** + # The agent-key mint's rules the CLI shares with the cmux app. + - cmux-tui/crates/cmux-cloud-config/** - workers/cmux-vm/sdk/** # The cli tests run every command in this agent guide (tests/agent_doc.rs). - skills/cmux-cloud-vm/references/headless.md @@ -29,6 +31,8 @@ on: paths: - workers/cmux-vm/openapi.json - workers/cmux-vm/cli/** + # The agent-key mint's rules the CLI shares with the cmux app. + - cmux-tui/crates/cmux-cloud-config/** - workers/cmux-vm/sdk/** # The cli tests run every command in this agent guide (tests/agent_doc.rs). - skills/cmux-cloud-vm/references/headless.md diff --git a/cmux-tui/Cargo.lock b/cmux-tui/Cargo.lock index a55e0dc765c8..dad3dcc0b713 100644 --- a/cmux-tui/Cargo.lock +++ b/cmux-tui/Cargo.lock @@ -1063,6 +1063,14 @@ dependencies = [ name = "cmux-cloud-cli" version = "0.1.0" +[[package]] +name = "cmux-cloud-config" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "cmux-cloud-environment" version = "0.1.0" @@ -1770,6 +1778,7 @@ dependencies = [ "cmux-automations", "cmux-build-identity", "cmux-chat-index", + "cmux-cloud-config", "cmux-cloud-environment", "cmux-config", "cmux-conversation", @@ -2069,6 +2078,7 @@ version = "0.1.0" dependencies = [ "base64", "clap", + "cmux-cloud-config", "cmux-vm-client", "futures", "nix 0.31.3", diff --git a/cmux-tui/Cargo.toml b/cmux-tui/Cargo.toml index 8e2dcdbe32cb..0622fca79095 100644 --- a/cmux-tui/Cargo.toml +++ b/cmux-tui/Cargo.toml @@ -51,6 +51,7 @@ members = [ "crates/cmux-layout-reducer", "crates/cmux-focus-core", "crates/cmux-cloud-start-core", + "crates/cmux-cloud-config", "crates/cmux-unix-socket", "crates/cmux-panic-log", "crates/cmux-pty", @@ -135,6 +136,7 @@ default-members = [ "crates/cmux-layout-reducer", "crates/cmux-focus-core", "crates/cmux-cloud-start-core", + "crates/cmux-cloud-config", "crates/cmux-pty", "crates/cmux-process", "crates/cmux-remote-protocol", @@ -223,6 +225,7 @@ cmux-feed-core = { path = "crates/cmux-feed-core" } cmux-layout-reducer = { path = "crates/cmux-layout-reducer" } cmux-bookmarks-core = { path = "crates/cmux-bookmarks-core" } cmux-cloud-start-core = { path = "crates/cmux-cloud-start-core" } +cmux-cloud-config = { path = "crates/cmux-cloud-config" } cmux-cloud-environment = { path = "crates/cmux-cloud-environment" } cmux-unix-socket = { path = "crates/cmux-unix-socket" } cmux-panic-log = { path = "crates/cmux-panic-log" } diff --git a/cmux-tui/crates/cmux-app-ffi/Cargo.lock b/cmux-tui/crates/cmux-app-ffi/Cargo.lock index 3611b0e5ef4f..f61b666c609f 100644 --- a/cmux-tui/crates/cmux-app-ffi/Cargo.lock +++ b/cmux-tui/crates/cmux-app-ffi/Cargo.lock @@ -57,6 +57,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cmux-cloud-config" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "cmux-cloud-start-core" version = "0.1.0" @@ -98,6 +106,7 @@ name = "cmux-layout-reducer-ffi" version = "0.1.0" dependencies = [ "cmux-bookmarks-core", + "cmux-cloud-config", "cmux-cloud-start-core", "cmux-focus-core", "cmux-imessage", diff --git a/cmux-tui/crates/cmux-cloud-config/Cargo.toml b/cmux-tui/crates/cmux-cloud-config/Cargo.toml new file mode 100644 index 000000000000..5c8043186962 --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cmux-cloud-config" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "NEXT Cloud's configuration rules (pure; the macOS app asks them over the app FFI, the cmux vm CLI links them): the backend pick, the auth rules around the Keychain, and the VM agent-key mint" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/cmux-tui/crates/cmux-cloud-config/src/agent_key.rs b/cmux-tui/crates/cmux-cloud-config/src/agent_key.rs new file mode 100644 index 000000000000..259774a49697 --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/src/agent_key.rs @@ -0,0 +1,228 @@ +//! A cmux VM agent key's mint (`POST /v1/agent-keys` on the cmux VM API, workers/cmux-vm +//! src/api/agents.ts): the request body, where it goes, and how the answer reads. One set of rules +//! for both mint paths: the app's `vm.agent_key.create` (it holds the Stack session, asks +//! [`request`] and [`answer`] over the app FFI and does the HTTP call), and the `cmux vm token +//! create` CLI with its own credential (a session token or a team key), which builds the same +//! body with [`body`] and reads the app's refusals with [`outcome`]. +//! +//! An agent key holds only `vm:read`, `vm:write` and `vm:exec`, reaches only the VMs it creates, +//! has a VM count and a USD/h budget, and expires within a day. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::config::{Backend, vm_api_base_url}; + +/// The only scopes an agent key is asked for, whatever the caller sent. +pub const SCOPES: [&str; 3] = ["vm:read", "vm:write", "vm:exec"]; +pub const DEFAULT_NAME: &str = "cmux vm token"; +pub const NAME_MAX_CHARS: usize = 200; +pub const TTL_SECONDS: std::ops::RangeInclusive = 60..=86_400; +pub const MAX_VMS: std::ops::RangeInclusive = 1..=20; +pub const MAX_USD_PER_HOUR: f64 = 50.0; +pub const DEFAULT_TTL_SECONDS: u64 = 8 * 3600; +pub const DEFAULT_MAX_VMS: u32 = 3; +pub const DEFAULT_MAX_USD_PER_HOUR: f64 = 2.0; +/// The path the key is minted at, under the VM API's base URL. +pub const PATH: &str = "v1/agent-keys"; + +/// A mint refused here or by the VM API, as the app socket's error: the code the CLI maps to an +/// exit status ([`outcome`]) and a message that never carries the key. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Refusal { + pub code: String, + pub message: String, + /// The HTTP status of an `unavailable` answer (0: no answer at all). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + /// The app's own localized text to show instead of `message` (`notSignedIn`, `unreachable`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_text: Option, +} + +impl Refusal { + fn new(code: &str, message: impl Into, status: Option) -> Self { + Self { code: code.to_owned(), message: message.into(), status, app_text: None } + } + + fn with_app_text(mut self, text: &str) -> Self { + self.app_text = Some(text.to_owned()); + self + } +} + +/// The request body for the caller's `params` (`name`, `ttlSeconds`, `maxVms`, `maxUsdPerHour`): +/// only VM scopes. A limit that is present must be valid: a bad one is refused (`invalid_params`), +/// never dropped, since the server's default would be larger. An absent one is left to the server. +pub fn body(params: &Map) -> Result { + let invalid = + |name: &str| Refusal::new("invalid_params", format!("{name} is out of range"), None); + let name = params + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + .map(|name| name.chars().take(NAME_MAX_CHARS).collect::()) + .unwrap_or_else(|| DEFAULT_NAME.to_owned()); + let mut body = Map::new(); + body.insert("name".into(), json!(name)); + body.insert("scopes".into(), json!(SCOPES)); + if let Some(raw) = params.get("ttlSeconds") { + let ttl = exact_integer(raw) + .filter(|ttl| TTL_SECONDS.contains(ttl)) + .ok_or_else(|| invalid("ttlSeconds"))?; + body.insert("ttlSeconds".into(), json!(ttl)); + } + if let Some(raw) = params.get("maxVms") { + let vms = exact_integer(raw) + .filter(|vms| MAX_VMS.contains(vms)) + .ok_or_else(|| invalid("maxVms"))?; + body.insert("maxVms".into(), json!(vms)); + } + if let Some(raw) = params.get("maxUsdPerHour") { + let usd = raw + .as_f64() + .filter(|usd| usd.is_finite() && *usd > 0.0 && *usd <= MAX_USD_PER_HOUR) + .ok_or_else(|| invalid("maxUsdPerHour"))?; + body.insert("maxUsdPerHour".into(), json!(usd)); + } + Ok(Value::Object(body)) +} + +/// A JSON number that is an exact, non-negative integer (`60` and `60.0`), else `None`. +fn exact_integer(value: &Value) -> Option { + if let Some(n) = value.as_u64() { + return Some(n); + } + let n = value.as_f64().filter(|n| *n >= 0.0 && n.fract() == 0.0 && *n < 1e15)?; + // Below 1e15 an integral f64 prints exactly, so this is the number with no lossy cast. + format!("{n:.0}").parse().ok() +} + +/// What the app sends: `POST ` with `body`, `Authorization: Bearer `, +/// `x-cmux-team-id: ` and `content-type: application/json`, within 20 s. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub url: String, + pub base_url: String, + pub body: Value, + pub timeout_seconds: u32, +} + +/// The mint request for this build's Cloud: the VM API for `backend`, never another (the app +/// sends its Stack session only there). +pub fn request(backend: &Backend, params: &Map) -> Result { + let base_url = vm_api_base_url(backend).to_owned(); + Ok(Request { + url: format!("{base_url}/{PATH}"), + base_url, + body: body(params)?, + timeout_seconds: 20, + }) +} + +/// The answer as the socket's result: the VM API's fields plus `baseUrl` and `teamId` (so the CLI +/// stores the key with the service and team it works at), or the refusal. `status` 0 is no +/// answer at all. The key is never in a refusal. +pub fn answer(status: u16, body: &[u8], base_url: &str, team_id: &str) -> Result { + let parsed: Option = serde_json::from_slice(body).ok(); + if status == 0 { + return Err(Refusal::new( + "unavailable", + "The cmux VM service didn't answer. Try again in a moment.", + Some(0), + ) + .with_app_text("unreachable")); + } + if (200..300).contains(&status) + && let Some(Value::Object(mut fields)) = parsed.clone() + && fields.get("key").is_some_and(Value::is_string) + { + fields.insert("baseUrl".into(), json!(base_url)); + fields.insert("teamId".into(), json!(team_id)); + return Ok(Value::Object(fields)); + } + let key = parsed.as_ref().and_then(|answer| answer.get("key")).and_then(Value::as_str); + let message = parsed + .as_ref() + .and_then(|answer| answer.get("message")) + .and_then(Value::as_str) + .map(|message| redacted(message, key).chars().take(300).collect::()) + .unwrap_or_else(|| format!("HTTP {status}")); + Err(match status { + 401 => Refusal::new("not_signed_in", "Sign in to cmux in the cmux app first.", None) + .with_app_text("notSignedIn"), + 402 => Refusal::new("plan_required", message, None), + 403 => Refusal::new("forbidden", message, None), + 400 => Refusal::new("invalid_params", message, None), + 429 => Refusal::new("rate_limited", message, None), + _ => Refusal::new("unavailable", message, Some(status)), + }) +} + +/// The VM API's key prefix (workers/cmux-vm src/auth/credentials.ts `API_KEY_PREFIX`). +pub const KEY_PREFIX: &str = "cmuxvm_sk_"; + +/// `message` with the answer's own key and anything shaped like a VM API key taken out, so a +/// refusal never carries a secret whatever the service put in its text. +fn redacted(message: &str, key: Option<&str>) -> String { + let text = match key.filter(|key| !key.is_empty()) { + Some(key) => message.replace(key, ""), + None => message.to_owned(), + }; + let mut out = String::with_capacity(text.len()); + let mut rest = text.as_str(); + while let Some((before, after)) = rest.split_once(KEY_PREFIX) { + out.push_str(before); + out.push_str(KEY_PREFIX); + out.push_str(""); + rest = + after.trim_start_matches(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + } + out.push_str(rest); + out +} + +/// What a mint refused by the app means for the CLI's exit status: the cmux-vm CLI maps each +/// to its own code (`exit.rs`), so scripts tell the cases apart the same way on either mint path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome { + /// Nothing reached the service, or the app cannot mint now (no team, Cloud off). + Network, + Unauthenticated, + PaymentRequired, + Forbidden, + QuotaExceeded, + Usage, + /// The app has no `vm.agent_key.create`. + MethodNotFound, + ServiceUnavailable, + /// Another HTTP status from the service: the CLI's own status map. + Status(u16), + Unexpected, +} + +/// The outcome of a mint the app refused with socket error `code` (and, for `unavailable`, the +/// VM API's HTTP `status`). +pub fn outcome(code: &str, status: Option) -> Outcome { + match code { + "labs_off" | "no_team" | "feature.disabled" => Outcome::Network, + "not_signed_in" => Outcome::Unauthenticated, + "plan_required" => Outcome::PaymentRequired, + "forbidden" => Outcome::Forbidden, + "rate_limited" => Outcome::QuotaExceeded, + "invalid_params" => Outcome::Usage, + "method_not_found" => Outcome::MethodNotFound, + "unavailable" => match status.and_then(|status| u16::try_from(status).ok()) { + None | Some(0) => Outcome::Network, + Some(status) if status >= 500 => Outcome::ServiceUnavailable, + Some(status) => Outcome::Status(status), + }, + _ => Outcome::Unexpected, + } +} + +#[cfg(test)] +#[path = "agent_key_tests.rs"] +mod tests; diff --git a/cmux-tui/crates/cmux-cloud-config/src/agent_key_tests.rs b/cmux-tui/crates/cmux-cloud-config/src/agent_key_tests.rs new file mode 100644 index 000000000000..51024a70fdec --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/src/agent_key_tests.rs @@ -0,0 +1,143 @@ +use serde_json::{Map, Value, json}; + +use super::*; + +fn params(value: Value) -> Map { + match value { + Value::Object(map) => map, + _ => unreachable!("an object"), + } +} + +#[test] +fn the_body_asks_only_for_vm_scopes_whatever_was_sent() { + let body = body(¶ms(json!({"scopes": ["team:admin"], "name": "ci"}))).expect("body"); + assert_eq!(body, json!({"name": "ci", "scopes": ["vm:read", "vm:write", "vm:exec"]})); +} + +#[test] +fn a_blank_or_long_name_is_defaulted_or_cut() { + assert_eq!(body(¶ms(json!({}))).expect("body")["name"], DEFAULT_NAME); + assert_eq!(body(¶ms(json!({"name": ""}))).expect("body")["name"], DEFAULT_NAME); + let long = "x".repeat(250); + assert_eq!( + body(¶ms(json!({"name": long}))).expect("body")["name"].as_str().map(str::len), + Some(200) + ); +} + +#[test] +fn limits_in_range_are_kept_and_others_refused_never_dropped() { + let body = body(¶ms(json!({"ttlSeconds": 60.0, "maxVms": 20, "maxUsdPerHour": 50}))) + .expect("body"); + assert_eq!( + (body["ttlSeconds"].clone(), body["maxVms"].clone(), body["maxUsdPerHour"].clone()), + (json!(60), json!(20), json!(50.0)) + ); + for (name, value) in [ + ("ttlSeconds", json!(59)), + ("ttlSeconds", json!(86_401)), + ("ttlSeconds", json!(90.5)), + ("ttlSeconds", json!("60")), + ("maxVms", json!(0)), + ("maxVms", json!(21)), + ("maxVms", json!(-1)), + ("maxUsdPerHour", json!(0)), + ("maxUsdPerHour", json!(50.01)), + ("maxUsdPerHour", json!(null)), + ] { + let mut sent = Map::new(); + sent.insert(name.to_owned(), value.clone()); + let refusal = super::body(&sent).expect_err(&format!("{name}={value}")); + assert_eq!(refusal.code, "invalid_params"); + assert_eq!(refusal.message, format!("{name} is out of range")); + } +} + +#[test] +fn the_request_goes_only_to_this_builds_vm_api() { + let production = request(&Backend::Production, &Map::new()).expect("request"); + assert_eq!(production.url, "https://vm.cmux.dev/v1/agent-keys"); + assert_eq!(production.base_url, "https://vm.cmux.dev"); + for backend in [ + Backend::Development("https://cmux-staging.vercel.app".into()), + Backend::LocalOnly("http://localhost:3777".into()), + ] { + assert_eq!( + request(&backend, &Map::new()).expect("request").url, + "https://vm-staging.cmux.dev/v1/agent-keys" + ); + } +} + +#[test] +fn a_minted_key_comes_back_with_where_it_works() { + let created = br#"{"key":"vmk_secret","id":"vmk_1","expiresAt":"2026-11-05T00:00:00Z"}"#; + let answer = answer(201, created, "https://vm-staging.cmux.dev", "team_1").expect("minted"); + assert_eq!(answer["id"], "vmk_1"); + assert_eq!(answer["baseUrl"], "https://vm-staging.cmux.dev"); + assert_eq!(answer["teamId"], "team_1"); +} + +#[test] +fn a_refusal_names_its_code_and_never_the_key() { + let read = |status: u16, body: &str| { + answer(status, body.as_bytes(), "https://vm.cmux.dev", "t").expect_err("refused") + }; + let no_key = read(200, r#"{"id":"vmk_1"}"#); + assert_eq!((no_key.code.as_str(), no_key.status), ("unavailable", Some(200))); + let signed_out = read(401, r#"{"message":"expired"}"#); + assert_eq!( + (signed_out.code.as_str(), signed_out.app_text.as_deref()), + ("not_signed_in", Some("notSignedIn")) + ); + assert_eq!( + read(402, r#"{"message":"upgrade"}"#), + Refusal::new("plan_required", "upgrade", None) + ); + assert_eq!(read(403, r#"{"message":"no"}"#).code, "forbidden"); + assert_eq!(read(400, r#"{"message":"bad"}"#).code, "invalid_params"); + assert_eq!(read(429, r#"{"message":"slow"}"#).code, "rate_limited"); + let down = read(503, ""); + assert_eq!( + (down.code.as_str(), down.message.as_str(), down.status), + ("unavailable", "HTTP 503", Some(503)) + ); + let long = read(500, &format!(r#"{{"message":"{}"}}"#, "m".repeat(400))); + assert_eq!(long.message.len(), 300); + let none = read(0, ""); + assert_eq!( + (none.code.as_str(), none.status, none.app_text.as_deref()), + ("unavailable", Some(0), Some("unreachable")) + ); + let leaked = read(500, r#"{"key":"vmk_secret","message":"half"}"#); + assert!(!format!("{leaked:?}").contains("vmk_secret")); +} + +#[test] +fn an_app_refusal_reads_as_the_clis_outcome() { + assert_eq!(outcome("no_team", None), Outcome::Network); + assert_eq!(outcome("not_signed_in", None), Outcome::Unauthenticated); + assert_eq!(outcome("plan_required", None), Outcome::PaymentRequired); + assert_eq!(outcome("forbidden", None), Outcome::Forbidden); + assert_eq!(outcome("rate_limited", None), Outcome::QuotaExceeded); + assert_eq!(outcome("invalid_params", None), Outcome::Usage); + assert_eq!(outcome("method_not_found", None), Outcome::MethodNotFound); + assert_eq!(outcome("unavailable", None), Outcome::Network); + assert_eq!(outcome("unavailable", Some(0)), Outcome::Network); + assert_eq!(outcome("unavailable", Some(502)), Outcome::ServiceUnavailable); + assert_eq!(outcome("unavailable", Some(404)), Outcome::Status(404)); + assert_eq!(outcome("anything", None), Outcome::Unexpected); +} + +#[test] +fn a_refusal_never_carries_a_key_the_service_put_in_its_text() { + let read = |status: u16, body: &str| { + answer(status, body.as_bytes(), "https://vm.cmux.dev", "t").expect_err("refused") + }; + let echoed = read(500, r#"{"key":"vmk_secret","message":"could not store vmk_secret"}"#); + assert_eq!(echoed.message, "could not store "); + let shaped = read(400, r#"{"message":"bad cmuxvm_sk_abcDEF012_-xyz, and cmuxvm_sk_second."}"#); + assert_eq!(shaped.message, "bad cmuxvm_sk_, and cmuxvm_sk_."); + assert_eq!(read(403, r#"{"message":"cmuxvm_sk_"}"#).message, "cmuxvm_sk_"); +} diff --git a/cmux-tui/crates/cmux-cloud-config/src/auth.rs b/cmux-tui/crates/cmux-cloud-config/src/auth.rs new file mode 100644 index 000000000000..2e8990df269e --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/src/auth.rs @@ -0,0 +1,257 @@ +//! The auth rules around the stored session that are not Keychain I/O (the app's +//! `CmuxNextCloud/Auth`): which launch keys auth keeps, the debug auto sign-in's account pick +//! (`DogfoodCredentials`), and the active team's checks (`ActiveTeam`). The app keeps the +//! Keychain and file reads, the Stack client and the team save; it asks here what they mean. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// The variables that choose a debug build's sign-in account. The app strips inherited +/// `CMUX_*` variables at launch, so these are captured first and reach auth only, never a child. +pub const LAUNCH_KEY_NAMES: [&str; 8] = [ + "CMUX_AUTH_CREDENTIALS_FILE", + "CMUX_DEV_AUTH_PROFILE", + "CMUX_DEV_AUTH_ACCOUNT", + "CMUX_DEV_AUTH_REPLACE_SESSION", + "CMUX_DOGFOOD_STACK_EMAIL", + "CMUX_DOGFOOD_STACK_PASSWORD", + "CMUX_UITEST_STACK_EMAIL", + "CMUX_UITEST_STACK_PASSWORD", +]; + +/// The auth launch keys of `environment`. +pub fn launch_keys(environment: &BTreeMap) -> BTreeMap { + environment + .iter() + .filter(|(key, _)| LAUNCH_KEY_NAMES.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +/// The environment auth reads: the process's, plus the captured launch keys it no longer has +/// (a value it still has wins). +pub fn auth_environment( + process: &BTreeMap, + launch: &BTreeMap, +) -> BTreeMap { + let mut merged = launch.clone(); + merged.extend(process.iter().map(|(key, value)| (key.clone(), value.clone()))); + merged +} + +/// An email with everything but its first letter and its domain masked, for logs. +pub fn mask(email: &str) -> String { + match email.split_once('@') { + Some((_, domain)) => { + format!("{}***@{domain}", email.chars().next().map(String::from).unwrap_or_default()) + } + None => "***".to_owned(), + } +} + +/// A debug build's sign-in account (never logged; the password stays in process). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Credentials { + pub email: String, + pub password: String, +} + +/// Why a tagged build starts signed out (logged masked, never the secret). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Refusal { + /// No `~/.config/cmux/dev-account` and no `CMUX_DEV_AUTH_ACCOUNT`. + NoDeclaredAccount, + /// The account found is not the machine owner's declared one. + AccountMismatch { found: String, declared: String }, +} + +/// The account to sign in, or why none. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Decision { + pub credentials: Option, + pub refusal: Option, +} + +/// The files [`decide`] reads under `home` for `environment`: the explicit credentials file +/// alone when one is named, else the declared account and the two ambient secrets files. The +/// app reads them (and checks the explicit one is this user's regular 0600 file) and passes the +/// contents back. +pub fn credential_paths(environment: &BTreeMap, home: &str) -> Vec { + match environment.get("CMUX_AUTH_CREDENTIALS_FILE").filter(|path| !path.is_empty()) { + Some(explicit) => vec![explicit.clone()], + None => vec![ + format!("{home}/.config/cmux/dev-account"), + format!("{home}/.secrets/cmuxterm-dev.env"), + format!("{home}/.secrets/cmux.env"), + ], + } +} + +/// What the app read for [`decide`]. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Reads { + #[serde(default)] + pub environment: BTreeMap, + pub home: String, + /// The contents of each path of [`credential_paths`] that could be read. + #[serde(default)] + pub files: BTreeMap, + /// Whether the explicit credentials file is a regular file owned by this user, 0600 or stricter. + #[serde(default)] + pub explicit_file_secure: bool, +} + +/// The account to sign in (the app's `DogfoodCredentials.decide`). An explicit credentials file +/// (`CMUX_AUTH_CREDENTIALS_FILE`) is taken as given, unless `CMUX_DEV_AUTH_ACCOUNT` names another +/// account. The ambient files (`~/.secrets/cmuxterm-dev.env`, `~/.secrets/cmux.env`) and exports +/// sign in only the machine's declared owner account (`CMUX_DEV_AUTH_ACCOUNT`, else +/// `~/.config/cmux/dev-account`): a machine's secrets file can name another person. +/// `CMUX_DEV_AUTH_PROFILE` picks `personal` (dogfood keys) or `agent` (uitest keys). +pub fn decide(reads: &Reads) -> Decision { + let environment = &reads.environment; + let expected = environment.get("CMUX_DEV_AUTH_ACCOUNT").filter(|v| !v.is_empty()); + if let Some(explicit) = environment.get("CMUX_AUTH_CREDENTIALS_FILE").filter(|v| !v.is_empty()) + { + let Some(text) = reads.files.get(explicit).filter(|_| reads.explicit_file_secure) else { + return Decision::default(); + }; + let values = parse(text); + let Some(found) = + pair(&values, "CMUX_DOGFOOD_STACK").or_else(|| pair(&values, "CMUX_UITEST_STACK")) + else { + return Decision::default(); + }; + if let Some(expected) = expected + && !found.email.eq_ignore_ascii_case(expected) + { + return Decision { + credentials: None, + refusal: Some(Refusal::AccountMismatch { + found: found.email, + declared: expected.clone(), + }), + }; + } + return Decision { credentials: Some(found), refusal: None }; + } + let home = &reads.home; + let declared = expected.cloned().or_else(|| { + reads + .files + .get(&format!("{home}/.config/cmux/dev-account")) + .map(|text| text.trim().to_owned()) + .filter(|email| !email.is_empty()) + }); + let Some(declared) = declared else { + return Decision { credentials: None, refusal: Some(Refusal::NoDeclaredAccount) }; + }; + let prefixes: &[&str] = + match environment.get("CMUX_DEV_AUTH_PROFILE").map(|p| p.to_lowercase()).as_deref() { + None | Some("") => &["CMUX_DOGFOOD_STACK", "CMUX_UITEST_STACK"], + Some("personal") => &["CMUX_DOGFOOD_STACK"], + Some("agent") => &["CMUX_UITEST_STACK"], + Some(_) => &[], + }; + let mut sources: Vec> = + [".secrets/cmuxterm-dev.env", ".secrets/cmux.env"] + .iter() + .filter_map(|file| reads.files.get(&format!("{home}/{file}"))) + .map(|text| parse(text)) + .collect(); + sources.push(environment.clone()); + let mut first: Option = None; + for prefix in prefixes { + for found in sources.iter().filter_map(|values| pair(values, prefix)) { + if found.email.eq_ignore_ascii_case(&declared) { + return Decision { credentials: Some(found), refusal: None }; + } + first.get_or_insert(found); + } + } + Decision { + credentials: None, + refusal: first.map(|found| Refusal::AccountMismatch { found: found.email, declared }), + } +} + +/// A complete pair from one source; never mixes sources. +fn pair(values: &BTreeMap, prefix: &str) -> Option { + let email = values.get(&format!("{prefix}_EMAIL")).filter(|v| !v.is_empty())?; + let password = values.get(&format!("{prefix}_PASSWORD")).filter(|v| !v.is_empty())?; + Some(Credentials { email: email.clone(), password: password.clone() }) +} + +/// `KEY=value` lines; `export`, quotes and comments tolerated. +pub fn parse(text: &str) -> BTreeMap { + let mut values = BTreeMap::new(); + for raw in text.lines() { + let mut line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(rest) = line.strip_prefix("export ") { + line = rest; + } + let Some((key, value)) = line.split_once('=') else { continue }; + let mut value = value.trim(); + for quote in ['"', '\''] { + if value.len() >= 2 + && let Some(inner) = value.strip_prefix(quote).and_then(|v| v.strip_suffix(quote)) + { + value = inner; + break; + } + } + values.insert(key.trim().to_owned(), value.to_owned()); + } + values +} + +/// Why a call naming a team may not act in it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TeamRefusal { + /// The device is restricted to another team. + Managed, + /// A call named a team that is not the active one. + NotActive, +} + +/// Why a call naming `requested` (absent or blank: the active team) may not act in it: everything +/// acts in the active team, and a managed device only in its managed team. +pub fn team_refusal( + requested: Option<&str>, + active: Option<&str>, + managed: Option<&str>, +) -> Option { + let named = requested.map(str::trim).filter(|named| !named.is_empty()); + if let Some(managed) = managed + && (active != Some(managed) || named.is_some_and(|named| named != managed)) + { + return Some(TeamRefusal::Managed); + } + match named { + Some(named) if Some(named) != active => Some(TeamRefusal::NotActive), + _ => None, + } +} + +/// The active team: the account's resolved one, or, on a device restricted to a managed team, +/// that team when the account belongs to it and none otherwise. +pub fn allowed_team_id( + resolved: Option<&str>, + available: &[String], + managed: Option<&str>, +) -> Option { + match managed { + None => resolved.map(str::to_owned), + Some(managed) => available.iter().any(|id| id == managed).then(|| managed.to_owned()), + } +} + +#[cfg(test)] +#[path = "auth_tests.rs"] +mod tests; diff --git a/cmux-tui/crates/cmux-cloud-config/src/auth_tests.rs b/cmux-tui/crates/cmux-cloud-config/src/auth_tests.rs new file mode 100644 index 000000000000..5d62c6d17af9 --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/src/auth_tests.rs @@ -0,0 +1,138 @@ +use std::collections::BTreeMap; + +use super::*; + +fn map(pairs: &[(&str, &str)]) -> BTreeMap { + pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect() +} + +const HOME: &str = "/home/me"; + +fn reads(environment: &[(&str, &str)], files: &[(&str, &str)], secure: bool) -> Reads { + Reads { + environment: map(environment), + home: HOME.to_owned(), + files: map(files), + explicit_file_secure: secure, + } +} + +fn credentials(email: &str, password: &str) -> Option { + Some(Credentials { email: email.to_owned(), password: password.to_owned() }) +} + +#[test] +fn launch_keys_are_kept_and_the_process_value_wins() { + let environment = map(&[("CMUX_DEV_AUTH_ACCOUNT", "a@x"), ("CMUX_TAG", "t"), ("PATH", "/bin")]); + assert_eq!(launch_keys(&environment), map(&[("CMUX_DEV_AUTH_ACCOUNT", "a@x")])); + let merged = auth_environment( + &map(&[("CMUX_DEV_AUTH_ACCOUNT", "now@x")]), + &map(&[("CMUX_DEV_AUTH_ACCOUNT", "then@x"), ("CMUX_DEV_AUTH_PROFILE", "agent")]), + ); + assert_eq!( + merged, + map(&[("CMUX_DEV_AUTH_ACCOUNT", "now@x"), ("CMUX_DEV_AUTH_PROFILE", "agent")]) + ); +} + +#[test] +fn a_mask_keeps_the_first_letter_and_the_domain() { + assert_eq!(mask("leo@example.com"), "l***@example.com"); + assert_eq!(mask("no-at-sign"), "***"); +} + +#[test] +fn the_paths_read_are_the_explicit_file_alone_or_the_ambient_ones() { + assert_eq!( + credential_paths(&map(&[("CMUX_AUTH_CREDENTIALS_FILE", "/c.env")]), HOME), + ["/c.env"] + ); + assert_eq!( + credential_paths(&map(&[]), HOME), + [ + "/home/me/.config/cmux/dev-account", + "/home/me/.secrets/cmuxterm-dev.env", + "/home/me/.secrets/cmux.env" + ] + ); +} + +#[test] +fn an_explicit_file_is_taken_as_given_unless_another_account_is_declared() { + let file = + "export CMUX_UITEST_STACK_EMAIL=\"agent@x\"\nCMUX_UITEST_STACK_PASSWORD='pw'\n# comment\n"; + let explicit = [("CMUX_AUTH_CREDENTIALS_FILE", "/c.env")]; + assert_eq!( + decide(&reads(&explicit, &[("/c.env", file)], true)).credentials, + credentials("agent@x", "pw") + ); + // Not this user's 0600 regular file, or unreadable: nothing, and no refusal. + assert_eq!(decide(&reads(&explicit, &[("/c.env", file)], false)), Decision::default()); + assert_eq!(decide(&reads(&explicit, &[], true)), Decision::default()); + let declared = [("CMUX_AUTH_CREDENTIALS_FILE", "/c.env"), ("CMUX_DEV_AUTH_ACCOUNT", "Owner@x")]; + assert_eq!( + decide(&reads(&declared, &[("/c.env", file)], true)).refusal, + Some(Refusal::AccountMismatch { + found: "agent@x".to_owned(), + declared: "Owner@x".to_owned() + }) + ); +} + +#[test] +fn ambient_files_sign_in_only_the_declared_owner() { + assert_eq!(decide(&reads(&[], &[], false)).refusal, Some(Refusal::NoDeclaredAccount)); + let other = "CMUX_DOGFOOD_STACK_EMAIL=someone@x\nCMUX_DOGFOOD_STACK_PASSWORD=p1\n"; + let owner = "CMUX_DOGFOOD_STACK_EMAIL=OWNER@x\nCMUX_DOGFOOD_STACK_PASSWORD=p2\n"; + let files = [ + ("/home/me/.config/cmux/dev-account", " owner@x\n"), + ("/home/me/.secrets/cmuxterm-dev.env", other), + ("/home/me/.secrets/cmux.env", owner), + ]; + assert_eq!(decide(&reads(&[], &files, false)).credentials, credentials("OWNER@x", "p2")); + let only_other = [files[0], files[1]]; + assert_eq!( + decide(&reads(&[], &only_other, false)).refusal, + Some(Refusal::AccountMismatch { + found: "someone@x".to_owned(), + declared: "owner@x".to_owned() + }) + ); + // The environment is the last source, and the profile picks the key family. + let exported = [("CMUX_UITEST_STACK_EMAIL", "owner@x"), ("CMUX_UITEST_STACK_PASSWORD", "p3")]; + assert_eq!( + decide(&reads(&exported, &only_other, false)).credentials, + credentials("owner@x", "p3") + ); + let personal = [exported[0], exported[1], ("CMUX_DEV_AUTH_PROFILE", "personal")]; + assert!(decide(&reads(&personal, &only_other, false)).credentials.is_none()); + let unknown = [("CMUX_DEV_AUTH_PROFILE", "nobody"), ("CMUX_DEV_AUTH_ACCOUNT", "owner@x")]; + assert_eq!(decide(&reads(&unknown, &files, false)), Decision::default()); + // A half pair never mixes sources. + let half = [ + ("/home/me/.config/cmux/dev-account", "owner@x"), + ("/home/me/.secrets/cmux.env", "CMUX_DOGFOOD_STACK_EMAIL=owner@x\n"), + ]; + let rest = [("CMUX_DOGFOOD_STACK_PASSWORD", "p4")]; + assert!(decide(&reads(&rest, &half, false)).credentials.is_none()); +} + +#[test] +fn a_call_acts_only_in_the_active_or_managed_team() { + assert_eq!(team_refusal(None, Some("a"), None), None); + assert_eq!(team_refusal(Some(" "), Some("a"), None), None); + assert_eq!(team_refusal(Some("a"), Some("a"), None), None); + assert_eq!(team_refusal(Some("b"), Some("a"), None), Some(TeamRefusal::NotActive)); + assert_eq!(team_refusal(Some("b"), None, None), Some(TeamRefusal::NotActive)); + assert_eq!(team_refusal(None, Some("a"), Some("m")), Some(TeamRefusal::Managed)); + assert_eq!(team_refusal(Some("a"), Some("m"), Some("m")), Some(TeamRefusal::Managed)); + assert_eq!(team_refusal(Some("m"), Some("m"), Some("m")), None); +} + +#[test] +fn a_managed_device_uses_only_its_team() { + let available = ["a".to_owned(), "m".to_owned()]; + assert_eq!(allowed_team_id(Some("a"), &available, None).as_deref(), Some("a")); + assert_eq!(allowed_team_id(Some("a"), &available, Some("m")).as_deref(), Some("m")); + assert_eq!(allowed_team_id(Some("a"), &available[..1], Some("m")), None); +} diff --git a/cmux-tui/crates/cmux-cloud-config/src/config.rs b/cmux-tui/crates/cmux-cloud-config/src/config.rs new file mode 100644 index 000000000000..9defc859b57b --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/src/config.rs @@ -0,0 +1,458 @@ +//! Which Cloud a build talks to: the app's `CloudConfiguration` rules. +//! +//! The app reads its bundle id, the bundle's `LSEnvironment` (`bundled`, what +//! `scripts/reload.sh` bakes into tagged builds) and the process environment, +//! and asks [`resolve`]. Release builds use production except the signed NEXT +//! DEV channel (bundle [`NEXT_DEV_BUNDLE_ID`]), which uses the development +//! Worker and staging sign-in. A Debug build built without `--direct-backend` +//! points at a loopback origin with no server; [`Backend::LocalOnly`] says so, +//! so Cloud actions can explain how to fix the build instead of timing out. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::origin; + +/// The signed NEXT DEV channel: a signed Release compile, not a Debug build. +pub const NEXT_DEV_BUNDLE_ID: &str = "com.cmuxterm.app.debug.next"; + +pub const PRODUCTION_ORIGIN: &str = "https://cmux.com"; +/// The staging web app: the hosted sign-in pages (`handler/native-sign-in`), the team API +/// (`/api/teams`) and the classic machine routes a development build still calls. +pub const STAGING_WEB_ORIGIN: &str = "https://cmux-staging.vercel.app"; +/// The development owner API Worker (`/v1/read`, `/v1/ops`, `/v1/auth/*`, `/v1/cloud/*`), which +/// accepts staging Stack tokens. +pub const DEVELOPMENT_WORKER_ORIGIN: &str = "https://cmux-api-development.debussy.workers.dev"; +pub const PRODUCTION_OWNER_API: &str = "https://cloud-api.cmux.dev"; +pub const STAGING_OWNER_API: &str = "https://cloud-api-staging.cmux.dev"; +pub const STACK_ORIGIN: &str = "https://api.stack-auth.com"; +pub const LOCAL_DEVELOPMENT_ORIGIN: &str = "http://localhost:3777"; +pub const DEVELOPMENT_PROJECT_ID: &str = "454ecd03-1db2-4050-845e-4ce5b0cd9895"; +pub const DEVELOPMENT_CLIENT_KEY: &str = "pck_xb63160bwe9699vtxfzfj6emmxpafg5mkjrtp6ehzxv5g"; +pub const PRODUCTION_PROJECT_ID: &str = "9790718f-14cd-4f7e-824d-eaf527a82b82"; +/// Empty on purpose: the production project does not require a publishable key, and a shipped +/// key would break sign-in once its key set is revoked. +pub const PRODUCTION_CLIENT_KEY: &str = ""; +/// The cmux VM API (workers/cmux-vm): production's and staging's. +pub const PRODUCTION_VM_API: &str = "https://vm.cmux.dev"; +pub const STAGING_VM_API: &str = "https://vm-staging.cmux.dev"; +pub const PRODUCTION_PLATFORM: &str = "https://cmux-platform.debussy.workers.dev"; +pub const STAGING_PLATFORM: &str = "https://cmux-platform-staging.debussy.workers.dev"; +pub const DEVELOPMENT_PLATFORM: &str = "https://cmux-platform-development.debussy.workers.dev"; +/// The cmux APIs a share room takes tokens from (cmux-tui `platform::share_owner_api`). +pub const SHARE_API_HOSTS: [&str; 3] = [ + "cloud-api.cmux.dev", + "cloud-api-staging.cmux.dev", + "cmux-api-development.debussy.workers.dev", +]; +const CAPTURE_BUNDLE_PREFIX: &str = "com.cmuxterm.app.debug.cap"; + +/// Where the build's Cloud is. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "url", rename_all = "camelCase")] +pub enum Backend { + /// Production (`https://cmux.com`). + Production, + /// The tag's shared development stack (`--direct-backend`), or the signed DEV channel's. + Development(String), + /// A loopback origin: `scripts/reload.sh` local backend mode. + LocalOnly(String), +} + +/// Where a Cloud machine's link socket comes from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LinkSource { + /// The classic `/api/vm` attach endpoint and a `cmux-tui remote connect` process. + Legacy, + /// The Cloud app server: the machine list, create and the `cloud.machine.connect` carrier + /// socket, all on the owner API Worker. + AppServer, +} + +/// What [`resolve`] reads. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Inputs { + pub bundle_id: Option, + /// The bundle's `LSEnvironment`; it wins over `process`, so a launch from a shell cannot + /// redirect a tagged build to another backend. + #[serde(default)] + pub bundled: BTreeMap, + #[serde(default)] + pub process: BTreeMap, + pub is_debug_build: bool, +} + +/// One build's Cloud. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Configuration { + pub backend: Backend, + /// The web API origin: the team API, account, billing, CodeRouter, Stack's `apiBaseURL`, and + /// Sandboxed runs (`/api/runs`) until they move to cmux-platform's hand-offs. + pub api_base_url: String, + /// The Cloud API base the app's machine client (`CloudAPIClient`) talks to. Production keeps + /// `cmux.com`; DEV, NEXT DEV and every other development build use the development owner API + /// Worker (NEXT Cloud, which forwards to cmux-platform), never the classic staging web app + /// (Leo, 2026-11-04: "were doing new cloud with cmux-platform and stuff"). A debug build that + /// names an origin (`CMUX_VM_API_BASE_URL`, `CMUX_API_BASE_URL`, `CMUX_DEV_BACKEND_URL`, local + /// mode) keeps it. + pub cloud_api_base_url: String, + /// Origin of the web sign-in pages (`handler/native-sign-in`). + pub auth_web_origin: String, + pub stack_base_url: String, + pub stack_project_id: String, + pub stack_publishable_client_key: String, + pub is_production_auth: bool, + pub callback_scheme: String, + pub bundle_id: Option, + pub is_debug_build: bool, + pub link_source: LinkSource, +} + +/// The facts the derived names read; a test may change them after [`resolve`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Identity { + pub bundle_id: Option, + pub is_debug_build: bool, + pub is_production_auth: bool, + #[serde(default)] + pub stack_project_id: String, + #[serde(default)] + pub link_source: Option, +} + +impl Configuration { + pub fn identity(&self) -> Identity { + Identity { + bundle_id: self.bundle_id.clone(), + is_debug_build: self.is_debug_build, + is_production_auth: self.is_production_auth, + stack_project_id: self.stack_project_id.clone(), + link_source: Some(self.link_source), + } + } +} + +fn is_next_dev(bundle_id: Option<&str>, is_debug_build: bool) -> bool { + !is_debug_build && bundle_id == Some(NEXT_DEV_BUNDLE_ID) +} + +/// The pure resolution (the app's `CloudConfiguration.resolve`). +pub fn resolve(inputs: &Inputs) -> Configuration { + let bundle_id = inputs.bundle_id.clone(); + let is_debug_build = inputs.is_debug_build; + // NEXT DEV is a signed Release compile. Its Worker accepts staging Stack tokens. Its + // endpoints are fixed so inherited environment variables cannot redirect its credentials. + if is_next_dev(bundle_id.as_deref(), is_debug_build) { + return Configuration { + backend: Backend::Development(DEVELOPMENT_WORKER_ORIGIN.to_owned()), + api_base_url: STAGING_WEB_ORIGIN.to_owned(), + cloud_api_base_url: DEVELOPMENT_WORKER_ORIGIN.to_owned(), + auth_web_origin: STAGING_WEB_ORIGIN.to_owned(), + stack_base_url: STACK_ORIGIN.to_owned(), + stack_project_id: DEVELOPMENT_PROJECT_ID.to_owned(), + stack_publishable_client_key: DEVELOPMENT_CLIENT_KEY.to_owned(), + is_production_auth: false, + callback_scheme: "cmux-next-dev".to_owned(), + bundle_id, + is_debug_build: false, + link_source: LinkSource::AppServer, + }; + } + let value = |key: &str| -> Option { + let raw = inputs.bundled.get(key).or_else(|| inputs.process.get(key))?; + let trimmed = raw.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) + }; + let production = !is_debug_build + || value("CMUX_AUTH_ENVIRONMENT").is_some_and(|v| v.eq_ignore_ascii_case("production")); + let tag = value("CMUX_TAG").and_then(|tag| sanitized_scheme_tag(&tag)); + // A scheme that cannot form `://auth-callback` is ignored (it trapped at sign-in). + let scheme = value("CMUX_AUTH_CALLBACK_SCHEME") + .filter(|scheme| origin::parse(&format!("{scheme}://auth-callback")).is_some()) + .unwrap_or_else(|| { + if is_debug_build { + tag.map_or_else(|| "cmux-dev".to_owned(), |tag| format!("cmux-dev-{tag}")) + } else { + default_scheme(bundle_id.as_deref()).to_owned() + } + }); + let stack_base = value("CMUX_STACK_BASE_URL").unwrap_or_else(|| STACK_ORIGIN.to_owned()); + // CMUX_NEXT_CLOUD_LINK survives the launch (the app keeps CMUX_NEXT_* variables); + // CMUX_CLOUD_LINK works only when baked into the bundle's LSEnvironment. The app-server + // link is the default everywhere; `legacy` keeps the classic one as the escape hatch. + let link_source = match value("CMUX_NEXT_CLOUD_LINK").or_else(|| value("CMUX_CLOUD_LINK")) { + Some(link) if link.eq_ignore_ascii_case("legacy") => LinkSource::Legacy, + _ => LinkSource::AppServer, + }; + if production { + return Configuration { + backend: Backend::Production, + api_base_url: PRODUCTION_ORIGIN.to_owned(), + cloud_api_base_url: PRODUCTION_ORIGIN.to_owned(), + auth_web_origin: PRODUCTION_ORIGIN.to_owned(), + stack_base_url: stack_base, + stack_project_id: PRODUCTION_PROJECT_ID.to_owned(), + stack_publishable_client_key: PRODUCTION_CLIENT_KEY.to_owned(), + is_production_auth: true, + callback_scheme: scheme, + bundle_id, + is_debug_build, + link_source, + }; + } + // Every non-production build signs in on staging and uses the new cloud with no launch + // environment. Tagged builds bake loopback origins for a local web server that is usually + // not running, so a loopback value is taken only when the launch asks for it + // (`CMUX_NEXT_CLOUD_BACKEND=local`); any other origin still overrides. + let wants_local = inputs + .process + .get("CMUX_NEXT_CLOUD_BACKEND") + .is_some_and(|v| v.eq_ignore_ascii_case("local")); + let origin_of = |key: &str| value(key).filter(|url| wants_local || !origin::is_loopback(url)); + let dev_backend = origin_of("CMUX_DEV_BACKEND_URL"); + let named = origin_of("CMUX_VM_API_BASE_URL") + .or_else(|| origin_of("CMUX_API_BASE_URL")) + .or_else(|| dev_backend.clone()) + .or_else(|| { + wants_local.then(|| { + format!( + "http://localhost:{}", + value("CMUX_PORT").unwrap_or_else(|| "3777".to_owned()) + ) + }) + }); + let api = named.clone().unwrap_or_else(|| STAGING_WEB_ORIGIN.to_owned()); + let cloud_api = named.unwrap_or_else(|| DEVELOPMENT_WORKER_ORIGIN.to_owned()); + let web = origin_of("CMUX_AUTH_WWW_ORIGIN") + .or_else(|| dev_backend.clone()) + .unwrap_or_else(|| api.clone()); + let backend = if origin::is_loopback(&cloud_api) && dev_backend.is_none() { + Backend::LocalOnly(cloud_api.clone()) + } else { + Backend::Development(cloud_api.clone()) + }; + Configuration { + backend, + api_base_url: api, + cloud_api_base_url: cloud_api, + auth_web_origin: web, + stack_base_url: stack_base, + stack_project_id: value("CMUX_STACK_PROJECT_ID") + .unwrap_or_else(|| DEVELOPMENT_PROJECT_ID.to_owned()), + stack_publishable_client_key: value("CMUX_STACK_PUBLISHABLE_CLIENT_KEY") + .unwrap_or_else(|| DEVELOPMENT_CLIENT_KEY.to_owned()), + is_production_auth: false, + callback_scheme: scheme, + bundle_id, + is_debug_build, + link_source, + } +} + +/// The namespace that keeps signed NEXT DEV's staging tokens apart from its old production +/// session: its first staging launch must not read production tokens or prime its cached user +/// and team, and later launches keep their staging sign-in. `None` for every other build. +pub fn auth_storage_project(identity: &Identity) -> Option { + (is_next_dev(identity.bundle_id.as_deref(), identity.is_debug_build) + && !identity.is_production_auth + && identity.stack_project_id == DEVELOPMENT_PROJECT_ID) + .then(|| identity.stack_project_id.clone()) +} + +/// The Stack token Keychain service (`.auth`, scoped by [`auth_storage_project`]). +pub fn keychain_service(identity: &Identity) -> String { + let Some(bundle_id) = identity.bundle_id.as_deref().filter(|id| !id.is_empty()) else { + return "com.cmuxterm.app.auth".to_owned(); + }; + match auth_storage_project(identity) { + Some(project) => format!("{bundle_id}.auth.{project}"), + None => format!("{bundle_id}.auth"), + } +} + +/// The file-fallback token store, under the home folder: `Library/Application Support/cmux/[/]`. +pub fn credentials_subpath(identity: &Identity) -> Vec { + let mut parts = vec![ + "Library".to_owned(), + "Application Support".to_owned(), + "cmux".to_owned(), + identity.bundle_id.clone().unwrap_or_else(|| "cmux".to_owned()), + ]; + parts.extend(auth_storage_project(identity)); + parts +} + +/// A `cmux.auth.*` defaults key, scoped like the token store. +pub fn auth_defaults_key(identity: &Identity, key: &str) -> String { + match auth_storage_project(identity) { + Some(project) => format!("{key}.{project}"), + None => key.to_owned(), + } +} + +/// The owner API Worker (`/v1/read`, `/v1/ops`, `/v1/auth/*`): production auth uses +/// cloud-api.cmux.dev, signed NEXT DEV and every other development build the development Worker. +/// `CMUX_NEXT_FEED_API_URL` is honored in debug builds only, so a release build never sends a +/// credential to another origin. +pub fn owner_api_base_url(identity: &Identity, environment: &BTreeMap) -> String { + if is_next_dev(identity.bundle_id.as_deref(), identity.is_debug_build) { + return DEVELOPMENT_WORKER_ORIGIN.to_owned(); + } + if identity.is_debug_build + && let Some(raw) = environment.get("CMUX_NEXT_FEED_API_URL") + && !raw.is_empty() + { + return raw.clone(); + } + if identity.is_production_auth { + PRODUCTION_OWNER_API.to_owned() + } else { + // Non-production builds sign in on staging, whose Stack tokens the development Worker accepts. + DEVELOPMENT_WORKER_ORIGIN.to_owned() + } +} + +/// The API a guest link's owner token comes from when the daemon does not name its room's: a +/// debug build signed in to staging mints from the development Worker unless +/// `CMUX_NEXT_FEED_API_URL` names the owner API; every other build uses its owner API. +pub fn share_api_base_url(identity: &Identity, environment: &BTreeMap) -> String { + if identity.is_debug_build + && !identity.is_production_auth + && !environment.contains_key("CMUX_NEXT_FEED_API_URL") + { + DEVELOPMENT_WORKER_ORIGIN.to_owned() + } else { + owner_api_base_url(identity, environment) + } +} + +/// Whether a guest link's owner token may come from `api`, the API the daemon's share room +/// names. Only a bare https origin; a release build takes only a cmux API. +pub fn accepts_share_api(identity: &Identity, api: &str) -> bool { + origin::is_bare_https_origin(api) + && (identity.is_debug_build + || origin::host(api).is_some_and(|host| SHARE_API_HOSTS.contains(&host.as_str()))) +} + +/// The cmux VM API for this build's Cloud: production's for production, staging's for a +/// development stack. There is no override: the app sends its Stack session only there. +pub fn vm_api_base_url(backend: &Backend) -> &'static str { + match backend { + Backend::Production => PRODUCTION_VM_API, + Backend::Development(_) | Backend::LocalOnly(_) => STAGING_VM_API, + } +} + +/// Preview ingress, served directly by cmux-platform: only the staging and development owners +/// have one (until production ingress is ready). A debug build signed in to staging on the app +/// link may name its own (`CMUX_NEXT_PLATFORM_PREVIEW_API_URL`, a bare https origin). +pub fn platform_preview_api_base_url( + identity: &Identity, + environment: &BTreeMap, +) -> Option { + if identity.link_source != Some(LinkSource::AppServer) + || !identity.is_debug_build + || identity.is_production_auth + { + return None; + } + if let Some(raw) = environment.get("CMUX_NEXT_PLATFORM_PREVIEW_API_URL") { + let named = origin::parse(raw).is_some_and(|parts| { + parts.scheme == "https" + && !parts.host.is_empty() + && parts.user.is_none() + && parts.password.is_none() + && parts.query.is_none() + && parts.fragment.is_none() + && (parts.path.is_empty() || parts.path == "/") + }); + return named.then(|| raw.clone()); + } + match origin::host(&owner_api_base_url(identity, environment)).as_deref() { + Some("cloud-api-staging.cmux.dev") => Some(STAGING_PLATFORM.to_owned()), + Some("cmux-api-development.debussy.workers.dev") => Some(DEVELOPMENT_PLATFORM.to_owned()), + _ => None, + } +} + +/// The cmux-platform origin of the Artifacts page: production's for production auth; a debug +/// build may name a platform origin (`CMUX_NEXT_ARTIFACTS_API_URL`). +pub fn artifacts_api_base_url( + identity: &Identity, + environment: &BTreeMap, +) -> String { + if identity.is_debug_build + && let Some(raw) = environment.get("CMUX_NEXT_ARTIFACTS_API_URL") + && is_platform_origin(raw) + { + return raw.clone(); + } + if identity.is_production_auth { + return PRODUCTION_PLATFORM.to_owned(); + } + platform_preview_api_base_url(identity, environment) + .unwrap_or_else(|| DEVELOPMENT_PLATFORM.to_owned()) +} + +fn is_platform_origin(raw: &str) -> bool { + let Some(parts) = origin::parse(raw) else { return false }; + if parts.host.is_empty() + || parts.user.is_some() + || parts.password.is_some() + || parts.query.is_some() + || parts.fragment.is_some() + { + return false; + } + match parts.scheme.as_str() { + "https" => parts.host.ends_with(".debussy.workers.dev") && parts.port.is_none(), + "http" => matches!(parts.host.as_str(), "localhost" | "127.0.0.1" | "::1"), + _ => false, + } +} + +/// Whether this is a PR capture's Release build: the bench configuration (`CMUX_BENCH_HOOKS`, +/// never in a shipped app) of the tag `cap`. Its Cloud is configured as a Debug build's. +pub fn is_capture_build(bundle_id: Option<&str>, bench_hooks: bool) -> bool { + let Some(tag) = bundle_id.and_then(|id| id.strip_prefix(CAPTURE_BUNDLE_PREFIX)) else { + return false; + }; + bench_hooks + && tag.len() == 10 + && tag.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// The release channel's sign-in callback scheme. +pub fn default_scheme(bundle_id: Option<&str>) -> &'static str { + match bundle_id { + Some("com.cmuxterm.app.nightly") => "cmux-nightly", + Some("com.cmuxterm.app.rc") => "cmux-rc", + _ => "cmux", + } +} + +/// `[a-z0-9]` runs joined by single hyphens, as the old app registered. +pub fn sanitized_scheme_tag(raw: &str) -> Option { + let mut result = String::new(); + let mut last_hyphen = false; + for c in raw.to_lowercase().chars() { + if c.is_ascii_lowercase() || c.is_ascii_digit() { + result.push(c); + last_hyphen = false; + } else if !last_hyphen { + result.push('-'); + last_hyphen = true; + } + } + let trimmed = result.trim_matches('-'); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +#[cfg(test)] +#[path = "config_tests.rs"] +mod tests; diff --git a/cmux-tui/crates/cmux-cloud-config/src/config_tests.rs b/cmux-tui/crates/cmux-cloud-config/src/config_tests.rs new file mode 100644 index 000000000000..1e6465e7f751 --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/src/config_tests.rs @@ -0,0 +1,336 @@ +//! Ported from the app's CloudConfigurationSignedDEVTests, CloudShareAPITests and +//! CloudSignedDEVAuthPersistenceTests (the configuration parts). + +use std::collections::BTreeMap; + +use super::*; + +fn map(pairs: &[(&str, &str)]) -> BTreeMap { + pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect() +} + +fn config( + bundle_id: &str, + bundled: &[(&str, &str)], + process: &[(&str, &str)], + debug: bool, +) -> Configuration { + resolve(&Inputs { + bundle_id: Some(bundle_id.to_owned()), + bundled: map(bundled), + process: map(process), + is_debug_build: debug, + }) +} + +fn none() -> BTreeMap { + BTreeMap::new() +} + +#[test] +fn signed_dev_uses_the_development_cloud_without_launch_overrides() { + let config = config(NEXT_DEV_BUNDLE_ID, &[], &[], false); + let identity = config.identity(); + assert_eq!(config.link_source, LinkSource::AppServer); + assert_eq!( + owner_api_base_url(&identity, &none()), + "https://cmux-api-development.debussy.workers.dev" + ); + assert_eq!(config.api_base_url, "https://cmux-staging.vercel.app"); + assert_eq!(config.auth_web_origin, "https://cmux-staging.vercel.app"); + assert_eq!(config.stack_project_id, "454ecd03-1db2-4050-845e-4ce5b0cd9895"); + assert!(!config.is_production_auth); + assert!(!config.is_debug_build); + assert_eq!(config.callback_scheme, "cmux-next-dev"); + assert_eq!( + keychain_service(&identity), + "com.cmuxterm.app.debug.next.auth.454ecd03-1db2-4050-845e-4ce5b0cd9895" + ); + // The Cloud API is NEXT Cloud's development Worker, never the classic staging web app. + assert_eq!(config.cloud_api_base_url, "https://cmux-api-development.debussy.workers.dev"); + assert_eq!(config.backend, Backend::Development(config.cloud_api_base_url.clone())); + assert_eq!(vm_api_base_url(&config.backend), "https://vm-staging.cmux.dev"); +} + +#[test] +fn signed_dev_does_not_accept_endpoint_or_auth_overrides() { + let overrides = [ + ("CMUX_AUTH_ENVIRONMENT", "production"), + ("CMUX_STACK_BASE_URL", "https://override.invalid"), + ("CMUX_STACK_PROJECT_ID", "other-project"), + ("CMUX_API_BASE_URL", "https://override.invalid"), + ("CMUX_AUTH_WWW_ORIGIN", "https://override.invalid"), + ("CMUX_NEXT_FEED_API_URL", "https://override.invalid"), + ("CMUX_AUTH_CALLBACK_SCHEME", "other-app"), + ("CMUX_NEXT_CLOUD_LINK", "legacy"), + ]; + let baseline = config(NEXT_DEV_BUNDLE_ID, &[], &[], false); + let config = config(NEXT_DEV_BUNDLE_ID, &overrides, &overrides, false); + assert_eq!(config, baseline); + assert_eq!( + owner_api_base_url(&config.identity(), &map(&overrides)), + owner_api_base_url(&baseline.identity(), &none()) + ); +} + +#[test] +fn other_release_channels_keep_production() { + for bundle_id in [ + "com.cmuxterm.app", + "com.cmuxterm.app.nightly", + "com.cmuxterm.app.nightly.cmux-next", + "com.cmuxterm.app.rc", + "com.cmuxterm.app.debug", + "com.cmuxterm.app.debug.next.other", + "com.cmuxterm.app.debug.nextx", + ] { + let config = config(bundle_id, &[], &[], false); + assert_eq!(config.link_source, LinkSource::AppServer, "{bundle_id}"); + assert!(config.is_production_auth, "{bundle_id}"); + assert_eq!(config.api_base_url, "https://cmux.com", "{bundle_id}"); + assert_eq!(config.cloud_api_base_url, "https://cmux.com", "{bundle_id}"); + assert_eq!(owner_api_base_url(&config.identity(), &none()), "https://cloud-api.cmux.dev"); + assert_eq!(vm_api_base_url(&config.backend), "https://vm.cmux.dev"); + assert_eq!(keychain_service(&config.identity()), format!("{bundle_id}.auth")); + } + assert_eq!(config("com.cmuxterm.app.nightly", &[], &[], false).callback_scheme, "cmux-nightly"); + assert_eq!(config("com.cmuxterm.app.rc", &[], &[], false).callback_scheme, "cmux-rc"); + assert_eq!(config("com.cmuxterm.app", &[], &[], false).callback_scheme, "cmux"); +} + +#[test] +fn production_keeps_the_legacy_escape_hatch() { + for bundled in [[("CMUX_NEXT_CLOUD_LINK", "legacy")], [("CMUX_CLOUD_LINK", "LEGACY")]] { + let config = config("com.cmuxterm.app.nightly.cmux-next", &bundled, &[], false); + assert_eq!(config.link_source, LinkSource::Legacy); + assert!(config.is_production_auth); + } + let launched = config( + "com.cmuxterm.app.nightly.cmux-next", + &[], + &[("CMUX_NEXT_CLOUD_LINK", "legacy")], + false, + ); + assert_eq!(launched.link_source, LinkSource::Legacy); +} + +#[test] +fn only_a_bench_capture_build_is_configured_as_debug() { + assert!(is_capture_build(Some("com.cmuxterm.app.debug.cap0123456789"), true)); + assert!(is_capture_build(Some("com.cmuxterm.app.debug.capabcdef0123"), true)); + assert!(!is_capture_build(Some("com.cmuxterm.app.debug.cap0123456789"), false)); + for bundle_id in [ + None, + Some("com.cmuxterm.app"), + Some("com.cmuxterm.app.nightly"), + Some("com.cmuxterm.app.rc"), + Some("com.cmuxterm.app.debug.next"), + Some("com.cmuxterm.app.debug.chrome.parity.x"), + Some("com.cmuxterm.app.debug.cap012345678"), + Some("com.cmuxterm.app.debug.cap0123456789a"), + Some("com.cmuxterm.app.debug.capABCDEF0123"), + Some("com.cmuxterm.app.debug.cap0123456789.x"), + Some("com.cmuxterm.app.nightly.cap0123456789"), + ] { + assert!(!is_capture_build(bundle_id, true), "{bundle_id:?}"); + } + let capture = config("com.cmuxterm.app.debug.cap0123456789", &[], &[], true); + assert!(!capture.is_production_auth); + assert_eq!( + owner_api_base_url(&capture.identity(), &none()), + "https://cmux-api-development.debussy.workers.dev" + ); +} + +#[test] +fn debug_build_keeps_its_explicit_link_and_worker_overrides() { + let config = config(NEXT_DEV_BUNDLE_ID, &[("CMUX_CLOUD_LINK", "app")], &[], true); + assert_eq!(config.link_source, LinkSource::AppServer); + assert_eq!( + owner_api_base_url( + &config.identity(), + &map(&[("CMUX_NEXT_FEED_API_URL", "https://override.invalid")]) + ), + "https://override.invalid" + ); +} + +#[test] +fn a_debug_build_mints_share_tokens_from_the_development_worker() { + let identity = config("com.cmuxterm.app.debug.walk", &[], &[], true).identity(); + assert_eq!(owner_api_base_url(&identity, &none()), DEVELOPMENT_WORKER_ORIGIN); + assert_eq!(share_api_base_url(&identity, &none()), DEVELOPMENT_WORKER_ORIGIN); +} + +#[test] +fn an_owner_api_override_is_the_share_api_too_and_a_release_build_keeps_its_owner_api() { + let debug = config("com.cmuxterm.app.debug.walk", &[], &[], true).identity(); + let named = map(&[("CMUX_NEXT_FEED_API_URL", "https://owner.standin.invalid")]); + assert_eq!(share_api_base_url(&debug, &named), owner_api_base_url(&debug, &named)); + let release = config("com.cmuxterm.app", &[], &[], false).identity(); + assert_eq!(share_api_base_url(&release, &none()), owner_api_base_url(&release, &none())); + // A release build never takes the debug override. + assert_eq!(owner_api_base_url(&release, &named), PRODUCTION_OWNER_API); +} + +#[test] +fn a_release_build_sends_credentials_only_to_a_cmux_api() { + let release = config("com.cmuxterm.app", &[], &[], false).identity(); + let debug = config("com.cmuxterm.app.debug.walk", &[], &[], true).identity(); + for api in [ + "https://cloud-api.cmux.dev", + "https://cloud-api-staging.cmux.dev", + "https://cmux-api-development.debussy.workers.dev/", + ] { + assert!(accepts_share_api(&release, api), "{api}"); + } + assert!(!accepts_share_api(&release, "https://elsewhere.example")); + assert!(accepts_share_api(&debug, "https://elsewhere.example")); + for api in [ + "http://cloud-api.cmux.dev", + "https://cloud-api.cmux.dev:8443", + "https://cloud-api.cmux.dev/v1", + "https://u:p@cloud-api.cmux.dev", + ] { + assert!(!accepts_share_api(&debug, api), "{api}"); + } +} + +#[test] +fn signed_dev_scopes_its_tokens_apart_from_the_old_production_session() { + let development = config(NEXT_DEV_BUNDLE_ID, &[], &[], false).identity(); + let previous_production = Identity { + is_production_auth: true, + stack_project_id: PRODUCTION_PROJECT_ID.to_owned(), + ..development.clone() + }; + assert_ne!(keychain_service(&development), keychain_service(&previous_production)); + assert_ne!(credentials_subpath(&development), credentials_subpath(&previous_production)); + assert_eq!( + credentials_subpath(&development), + ["Library", "Application Support", "cmux", NEXT_DEV_BUNDLE_ID, DEVELOPMENT_PROJECT_ID] + ); + assert_eq!( + auth_defaults_key(&development, "cmux.auth.cachedUser"), + format!("cmux.auth.cachedUser.{DEVELOPMENT_PROJECT_ID}") + ); + assert_eq!( + auth_defaults_key(&previous_production, "cmux.auth.cachedUser"), + "cmux.auth.cachedUser" + ); + let unnamed = Identity { bundle_id: None, ..previous_production }; + assert_eq!(keychain_service(&unnamed), "com.cmuxterm.app.auth"); + assert_eq!(credentials_subpath(&unnamed), ["Library", "Application Support", "cmux", "cmux"]); +} + +#[test] +fn a_debug_build_signs_in_on_staging_and_takes_only_asked_for_loopback_origins() { + let tagged = [("CMUX_TAG", "My Tag!"), ("CMUX_API_BASE_URL", "http://localhost:4000")]; + let plain = config("com.cmuxterm.app.debug.my-tag", &tagged, &[], true); + assert_eq!(plain.api_base_url, STAGING_WEB_ORIGIN); + assert_eq!(plain.cloud_api_base_url, DEVELOPMENT_WORKER_ORIGIN); + assert_eq!(plain.backend, Backend::Development(DEVELOPMENT_WORKER_ORIGIN.to_owned())); + assert_eq!(plain.callback_scheme, "cmux-dev-my-tag"); + assert!(!plain.is_production_auth); + let local = config( + "com.cmuxterm.app.debug.my-tag", + &tagged, + &[("CMUX_NEXT_CLOUD_BACKEND", "local")], + true, + ); + assert_eq!(local.api_base_url, "http://localhost:4000"); + assert_eq!(local.cloud_api_base_url, "http://localhost:4000"); + assert_eq!(local.backend, Backend::LocalOnly("http://localhost:4000".to_owned())); + let port = config( + "com.cmuxterm.app.debug", + &[("CMUX_PORT", "3999")], + &[("CMUX_NEXT_CLOUD_BACKEND", "LOCAL")], + true, + ); + assert_eq!(port.api_base_url, "http://localhost:3999"); + let remote = config( + "com.cmuxterm.app.debug", + &[("CMUX_DEV_BACKEND_URL", "https://dev.example")], + &[], + true, + ); + assert_eq!( + (remote.api_base_url.as_str(), remote.auth_web_origin.as_str()), + ("https://dev.example", "https://dev.example") + ); + // The bundle wins over the launch environment; blank values count as unset. + let bundled = config( + "com.cmuxterm.app.debug", + &[("CMUX_API_BASE_URL", "https://a.example")], + &[("CMUX_API_BASE_URL", "https://b.example")], + true, + ); + assert_eq!(bundled.api_base_url, "https://a.example"); + assert_eq!(bundled.cloud_api_base_url, "https://a.example"); + let blank = config("com.cmuxterm.app.debug", &[("CMUX_STACK_PROJECT_ID", " ")], &[], true); + assert_eq!(blank.stack_project_id, DEVELOPMENT_PROJECT_ID); + let production = + config("com.cmuxterm.app.debug", &[("CMUX_AUTH_ENVIRONMENT", "Production")], &[], true); + assert!(production.is_production_auth); + assert_eq!(production.callback_scheme, "cmux-dev"); +} + +#[test] +fn a_callback_scheme_that_cannot_form_a_url_is_ignored() { + let bad = config( + "com.cmuxterm.app.debug", + &[("CMUX_AUTH_CALLBACK_SCHEME", "not a scheme")], + &[], + true, + ); + assert_eq!(bad.callback_scheme, "cmux-dev"); + let good = + config("com.cmuxterm.app.debug", &[("CMUX_AUTH_CALLBACK_SCHEME", "cmux-x")], &[], true); + assert_eq!(good.callback_scheme, "cmux-x"); +} + +#[test] +fn previews_and_artifacts_come_from_each_owners_platform() { + let debug = config("com.cmuxterm.app.debug", &[], &[], true).identity(); + assert_eq!( + platform_preview_api_base_url(&debug, &none()).as_deref(), + Some(DEVELOPMENT_PLATFORM) + ); + let staging_owner = map(&[("CMUX_NEXT_FEED_API_URL", "https://cloud-api-staging.cmux.dev")]); + assert_eq!( + platform_preview_api_base_url(&debug, &staging_owner).as_deref(), + Some(STAGING_PLATFORM) + ); + let named = map(&[("CMUX_NEXT_PLATFORM_PREVIEW_API_URL", "https://p.example")]); + assert_eq!(platform_preview_api_base_url(&debug, &named).as_deref(), Some("https://p.example")); + let bad = map(&[("CMUX_NEXT_PLATFORM_PREVIEW_API_URL", "https://p.example/x")]); + assert_eq!(platform_preview_api_base_url(&debug, &bad), None); + let release = config("com.cmuxterm.app", &[], &[], false).identity(); + assert_eq!(platform_preview_api_base_url(&release, &none()), None); + assert_eq!(artifacts_api_base_url(&release, &none()), PRODUCTION_PLATFORM); + assert_eq!(artifacts_api_base_url(&debug, &none()), DEVELOPMENT_PLATFORM); + let local = map(&[("CMUX_NEXT_ARTIFACTS_API_URL", "http://localhost:8787")]); + assert_eq!(artifacts_api_base_url(&debug, &local), "http://localhost:8787"); + let foreign = map(&[("CMUX_NEXT_ARTIFACTS_API_URL", "https://elsewhere.example")]); + assert_eq!(artifacts_api_base_url(&debug, &foreign), DEVELOPMENT_PLATFORM); +} + +#[test] +fn scheme_tags_are_lowercase_runs_joined_by_single_hyphens() { + assert_eq!(sanitized_scheme_tag("My__Tag 2").as_deref(), Some("my-tag-2")); + assert_eq!(sanitized_scheme_tag("--x--").as_deref(), Some("x")); + assert_eq!(sanitized_scheme_tag("!!!"), None); +} + +#[test] +fn no_development_build_uses_the_classic_staging_web_app_as_its_cloud_api() { + for (bundle_id, debug) in [ + (NEXT_DEV_BUNDLE_ID, false), + ("com.cmuxterm.app.debug", true), + ("com.cmuxterm.app.debug.my-tag", true), + ] { + let config = config(bundle_id, &[], &[], debug); + assert_ne!(config.cloud_api_base_url, STAGING_WEB_ORIGIN, "{bundle_id}"); + assert_eq!(config.cloud_api_base_url, DEVELOPMENT_WORKER_ORIGIN, "{bundle_id}"); + } +} diff --git a/cmux-tui/crates/cmux-cloud-config/src/lib.rs b/cmux-tui/crates/cmux-cloud-config/src/lib.rs new file mode 100644 index 000000000000..c24d54a9354d --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/src/lib.rs @@ -0,0 +1,20 @@ +//! NEXT Cloud's configuration rules, in Rust so the macOS app keeps only the +//! platform calls (Leo, 2026-11-04, on whether this belongs in Swift: "Yes, +//! start it"). Pure: no I/O. The app reads its bundle, its environment and the +//! Keychain, asks here, and acts on the answer; the `cmux vm` CLI links the +//! same rules in process. Served to the app over the app FFI's JSON channel +//! (`cmux-layout-reducer-ffi`, ops `cloud_config_*`, `cloud_auth_*`, +//! `cloud_vm_agent_key_*`). +//! +//! - [`config`]: which Cloud a build talks to (the backend, the API origins, +//! the Stack project, the token store's names, the VM API). +//! - [`auth`]: the rules around the stored session that are not Keychain I/O: +//! the debug auto sign-in's account pick, the launch keys auth keeps, the +//! active team's checks. +//! - [`agent_key`]: `vm.agent_key.create` and `cmux vm token create`: the +//! request a VM agent key is minted with and how the VM API's answer reads. + +pub mod agent_key; +pub mod auth; +pub mod config; +pub mod origin; diff --git a/cmux-tui/crates/cmux-cloud-config/src/origin.rs b/cmux-tui/crates/cmux-cloud-config/src/origin.rs new file mode 100644 index 000000000000..ad39e6219549 --- /dev/null +++ b/cmux-tui/crates/cmux-cloud-config/src/origin.rs @@ -0,0 +1,148 @@ +//! Just enough URL reading for the configuration rules, without a URL parser +//! (the app FFI links none): an absolute `scheme://authority/path?query#fragment` +//! split into its parts. A string without `://` has no host. + +/// An absolute URL's parts. `host` is lowercased, without brackets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Parts<'a> { + pub scheme: String, + pub user: Option<&'a str>, + pub password: Option<&'a str>, + pub host: String, + pub port: Option<&'a str>, + pub path: &'a str, + pub query: Option<&'a str>, + pub fragment: Option<&'a str>, +} + +/// The parts of `raw`, or `None` when it is not `scheme://…` with a valid scheme. +pub fn parse(raw: &str) -> Option> { + let (scheme, rest) = raw.split_once("://")?; + let mut chars = scheme.chars(); + if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return None; + } + let (rest, fragment) = match rest.split_once('#') { + Some((rest, fragment)) => (rest, Some(fragment)), + None => (rest, None), + }; + let (rest, query) = match rest.split_once('?') { + Some((rest, query)) => (rest, Some(query)), + None => (rest, None), + }; + let (authority, path) = match rest.split_once('/') { + Some((authority, _)) => (authority, rest.strip_prefix(authority).unwrap_or_default()), + None => (rest, ""), + }; + let (userinfo, hostport) = match authority.rsplit_once('@') { + Some((userinfo, hostport)) => (Some(userinfo), hostport), + None => (None, authority), + }; + let (user, password) = match userinfo { + Some(info) => match info.split_once(':') { + Some((user, password)) => (Some(user), Some(password)), + None => (Some(info), None), + }, + None => (None, None), + }; + let (host, port) = if let Some(bracketed) = hostport.strip_prefix('[') { + let (host, after) = bracketed.split_once(']')?; + (host, after.strip_prefix(':')) + } else { + match hostport.rsplit_once(':') { + Some((host, port)) => (host, Some(port)), + None => (hostport, None), + } + }; + Some(Parts { + scheme: scheme.to_ascii_lowercase(), + user, + password, + host: host.to_ascii_lowercase(), + port, + path, + query, + fragment, + }) +} + +/// The lowercased host of `raw`, `None` when it has none. +pub fn host(raw: &str) -> Option { + parse(raw).map(|parts| parts.host).filter(|host| !host.is_empty()) +} + +/// Whether `raw` names this machine (or no host at all, which reaches nothing but this machine). +pub fn is_loopback(raw: &str) -> bool { + match host(raw) { + None => true, + Some(host) => matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"), + } +} + +/// Whether `raw` is a bare origin: https, a host, and no credentials, port, path, query or fragment. +pub fn is_bare_https_origin(raw: &str) -> bool { + parse(raw).is_some_and(|parts| { + parts.scheme == "https" + && !parts.host.is_empty() + && parts.user.is_none() + && parts.password.is_none() + && parts.port.is_none() + && (parts.path.is_empty() || parts.path == "/") + && parts.query.is_none() + && parts.fragment.is_none() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_parts_of_an_origin() { + let parts = parse("HTTPS://u:p@Example.COM:8443/a/b?q=1#f").expect("parts"); + assert_eq!(parts.scheme, "https"); + assert_eq!((parts.user, parts.password), (Some("u"), Some("p"))); + assert_eq!(parts.host, "example.com"); + assert_eq!(parts.port, Some("8443")); + assert_eq!(parts.path, "/a/b"); + assert_eq!((parts.query, parts.fragment), (Some("q=1"), Some("f"))); + assert_eq!(host("http://[::1]:3777"), Some("::1".to_owned())); + assert_eq!(host("not a url"), None); + assert!(parse("1x://host").is_none()); + } + + #[test] + fn loopback_is_this_machine_or_no_host() { + for url in [ + "http://localhost:3777", + "http://127.0.0.1", + "http://[::1]:1", + "garbage", + "file:///tmp", + ] { + assert!(is_loopback(url), "{url}"); + } + for url in ["https://cmux.com", "http://127.0.0.2", "https://localhost.example"] { + assert!(!is_loopback(url), "{url}"); + } + } + + #[test] + fn a_bare_origin_has_nothing_but_scheme_and_host() { + assert!(is_bare_https_origin("https://cloud-api.cmux.dev")); + assert!(is_bare_https_origin("https://cloud-api.cmux.dev/")); + for url in [ + "http://cloud-api.cmux.dev", + "https://cloud-api.cmux.dev:443", + "https://u@cloud-api.cmux.dev", + "https://cloud-api.cmux.dev/v1", + "https://cloud-api.cmux.dev?x", + "https://cloud-api.cmux.dev#x", + "https://", + ] { + assert!(!is_bare_https_origin(url), "{url}"); + } + } +} diff --git a/cmux-tui/crates/cmux-layout-reducer-ffi/Cargo.toml b/cmux-tui/crates/cmux-layout-reducer-ffi/Cargo.toml index 1dc762a1b09e..d051bcf42f95 100644 --- a/cmux-tui/crates/cmux-layout-reducer-ffi/Cargo.toml +++ b/cmux-tui/crates/cmux-layout-reducer-ffi/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.91" license = "GPL-3.0-or-later" publish = false -description = "C ABI for the pure cmux sidebar drop resolver, section layout reducer, Tasks preview, bookmark preview, window focus rules and the Cloud start's decisions" +description = "C ABI for the pure cmux sidebar drop resolver, section layout reducer, Tasks preview, bookmark preview, window focus rules, the Cloud start's decisions and NEXT Cloud's configuration rules" [workspace] @@ -15,6 +15,7 @@ crate-type = ["staticlib", "rlib"] [dependencies] # Without its `url` feature: the app links no URL parser (cmux-bookmarks-core `validate_url`). cmux-bookmarks-core = { path = "../cmux-bookmarks-core" } +cmux-cloud-config = { path = "../cmux-cloud-config" } cmux-cloud-start-core = { path = "../cmux-cloud-start-core" } cmux-focus-core = { path = "../cmux-focus-core" } cmux-imessage = { path = "../cmux-imessage" } diff --git a/cmux-tui/crates/cmux-layout-reducer-ffi/src/cloud_config.rs b/cmux-tui/crates/cmux-layout-reducer-ffi/src/cloud_config.rs new file mode 100644 index 000000000000..5dfed7963f97 --- /dev/null +++ b/cmux-tui/crates/cmux-layout-reducer-ffi/src/cloud_config.rs @@ -0,0 +1,196 @@ +//! `cmux-cloud-config` over the JSON channel: which Cloud the app talks to +//! (`cloud_config_*`), the auth rules around its Keychain I/O (`cloud_auth_*`) +//! and the VM agent-key mint (`cloud_vm_agent_key_*`). The app reads its bundle, +//! environment, files and the Keychain, sends the facts and acts on the answer; +//! it does the HTTP call itself. + +use std::collections::BTreeMap; + +use cmux_cloud_config::{agent_key, auth, config}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +use crate::ERR_INVALID; + +/// Whether `operation` is one of this module's. +pub(crate) fn handles(operation: &[u8]) -> bool { + [&b"cloud_config_"[..], b"cloud_auth_", b"cloud_vm_agent_key_"] + .iter() + .any(|prefix| operation.starts_with(prefix)) +} + +fn read<'a, T: Deserialize<'a>>(request: &'a [u8]) -> Result { + serde_json::from_slice(request).map_err(|_| ERR_INVALID) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WithEnvironment { + identity: config::Identity, + #[serde(default)] + environment: BTreeMap, +} + +pub(crate) fn respond(operation: &[u8], request: &[u8]) -> Result, i32> { + let answer = match operation { + // The build's Cloud, with the names derived from it. + b"cloud_config_resolve" => { + let inputs: config::Inputs = read(request)?; + let configuration = config::resolve(&inputs); + let identity = configuration.identity(); + json!({ + "configuration": configuration, + "keychainService": config::keychain_service(&identity), + "credentialsSubpath": config::credentials_subpath(&identity), + "vmApiBaseUrl": config::vm_api_base_url(&configuration.backend), + }) + } + // The token store's names for facts a caller may have changed after resolving. + b"cloud_config_names" => { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Request { + identity: config::Identity, + #[serde(default)] + defaults_keys: Vec, + } + let request: Request = read(request)?; + let keys: Map = request + .defaults_keys + .iter() + .map(|key| (key.clone(), json!(config::auth_defaults_key(&request.identity, key)))) + .collect(); + json!({ + "keychainService": config::keychain_service(&request.identity), + "credentialsSubpath": config::credentials_subpath(&request.identity), + "defaultsKeys": keys, + }) + } + // The APIs that read the launch environment at call time. + b"cloud_config_apis" => { + let request: WithEnvironment = read(request)?; + let (identity, environment) = (&request.identity, &request.environment); + json!({ + "ownerApi": config::owner_api_base_url(identity, environment), + "shareApi": config::share_api_base_url(identity, environment), + "previewApi": config::platform_preview_api_base_url(identity, environment), + "artifactsApi": config::artifacts_api_base_url(identity, environment), + }) + } + b"cloud_config_accepts_share_api" => { + #[derive(Deserialize)] + struct Request { + identity: config::Identity, + api: String, + } + let request: Request = read(request)?; + json!({ "accepts": config::accepts_share_api(&request.identity, &request.api) }) + } + b"cloud_config_capture_build" => { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Request { + bundle_id: Option, + bench_hooks: bool, + } + let request: Request = read(request)?; + json!({ "capture": config::is_capture_build(request.bundle_id.as_deref(), request.bench_hooks) }) + } + b"cloud_auth_launch_keys" => { + #[derive(Deserialize)] + struct Request { + environment: BTreeMap, + } + let request: Request = read(request)?; + json!({ "keys": auth::launch_keys(&request.environment) }) + } + b"cloud_auth_environment" => { + #[derive(Deserialize)] + struct Request { + process: BTreeMap, + launch: BTreeMap, + } + let request: Request = read(request)?; + json!({ "environment": auth::auth_environment(&request.process, &request.launch) }) + } + b"cloud_auth_credential_paths" => { + #[derive(Deserialize)] + struct Request { + environment: BTreeMap, + home: String, + } + let request: Request = read(request)?; + json!({ "paths": auth::credential_paths(&request.environment, &request.home) }) + } + b"cloud_auth_credentials" => { + let reads: auth::Reads = read(request)?; + json!(auth::decide(&reads)) + } + b"cloud_auth_mask" => { + #[derive(Deserialize)] + struct Request { + email: String, + } + let request: Request = read(request)?; + json!({ "masked": auth::mask(&request.email) }) + } + b"cloud_auth_team" => { + #[derive(Deserialize)] + struct Request { + requested: Option, + resolved: Option, + #[serde(default)] + available: Vec, + managed: Option, + } + let request: Request = read(request)?; + let active = auth::allowed_team_id( + request.resolved.as_deref(), + &request.available, + request.managed.as_deref(), + ); + let refusal = auth::team_refusal( + request.requested.as_deref(), + active.as_deref(), + request.managed.as_deref(), + ); + json!({ "active": active, "refusal": refusal }) + } + b"cloud_vm_agent_key_request" => { + #[derive(Deserialize)] + struct Request { + backend: config::Backend, + #[serde(default)] + params: Map, + } + let request: Request = read(request)?; + match agent_key::request(&request.backend, &request.params) { + Ok(request) => json!({ "request": request }), + Err(refusal) => json!({ "refusal": refusal }), + } + } + b"cloud_vm_agent_key_answer" => { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Request { + status: u16, + #[serde(default)] + body: String, + base_url: String, + team_id: String, + } + let request: Request = read(request)?; + match agent_key::answer( + request.status, + request.body.as_bytes(), + &request.base_url, + &request.team_id, + ) { + Ok(fields) => json!({ "result": fields }), + Err(refusal) => json!({ "refusal": refusal }), + } + } + _ => return Err(ERR_INVALID), + }; + serde_json::to_vec(&answer).map_err(|_| ERR_INVALID) +} diff --git a/cmux-tui/crates/cmux-layout-reducer-ffi/src/lib.rs b/cmux-tui/crates/cmux-layout-reducer-ffi/src/lib.rs index 7cb67dabc441..0c9b1ba6dcb6 100644 --- a/cmux-tui/crates/cmux-layout-reducer-ffi/src/lib.rs +++ b/cmux-tui/crates/cmux-layout-reducer-ffi/src/lib.rs @@ -11,6 +11,9 @@ //! `cloud_ask_answer`, `cloud_ask_first`: who may answer an agent's Cloud ask; and //! `cloud_harness_login_provider`, `cloud_guest_kind`, `cloud_guest_kind_probe`, //! kept for an app pinned before `cloud_harness_login` read them itself), +//! `cmux-cloud-config`'s rules (`cloud_config_*`: which Cloud a build talks to; +//! `cloud_auth_*`: the auth rules around the app's Keychain I/O; +//! `cloud_vm_agent_key_*`: the VM agent-key mint's request and answer), //! `cmux-focus-core`'s per-key rules (`focus_type_ahead`, //! `focus_child_window_key`), agent cursor visibility //! (`agent_cursor_visibility`) and input invariants (`focus_input_world`, @@ -32,6 +35,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; pub mod agent_cursor; pub mod close_focus; +mod cloud_config; pub mod focus; mod imessage; pub mod scroll; @@ -357,6 +361,9 @@ pub unsafe extern "C" fn cmux_layout_reducer_json( serde_json::to_vec(&cmux_focus_core::wire::agent_cursor_visibility(&request)) .map_err(|_| ERR_INVALID)? } + operation if cloud_config::handles(operation) => { + cloud_config::respond(operation, request)? + } _ => return Err(ERR_INVALID), }; Ok(response) diff --git a/cmux-tui/crates/cmux-layout-reducer-ffi/tests/cloud_config.rs b/cmux-tui/crates/cmux-layout-reducer-ffi/tests/cloud_config.rs new file mode 100644 index 000000000000..1fd19f967aa0 --- /dev/null +++ b/cmux-tui/crates/cmux-layout-reducer-ffi/tests/cloud_config.rs @@ -0,0 +1,5 @@ +//! NEXT Cloud's configuration ops through the C ABI, under `cloud_config::` +//! so the fleet's one-substring test filter selects it. + +#[path = "cloud_config_ffi/cases.rs"] +mod cloud_config; diff --git a/cmux-tui/crates/cmux-layout-reducer-ffi/tests/cloud_config_ffi/cases.rs b/cmux-tui/crates/cmux-layout-reducer-ffi/tests/cloud_config_ffi/cases.rs new file mode 100644 index 000000000000..944a35554b64 --- /dev/null +++ b/cmux-tui/crates/cmux-layout-reducer-ffi/tests/cloud_config_ffi/cases.rs @@ -0,0 +1,142 @@ +//! `cloud_config_*`, `cloud_auth_*` and `cloud_vm_agent_key_*` through the C ABI: the app sends +//! its facts and acts on the answer (cmux-cloud-config holds the rules and their cases). + +use cmux_layout_reducer_ffi::{ERR_INVALID, OK, cmux_layout_reducer_json}; +use serde_json::{Value, json}; +use std::ffi::CStr; + +fn call(operation: &CStr, request: &Value) -> (i32, Value) { + let request = serde_json::to_vec(request).unwrap(); + let mut output = vec![0u8; 1 << 13]; + let mut len = 0usize; + let code = unsafe { + cmux_layout_reducer_json( + request.as_ptr(), + request.len(), + operation.as_ptr(), + output.as_mut_ptr(), + output.len(), + &mut len, + ) + }; + let body = + if code == OK { serde_json::from_slice(&output[..len]).unwrap() } else { Value::Null }; + (code, body) +} + +#[test] +fn signed_dev_resolves_to_the_development_cloud_with_its_names() { + let (code, body) = call( + c"cloud_config_resolve", + &json!({"bundleId": "com.cmuxterm.app.debug.next", "bundled": {"CMUX_API_BASE_URL": "https://x.invalid"}, + "process": {}, "isDebugBuild": false}), + ); + assert_eq!(code, OK); + let configuration = &body["configuration"]; + assert_eq!( + configuration["backend"], + json!({"kind": "development", "url": "https://cmux-api-development.debussy.workers.dev"}) + ); + // NEXT Cloud's Worker, never the classic staging web app; sign-in and teams stay on the web app. + assert_eq!( + configuration["cloudApiBaseUrl"], + "https://cmux-api-development.debussy.workers.dev" + ); + assert_eq!(configuration["apiBaseUrl"], "https://cmux-staging.vercel.app"); + assert_eq!(configuration["linkSource"], "appServer"); + assert_eq!(configuration["callbackScheme"], "cmux-next-dev"); + assert_eq!(configuration["isProductionAuth"], false); + assert_eq!( + body["keychainService"], + "com.cmuxterm.app.debug.next.auth.454ecd03-1db2-4050-845e-4ce5b0cd9895" + ); + assert_eq!(body["vmApiBaseUrl"], "https://vm-staging.cmux.dev"); + let (_, production) = call( + c"cloud_config_resolve", + &json!({"bundleId": "com.cmuxterm.app", "isDebugBuild": false}), + ); + assert_eq!(production["configuration"]["backend"], json!({"kind": "production"})); + assert_eq!(production["vmApiBaseUrl"], "https://vm.cmux.dev"); +} + +#[test] +fn names_and_apis_follow_the_identity_sent() { + let identity = json!({"bundleId": "com.cmuxterm.app.debug.next", "isDebugBuild": false, "isProductionAuth": true, + "stackProjectId": "9790718f-14cd-4f7e-824d-eaf527a82b82"}); + let (code, names) = call( + c"cloud_config_names", + &json!({"identity": identity, "defaultsKeys": ["cmux.auth.hasTokens"]}), + ); + assert_eq!(code, OK); + assert_eq!(names["keychainService"], "com.cmuxterm.app.debug.next.auth"); + assert_eq!(names["defaultsKeys"]["cmux.auth.hasTokens"], "cmux.auth.hasTokens"); + let debug = json!({"bundleId": "com.cmuxterm.app.debug.walk", "isDebugBuild": true, "isProductionAuth": false, "linkSource": "appServer"}); + let (_, apis) = call(c"cloud_config_apis", &json!({"identity": debug, "environment": {}})); + assert_eq!(apis["ownerApi"], "https://cmux-api-development.debussy.workers.dev"); + assert_eq!(apis["shareApi"], "https://cmux-api-development.debussy.workers.dev"); + assert_eq!(apis["previewApi"], "https://cmux-platform-development.debussy.workers.dev"); + let (_, accepts) = call( + c"cloud_config_accepts_share_api", + &json!({"identity": identity, "api": "https://elsewhere.example"}), + ); + assert_eq!(accepts["accepts"], false); + let (_, capture) = call( + c"cloud_config_capture_build", + &json!({"bundleId": "com.cmuxterm.app.debug.cap0123456789", "benchHooks": true}), + ); + assert_eq!(capture["capture"], true); +} + +#[test] +fn auth_rules_answer_from_what_the_app_read() { + let (_, paths) = + call(c"cloud_auth_credential_paths", &json!({"environment": {}, "home": "/h"})); + assert_eq!(paths["paths"][0], "/h/.config/cmux/dev-account"); + let (_, decision) = + call(c"cloud_auth_credentials", &json!({"environment": {}, "home": "/h", "files": {}})); + assert_eq!(decision, json!({"credentials": null, "refusal": {"kind": "noDeclaredAccount"}})); + let (_, team) = call( + c"cloud_auth_team", + &json!({"requested": "b", "resolved": "a", "available": ["a", "b"], "managed": null}), + ); + assert_eq!(team, json!({"active": "a", "refusal": "notActive"})); + let (_, keys) = call( + c"cloud_auth_launch_keys", + &json!({"environment": {"CMUX_DEV_AUTH_PROFILE": "agent", "HOME": "/h"}}), + ); + assert_eq!(keys["keys"], json!({"CMUX_DEV_AUTH_PROFILE": "agent"})); + let (_, masked) = call(c"cloud_auth_mask", &json!({"email": "leo@example.com"})); + assert_eq!(masked["masked"], "l***@example.com"); +} + +#[test] +fn the_agent_key_mint_is_planned_and_read_here() { + let (code, planned) = call( + c"cloud_vm_agent_key_request", + &json!({"backend": {"kind": "production"}, "params": {"ttlSeconds": 3600, "maxVms": 2}}), + ); + assert_eq!(code, OK); + assert_eq!(planned["request"]["url"], "https://vm.cmux.dev/v1/agent-keys"); + assert_eq!(planned["request"]["body"]["scopes"], json!(["vm:read", "vm:write", "vm:exec"])); + let (_, refused) = call( + c"cloud_vm_agent_key_request", + &json!({"backend": {"kind": "production"}, "params": {"maxVms": 99}}), + ); + assert_eq!(refused["refusal"]["code"], "invalid_params"); + let (_, minted) = call( + c"cloud_vm_agent_key_answer", + &json!({"status": 201, "body": "{\"key\":\"vmk_s\",\"id\":\"vmk_1\"}", "baseUrl": "https://vm.cmux.dev", "teamId": "t"}), + ); + assert_eq!(minted["result"]["teamId"], "t"); + let (_, signed_out) = call( + c"cloud_vm_agent_key_answer", + &json!({"status": 401, "body": "", "baseUrl": "https://vm.cmux.dev", "teamId": "t"}), + ); + assert_eq!(signed_out["refusal"]["appText"], "notSignedIn"); +} + +#[test] +fn an_unknown_op_or_a_bad_request_is_refused() { + assert_eq!(call(c"cloud_config_nope", &json!({})).0, ERR_INVALID); + assert_eq!(call(c"cloud_config_resolve", &json!({"isDebugBuild": "no"})).0, ERR_INVALID); +} diff --git a/cmux-tui/crates/cmux-tui/Cargo.toml b/cmux-tui/crates/cmux-tui/Cargo.toml index 9f73ff989f0c..c57b3c6ce73b 100644 --- a/cmux-tui/crates/cmux-tui/Cargo.toml +++ b/cmux-tui/crates/cmux-tui/Cargo.toml @@ -61,6 +61,8 @@ cmux-process.workspace = true # `cmux vm`: the cmux VM API CLI (workers/cmux-vm/cli), linked in process so the # bundle needs no cmux-vm binary. Its TLS rides this crate's rustls (ring). cmux-vm = { path = "../../../workers/cmux-vm/cli/crates/cmux-vm", default-features = false } +# `cmux vm token create`'s mint rules, shared with the app (cli/vm.rs `mint_exit`). +cmux-cloud-config.workspace = true cmux-api-compat.workspace = true # `cmux settings list|schema|validate`: the settings schema and owner state. cmux-config.workspace = true diff --git a/cmux-tui/crates/cmux-tui/src/cli/vm.rs b/cmux-tui/crates/cmux-tui/src/cli/vm.rs index c0df3d584648..8d55b479b09b 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/vm.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/vm.rs @@ -724,21 +724,20 @@ pub(super) fn mint_params(plan: &[String]) -> Result, UsageEr /// service answered (401 11, 402 12, 403 13, 429 16, 503 18, no answer 3), so /// scripts tell the cases apart the same way on either mint path. pub(super) fn mint_exit(code: &str, status: Option) -> i32 { + use cmux_cloud_config::agent_key::{Outcome, outcome}; use cmux_vm::exit; - match code { - "labs_off" | "no_team" | "feature.disabled" => 3, - "not_signed_in" => exit::UNAUTHENTICATED, - "plan_required" => exit::PAYMENT_REQUIRED, - "forbidden" => exit::FORBIDDEN, - "rate_limited" => exit::QUOTA_EXCEEDED, - "invalid_params" => exit::USAGE, - "method_not_found" => 5, - "unavailable" => match status.and_then(|status| u16::try_from(status).ok()) { - None | Some(0) => exit::NETWORK, - Some(status) if status >= 500 => exit::SERVICE_UNAVAILABLE, - Some(status) => exit::for_status(status), - }, - _ => exit::UNEXPECTED, + // What each refusal means is the app's and this CLI's shared rule (cmux-cloud-config). + match outcome(code, status) { + Outcome::Network => exit::NETWORK, + Outcome::Unauthenticated => exit::UNAUTHENTICATED, + Outcome::PaymentRequired => exit::PAYMENT_REQUIRED, + Outcome::Forbidden => exit::FORBIDDEN, + Outcome::QuotaExceeded => exit::QUOTA_EXCEEDED, + Outcome::Usage => exit::USAGE, + Outcome::MethodNotFound => 5, + Outcome::ServiceUnavailable => exit::SERVICE_UNAVAILABLE, + Outcome::Status(status) => exit::for_status(status), + Outcome::Unexpected => exit::UNEXPECTED, } } diff --git a/scripts/ci/cmux_next_route.py b/scripts/ci/cmux_next_route.py index 615dc9cd5956..4b76a0539fa0 100755 --- a/scripts/ci/cmux_next_route.py +++ b/scripts/ci/cmux_next_route.py @@ -142,6 +142,7 @@ "cmux-tui/crates/cmux-app-ffi/", "cmux-tui/crates/cmux-focus-core/", "cmux-tui/crates/cmux-cloud-start-core/", + "cmux-tui/crates/cmux-cloud-config/", "cmux-tui/crates/cmux-imessage/", "cmux-tui/crates/cmux-layout-reducer/", "cmux-tui/crates/cmux-layout-reducer-ffi/", diff --git a/scripts/cmux-next/check_app_crate_licenses.py b/scripts/cmux-next/check_app_crate_licenses.py index eda0915328c7..8aef878de575 100644 --- a/scripts/cmux-next/check_app_crate_licenses.py +++ b/scripts/cmux-next/check_app_crate_licenses.py @@ -55,6 +55,7 @@ "cmux-tasks-core", "cmux-bookmarks-core", "cmux-cloud-start-core", + "cmux-cloud-config", "cmux-palette-ranker", "cmux-remote-browser", } diff --git a/tests/classic-cloud-route-allowlist.json b/tests/classic-cloud-route-allowlist.json index ffddfa227e31..5b56a4b966b5 100644 --- a/tests/classic-cloud-route-allowlist.json +++ b/tests/classic-cloud-route-allowlist.json @@ -83,6 +83,18 @@ "count": 19, "reason": "debt: code mode's cloud relay maps vm.* operations onto classic /api/vm routes until it calls cmux-platform" }, + { + "file": "cmux-tui/crates/cmux-cloud-config/src/config.rs", + "pattern": "base:cmux-staging", + "count": 1, + "reason": "keep: moved from CloudConfiguration.swift (#34982, step A); the staging web app still serves sign-in, teams and billing for development builds, and its Cloud API base is now the development Worker; step B deletes the Swift copy" + }, + { + "file": "cmux-tui/crates/cmux-cloud-config/src/config.rs", + "pattern": "base:cmux.com", + "count": 1, + "reason": "keep: moved from CloudConfiguration.swift (#34982, step A); production NEXT customers still sign in and reach their team on cmux.com until cmux-platform serves those routes; step B deletes the Swift copy" + }, { "file": "webviews/src/agent-session/acpmux/run-board/runApi.ts", "pattern": "route:/api/runs", diff --git a/tests/test_app_crate_licenses.py b/tests/test_app_crate_licenses.py index c886c08cdf02..b722c538e595 100644 --- a/tests/test_app_crate_licenses.py +++ b/tests/test_app_crate_licenses.py @@ -102,6 +102,8 @@ def test_first_party_gpl_crates_are_named_exactly(self): "cmux-bookmarks-core", # The Cloud start's harness login decision (cmux-cloud-start-core). "cmux-cloud-start-core", + # NEXT Cloud's configuration rules (cmux-cloud-config). + "cmux-cloud-config", "cmux-palette-ranker", "cmux-remote-browser", ] diff --git a/workers/cmux-vm/cli/Cargo.lock b/workers/cmux-vm/cli/Cargo.lock index 5d79ecf3e149..f1aa239dff0e 100644 --- a/workers/cmux-vm/cli/Cargo.lock +++ b/workers/cmux-vm/cli/Cargo.lock @@ -214,12 +214,21 @@ dependencies = [ "cc", ] +[[package]] +name = "cmux-cloud-config" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "cmux-vm" version = "0.1.0" dependencies = [ "base64 0.22.1", "clap", + "cmux-cloud-config", "cmux-vm-client", "futures", "nix", diff --git a/workers/cmux-vm/cli/crates/cmux-vm/Cargo.toml b/workers/cmux-vm/cli/crates/cmux-vm/Cargo.toml index ea962e7a3206..57aa183337fe 100644 --- a/workers/cmux-vm/cli/crates/cmux-vm/Cargo.toml +++ b/workers/cmux-vm/cli/crates/cmux-vm/Cargo.toml @@ -23,6 +23,8 @@ preserve-order = ["serde_json/preserve_order"] clap.workspace = true base64.workspace = true cmux-vm-client = { path = "../cmux-vm-client", default-features = false } +# The agent-key mint's rules, shared with the cmux app (pure: serde only). +cmux-cloud-config = { path = "../../../../../cmux-tui/crates/cmux-cloud-config" } futures.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/workers/cmux-vm/cli/crates/cmux-vm/src/token.rs b/workers/cmux-vm/cli/crates/cmux-vm/src/token.rs index 53a5a78cfbdb..5ab04633884c 100644 --- a/workers/cmux-vm/cli/crates/cmux-vm/src/token.rs +++ b/workers/cmux-vm/cli/crates/cmux-vm/src/token.rs @@ -33,10 +33,12 @@ const ACCOUNT: &str = "default"; #[cfg(not(target_os = "macos"))] const FILE_NAME: &str = "vm-token.json"; -pub const DEFAULT_TTL_SECONDS: u64 = 8 * 3600; -pub const MAX_TTL_SECONDS: u64 = 24 * 3600; -pub const DEFAULT_MAX_VMS: u32 = 3; -pub const DEFAULT_MAX_USD_PER_HOUR: f64 = 2.0; +// The mint's rules are shared with the cmux app's `vm.agent_key.create` +// (cmux-tui/crates/cmux-cloud-config `agent_key`), so both paths ask for the same key. +pub use cmux_cloud_config::agent_key::{ + DEFAULT_MAX_USD_PER_HOUR, DEFAULT_MAX_VMS, DEFAULT_TTL_SECONDS, +}; +pub const MAX_TTL_SECONDS: u64 = *cmux_cloud_config::agent_key::TTL_SECONDS.end(); /// A stored token. `key` is the secret; nothing prints it. #[derive(Clone, Serialize, Deserialize, PartialEq)] @@ -415,13 +417,16 @@ pub fn create_body(args: &TokenCreateArgs) -> Result { "--max-usd-per-hour must be above 0 and at most 50", )); } - Ok(json!({ - "name": args.name, - "scopes": ["vm:read", "vm:write", "vm:exec"], - "ttlSeconds": ttl, - "maxVms": args.max_vms, - "maxUsdPerHour": args.max_usd_per_hour, - })) + let params: serde_json::Map = [ + ("name", json!(args.name)), + ("ttlSeconds", json!(ttl)), + ("maxVms", json!(args.max_vms)), + ("maxUsdPerHour", json!(args.max_usd_per_hour)), + ] + .into_iter() + .map(|(key, value)| (key.to_owned(), value)) + .collect(); + cmux_cloud_config::agent_key::body(¶ms).map_err(|refusal| CliError::usage(refusal.message)) } /// Where `token create` and `revoke` serialize this user's changes to the @@ -829,6 +834,26 @@ mod tests { assert_eq!(body["ttlSeconds"], json!(DEFAULT_TTL_SECONDS)); } + #[test] + fn the_request_is_the_one_the_app_asks_for() { + // The app's vm.agent_key.create and this command share cmux-cloud-config's body. + let args = TokenCreateArgs { + name: String::new(), + ttl: Some("1h".into()), + max_vms: 2, + max_usd_per_hour: 1.5, + }; + let mut params = serde_json::Map::new(); + params.insert("ttlSeconds".into(), json!(3600)); + params.insert("maxVms".into(), json!(2)); + params.insert("maxUsdPerHour".into(), json!(1.5)); + assert_eq!( + create_body(&args).unwrap(), + cmux_cloud_config::agent_key::body(¶ms).unwrap() + ); + assert_eq!(create_body(&args).unwrap()["name"], "cmux vm token"); + } + #[test] fn a_stored_key_goes_only_to_its_issuer() { let mut token = StoredToken {