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
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ extension AppControl {
/// One Cloud machine's row of `cloud.machines` (and of the Cloud
/// diagnostics' details): its record, stage, daemon link state and the
/// capability negotiation of its daemon.
/// The daemon capability that serves file ops over a Cloud machine's home (cmux-tui `FS_CAPABILITY`).
static let cloudFileOpsCapability = "fs-v1"

static func cloudMachineRow(_ session: CloudMachineSession, _ machines: MachineRegistry) -> [String: JSONValue] {
let store = session.daemon.store
let state: String = switch store.connectionState {
Expand All @@ -82,7 +85,17 @@ extension AppControl {
case .disconnected: "disconnected"
case .failed(let reason): "failed: \(reason)"
}
let compat = machines.compatibility(of: session.daemon)
var compat = machines.compatibility(of: session.daemon)
// File ops (`fs-v1`) are what Move to Cloud and a Cloud chat's first send copy the folder
// with. A Cloud machine's daemon serves them only when it knows its model plane identity,
// so their absence is not an optional extra there: say so here, before a sync fails on it.
// Nil before the daemon answers.
let fileOps = session.daemon.identity.map { _ in session.daemon.supports(Self.cloudFileOpsCapability) }
if fileOps == false, var known = compat, known.level != .incompatible {
if !known.missingOptional.contains(Self.cloudFileOpsCapability) { known.missingOptional.append(Self.cloudFileOpsCapability) }
known.level = .limited
compat = known
}
return [
"id": .string(session.machineID),
// The id its daemon has in the machine catalog (`machine list --catalog`),
Expand All @@ -107,6 +120,7 @@ extension AppControl {
"compatibility": compat.map { .string($0.level.rawValue) } ?? .null,
"missing_required": .array((compat?.missingRequired ?? []).map(JSONValue.string)),
"missing_features": .array((compat?.missingOptional ?? []).map(JSONValue.string)),
"file_ops": fileOps.map(JSONValue.bool) ?? .null,
"workspaces": .array(store.workspaces.map { .object(["id": .string($0.id), "name": .string($0.displayName)]) }),
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,37 @@ struct CloudCLITargetTests {
#expect(report.details.contains("9c1d2e3f4a5b"), "the ghostty commit is in the copied text")
}

/// A Cloud machine daemon's handshake with every required capability and `extra`.
static func cloudHandshake(_ store: DaemonStore, extra: [String]) throws {
let list = (DaemonCapability.required + extra).map { "\"\($0)\"" }.joined(separator: ",")
let json = #"{"app":"cmux-tui","protocol":12,"session":"cloud","generation":"g1","capabilities":["# + list + "]}"
let identity = try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8))
store.noteHandshake(identity)
store.connectionState = .connected(identity)
}

/// A Cloud machine whose daemon does not serve `fs-v1` (no model plane identity on a fresh
/// VM) cannot take Move to Cloud's or a Cloud chat's first sync: its row says so, and its
/// compatibility is not `current`, before any sync fails on `fs.unavailable`.
@Test func aCloudMachineWithoutFileOpsIsReportedBeforeTheSync() throws {
let unknown = Self.fixture()
#expect(AppControl.cloudMachineRow(unknown.session, unknown.services.machines)["file_ops"] == .null,
"unknown before the daemon answers")

let without = Self.fixture()
try Self.cloudHandshake(without.session.daemon.store, extra: DaemonCapability.optional.filter { $0 != "fs-v1" })
let row = AppControl.cloudMachineRow(without.session, without.services.machines)
#expect(row["file_ops"] == .bool(false))
#expect(row["compatibility"] == .string("limited"))
#expect(row["missing_features"]?.arrayValue?.contains(.string("fs-v1")) == true)

let with = Self.fixture()
try Self.cloudHandshake(with.session.daemon.store, extra: DaemonCapability.optional + ["fs-v1"])
let served = AppControl.cloudMachineRow(with.session, with.services.machines)
#expect(served["file_ops"] == .bool(true))
#expect(served["missing_features"]?.arrayValue?.contains(.string("fs-v1")) != true)
}

/// A field added to `identify` must reach the details too: every stored property of
/// `DaemonIdentity` (but the lookup set it derives) has a wire key in the fixture above.
@Test func everyStoredFieldOfTheHandshakeIsInTheDetails() {
Expand Down
117 changes: 108 additions & 9 deletions cmux-tui/crates/cmux-tui/src/coderouter_usage.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Machine spend readout for cmux Cloud VMs.
//!
//! A Cloud VM daemon learns its coderouter identity from the model-plane env
//! (`CMUX_CODEROUTER_URL` and `CMUX_VM_ID`, in the process environment or in
//! `$HOME/.config/cmux/model-plane.env`) and polls
//! (`CMUX_CODEROUTER_URL` and `CMUX_VM_ID`; see [`machine_source`] for where
//! each comes from) and polls
//! `GET {url}/api/coderouter/vm-usage/self` at a low cadence. The Freestyle
//! edge injects the real credential; the guest only sends a public
//! placeholder bearer. The latest totals land in the mux as
Expand All @@ -27,6 +27,15 @@ use tokio::sync::Notify;
pub(crate) const CODEROUTER_URL_VAR: &str = "CMUX_CODEROUTER_URL";
pub(crate) const VM_ID_VAR: &str = "CMUX_VM_ID";
const ENV_FILE_RELATIVE_PATH: &str = ".config/cmux/model-plane.env";
/// The model-plane env every Cloud image bakes (`/etc/cmux/model-plane.env`,
/// web/services/coderouter/vmGuestEnv.ts): the same bytes on every machine,
/// so it carries the coderouter origin but no `CMUX_VM_ID`.
const IMAGE_ENV_FILE: &str = "model-plane.env";
/// The instance id the bind agent wrote for this machine before it started
/// the daemon (`/etc/cmux/daemon-instance-id`, cmux-host `BOUND_INSTANCE_FILE`).
const BOUND_INSTANCE_FILE: &str = "daemon-instance-id";
/// Longest instance id read.
const MAX_INSTANCE_ID_BYTES: usize = 128;
const USAGE_PATH: &str = "/api/coderouter/vm-usage/self";
/// The guest never holds a credential; the edge replaces this bearer.
const PLACEHOLDER_BEARER: &str = "cmux-vm-edge-placeholder";
Expand Down Expand Up @@ -117,30 +126,76 @@ fn parse_shell_value(raw: &str) -> Option<String> {
/// Resolve the usage source from the process environment first, then from
/// the env file. Each key resolves independently so a daemon started with
/// only one of the two in its environment still finds the other in the file.
#[cfg(test)]
pub(crate) fn resolve_source(
env: impl Fn(&str) -> Option<String>,
env_file: Option<&str>,
) -> Option<UsageSource> {
let file_values = env_file.map(parse_env_exports).unwrap_or_default();
resolve_layered_source(env, &[env_file], None)
}

/// [`resolve_source`] over several env files, earlier ones first, each key
/// on its own. `bound_instance_id` is the last place `CMUX_VM_ID` comes
/// from: a fresh Cloud VM's shells never export it (the baked env has none),
/// but the bind agent has written the machine's id.
pub(crate) fn resolve_layered_source(
env: impl Fn(&str) -> Option<String>,
env_files: &[Option<&str>],
bound_instance_id: Option<&str>,
) -> Option<UsageSource> {
let file_values: Vec<_> = env_files.iter().flatten().map(|file| parse_env_exports(file)).collect();
let lookup = |key: &str| {
env(key)
.filter(|value| !value.trim().is_empty())
.or_else(|| file_values.get(key).cloned())
.into_iter()
.chain(file_values.iter().filter_map(|values| values.get(key).cloned()))
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.find(|value| !value.is_empty())
};
let coderouter_url = lookup(CODEROUTER_URL_VAR)?;
let vm_id = lookup(VM_ID_VAR)?;
let vm_id = lookup(VM_ID_VAR).or_else(|| bound_instance_id.and_then(instance_id))?;
if !(coderouter_url.starts_with("https://") || coderouter_url.starts_with("http://")) {
return None;
}
Some(UsageSource { coderouter_url, vm_id })
}

/// A bound instance id file's text as an id: one token of printable ASCII,
/// at most [`MAX_INSTANCE_ID_BYTES`]; `None` otherwise.
fn instance_id(text: &str) -> Option<String> {
let id = text.trim();
(!id.is_empty()
&& id.len() <= MAX_INSTANCE_ID_BYTES
&& id.bytes().all(|byte| byte.is_ascii_graphic()))
.then(|| id.to_string())
}

pub(crate) fn default_env_file_path() -> Option<PathBuf> {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(ENV_FILE_RELATIVE_PATH))
}

/// This machine's usage source, each key from the first place that has it:
/// the process environment, `$HOME/.config/cmux/model-plane.env` (what a
/// shell persisted from a boot env), the image's `/etc/cmux/model-plane.env`,
/// and for `CMUX_VM_ID` last the bound `/etc/cmux/daemon-instance-id`.
///
/// The daemon's unit carries none of these, and a fresh VM's shells only
/// source the image file without writing a per-home one, so without the two
/// `/etc/cmux` files a fresh VM never learned its identity and served no
/// `fs-v1`. The `/etc/cmux` folder follows the debug-only test seam
/// ([`crate::agent_start_host::cloud_etc`]).
pub(crate) fn machine_source() -> Option<UsageSource> {
let home_env = read_env_file(default_env_file_path().as_deref());
let etc = crate::agent_start_host::cloud_etc();
let image_env = read_env_file(Some(&etc.file(IMAGE_ENV_FILE)));
let bound_id = read_env_file(Some(&etc.file(BOUND_INSTANCE_FILE)));
resolve_layered_source(
|key| std::env::var(key).ok(),
&[home_env.as_deref(), image_env.as_deref()],
bound_id.as_deref(),
)
}

fn read_env_file(path: Option<&Path>) -> Option<String> {
std::fs::read_to_string(path?).ok()
}
Expand Down Expand Up @@ -278,9 +333,7 @@ impl Drop for UsagePoller {

/// Start polling when the process resolves a usage source; otherwise `None`.
pub(crate) fn start_poller(mux: Weak<Mux>) -> Option<UsagePoller> {
let env_file = default_env_file_path();
let contents = read_env_file(env_file.as_deref());
let source = resolve_source(|key| std::env::var(key).ok(), contents.as_deref())?;
let source = machine_source()?;
start_poller_with_source(mux, source, STARTUP_DELAY, POLL_INTERVAL)
}

Expand Down Expand Up @@ -466,6 +519,52 @@ mod tests {
);
}

/// A fresh devbox VM: no identity in the unit's env, no per-home file,
/// the image's baked env without `CMUX_VM_ID`, and the bound id.
#[test]
fn fresh_vm_resolves_from_the_image_env_and_the_bound_id() {
let image = "# generated by cmux; the same for every machine; managed, do not edit\n\
export OPENAI_BASE_URL='https://coderouter.cmux.internal/v1'\n\
export OPENAI_API_KEY='cmux-vm-edge-placeholder'\n\
export CMUX_CODEROUTER_URL='https://coderouter.cmux.internal'\n";
assert_eq!(
resolve_layered_source(|_| None, &[None, Some(image)], Some("vm-bound\n")),
Some(UsageSource {
coderouter_url: "https://coderouter.cmux.internal".to_string(),
vm_id: "vm-bound".to_string()
})
);
assert_eq!(
resolve_layered_source(|_| None, &[None, Some(image)], None),
None,
"the image env alone has no machine id"
);
assert_eq!(
resolve_layered_source(|_| None, &[None, None], Some("vm-bound")),
None,
"an id alone has no coderouter origin"
);
}

#[test]
fn earlier_layers_win_per_key_and_bad_bound_ids_are_ignored() {
let home = "export CMUX_VM_ID='home-vm'\n";
let image = "export CMUX_CODEROUTER_URL='https://image.example'\nexport CMUX_VM_ID='image-vm'\n";
let source =
resolve_layered_source(|_| None, &[Some(home), Some(image)], Some("bound")).unwrap();
assert_eq!(source.coderouter_url, "https://image.example");
assert_eq!(source.vm_id, "home-vm", "a per-home value wins over the image's");
let env = |key: &str| (key == VM_ID_VAR).then(|| "env-vm".to_string());
assert_eq!(
resolve_layered_source(env, &[Some(image)], Some("bound")).unwrap().vm_id,
"env-vm"
);
let url_only = "export CMUX_CODEROUTER_URL='https://x'\n";
for bad in ["", " \n", "two words", &"a".repeat(MAX_INSTANCE_ID_BYTES + 1)] {
assert_eq!(resolve_layered_source(|_| None, &[Some(url_only)], Some(bad)), None, "{bad:?}");
}
}

#[test]
fn ready_body_decodes_to_a_readout() {
let usage = decode_usage(READY_BODY.as_bytes()).unwrap().expect("ready totals");
Expand Down
14 changes: 6 additions & 8 deletions cmux-tui/crates/cmux-tui/src/link/cloud_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
//! not writable by group or others, and are not symlinks, so a normal
//! user cannot create or plant it;
//! - the Cloud model plane identity (`CMUX_CODEROUTER_URL` and
//! `CMUX_VM_ID`, in the process environment or in
//! `$HOME/.config/cmux/model-plane.env`; the source the machine spend
//! readout uses).
//! `CMUX_VM_ID`, from the process environment,
//! `$HOME/.config/cmux/model-plane.env`, the image's
//! `/etc/cmux/model-plane.env` and the bound `/etc/cmux/daemon-instance-id`;
//! the source the machine spend readout uses,
//! `coderouter_usage::machine_source`).
//!
//! No argv flag is added, so an upgraded daemon starts with the guest's
//! existing command line.
Expand Down Expand Up @@ -74,11 +76,7 @@ pub(super) fn trusted_stamp(stamp: &Path, top: &Path, owner_uid: u32) -> bool {

/// True when this machine is a cmux Cloud host (the module's test).
fn this_is_a_cloud_host() -> bool {
let env_file = crate::coderouter_usage::default_env_file_path()
.and_then(|path| std::fs::read_to_string(path).ok());
let identity =
crate::coderouter_usage::resolve_source(|key| std::env::var(key).ok(), env_file.as_deref())
.is_some();
let identity = crate::coderouter_usage::machine_source().is_some();
let linux = cfg!(target_os = "linux");
// The root-owned stamp of a Cloud image (`/etc/cmux/image-stamp`).
let etc = crate::agent_start_host::cloud_etc();
Expand Down
66 changes: 66 additions & 0 deletions cmux-tui/crates/cmux-tui/tests/cloud_move_journey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,72 @@ fn cloud_move_journey_moves_a_dirty_folder_and_its_chat_to_a_personal_cloud_vm()
assert_eq!(count(&events, "user_message", USER_TEXT, QUEUED), 0, "ran on the Mac too");
}

/// Whether the daemon's `identify` lists `capability`.
fn advertises(machine: &Machine, capability: &str) -> bool {
let identity = machine.rpc(json!({"id": 1, "cmd": "identify"}));
identity["data"]["capabilities"]
.as_array()
.unwrap_or_else(|| panic!("no capabilities: {identity}"))
.iter()
.any(|value| value == capability)
}

/// A VM made fresh from the devbox image: the daemon's unit carries no model
/// plane identity and no shell wrote a per-home model-plane.env. The image's
/// baked `/etc/cmux/model-plane.env` has the coderouter origin but no
/// `CMUX_VM_ID`; the bind agent wrote the machine's id to
/// `/etc/cmux/daemon-instance-id`. That daemon serves `fs-v1`, so
/// `cmux link sync push` (Move to Cloud, Cloud first send) works with no
/// manual step; without the bound id it does not.
#[test]
fn a_fresh_image_vm_serves_fs_v1_from_the_baked_env_and_the_bound_id() {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() % 1_000_000_000;
let root = PathBuf::from("/tmp").join(format!("cmf-{}-{stamp}", std::process::id()));
fs::create_dir_all(&root).unwrap();
let root = fs::canonicalize(&root).unwrap();
let _cleanup = Cleanup(root.clone());

let etc = cloud_etc(&root);
fs::write(
etc.join("model-plane.env"),
"# generated by cmux; the same for every machine; managed, do not edit\n\
export OPENAI_BASE_URL='https://coderouter.cmux.internal/v1'\n\
export OPENAI_API_KEY='cmux-vm-edge-placeholder'\n\
export CMUX_CODEROUTER_URL='http://127.0.0.1:9'\n\
export ANTHROPIC_BASE_URL='https://coderouter.cmux.internal'\n\
export ANTHROPIC_API_KEY='cmux-vm-edge-placeholder'\n",
)
.unwrap();
let seam = vec![("CMUX_TEST_CLOUD_ETC", etc.to_string_lossy().into_owned())];

// No bound id yet: no identity, no fs-v1 (as before the fix, a fresh VM).
let unbound = Machine::start(&root, "unbound", seam.clone());
assert!(!advertises(&unbound, "fs-v1"), "fs-v1 without a machine id");
drop(unbound);

fs::write(etc.join("daemon-instance-id"), "vm-fresh-image\n").unwrap();
let mac = Machine::start(&root, "mac", Vec::new());
let vm = Machine::start(&root, "vm", seam);
assert!(advertises(&vm, "fs-v1"), "a fresh image VM does not serve fs-v1");
assert!(
!vm.home().join(".config/cmux/model-plane.env").exists(),
"the daemon must not write a per-home model-plane.env (it would shadow the image's)"
);

let repo = dirty_repo(&mac.home());
let synced = vm.home().join("work/app");
let (ok, summary) = run_json(
Command::new(bin())
.env("CMUX_TUI_STATE_DIR", mac.dir.join("state"))
.args(["link", "sync", "push", "--socket"])
.arg(&vm.socket)
.arg(&repo)
.arg(&synced),
);
assert!(ok && summary["ok"] == json!(true), "sync push: {summary}");
assert_eq!(fs::read_to_string(synced.join("notes.txt")).unwrap(), "untracked\n");
}

/// A plain chat on `acpmux` with one finished turn.
fn plain_chat(acpmux: &mut Acpmux, cwd: &Path, text: &str) -> String {
let created = acpmux.call("session/new", json!({"cwd": cwd, "mcpServers": []}));
Expand Down
6 changes: 4 additions & 2 deletions scripts/cmux-next/check-app-capability-references.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@
# The bundled acpmux's capability list: the `id: "..."` of each entry, which its `initialize` advertises.
ACPMUX_LIST = "cmux-tui/crates/acpmux/src/capabilities.rs"

# Served by cmux-tui only when a sidecar binary sits beside it, so the
# isolated identify probe of check-daemon-capabilities.sh cannot require it.
# Served by cmux-tui only under a host condition (a sidecar binary beside it,
# or a cmux Cloud host), so the isolated identify probe of
# check-daemon-capabilities.sh cannot require it.
CONDITIONAL = {
"apps-v1": "only with cmux-app-host beside the binary (cmux-tui-core apps.rs advertised())",
"fs-v1": "only on a cmux Cloud host: a root-owned image stamp and the model plane identity (cmux-tui link/cloud_fs.rs)",
}

# Capabilities of other servers that the app gates on with the same helpers.
Expand Down
Loading