use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use disasmer_core::{ BrowserLoginFlow, BundleIdentityInputs, BundleMetadata, Capability, CliLoginFlow, Digest, NodeCapabilities, ProjectModel, SelectedInput, SourceProviderKind, SourceProviderManifest, }; use serde::Serialize; use serde_json::{json, Value}; const DEFAULT_OPERATOR_ENDPOINT: &str = "https://disasmer.michelpaulissen.com:9443"; const DEFAULT_BROWSER_LOGIN_START: &str = "https://disasmer.michelpaulissen.com/auth/browser/start"; const DEFAULT_OIDC_ISSUER_URL: &str = "https://auth.michelpaulissen.com"; const BROWSER_CALLBACK_ADDR: &str = "127.0.0.1:45173"; const BROWSER_CALLBACK_PATH: &str = "/callback"; const DEFAULT_BROWSER_LOGIN_CALLBACK_TIMEOUT_SECONDS: u64 = 300; #[derive(Clone, Debug, Parser)] #[command(name = "disasmer")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Clone, Debug, Subcommand)] enum Commands { Login(LoginArgs), Agent { #[command(subcommand)] command: AgentCommands, }, Bundle { #[command(subcommand)] command: BundleCommands, }, Run(RunArgs), Node { #[command(subcommand)] command: NodeCommands, }, } #[derive(Clone, Debug, Parser)] struct LoginArgs { #[arg(long)] browser: bool, #[arg(long)] plan: bool, #[arg(long)] json: bool, #[arg(long, default_value_t = default_operator_endpoint())] coordinator: String, #[arg(long = "complete-browser-code")] complete_browser_code: Option, #[arg(long, default_value = "tenant")] tenant: String, #[arg(long = "project-id", default_value = "project")] project: String, #[arg(long, default_value = "user")] user: String, #[arg(long = "oidc-issuer-url")] oidc_issuer_url: Option, #[arg(long = "oidc-client-id", default_value = "disasmer")] oidc_client_id: String, #[arg(long)] state: Option, } #[derive(Clone, Debug, Subcommand)] enum AgentCommands { Enroll(AgentEnrollArgs), } #[derive(Clone, Debug, Subcommand)] enum BundleCommands { Inspect(BundleInspectArgs), } #[derive(Clone, Debug, Parser)] struct AgentEnrollArgs { #[arg(long = "public-key")] public_key: String, } #[derive(Clone, Debug, Parser)] struct BundleInspectArgs { #[arg(long)] project: Option, } #[derive(Clone, Debug, Parser)] struct RunArgs { entry: Option, #[arg(long)] project: Option, #[arg(long)] coordinator: Option, #[arg(long)] local: bool, } #[derive(Clone, Debug, Subcommand)] enum NodeCommands { Attach(AttachArgs), } #[derive(Clone, Debug, Parser)] struct AttachArgs { #[arg(long)] coordinator: Option, #[arg(long, default_value = "tenant")] tenant: String, #[arg(long = "project-id", default_value = "project")] project: String, #[arg(long)] node: Option, #[arg(long = "cap")] caps: Vec, #[arg(long = "enrollment-grant")] enrollment_grant: Option, #[arg(long = "public-key")] public_key: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct RunPlan { project: PathBuf, entry: String, coordinator: CoordinatorSelection, #[serde(skip_serializing_if = "Option::is_none")] operator_endpoint: Option, session: CliSession, } #[derive(Clone, Debug, PartialEq, Serialize)] struct RunExecutionReport { plan: RunPlan, boundary: RunBoundaryEvidence, node_report: serde_json::Value, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct RunBoundaryEvidence { cli_process_started_node_process: bool, cli_process_started_coordinator_process: bool, coordinator_address: String, #[serde(skip_serializing_if = "Option::is_none")] coordinator_process_id: Option, spawned_node_process_id: u32, node_session_requests: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] enum CoordinatorSelection { Hosted, LocalOverride(String), LocalOnly, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] enum CliSession { Anonymous, HumanSession, AgentPublicKey { public_key_fingerprint: Digest, browser_interaction_required: bool, }, } impl CliSession { fn is_authenticated(&self) -> bool { !matches!(self, Self::Anonymous) } } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct LoginPlan { coordinator: String, human_flow: LoginFlowPlan, } #[derive(Clone, Debug, PartialEq, Serialize)] struct LoginCompletionReport { plan: LoginPlan, boundary: LoginCompletionBoundaryEvidence, coordinator_response: Value, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct LoginCompletionBoundaryEvidence { cli_contacted_coordinator: bool, coordinator_address: String, scoped_cli_session_received: bool, provider_tokens_exposed_to_cli: bool, provider_tokens_sent_to_nodes: bool, coordinator_session_requests: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] enum LoginFlowPlan { Browser(BrowserLoginFlow), Device(CliLoginFlow), } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct AgentEnrollmentPlan { public_key_fingerprint: Digest, browser_interaction_required_each_run: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct BundleInspection { project: PathBuf, default_source_providers: Vec, source_provider_manifest: SourceProviderManifest, metadata: BundleMetadata, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct NodeAttachPlan { node: String, coordinator: Option, capabilities: NodeCapabilities, enrollment: Option, } #[derive(Clone, Debug, PartialEq, Serialize)] struct NodeAttachReport { plan: NodeAttachPlan, boundary: NodeAttachBoundaryEvidence, coordinator_response: Value, heartbeat_response: Value, capability_response: Value, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct NodeAttachBoundaryEvidence { cli_contacted_coordinator: bool, coordinator_address: String, used_enrollment_exchange: bool, coordinator_session_requests: u64, } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] struct NodeEnrollmentPlan { grant: String, public_key_fingerprint: Digest, exchanges_short_lived_grant_for_long_lived_node_identity: bool, } fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Login(args) => { if args.complete_browser_code.is_some() { let report = execute_browser_login_completion(args)?; println!("{}", serde_json::to_string_pretty(&report)?); } else if args.browser && !args.plan { let json_output = args.json; let report = execute_interactive_browser_login(args)?; if json_output { println!("{}", serde_json::to_string_pretty(&report)?); } else { print_browser_login_success(&report); } } else { let plan = login_plan(args); println!("{}", serde_json::to_string_pretty(&plan)?); } } Commands::Agent { command: AgentCommands::Enroll(args), } => { let plan = agent_enrollment_plan(args); println!("{}", serde_json::to_string_pretty(&plan)?); } Commands::Bundle { command: BundleCommands::Inspect(args), } => { let inspection = bundle_inspection(args, std::env::current_dir()?)?; println!("{}", serde_json::to_string_pretty(&inspection)?); } Commands::Run(args) => { let plan = run_plan(args, std::env::current_dir()?, session_from_env())?; if should_execute_local_node(&plan) { let report = execute_local_node_run(plan)?; println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("{}", serde_json::to_string_pretty(&plan)?); } } Commands::Node { command: NodeCommands::Attach(args), } => { if args.coordinator.is_some() { let report = execute_node_attach(args)?; println!("{}", serde_json::to_string_pretty(&report)?); } else { let plan = attach_plan(args); println!("{}", serde_json::to_string_pretty(&plan)?); } } } Ok(()) } fn login_plan(args: LoginArgs) -> LoginPlan { login_plan_with_nonce(args, login_nonce()) } fn default_operator_endpoint() -> String { DEFAULT_OPERATOR_ENDPOINT.to_owned() } fn login_plan_with_nonce(args: LoginArgs, nonce: String) -> LoginPlan { login_plan_with_nonce_and_callback(args, nonce, browser_callback_url()) } fn login_plan_with_nonce_and_callback( args: LoginArgs, nonce: String, callback_path: String, ) -> LoginPlan { let challenge = Digest::from_parts([ b"cli-login-challenge:v1".as_slice(), args.coordinator.as_bytes(), nonce.as_bytes(), ]); let human_flow = if args.browser { let state = challenge.as_str().to_owned(); LoginFlowPlan::Browser(BrowserLoginFlow { authorization_url: browser_authorization_url(&args, &state, &callback_path), callback_path, state, }) } else { LoginFlowPlan::Device(CliLoginFlow { verification_url: format!("{}/auth/device", args.coordinator), user_code: device_user_code(&challenge), device_code: challenge.as_str().to_owned(), expires_in_seconds: 900, yields_long_lived_secret_directly: false, }) }; LoginPlan { coordinator: args.coordinator, human_flow, } } fn execute_browser_login_completion(args: LoginArgs) -> Result { let plan = login_plan_with_nonce(args.clone(), login_nonce()); execute_browser_login_completion_for_plan(args, plan) } fn execute_browser_login_completion_for_plan( args: LoginArgs, plan: LoginPlan, ) -> Result { if !args.browser { anyhow::bail!("browser login completion requires --browser"); } let authorization_code = args .complete_browser_code .clone() .context("--complete-browser-code requires an authorization code")?; let issuer_url = args .oidc_issuer_url .clone() .unwrap_or_else(default_oidc_issuer_url); let coordinator = args.coordinator.clone(); let tenant = args.tenant.clone(); let project = args.project.clone(); let user = args.user.clone(); let client_id = args.oidc_client_id.clone(); let LoginFlowPlan::Browser(flow) = &plan.human_flow else { anyhow::bail!("browser login completion requires a browser flow"); }; let state = args.state.clone().unwrap_or_else(|| flow.state.clone()); let mut session = JsonLineSession::connect(&coordinator)?; let coordinator_response = session.request(json!({ "type": "oidc_browser_login", "tenant": tenant, "project": project, "user": user, "issuer_url": issuer_url, "client_id": client_id, "redirect_path": flow.callback_path.clone(), "state": state, "authorization_code": authorization_code, }))?; let scoped_cli_session_received = coordinator_response .pointer("/session/cli_session_credential_kind") .and_then(Value::as_str) == Some("CliDeviceSession"); let provider_tokens_sent_to_nodes = coordinator_response .pointer("/session/provider_tokens_sent_to_nodes") .and_then(Value::as_bool) .unwrap_or(true); let provider_tokens_exposed_to_cli = contains_provider_token_field(&coordinator_response); Ok(LoginCompletionReport { plan, boundary: LoginCompletionBoundaryEvidence { cli_contacted_coordinator: true, coordinator_address: coordinator, scoped_cli_session_received, provider_tokens_exposed_to_cli, provider_tokens_sent_to_nodes, coordinator_session_requests: session.requests(), }, coordinator_response, }) } fn execute_interactive_browser_login(mut args: LoginArgs) -> Result { args.browser = true; let callback_path = browser_callback_url(); let listener = TcpListener::bind(BROWSER_CALLBACK_ADDR).with_context(|| { format!("failed to listen for browser login callback on {BROWSER_CALLBACK_ADDR}") })?; let plan = login_plan_with_nonce_and_callback(args.clone(), login_nonce(), callback_path); let LoginFlowPlan::Browser(flow) = &plan.human_flow else { anyhow::bail!("browser login requires a browser flow"); }; eprintln!("Opening Disasmer browser login: {}", flow.authorization_url); eprintln!("Waiting for login callback on {}", flow.callback_path); open_browser(&flow.authorization_url)?; let callback = receive_browser_callback(&listener)?; if callback.state != flow.state { anyhow::bail!("browser login callback state did not match the requested login state"); } args.complete_browser_code = Some(callback.authorization_code); args.state = Some(callback.state); execute_browser_login_completion_for_plan(args, plan) } fn print_browser_login_success(report: &LoginCompletionReport) { let session = report.coordinator_response.get("session"); let tenant = session .and_then(|value| value.get("tenant")) .and_then(Value::as_str) .unwrap_or("tenant"); let project = session .and_then(|value| value.get("project")) .and_then(Value::as_str) .unwrap_or("project"); let user = session .and_then(|value| value.get("user")) .and_then(Value::as_str) .unwrap_or("user"); println!( "Signed in to {} as {user} for {tenant}/{project}.", report.plan.coordinator ); if report.boundary.scoped_cli_session_received { println!("Received a scoped CLI session from the coordinator."); } } #[derive(Clone, Debug, Eq, PartialEq)] struct BrowserCallback { authorization_code: String, state: String, } fn browser_callback_url() -> String { format!("http://{BROWSER_CALLBACK_ADDR}{BROWSER_CALLBACK_PATH}") } fn default_oidc_issuer_url() -> String { DEFAULT_OIDC_ISSUER_URL.to_owned() } fn browser_authorization_url(args: &LoginArgs, state: &str, callback_path: &str) -> String { if let Some(issuer_url) = &args.oidc_issuer_url { return oidc_authorization_url(issuer_url, &args.oidc_client_id, state, callback_path); } let start = if args.coordinator == DEFAULT_OPERATOR_ENDPOINT { DEFAULT_BROWSER_LOGIN_START.to_owned() } else { format!( "{}/auth/browser/start", args.coordinator.trim_end_matches('/') ) }; format!( "{start}?client_id={}&state={}&redirect_uri={}", percent_encode(&args.oidc_client_id), percent_encode(state), percent_encode(callback_path) ) } fn oidc_authorization_url( issuer_url: &str, client_id: &str, state: &str, callback_path: &str, ) -> String { format!( "{}/application/o/authorize/?client_id={}&response_type=code&scope=openid%20profile%20email&state={}&redirect_uri={}", issuer_url.trim_end_matches('/'), percent_encode(client_id), percent_encode(state), percent_encode(callback_path) ) } fn open_browser(url: &str) -> Result<()> { let mut command = if let Some(command) = std::env::var_os("DISASMER_BROWSER_OPEN_COMMAND") { Command::new(command) } else { platform_browser_command() }; command.arg(url); command .spawn() .with_context(|| format!("failed to start browser opener for {url}"))?; Ok(()) } #[cfg(target_os = "macos")] fn platform_browser_command() -> Command { Command::new("open") } #[cfg(target_os = "windows")] fn platform_browser_command() -> Command { let mut command = Command::new("cmd"); command.args(["/C", "start", ""]); command } #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] fn platform_browser_command() -> Command { Command::new("xdg-open") } fn receive_browser_callback(listener: &TcpListener) -> Result { listener .set_nonblocking(true) .context("failed to configure browser callback listener")?; let timeout = browser_callback_timeout(); let started = Instant::now(); let mut stream = loop { match listener.accept() { Ok((stream, _)) => break stream, Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { if started.elapsed() >= timeout { anyhow::bail!( "timed out waiting for browser login callback on {BROWSER_CALLBACK_ADDR}" ); } std::thread::sleep(Duration::from_millis(50)); } Err(error) => return Err(error).context("failed to accept browser login callback"), } }; let callback = read_browser_callback_request(&stream); match &callback { Ok(_) => write_browser_callback_response( &mut stream, "200 OK", "Disasmer login callback received. You can return to the terminal.", )?, Err(error) => write_browser_callback_response( &mut stream, "400 Bad Request", &format!("Disasmer login callback was rejected: {error}"), )?, } callback } fn browser_callback_timeout() -> Duration { let seconds = std::env::var("DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS") .ok() .and_then(|value| value.parse::().ok()) .filter(|seconds| *seconds > 0) .unwrap_or(DEFAULT_BROWSER_LOGIN_CALLBACK_TIMEOUT_SECONDS); Duration::from_secs(seconds) } fn read_browser_callback_request(stream: &TcpStream) -> Result { let mut reader = BufReader::new( stream .try_clone() .context("failed to read browser callback request")?, ); let mut request_line = String::new(); reader .read_line(&mut request_line) .context("failed to read browser callback request line")?; parse_browser_callback_request_line(&request_line) } fn parse_browser_callback_request_line(line: &str) -> Result { let mut parts = line.split_whitespace(); let method = parts.next().context("browser callback omitted method")?; let target = parts.next().context("browser callback omitted path")?; if method != "GET" { anyhow::bail!("browser callback must use GET"); } let (path, query) = target .split_once('?') .context("browser callback omitted query string")?; if path != BROWSER_CALLBACK_PATH { anyhow::bail!("browser callback used unexpected path {path}"); } let mut authorization_code = None; let mut state = None; for pair in query.split('&').filter(|item| !item.is_empty()) { let (key, value) = pair.split_once('=').unwrap_or((pair, "")); match key { "code" => authorization_code = Some(percent_decode(value)?), "state" => state = Some(percent_decode(value)?), _ => {} } } Ok(BrowserCallback { authorization_code: authorization_code.context("browser callback omitted code")?, state: state.context("browser callback omitted state")?, }) } fn write_browser_callback_response(stream: &mut TcpStream, status: &str, body: &str) -> Result<()> { let body = html_escape(body); let html = format!( "Disasmer login

