clusterflux-public/crates/disasmer-cli/src/main.rs
Disasmer release dry run ded7d5a949 Public dry run dryrun-75438361f386
Source commit: 75438361f3867d6802c132c8cde99834e2c107ed

Public tree identity: sha256:6f1720ab7bffa98e56eb700fb376202dad8b7fbc546ef1446912c9d0fa42a17d
2026-07-03 16:54:14 +02:00

1631 lines
51 KiB
Rust

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<String>,
#[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<String>,
#[arg(long = "oidc-client-id", default_value = "disasmer")]
oidc_client_id: String,
#[arg(long)]
state: Option<String>,
}
#[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<PathBuf>,
}
#[derive(Clone, Debug, Parser)]
struct RunArgs {
entry: Option<String>,
#[arg(long)]
project: Option<PathBuf>,
#[arg(long)]
coordinator: Option<String>,
#[arg(long)]
local: bool,
}
#[derive(Clone, Debug, Subcommand)]
enum NodeCommands {
Attach(AttachArgs),
}
#[derive(Clone, Debug, Parser)]
struct AttachArgs {
#[arg(long)]
coordinator: Option<String>,
#[arg(long, default_value = "tenant")]
tenant: String,
#[arg(long = "project-id", default_value = "project")]
project: String,
#[arg(long)]
node: Option<String>,
#[arg(long = "cap")]
caps: Vec<String>,
#[arg(long = "enrollment-grant")]
enrollment_grant: Option<String>,
#[arg(long = "public-key")]
public_key: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct RunPlan {
project: PathBuf,
entry: String,
coordinator: CoordinatorSelection,
#[serde(skip_serializing_if = "Option::is_none")]
operator_endpoint: Option<String>,
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<u32>,
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<SourceProviderKind>,
source_provider_manifest: SourceProviderManifest,
metadata: BundleMetadata,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct NodeAttachPlan {
node: String,
coordinator: Option<String>,
capabilities: NodeCapabilities,
enrollment: Option<NodeEnrollmentPlan>,
}
#[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<LoginCompletionReport> {
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<LoginCompletionReport> {
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<LoginCompletionReport> {
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<BrowserCallback> {
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::<u64>().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<BrowserCallback> {
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<BrowserCallback> {
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!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Disasmer login</title></head><body><h1>Disasmer login</h1><p>{body}</p></body></html>"
);
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<String> {
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<u8> {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
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<BundleInspection> {
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<Vec<SelectedInput>> {
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<RunPlan> {
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<RunExecutionReport> {
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<SpawnedNodeOutput> {
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<Command> {
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<u32>,
child: Option<std::process::Child>,
}
impl LocalCoordinator {
fn external(address: &str) -> Self {
Self {
address: address.to_owned(),
process_id: None,
child: None,
}
}
fn start_ephemeral() -> Result<Self> {
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<String> {
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<Command> {
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<NodeAttachReport> {
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<TcpStream>,
requests: u64,
}
impl JsonLineSession {
fn connect(addr: &str) -> Result<Self> {
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<Value> {
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<Capability> {
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");
}
}