Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmux-tui/crates/cmux-tui/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
27 changes: 21 additions & 6 deletions cmux-tui/crates/cmux-tui/src/cli/machine_join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
) -> Result<String, String> {
Expand All @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -360,7 +360,7 @@ impl CallError {
}

impl Platform {
pub(super) fn new(
pub(crate) fn new(
origin: String,
credential: String,
timeout: Duration,
Expand Down Expand Up @@ -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<Value, CallError> {
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<Vec<Value>, CallError> {
self.runtime.block_on(self.credentials_async())
Expand Down Expand Up @@ -521,7 +526,7 @@ fn entries(body: Value) -> Option<Vec<Value>> {
}

/// 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",
Expand All @@ -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",
Expand Down
236 changes: 236 additions & 0 deletions cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//!
Expand All @@ -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...";

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -171,6 +181,12 @@ struct PlanRequest {
/// since): a send pinned to one keeps it, but none is picked.
#[serde(default)]
stored: Vec<String>,
/// The chat's folder on this Mac: its GitHub `origin` is the plan's `repo`.
#[serde(default)]
local: Option<String>,
/// The cloud environment the send chose (`cloud.startChat`'s `environment`), echoed for `ensure`.
#[serde(default)]
environment: Option<String>,
}

/// `plan`: where the send goes, pinned when it is a machine that exists.
Expand All @@ -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| {
Expand Down Expand Up @@ -227,10 +246,227 @@ 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` (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<String> {
let local = Path::new(local);
if !local.is_absolute() {
return None;
}
let config = std::fs::read_to_string(git_common_dir(local)?.join("config")).ok()?;
github_repo(&origin_url(&config)?)
}

/// 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<PathBuf> {
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<String> {
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
&& let Some((key, value)) = line.split_once('=')
&& 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<String> {
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)?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: github_repo rejects valid GitHub remotes with explicit transport ports, such as https://github.com:443/owner/repo and ssh://git@github.com:22/owner/repo. Parse the authority and port separately, normalizing HTTPS 443 and ignoring SSH transport ports, so plan can identify the repository for zero-setup boot.

(Based on your team's feedback about GitHub remote port normalization.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs, line 336:

<comment>`github_repo` rejects valid GitHub remotes with explicit transport ports, such as `https://github.com:443/owner/repo` and `ssh://git@github.com:22/owner/repo`. Parse the authority and port separately, normalizing HTTPS `443` and ignoring SSH transport ports, so `plan` can identify the repository for zero-setup boot.

(Based on your team's feedback about GitHub remote port normalization.) </comment>

<file context>
@@ -227,10 +246,227 @@ pub(super) fn plan(ledger: &Path, request: &str) -> Value {
+        Some(rest) => {
+            let (host, path) = rest.split_once('/')?;
+            let host = host.rsplit('@').next()?;
+            host.eq_ignore_ascii_case("github.com").then_some(path)?
+        }
+        None => {
</file context>

}
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())
}

/// `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<String>,
environment: Option<String>,
platform: Option<String>,
}

impl Ensure {
fn of(flags: &Flags) -> Option<Self> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: ensure silently ignores unknown flags, so a misspelled --environment can create the machine in the default environment. Reject any flag not in the ensure vocabulary before sending the request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs, line 361:

<comment>`ensure` silently ignores unknown flags, so a misspelled `--environment` can create the machine in the default environment. Reject any flag not in the `ensure` vocabulary before sending the request.</comment>

<file context>
@@ -227,10 +246,227 @@ pub(super) fn plan(ledger: &Path, request: &str) -> Value {
+}
+
+impl Ensure {
+    fn of(flags: &Flags) -> Option<Self> {
+        let optional = |name: &str| match flags.all(name).as_slice() {
+            [] => Some(None),
</file context>

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'~' => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: percent_segment leaves . unescaped, so workspace IDs . or .. are normalized by URL parsing and can target a different platform route. Percent-encode dots (or reject dot-segment workspace IDs) before building the request path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At cmux-tui/crates/cmux-tui/src/link/start_chat_cli.rs, line 427:

<comment>`percent_segment` leaves `.` unescaped, so workspace IDs `.` or `..` are normalized by URL parsing and can target a different platform route. Percent-encode dots (or reject dot-segment workspace IDs) before building the request path.</comment>

<file context>
@@ -227,10 +246,227 @@ pub(super) fn plan(ledger: &Path, request: &str) -> Value {
+    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()
+            }
</file context>
Suggested change
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | 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, crate::cli::PlatformCallError>) -> 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: "" | "not_found" }) => {
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`]).
Expand Down
Loading
Loading