Source commit: 75438361f3867d6802c132c8cde99834e2c107ed Public tree identity: sha256:6f1720ab7bffa98e56eb700fb376202dad8b7fbc546ef1446912c9d0fa42a17d
1631 lines
51 KiB
Rust
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('&', "&")
|
|
.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<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");
|
|
}
|
|
}
|