Disasmer login

{body}

" ); write!( stream, "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}", html.len() ) .context("failed to write browser callback response") } fn percent_encode(input: &str) -> String { let mut encoded = String::new(); for byte in input.as_bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { encoded.push(*byte as char); } else { encoded.push_str(&format!("%{byte:02X}")); } } encoded } fn percent_decode(input: &str) -> Result { let mut decoded = Vec::new(); let bytes = input.as_bytes(); let mut index = 0; while index < bytes.len() { match bytes[index] { b'+' => { decoded.push(b' '); index += 1; } b'%' => { let high = bytes .get(index + 1) .copied() .and_then(hex_digit) .context("invalid percent-encoded callback value")?; let low = bytes .get(index + 2) .copied() .and_then(hex_digit) .context("invalid percent-encoded callback value")?; decoded.push((high << 4) | low); index += 3; } byte => { decoded.push(byte); index += 1; } } } String::from_utf8(decoded).context("callback value was not UTF-8") } fn hex_digit(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, } } fn html_escape(input: &str) -> String { input .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn contains_provider_token_field(value: &Value) -> bool { match value { Value::Object(object) => object.iter().any(|(key, value)| { matches!( key.as_str(), "access_token" | "refresh_token" | "id_token" | "provider_token" | "oauth_token" ) || contains_provider_token_field(value) }), Value::Array(items) => items.iter().any(contains_provider_token_field), _ => false, } } fn login_nonce() -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or_default(); format!("{now}:{}", std::process::id()) } fn device_user_code(challenge: &Digest) -> String { let hex = challenge.as_str().trim_start_matches("sha256:"); let first = hex.get(0..4).unwrap_or(hex).to_ascii_uppercase(); let second = hex.get(4..8).unwrap_or(hex).to_ascii_uppercase(); format!("DISASMER-{first}-{second}") } fn agent_enrollment_plan(args: AgentEnrollArgs) -> AgentEnrollmentPlan { AgentEnrollmentPlan { public_key_fingerprint: Digest::sha256(args.public_key), browser_interaction_required_each_run: false, } } fn bundle_inspection(args: BundleInspectArgs, cwd: PathBuf) -> Result { let project = args.project.unwrap_or(cwd); let model = ProjectModel::discover_without_config(&project)?; let selected_inputs = discover_selected_inputs(&project)?; let source_provider_manifest = source_provider_manifest(&project); source_provider_manifest.validate_public_mvp()?; let identity_inputs = BundleIdentityInputs { wasm_code: wasm_source_proxy_digest(&selected_inputs), task_abi: task_abi_digest(&model), environments: model.environments, source_provider_manifest: source_provider_manifest.digest.clone(), selected_inputs, }; Ok(BundleInspection { project, default_source_providers: vec![SourceProviderKind::Filesystem, SourceProviderKind::Git], source_provider_manifest, metadata: identity_inputs.inspectable_metadata(), }) } fn discover_selected_inputs(project: &Path) -> Result> { let mut inputs = Vec::new(); for path in [ "Cargo.toml", "Cargo.lock", "src/main.rs", "src/lib.rs", "src/build.rs", ] { let absolute = project.join(path); if !absolute.is_file() { continue; } let bytes = std::fs::read(&absolute)?; inputs.push(SelectedInput { path: path.to_owned(), digest: Digest::from_parts([ b"bundle-selected-input:v1".as_slice(), path.as_bytes(), bytes.as_slice(), ]), }); } Ok(inputs) } fn source_provider_manifest(project: &Path) -> SourceProviderManifest { let kind = if project.join(".git").exists() { SourceProviderKind::Git } else { SourceProviderKind::Filesystem }; SourceProviderManifest::local_first( kind, "default source provider manifest; snapshot creation can be scheduled as a node task", ) } fn wasm_source_proxy_digest(selected_inputs: &[SelectedInput]) -> Digest { let mut parts = vec![b"wasm-source-proxy:v1".to_vec()]; for input in selected_inputs { parts.push(input.path.as_bytes().to_vec()); parts.push(input.digest.as_str().as_bytes().to_vec()); } Digest::from_parts(parts) } fn task_abi_digest(model: &ProjectModel) -> Digest { let mut parts = vec![b"task-abi:v1".to_vec()]; for entrypoint in model.entrypoints.values() { parts.push(entrypoint.name.as_bytes().to_vec()); parts.push(entrypoint.function.as_bytes().to_vec()); } Digest::from_parts(parts) } fn run_plan(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result { let (coordinator, operator_endpoint) = if let Some(url) = args.coordinator { (CoordinatorSelection::LocalOverride(url), None) } else if args.local { (CoordinatorSelection::LocalOnly, None) } else if session.is_authenticated() { ( CoordinatorSelection::Hosted, Some(default_operator_endpoint()), ) } else { (CoordinatorSelection::LocalOnly, None) }; Ok(RunPlan { project: args.project.unwrap_or(cwd), entry: args.entry.unwrap_or_else(|| "build".to_owned()), coordinator, operator_endpoint, session, }) } fn should_execute_local_node(plan: &RunPlan) -> bool { match &plan.coordinator { CoordinatorSelection::LocalOnly => true, CoordinatorSelection::LocalOverride(coordinator) => !coordinator.contains("://"), CoordinatorSelection::Hosted => false, } } fn execute_local_node_run(plan: RunPlan) -> Result { let local_coordinator = match &plan.coordinator { CoordinatorSelection::LocalOverride(coordinator) => LocalCoordinator::external(coordinator), CoordinatorSelection::LocalOnly => LocalCoordinator::start_ephemeral()?, CoordinatorSelection::Hosted => anyhow::bail!("local node execution requires local mode"), }; let coordinator_address = local_coordinator.address.clone(); let spawned = spawn_node_process(&plan, &coordinator_address)?; if !spawned.output.status.success() { anyhow::bail!( "node process failed with status {}\n{}", spawned.output.status, String::from_utf8_lossy(&spawned.output.stderr) ); } let stdout = String::from_utf8(spawned.output.stdout).context("node stdout was not UTF-8")?; let line = stdout .lines() .rev() .find(|line| !line.trim().is_empty()) .context("node did not print a JSON report")?; let node_report: serde_json::Value = serde_json::from_str(line).context("node report was not JSON")?; let node_session_requests = node_report .get("session_requests") .and_then(serde_json::Value::as_u64) .unwrap_or_default(); Ok(RunExecutionReport { plan, boundary: RunBoundaryEvidence { cli_process_started_node_process: true, cli_process_started_coordinator_process: local_coordinator.process_id.is_some(), coordinator_address, coordinator_process_id: local_coordinator.process_id, spawned_node_process_id: spawned.process_id, node_session_requests, }, node_report, }) } struct SpawnedNodeOutput { process_id: u32, output: std::process::Output, } fn spawn_node_process(plan: &RunPlan, coordinator: &str) -> Result { let mut command = node_command()?; command.args([ "--coordinator", coordinator, "--tenant", "tenant", "--project-id", "project", "--node", "node-cli-local", "--process", "vp-cli-local", "--task", "compile-linux", "--project", ]); command.arg(&plan.project); command.args(["--artifact", "/vfs/artifacts/cli-run-output.txt"]); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); let child = command.spawn().context("failed to spawn node process")?; let process_id = child.id(); let output = child .wait_with_output() .context("failed to wait for node process")?; Ok(SpawnedNodeOutput { process_id, output }) } fn node_command() -> Result { if let Some(path) = std::env::var_os("DISASMER_NODE_BIN") { return Ok(Command::new(path)); } let mut sibling = std::env::current_exe().context("cannot locate current executable")?; sibling.set_file_name(format!("disasmer-node{}", std::env::consts::EXE_SUFFIX)); if sibling.is_file() { return Ok(Command::new(sibling)); } let mut command = Command::new("cargo"); command.args([ "run", "-q", "-p", "disasmer-node", "--bin", "disasmer-node", "--", ]); Ok(command) } struct LocalCoordinator { address: String, process_id: Option, child: Option, } impl LocalCoordinator { fn external(address: &str) -> Self { Self { address: address.to_owned(), process_id: None, child: None, } } fn start_ephemeral() -> Result { let mut command = coordinator_command()?; command.args(["--listen", "127.0.0.1:0"]); command.stdout(Stdio::piped()); command.stderr(Stdio::inherit()); let mut child = command .spawn() .context("failed to spawn local coordinator process")?; let process_id = child.id(); let address = match read_coordinator_ready_address(&mut child) { Ok(address) => address, Err(error) => { let _ = child.kill(); let _ = child.wait(); return Err(error); } }; Ok(Self { address, process_id: Some(process_id), child: Some(child), }) } } fn read_coordinator_ready_address(child: &mut std::process::Child) -> Result { let stdout = child .stdout .take() .context("local coordinator stdout was not captured")?; let mut ready_line = String::new(); BufReader::new(stdout) .read_line(&mut ready_line) .context("failed to read local coordinator ready line")?; if ready_line.trim().is_empty() { anyhow::bail!("local coordinator exited before reporting its listen address"); } let ready: Value = serde_json::from_str(&ready_line).context("local coordinator ready line was not JSON")?; ready .get("listen") .and_then(Value::as_str) .map(str::to_owned) .context("local coordinator did not report a listen address") } impl Drop for LocalCoordinator { fn drop(&mut self) { if let Some(mut child) = self.child.take() { let _ = child.kill(); let _ = child.wait(); } } } fn coordinator_command() -> Result { if let Some(path) = std::env::var_os("DISASMER_COORDINATOR_BIN") { return Ok(Command::new(path)); } let mut sibling = std::env::current_exe().context("cannot locate current executable")?; sibling.set_file_name(format!( "disasmer-coordinator{}", std::env::consts::EXE_SUFFIX )); if sibling.is_file() { return Ok(Command::new(sibling)); } let mut command = Command::new("cargo"); command.args([ "run", "-q", "-p", "disasmer-coordinator", "--bin", "disasmer-coordinator", "--", ]); Ok(command) } fn session_from_env() -> CliSession { if let Some(public_key) = std::env::var_os("DISASMER_AGENT_PUBLIC_KEY") { let public_key = public_key.to_string_lossy(); return CliSession::AgentPublicKey { public_key_fingerprint: Digest::sha256(public_key.as_bytes()), browser_interaction_required: false, }; } if std::env::var_os("DISASMER_TOKEN").is_some() { return CliSession::HumanSession; } CliSession::Anonymous } fn attach_plan(args: AttachArgs) -> NodeAttachPlan { let mut capabilities = NodeCapabilities::detect_current(); for cap in &args.caps { if let Some(parsed) = parse_capability(&cap) { capabilities.capabilities.insert(parsed); } } let node = args.node.unwrap_or_else(default_node_id); let public_key = args .public_key .unwrap_or_else(|| format!("{node}-public-key")); let enrollment = args.enrollment_grant.map(|grant| NodeEnrollmentPlan { grant, public_key_fingerprint: Digest::sha256(public_key), exchanges_short_lived_grant_for_long_lived_node_identity: true, }); NodeAttachPlan { node, coordinator: args.coordinator, capabilities, enrollment, } } fn execute_node_attach(args: AttachArgs) -> Result { let coordinator = args .coordinator .clone() .context("node attach execution requires --coordinator")?; let tenant = args.tenant.clone(); let project = args.project.clone(); let node = args.node.clone().unwrap_or_else(default_node_id); let public_key = args .public_key .clone() .unwrap_or_else(|| format!("{node}-public-key")); let plan = attach_plan(args); let mut session = JsonLineSession::connect(&coordinator)?; let used_enrollment_exchange = plan.enrollment.is_some(); let coordinator_response = if let Some(enrollment) = &plan.enrollment { session.request(json!({ "type": "exchange_node_enrollment_grant", "tenant": &tenant, "project": &project, "node": &node, "public_key": &public_key, "enrollment_grant": enrollment.grant, "now_epoch_seconds": 0, }))? } else { session.request(json!({ "type": "attach_node", "tenant": &tenant, "project": &project, "node": &node, "public_key": &public_key, }))? }; let heartbeat_response = session.request(json!({ "type": "node_heartbeat", "node": &plan.node, }))?; let capability_response = session.request(json!({ "type": "report_node_capabilities", "tenant": &tenant, "project": &project, "node": &plan.node, "capabilities": &plan.capabilities, "cached_environment_digests": [], "dependency_cache_digests": [], "source_snapshots": [], "artifact_locations": [], "direct_connectivity": true, "online": false, }))?; Ok(NodeAttachReport { plan, boundary: NodeAttachBoundaryEvidence { cli_contacted_coordinator: true, coordinator_address: coordinator, used_enrollment_exchange, coordinator_session_requests: session.requests(), }, coordinator_response, heartbeat_response, capability_response, }) } struct JsonLineSession { writer: TcpStream, reader: BufReader, requests: u64, } impl JsonLineSession { fn connect(addr: &str) -> Result { let transport_addr = json_line_transport_addr(addr); let writer = TcpStream::connect(&transport_addr) .with_context(|| format!("failed to connect to {addr} via {transport_addr}"))?; let reader = BufReader::new(writer.try_clone()?); Ok(Self { writer, reader, requests: 0, }) } fn request(&mut self, value: Value) -> Result { serde_json::to_writer(&mut self.writer, &value)?; self.writer.write_all(b"\n")?; self.writer.flush()?; let mut line = String::new(); if self.reader.read_line(&mut line)? == 0 { anyhow::bail!("coordinator closed session without a response"); } self.requests += 1; let response: Value = serde_json::from_str(&line)?; if response.get("type").and_then(Value::as_str) == Some("error") { anyhow::bail!("coordinator error: {response}"); } Ok(response) } fn requests(&self) -> u64 { self.requests } } fn json_line_transport_addr(endpoint: &str) -> String { let endpoint = endpoint.trim(); for (scheme, default_port) in [("https://", 443), ("http://", 80)] { if let Some(rest) = endpoint.strip_prefix(scheme) { let authority = rest.split('/').next().unwrap_or(rest); if authority.contains(':') { return authority.to_owned(); } return format!("{authority}:{default_port}"); } } endpoint.to_owned() } fn default_node_id() -> String { std::env::var("DISASMER_NODE_ID") .or_else(|_| std::env::var("HOSTNAME")) .or_else(|_| std::env::var("COMPUTERNAME")) .unwrap_or_else(|_| "node-local".to_owned()) } fn parse_capability(cap: &str) -> Option { match cap { "command" => Some(Capability::Command), "containers" => Some(Capability::Containers), "rootless-podman" => Some(Capability::RootlessPodman), "source-filesystem" => Some(Capability::SourceFilesystem), "source-git" => Some(Capability::SourceGit), "vfs-artifacts" => Some(Capability::VfsArtifacts), "wasmtime" => Some(Capability::Wasmtime), "windows-command-dev" => Some(Capability::WindowsCommandDev), "quic-direct" => Some(Capability::QuicDirect), _ => None, } } #[cfg(test)] mod tests { use std::fs; use super::*; fn parse(args: &[&str]) -> Cli { Cli::parse_from(args) } #[test] fn run_defaults_to_current_project_and_build_entry() { let Cli { command: Commands::Run(args), } = parse(&["disasmer", "run"]) else { panic!("wrong command"); }; let plan = run_plan(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap(); assert_eq!(plan.project, PathBuf::from("/repo")); assert_eq!(plan.entry, "build"); assert_eq!(plan.coordinator, CoordinatorSelection::LocalOnly); assert_eq!(plan.operator_endpoint, None); assert_eq!(plan.session, CliSession::Anonymous); } #[test] fn run_project_and_named_entry_are_respected() { let Cli { command: Commands::Run(args), } = parse(&["disasmer", "run", "test", "--project", "/other"]) else { panic!("wrong command"); }; let plan = run_plan(args, PathBuf::from("/repo"), CliSession::HumanSession).unwrap(); assert_eq!(plan.project, PathBuf::from("/other")); assert_eq!(plan.entry, "test"); assert_eq!(plan.coordinator, CoordinatorSelection::Hosted); assert_eq!( plan.operator_endpoint.as_deref(), Some(DEFAULT_OPERATOR_ENDPOINT) ); assert_eq!(plan.session, CliSession::HumanSession); } #[test] fn node_attach_detects_and_accepts_capability_overrides() { let Cli { command: Commands::Node { command: NodeCommands::Attach(args), }, } = parse(&["disasmer", "node", "attach", "--cap", "quic-direct"]) else { panic!("wrong command"); }; let plan = attach_plan(args); assert!(plan .capabilities .capabilities .contains(&Capability::QuicDirect)); assert!(!plan.capabilities.arch.is_empty()); } #[test] fn agents_can_select_hosted_with_public_key_identity() { let args = RunArgs { entry: None, project: None, coordinator: None, local: false, }; let plan = run_plan( args, PathBuf::from("/repo"), CliSession::AgentPublicKey { public_key_fingerprint: Digest::sha256("agent-key"), browser_interaction_required: false, }, ) .unwrap(); assert_eq!(plan.coordinator, CoordinatorSelection::Hosted); assert_eq!( plan.operator_endpoint.as_deref(), Some(DEFAULT_OPERATOR_ENDPOINT) ); assert_eq!( plan.session, CliSession::AgentPublicKey { public_key_fingerprint: Digest::sha256("agent-key"), browser_interaction_required: false, } ); } #[test] fn run_local_flag_overrides_logged_in_hosted_selection() { let Cli { command: Commands::Run(args), } = parse(&["disasmer", "run", "--local"]) else { panic!("wrong command"); }; let plan = run_plan(args, PathBuf::from("/repo"), CliSession::HumanSession).unwrap(); assert_eq!(plan.coordinator, CoordinatorSelection::LocalOnly); assert_eq!(plan.operator_endpoint, None); } #[test] fn local_only_run_executes_ephemeral_local_services() { let Cli { command: Commands::Run(args), } = parse(&["disasmer", "run", "--local"]) else { panic!("wrong command"); }; let plan = run_plan(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap(); assert!(should_execute_local_node(&plan)); } #[test] fn login_uses_device_flow_without_long_lived_secret_copying() { let Cli { command: Commands::Login(args), } = parse(&["disasmer", "login"]) else { panic!("wrong command"); }; let plan = login_plan(args); assert_eq!(plan.coordinator, DEFAULT_OPERATOR_ENDPOINT); match plan.human_flow { LoginFlowPlan::Device(flow) => { assert!(!flow.yields_long_lived_secret_directly); assert!(flow.verification_url.contains("/auth/device")); assert_ne!(flow.user_code, format!("DISASMER-{}", "DEMO")); assert!(flow.user_code.starts_with("DISASMER-")); assert!(flow.device_code.starts_with("sha256:")); } LoginFlowPlan::Browser(_) => panic!("expected device flow"), } } #[test] fn browser_login_flow_is_available_for_humans() { let Cli { command: Commands::Login(args), } = parse(&["disasmer", "login", "--browser"]) else { panic!("wrong command"); }; let plan = login_plan(args); let LoginFlowPlan::Browser(flow) = plan.human_flow else { panic!("expected browser flow"); }; assert!(flow.authorization_url.contains("/auth/browser/start")); assert!(flow.state.starts_with("sha256:")); } #[test] fn browser_login_completion_detects_raw_provider_token_fields() { assert!(contains_provider_token_field(&json!({ "session": { "access_token": "secret" } }))); assert!(!contains_provider_token_field(&json!({ "session": { "cli_session_credential_kind": "CliDeviceSession", "oidc_token_exchange": { "received_access_token": true, "received_id_token": true } } }))); } #[test] fn agent_enroll_uses_public_key_without_browser_each_run() { let Cli { command: Commands::Agent { command: AgentCommands::Enroll(args), }, } = parse(&["disasmer", "agent", "enroll", "--public-key", "agent-key"]) else { panic!("wrong command"); }; let plan = agent_enrollment_plan(args); assert!(!plan.browser_interaction_required_each_run); assert!(plan.public_key_fingerprint.as_str().starts_with("sha256:")); } #[test] fn bundle_inspect_discovers_environments_selected_inputs_and_source_providers() { let temp = tempfile::tempdir().unwrap(); fs::create_dir_all(temp.path().join("envs/linux")).unwrap(); fs::create_dir_all(temp.path().join("src")).unwrap(); fs::write( temp.path().join("envs/linux/Containerfile"), "FROM alpine\n", ) .unwrap(); fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap(); let Cli { command: Commands::Bundle { command: BundleCommands::Inspect(args), }, } = parse(&[ "disasmer", "bundle", "inspect", "--project", temp.path().to_str().unwrap(), ]) else { panic!("wrong command"); }; let inspection = bundle_inspection(args, PathBuf::from("/unused")).unwrap(); assert_eq!(inspection.project, temp.path()); assert!(inspection .default_source_providers .contains(&SourceProviderKind::Git)); assert!(inspection .source_provider_manifest .description .contains("node task")); assert!( !inspection .source_provider_manifest .coordinator_requires_checkout_access ); assert!( !inspection .source_provider_manifest .transfer_policy .default_full_repo_tarball ); assert_eq!(inspection.metadata.environments[0].name, "linux"); assert!(!inspection.metadata.embeds_full_container_images); assert!(inspection .metadata .selected_inputs .iter() .any(|input| input.path == "Cargo.toml")); assert!(inspection .metadata .selected_inputs .iter() .any(|input| input.path == "src/main.rs")); } #[test] fn bundle_identity_changes_when_selected_input_file_changes() { let temp = tempfile::tempdir().unwrap(); fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); let first = bundle_inspection( BundleInspectArgs { project: Some(temp.path().to_path_buf()), }, PathBuf::from("/unused"), ) .unwrap(); fs::write( temp.path().join("Cargo.toml"), "[package]\nname='changed'\n", ) .unwrap(); let second = bundle_inspection( BundleInspectArgs { project: Some(temp.path().to_path_buf()), }, PathBuf::from("/unused"), ) .unwrap(); assert_ne!(first.metadata.identity, second.metadata.identity); } #[test] fn source_provider_manifest_digest_does_not_include_local_project_path() { let first = tempfile::tempdir().unwrap(); let second = tempfile::tempdir().unwrap(); fs::write(first.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); fs::write(second.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); let first = bundle_inspection( BundleInspectArgs { project: Some(first.path().to_path_buf()), }, PathBuf::from("/unused"), ) .unwrap(); let second = bundle_inspection( BundleInspectArgs { project: Some(second.path().to_path_buf()), }, PathBuf::from("/unused"), ) .unwrap(); assert_eq!( first.source_provider_manifest.digest, second.source_provider_manifest.digest ); } #[test] fn node_attach_can_exchange_enrollment_grant() { let Cli { command: Commands::Node { command: NodeCommands::Attach(args), }, } = parse(&[ "disasmer", "node", "attach", "--enrollment-grant", "grant", "--public-key", "node-key", ]) else { panic!("wrong command"); }; let plan = attach_plan(args); assert!( plan.enrollment .unwrap() .exchanges_short_lived_grant_for_long_lived_node_identity ); } #[test] fn node_attach_enrollment_uses_default_public_key_when_not_explicit() { let Cli { command: Commands::Node { command: NodeCommands::Attach(args), }, } = parse(&[ "disasmer", "node", "attach", "--node", "node-default-key", "--enrollment-grant", "grant", ]) else { panic!("wrong command"); }; let plan = attach_plan(args); let enrollment = plan.enrollment.unwrap(); assert_eq!(enrollment.grant, "grant"); assert_eq!( enrollment.public_key_fingerprint, Digest::sha256("node-default-key-public-key") ); } #[test] fn hosted_operator_url_maps_to_json_line_transport_address() { assert_eq!( json_line_transport_addr(DEFAULT_OPERATOR_ENDPOINT), "disasmer.michelpaulissen.com:9443" ); assert_eq!( json_line_transport_addr("https://disasmer.michelpaulissen.com:9443/auth/device"), "disasmer.michelpaulissen.com:9443" ); assert_eq!( json_line_transport_addr("http://operator.example.test"), "operator.example.test:80" ); assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999"); } }