From 0c3d3b9160df21365be791ada0fff0ee333d30f4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 07:39:15 +0000 Subject: [PATCH 1/5] cmux-tui: start-chat plan answers the folder's repo; start-chat ensure gets the workspace's machine from the platform The plan reads the chat folder's GitHub origin as repo and echoes the cloud environment cloud.startChat chose. ensure posts /v1/workspaces/{W}/machine under the plan's create_key with repo, ref and environment, so the machine boots from the repository's set-up snapshot once it is ready (cmuxterm-hq#5846, item 2). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3b729bb81a4ace643a60 --- cmux-tui/crates/cmux-tui/src/cli.rs | 3 +- .../crates/cmux-tui/src/cli/machine_join.rs | 27 ++- .../cmux-tui/src/link/start_chat_cli.rs | 187 ++++++++++++++++++ .../cmux-tui/src/link/start_chat_cli/tests.rs | 167 +++++++++++++++- 4 files changed, 376 insertions(+), 8 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/cli.rs b/cmux-tui/crates/cmux-tui/src/cli.rs index b6ef43847a67..3be1c69dfba7 100644 --- a/cmux-tui/crates/cmux-tui/src/cli.rs +++ b/cmux-tui/crates/cmux-tui/src/cli.rs @@ -93,7 +93,8 @@ mod machine_doctor; mod machine_join; #[cfg(unix)] pub(crate) use machine_join::{ - device_credential as joined_machine_credential, rows_for_devices as joined_machine_rows, + CallError as PlatformCallError, Platform, device_credential as joined_machine_credential, + platform_origin, rows_for_devices as joined_machine_rows, }; mod machine_server; #[cfg(test)] diff --git a/cmux-tui/crates/cmux-tui/src/cli/machine_join.rs b/cmux-tui/crates/cmux-tui/src/cli/machine_join.rs index bb1496f07f8e..01d6962f63ad 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/machine_join.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/machine_join.rs @@ -81,7 +81,7 @@ pub(super) struct Command { /// A failed verb: a stable code and a message that holds no secret. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct Failure { +pub(crate) struct Failure { pub code: &'static str, pub message: String, pub exit: i32, @@ -290,7 +290,7 @@ pub(super) fn read_credential( } /// `--platform`, else `CMUX_PLATFORM_URL`, else the development platform; https only. -pub(super) fn platform_origin( +pub(crate) fn platform_origin( flag: Option<&str>, env: &dyn Fn(&str) -> Option, ) -> Result { @@ -304,7 +304,7 @@ pub(super) fn platform_origin( } /// The cmux platform, as one signed-in caller. -pub(super) struct Platform { +pub(crate) struct Platform { origin: String, credential: String, client: reqwest::Client, @@ -315,7 +315,7 @@ pub(super) struct Platform { /// Why a platform call failed. Nothing the server answered is kept but its /// status and a code this file knows. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum CallError { +pub(crate) enum CallError { Status { status: u16, code: &'static str }, Unreachable(String), Unreadable(u16), @@ -360,7 +360,7 @@ impl CallError { } impl Platform { - pub(super) fn new( + pub(crate) fn new( origin: String, credential: String, timeout: Duration, @@ -435,6 +435,11 @@ impl Platform { .collect()) } + /// `POST path` with a JSON body: the answer, or why it failed (`link start-chat ensure`). + pub(crate) fn post(&self, path: &str, body: Value) -> Result { + self.runtime.block_on(self.call(reqwest::Method::POST, path, Some(body))) + } + /// Every joined machine's entry, as the platform lists them. pub(super) fn credentials(&self) -> Result, CallError> { self.runtime.block_on(self.credentials_async()) @@ -521,7 +526,7 @@ fn entries(body: Value) -> Option> { } /// The platform's machine-join refusal codes. Errors print only these, from this table. -const KNOWN_CODES: [&str; 24] = [ +const KNOWN_CODES: [&str; 33] = [ "auth.unauthenticated", "auth.forbidden", "auth.sso_required", @@ -539,6 +544,16 @@ const KNOWN_CODES: [&str; 24] = [ "rate_limited", "machine.join.unavailable", "machine.join.not_found", + // A workspace's machine (`POST /v1/workspaces/{w}/machine`, `link start-chat ensure`). + "cloud_environment.not_found", + "machine.conflict", + "machine.unavailable", + "machine.placement_unavailable", + "machine.provider_unavailable", + "workspace_machine.unconfigured", + "workspace_machine.not_admitted", + "workspace_machine.machine_taken", + "workspace_machine.machine_gone", "bad_request", "unauthorized", "not_found", diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs index a6d77c45d4ac..8eac5450cfb6 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs @@ -22,6 +22,14 @@ //! turn, unless this send's copy already finished there: a second push //! would overwrite what the chat changed since. Answers the chat's folder //! there. +//! - `ensure --workspace W --key K --repo OWNER/NAME [--ref REF] [--environment env_...] +//! [--platform URL]`, the cmux credential on stdin: the workspace's own +//! machine from the cmux platform (`POST /v1/workspaces/{W}/machine`), made +//! under the plan's `create_key` so a retry gets the same one. With the +//! folder's repository (the plan's `repo`) it boots from the repository's +//! set-up snapshot once that is ready (zero-setup hot start), keyed by the +//! chosen cloud environment (`cloud.startChat`'s `environment`; absent: the +//! personal Default). //! - `import --entries JSON`: takes in the sends the App kept in its user //! defaults before this did ([`Ledger::import`]), once, on upgrade. //! @@ -46,6 +54,7 @@ use pick::{Candidate, Destination}; const USAGE: &str = "usage: cmux link start-chat plan --request JSON\n \ cmux link start-chat pin --key KEY --account ACCOUNT --machine ID [--workspace ID]\n \ +cmux link start-chat ensure --workspace ID --key KEY --repo OWNER/NAME [--ref REF] [--environment env_ID] [--platform URL] (the cmux credential on stdin)\n \ cmux link start-chat import --entries JSON\n \ cmux link start-chat copy --key KEY --account ACCOUNT --machine ID --socket PATH --local FOLDER [--subpath PATH] --work-root PATH..."; @@ -83,6 +92,7 @@ pub(super) fn run(args: &[String]) -> i32 { }, "copy" => StartCopy::of(&flags).map(|copy| copy.run(&ledger, &push)), "import" => flags.one("--entries").map(|entries| import(&ledger, entries)), + "ensure" => Ensure::of(&flags).map(|ensure| ensure.run(&mut std::io::stdin().lock())), _ => None, }; let Some(answer) = answer else { @@ -171,6 +181,12 @@ struct PlanRequest { /// since): a send pinned to one keeps it, but none is picked. #[serde(default)] stored: Vec, + /// The chat's folder on this Mac: its GitHub `origin` is the plan's `repo`. + #[serde(default)] + local: Option, + /// The cloud environment the send chose (`cloud.startChat`'s `environment`), echoed for `ensure`. + #[serde(default)] + environment: Option, } /// `plan`: where the send goes, pinned when it is a machine that exists. @@ -192,6 +208,9 @@ pub(super) fn plan(ledger: &Path, request: &str) -> Value { "the workspace id is empty, too long or has control characters", ); } + if request.environment.as_deref().is_some_and(|id| !valid_environment(id)) { + return refused("bad_request", "the environment is not a cloud environment id (env_...)"); + } let keys = Keys::of(&request.key); let send = Send::new(&request.key, &request.account); let planned = Ledger::update(ledger, |pins| { @@ -227,10 +246,178 @@ pub(super) fn plan(ledger: &Path, request: &str) -> Value { }; if let (Value::Object(line), Value::Object(fields)) = (&mut line, fields) { line.extend(fields); + line.insert("repo".into(), json!(request.local.as_deref().and_then(folder_repo))); + line.insert("environment".into(), json!(request.environment)); } line } +/// A cloud environment's id, as the cmux platform makes them: `env_` and 24 hex digits. +pub(super) fn valid_environment(id: &str) -> bool { + id.len() == 28 + && id.starts_with("env_") + && id[4..].bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) +} + +/// A repository as the cmux platform names one: `owner/name`, GitHub's characters, at most 140 bytes. +pub(super) fn valid_repo(repo: &str) -> bool { + let part = |part: &str| { + !part.is_empty() + && part != "." + && part != ".." + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }; + repo.len() <= 140 + && matches!(repo.split_once('/'), Some((owner, name)) if part(owner) && part(name)) +} + +/// The GitHub repository of the folder `local`: its `origin` remote, when that is GitHub's. +fn folder_repo(local: &str) -> Option { + let output = std::process::Command::new("git") + .args(["-C", local, "remote", "get-url", "origin"]) + .stdin(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + github_repo(String::from_utf8_lossy(&output.stdout).trim()) +} + +/// `owner/name` of a GitHub remote URL (https, ssh or scp-like), None for any other host. +pub(super) fn github_repo(url: &str) -> Option { + let path = + ["https://github.com/", "http://github.com/", "ssh://git@github.com/", "git@github.com:"] + .iter() + .find_map(|prefix| url.strip_prefix(prefix))?; + let path = path.trim_end_matches('/'); + let path = path.strip_suffix(".git").unwrap_or(path); + valid_repo(path).then(|| path.to_string()) +} + +/// `ensure`: the Mac workspace's own machine from the cmux platform. +pub(super) struct Ensure { + workspace: String, + create_key: String, + repo: String, + git_ref: Option, + environment: Option, + platform: Option, +} + +impl Ensure { + fn of(flags: &Flags) -> Option { + let optional = |name: &str| match flags.all(name).as_slice() { + [] => Some(None), + [value] => Some(Some(value.to_string())), + _ => None, + }; + let environment = optional("--environment")?; + let git_ref = optional("--ref")?; + if environment.as_deref().is_some_and(|id| !valid_environment(id)) + || git_ref + .as_deref() + .is_some_and(|r| r.is_empty() || r.len() > 200 || r.chars().any(char::is_control)) + { + return None; + } + Some(Self { + workspace: flags.workspace()??.to_string(), + create_key: Keys::of(&flags.key()?).create, + repo: flags.one("--repo").filter(|repo| valid_repo(repo))?.to_string(), + git_ref, + environment, + platform: optional("--platform")?, + }) + } + + /// The request body: the plan's `create_key` as the client key, so a retry gets the same machine. + pub(super) fn body(&self) -> Value { + let mut body = json!({"clientKey": self.create_key, "repo": self.repo}); + if let Some(git_ref) = &self.git_ref { + body["ref"] = json!(git_ref); + } + if let Some(environment) = &self.environment { + body["environment"] = json!(environment); + } + body + } + + fn run(&self, stdin: &mut dyn std::io::BufRead) -> Value { + let mut credential = String::new(); + if stdin.read_line(&mut credential).is_err() || credential.trim().is_empty() { + return refused("unauthorized", "no cmux credential on stdin"); + } + let env = |name: &str| std::env::var(name).ok(); + let origin = match crate::cli::platform_origin(self.platform.as_deref(), &env) { + Ok(origin) => origin, + Err(error) => return refused("bad_request", error), + }; + // A new machine's placement may take a while; the platform answers once it is admitted. + let platform = match crate::cli::Platform::new( + origin, + credential.trim().to_string(), + std::time::Duration::from_secs(120), + ) { + Ok(platform) => platform, + Err(failure) => return refused(failure.code, failure.message), + }; + let path = format!("/v1/workspaces/{}/machine", percent_segment(&self.workspace)); + ensure_answer(platform.post(&path, self.body())) + } +} + +/// One URL path segment: unreserved bytes kept, every other byte percent-encoded. +pub(super) fn percent_segment(segment: &str) -> String { + segment + .bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + (byte as char).to_string() + } + _ => format!("%{byte:02X}"), + }) + .collect() +} + +/// `ensure`'s line from the platform's answer: the machine's id and what it booted from. +pub(super) fn ensure_answer(answer: Result) -> Value { + use crate::cli::PlatformCallError as E; + match answer { + Ok(body) => match body.pointer("/machine/id").and_then(Value::as_str) { + Some(id) => json!({ + "ok": true, + "machine": id, + "team": body.get("team"), + "created": body.get("created").and_then(Value::as_bool).unwrap_or(false), + "snapshot": body.get("snapshot"), + "environment": body.get("environment"), + }), + None => refused("bad_answer", "the cmux platform answered without the machine"), + }, + Err(E::Status { status: 404, code: "" }) => { + refused("unavailable", "this cmux platform has no workspace machines (it answered 404)") + } + Err(E::Status { status, code }) => refused( + if code.is_empty() { "refused" } else { code }, + format!( + "the cmux platform refused the workspace's machine: {}", + format!("{status} {code}").trim_end() + ), + ), + Err(E::Unreachable(reason)) => { + refused("unreachable", format!("the cmux platform did not answer: {reason}")) + } + Err(E::Unreadable(status)) => refused( + "bad_answer", + format!("the cmux platform answered {status} with no readable body"), + ), + } +} + /// `pin`: the send `key` of `account` runs on `machine` (one the App just made /// for it), which is the Mac workspace `workspace`'s own from now on when the /// workspace has none yet and no other workspace has it ([`pick::claim`]). diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs index 4b281f52b4f8..468e1645f53f 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs @@ -9,7 +9,10 @@ use serde_json::{Value, json}; use super::ledger::{LIMIT, Ledger, Send, WORKSPACE_LIMIT}; use super::pick::{self, Candidate, Destination}; -use super::{Keys, StartCopy, import, pin, plan, remote_name, valid_key, valid_workspace}; +use super::{ + Ensure, Flags, Keys, StartCopy, ensure_answer, github_repo, import, percent_segment, pin, plan, + remote_name, valid_environment, valid_key, valid_repo, valid_workspace, +}; const ME: &str = "user-leo"; const TEAMMATE: &str = "user-austin"; @@ -974,3 +977,165 @@ fn the_ledger_keeps_the_newest_workspaces_apart_from_the_sends() { assert_eq!(kept.workspace_machine("ws-first", ACCOUNT), None); assert_eq!(kept.workspace_machine("ws-0", ACCOUNT).as_deref(), Some("vm-ws-0")); } + +const ENV: &str = "env_0123456789abcdef01234567"; + +#[test] +fn the_folders_github_remote_is_the_repo() { + for url in [ + "https://github.com/manaflow-ai/cmux.git", + "https://github.com/manaflow-ai/cmux", + "https://github.com/manaflow-ai/cmux/", + "git@github.com:manaflow-ai/cmux.git", + "ssh://git@github.com/manaflow-ai/cmux.git", + ] { + assert_eq!(github_repo(url).as_deref(), Some("manaflow-ai/cmux"), "{url}"); + } + for url in [ + "https://gitlab.com/manaflow-ai/cmux.git", + "https://github.com/manaflow-ai", + "https://github.com/a/b/c", + "https://github.com/../x", + "/srv/git/cmux.git", + "", + ] { + assert_eq!(github_repo(url), None, "{url}"); + } + assert!(valid_repo("a.b/c_d-e")); + assert!(!valid_repo(&format!("o/{}", "n".repeat(140)))); + assert!(valid_environment(ENV)); + for bad in ["env_0123", "env_0123456789ABCDEF01234567", "abc_0123456789abcdef01234567"] { + assert!(!valid_environment(bad), "{bad}"); + } +} + +#[test] +fn plan_answers_the_folders_repo_and_echoes_the_environment() { + let dir = scratch(); + let path = ledger_in(dir.path()); + let folder = dir.path().join("project"); + std::fs::create_dir_all(&folder).unwrap(); + let git = |args: &[&str]| { + let ok = std::process::Command::new("git") + .arg("-C") + .arg(&folder) + .args(args) + .stdin(std::process::Stdio::null()) + .output() + .expect("git") + .status + .success(); + assert!(ok, "git {args:?}"); + }; + git(&["init", "-q"]); + let request = |environment: Value| { + json!({"key": key(), "account": ACCOUNT, "user": ME, "loaded": true, "machines": [], + "local": folder.to_str().unwrap(), "environment": environment}) + .to_string() + }; + // No origin: no repo, and the send still plans. + let answer = plan(&path, &request(Value::Null)); + assert_eq!(answer["ok"], json!(true), "{answer}"); + assert_eq!(answer["repo"], Value::Null); + assert_eq!(answer["environment"], Value::Null); + git(&["remote", "add", "origin", "git@github.com:manaflow-ai/cmux.git"]); + let answer = plan(&path, &request(json!(ENV))); + assert_eq!(answer["repo"], json!("manaflow-ai/cmux"), "{answer}"); + assert_eq!(answer["environment"], json!(ENV)); + assert_eq!(plan(&path, &request(json!("env_nope")))["error_code"], json!("bad_request")); + // A folder that is not there answers no repo. + let gone = + json!({"key": key(), "account": ACCOUNT, "local": "/nonexistent/cmux-test"}).to_string(); + assert_eq!(plan(&path, &gone)["repo"], Value::Null); +} + +fn flags(args: &[&str]) -> Flags { + Flags::parse(&args.iter().map(|arg| arg.to_string()).collect::>()).expect("flags") +} + +#[test] +fn ensure_asks_for_the_workspaces_machine_under_the_plans_create_key() { + let key = key(); + let ensure = Ensure::of(&flags(&[ + "--workspace", + "ws-1", + "--key", + &key, + "--repo", + "manaflow-ai/cmux", + "--environment", + ENV, + ])) + .expect("ensure"); + assert_eq!( + ensure.body(), + json!({"clientKey": Keys::of(&key).create, "repo": "manaflow-ai/cmux", "environment": ENV}) + ); + let with_ref = Ensure::of(&flags(&[ + "--workspace", + "ws-1", + "--key", + &key, + "--repo", + "a/b", + "--ref", + "main", + ])) + .unwrap(); + assert_eq!( + with_ref.body(), + json!({"clientKey": Keys::of(&key).create, "repo": "a/b", "ref": "main"}) + ); + for bad in [ + vec!["--key", key.as_str(), "--repo", "a/b"], + vec!["--workspace", "ws-1", "--repo", "a/b"], + vec!["--workspace", "ws-1", "--key", key.as_str()], + vec!["--workspace", "ws-1", "--key", key.as_str(), "--repo", "not-a-repo"], + vec![ + "--workspace", + "ws-1", + "--key", + key.as_str(), + "--repo", + "a/b", + "--environment", + "env_x", + ], + vec!["--workspace", "ws-1", "--key", key.as_str(), "--repo", "a/b", "--ref", ""], + ] { + assert!(Ensure::of(&flags(&bad)).is_none(), "{bad:?}"); + } + assert_eq!(percent_segment("ws 1/รค"), "ws%201%2F%C3%A4"); +} + +#[test] +fn ensures_line_names_the_machine_or_the_platforms_code() { + use crate::cli::PlatformCallError as E; + let made = ensure_answer(Ok(json!({ + "workspace": "ws-1", "machine": {"id": "vm-1", "provider": "freestyle"}, "team": "team_p", + "snapshot": "snap_x", "environment": {"id": "denv_1", "status": "ready"}, "created": true, "boundAt": 1, + }))); + assert_eq!( + made, + json!({"ok": true, "machine": "vm-1", "team": "team_p", "created": true, "snapshot": "snap_x", + "environment": {"id": "denv_1", "status": "ready"}}) + ); + let bound = ensure_answer(Ok(json!({"machine": {"id": "vm-1"}, "created": false}))); + assert_eq!( + (bound["created"].clone(), bound["environment"].clone()), + (json!(false), Value::Null) + ); + assert_eq!(ensure_answer(Ok(json!({"created": true})))["error_code"], json!("bad_answer")); + let not_found = + ensure_answer(Err(E::Status { status: 404, code: "cloud_environment.not_found" })); + assert_eq!(not_found["error_code"], json!("cloud_environment.not_found")); + assert_eq!( + ensure_answer(Err(E::Status { status: 404, code: "" }))["error_code"], + json!("unavailable") + ); + assert_eq!( + ensure_answer(Err(E::Status { status: 500, code: "" }))["error_code"], + json!("refused") + ); + assert_eq!(ensure_answer(Err(E::Unreachable("x".into())))["error_code"], json!("unreachable")); +} From 87cad70d513ff742eebe1a1ca8b11b79350d0525 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 07:39:35 +0000 Subject: [PATCH 2/5] start-chat: spawn git through cmux_process Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3b729bb81a4ace643a60 --- cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs index 8eac5450cfb6..2eca6d83638b 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs @@ -275,7 +275,7 @@ pub(super) fn valid_repo(repo: &str) -> bool { /// The GitHub repository of the folder `local`: its `origin` remote, when that is GitHub's. fn folder_repo(local: &str) -> Option { - let output = std::process::Command::new("git") + let output = cmux_process::Command::new("git") .args(["-C", local, "remote", "get-url", "origin"]) .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) From e9c2cfab4c8fb49269aa33c866a418f409d40166 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 07:51:39 +0000 Subject: [PATCH 3/5] start-chat: read the folder's origin from its git config, not by running git Review follow-ups on #30414: an empty or relative folder answers no repo; no git process (no inherited GIT_* variables, no Command Line Tools prompt, nothing to time out); subfolders and worktrees find their repository; github.com in any case and with a user; a 404 with a generic not_found code reads as unavailable. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3b729bb81a4ace643a60 --- .../cmux-tui/src/link/start_chat_cli.rs | 80 +++++++++++++++---- .../cmux-tui/src/link/start_chat_cli/tests.rs | 34 ++++---- 2 files changed, 82 insertions(+), 32 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs index 2eca6d83638b..7abce44456ad 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs @@ -273,26 +273,76 @@ pub(super) fn valid_repo(repo: &str) -> bool { && matches!(repo.split_once('/'), Some((owner, name)) if part(owner) && part(name)) } -/// The GitHub repository of the folder `local`: its `origin` remote, when that is GitHub's. +/// The GitHub repository of the folder `local` (absolute): its repository's `origin` remote, when that is +/// GitHub's. Read from the repository's config file, never by running git: no hooks, no inherited `GIT_*` +/// variables, no Command Line Tools prompt, nothing that can wait on a credential. fn folder_repo(local: &str) -> Option { - let output = cmux_process::Command::new("git") - .args(["-C", local, "remote", "get-url", "origin"]) - .stdin(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .output() - .ok()?; - if !output.status.success() { + let local = Path::new(local); + if !local.is_absolute() { return None; } - github_repo(String::from_utf8_lossy(&output.stdout).trim()) + let config = std::fs::read_to_string(git_common_dir(local)?.join("config")).ok()?; + github_repo(&origin_url(&config)?) } -/// `owner/name` of a GitHub remote URL (https, ssh or scp-like), None for any other host. +/// The git directory that holds the config of the repository `folder` is in: the nearest `.git` at or above +/// it, followed through a worktree's `gitdir:` file and its `commondir`. +fn git_common_dir(folder: &Path) -> Option { + let dot_git = + folder.ancestors().map(|dir| dir.join(".git")).find(|dot_git| dot_git.exists())?; + if dot_git.is_dir() { + return Some(dot_git); + } + let link = std::fs::read_to_string(&dot_git).ok()?; + let gitdir = link.lines().find_map(|line| line.strip_prefix("gitdir:"))?.trim(); + let gitdir = dot_git.parent()?.join(gitdir); + match std::fs::read_to_string(gitdir.join("commondir")) { + Ok(common) => Some(gitdir.join(common.trim())), + Err(_) => Some(gitdir), + } +} + +/// The `url` of `[remote "origin"]` in a git config file's text (the last one wins, as in git). +pub(super) fn origin_url(config: &str) -> Option { + let mut in_origin = false; + let mut url = None; + for line in config.lines().map(str::trim) { + if let Some(section) = line.strip_prefix('[') { + let section = section.split(']').next().unwrap_or(""); + let (name, sub) = section.split_once(char::is_whitespace).unwrap_or((section, "")); + in_origin = name.eq_ignore_ascii_case("remote") && sub.trim() == "\"origin\""; + } else if in_origin { + if let Some((key, value)) = line.split_once('=') { + if key.trim().eq_ignore_ascii_case("url") { + url = Some(value.trim().trim_matches('"').to_string()); + } + } + } + } + url +} + +/// `owner/name` of a GitHub remote URL (https, ssh or scp-like, any case, with or without a user), None for +/// any other host. pub(super) fn github_repo(url: &str) -> Option { - let path = - ["https://github.com/", "http://github.com/", "ssh://git@github.com/", "git@github.com:"] - .iter() - .find_map(|prefix| url.strip_prefix(prefix))?; + let rest = ["https://", "http://", "ssh://", "git+ssh://"].iter().find_map(|scheme| { + url.get(..scheme.len()) + .filter(|s| s.eq_ignore_ascii_case(scheme)) + .map(|_| &url[scheme.len()..]) + }); + let path = match rest { + Some(rest) => { + let (host, path) = rest.split_once('/')?; + let host = host.rsplit('@').next()?; + host.eq_ignore_ascii_case("github.com").then_some(path)? + } + None => { + // scp-like: [user@]github.com:owner/name + let (host, path) = url.split_once(':')?; + let host = host.rsplit('@').next()?; + host.eq_ignore_ascii_case("github.com").then_some(path)? + } + }; let path = path.trim_end_matches('/'); let path = path.strip_suffix(".git").unwrap_or(path); valid_repo(path).then(|| path.to_string()) @@ -398,7 +448,7 @@ pub(super) fn ensure_answer(answer: Result }), None => refused("bad_answer", "the cmux platform answered without the machine"), }, - Err(E::Status { status: 404, code: "" }) => { + Err(E::Status { status: 404, code: "" | "not_found" }) => { refused("unavailable", "this cmux platform has no workspace machines (it answered 404)") } Err(E::Status { status, code }) => refused( diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs index 468e1645f53f..4b5085e08988 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs @@ -10,8 +10,9 @@ use serde_json::{Value, json}; use super::ledger::{LIMIT, Ledger, Send, WORKSPACE_LIMIT}; use super::pick::{self, Candidate, Destination}; use super::{ - Ensure, Flags, Keys, StartCopy, ensure_answer, github_repo, import, percent_segment, pin, plan, - remote_name, valid_environment, valid_key, valid_repo, valid_workspace, + Ensure, Flags, Keys, StartCopy, ensure_answer, github_repo, import, origin_url, + percent_segment, pin, plan, remote_name, valid_environment, valid_key, valid_repo, + valid_workspace, }; const ME: &str = "user-leo"; @@ -988,6 +989,9 @@ fn the_folders_github_remote_is_the_repo() { "https://github.com/manaflow-ai/cmux/", "git@github.com:manaflow-ai/cmux.git", "ssh://git@github.com/manaflow-ai/cmux.git", + "https://user@GitHub.com/manaflow-ai/cmux", + "HTTPS://github.com/manaflow-ai/cmux.git", + "github.com:manaflow-ai/cmux", ] { assert_eq!(github_repo(url).as_deref(), Some("manaflow-ai/cmux"), "{url}"); } @@ -997,6 +1001,8 @@ fn the_folders_github_remote_is_the_repo() { "https://github.com/a/b/c", "https://github.com/../x", "/srv/git/cmux.git", + "https://github.com.evil.example/a/b", + "git@gitlab.com:a/b.git", "", ] { assert_eq!(github_repo(url), None, "{url}"); @@ -1014,20 +1020,10 @@ fn plan_answers_the_folders_repo_and_echoes_the_environment() { let dir = scratch(); let path = ledger_in(dir.path()); let folder = dir.path().join("project"); - std::fs::create_dir_all(&folder).unwrap(); - let git = |args: &[&str]| { - let ok = std::process::Command::new("git") - .arg("-C") - .arg(&folder) - .args(args) - .stdin(std::process::Stdio::null()) - .output() - .expect("git") - .status - .success(); - assert!(ok, "git {args:?}"); - }; - git(&["init", "-q"]); + let sub = folder.join("web"); + std::fs::create_dir_all(folder.join(".git")).unwrap(); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(folder.join(".git/config"), "[core]\n\tbare = false\n").unwrap(); let request = |environment: Value| { json!({"key": key(), "account": ACCOUNT, "user": ME, "loaded": true, "machines": [], "local": folder.to_str().unwrap(), "environment": environment}) @@ -1038,7 +1034,11 @@ fn plan_answers_the_folders_repo_and_echoes_the_environment() { assert_eq!(answer["ok"], json!(true), "{answer}"); assert_eq!(answer["repo"], Value::Null); assert_eq!(answer["environment"], Value::Null); - git(&["remote", "add", "origin", "git@github.com:manaflow-ai/cmux.git"]); + std::fs::write( + folder.join(".git/config"), + "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@github.com:manaflow-ai/cmux.git\n", + ) + .unwrap(); let answer = plan(&path, &request(json!(ENV))); assert_eq!(answer["repo"], json!("manaflow-ai/cmux"), "{answer}"); assert_eq!(answer["environment"], json!(ENV)); From addc257d6be26809de332601a6851902c856cec1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 08:19:00 +0000 Subject: [PATCH 4/5] start-chat: one let-chain in origin_url (clippy collapsible_if) Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3b729bb81a4ace643a60 --- cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs index 7abce44456ad..6d72ef21a493 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs @@ -311,12 +311,11 @@ pub(super) fn origin_url(config: &str) -> Option { let section = section.split(']').next().unwrap_or(""); let (name, sub) = section.split_once(char::is_whitespace).unwrap_or((section, "")); in_origin = name.eq_ignore_ascii_case("remote") && sub.trim() == "\"origin\""; - } else if in_origin { - if let Some((key, value)) = line.split_once('=') { - if key.trim().eq_ignore_ascii_case("url") { - url = Some(value.trim().trim_matches('"').to_string()); - } - } + } else if in_origin + && let Some((key, value)) = line.split_once('=') + && key.trim().eq_ignore_ascii_case("url") + { + url = Some(value.trim().trim_matches('"').to_string()); } } url From 0a9116920e2eb451892138633eddc065da631a48 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Sun, 1 Nov 2026 08:27:21 +0000 Subject: [PATCH 5/5] start-chat: test subfolders, worktrees, relative folders, origin_url and a generic 404 The previous commit's tests for these did not apply; add them and drop an unneeded super:: qualification. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-3b729bb81a4ace643a60 --- .../cmux-tui/src/link/start_chat_cli/tests.rs | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs index 4b5085e08988..d154cb222d7d 100644 --- a/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs +++ b/cmux-tui/crates/cmux-tui/src/link/start_chat_cli/tests.rs @@ -408,7 +408,7 @@ fn copy_refuses_paths_that_could_escape() { .map(|s| s.to_string()) .collect(); args.extend(extra.iter().map(|s| s.to_string())); - super::Flags::parse(&args).unwrap() + Flags::parse(&args).unwrap() }; assert!(StartCopy::of(&flags(&["--local", "/Users/me/src/paint"])).is_some()); assert!(StartCopy::of(&flags(&["--local", "relative"])).is_none()); @@ -1043,10 +1043,30 @@ fn plan_answers_the_folders_repo_and_echoes_the_environment() { assert_eq!(answer["repo"], json!("manaflow-ai/cmux"), "{answer}"); assert_eq!(answer["environment"], json!(ENV)); assert_eq!(plan(&path, &request(json!("env_nope")))["error_code"], json!("bad_request")); - // A folder that is not there answers no repo. - let gone = - json!({"key": key(), "account": ACCOUNT, "local": "/nonexistent/cmux-test"}).to_string(); - assert_eq!(plan(&path, &gone)["repo"], Value::Null); + // A subfolder of the repository, and a worktree whose `.git` file points at it, answer its repo. + let in_sub = + json!({"key": key(), "account": ACCOUNT, "local": sub.to_str().unwrap()}).to_string(); + assert_eq!(plan(&path, &in_sub)["repo"], json!("manaflow-ai/cmux")); + let worktree = dir.path().join("wt"); + std::fs::create_dir_all(folder.join(".git/worktrees/wt")).unwrap(); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::write(folder.join(".git/worktrees/wt/commondir"), "../..\n").unwrap(); + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", folder.join(".git/worktrees/wt").display()), + ) + .unwrap(); + let in_worktree = + json!({"key": key(), "account": ACCOUNT, "local": worktree.to_str().unwrap()}).to_string(); + assert_eq!(plan(&path, &in_worktree)["repo"], json!("manaflow-ai/cmux")); + // A folder that is not there, an empty one and a relative one answer no repo. + for local in ["/nonexistent/cmux-test", "", "project"] { + let request = json!({"key": key(), "account": ACCOUNT, "local": local}).to_string(); + assert_eq!(plan(&path, &request)["repo"], Value::Null, "{local:?}"); + } + let config = "[remote \"upstream\"]\n url = a\n[Remote \"origin\"]\n URL = \"b\"\n[branch \"main\"]\n url = c\n"; + assert_eq!(origin_url(config), Some("b".into())); + assert_eq!(origin_url("[remote \"upstream\"]\n url = a\n"), None); } fn flags(args: &[&str]) -> Flags { @@ -1133,6 +1153,10 @@ fn ensures_line_names_the_machine_or_the_platforms_code() { ensure_answer(Err(E::Status { status: 404, code: "" }))["error_code"], json!("unavailable") ); + assert_eq!( + ensure_answer(Err(E::Status { status: 404, code: "not_found" }))["error_code"], + json!("unavailable") + ); assert_eq!( ensure_answer(Err(E::Status { status: 500, code: "" }))["error_code"], json!("refused")