From cc8108ddcd6cda407b21b3fa1b8c6647009c07fb Mon Sep 17 00:00:00 2001 From: root Date: Thu, 28 May 2026 06:39:09 +0000 Subject: [PATCH] feat(worker): add manual pairing approval flow - codra worker pair [--trust ] - Probes GET /api/workers/health, derives provisional fingerprint from worker_id + protocol_version + os + arch. - Displays pairing preview: worker id, URL, hostname, os/arch, daemon version, protocol, capabilities, fingerprint, PIN. - Prompts user to type PIN to confirm; only stores on match. - Default trust level: standard; override with --trust . - codra worker trust - Updates trust level in ~/.codra/workers.json. - Valid values: untrusted, limited, standard, elevated, full. - codra worker unpair (alias for remove). - WorkerStore: add update_trust_level. - TrustLevel: add FromStr + Display for CLI parsing/fmt. - WorkerHealth: add provisional_fingerprint_bytes() helper. - 4 new tests: trust_level_display_and_parse, provisional_fingerprint_bytes_is_deterministic, update_trust_level_works, update_trust_level_nonexistent_returns_false. - No Noise XX, mDNS, remote execution, or desktop UI changes. Co-authored-by: CommandCodeBot --- crates/codra-cli/src/main.rs | 161 ++++++++++++++++++++++- crates/codra-runtime/src/types.rs | 87 ++++++++++++ crates/codra-runtime/src/worker_store.rs | 39 +++++- 3 files changed, 281 insertions(+), 6 deletions(-) diff --git a/crates/codra-cli/src/main.rs b/crates/codra-cli/src/main.rs index a890dbb..ef97282 100644 --- a/crates/codra-cli/src/main.rs +++ b/crates/codra-cli/src/main.rs @@ -1,11 +1,13 @@ use codra_core::provider::{create_provider, EchoMockProvider, IntelligenceProvider}; use codra_core::provider_config::ProviderConfigService; use codra_protocol::{McpServerInfo, ProviderConfig, ProviderKind}; -use codra_runtime::{StoredPairing, TrustLevel, WorkerHealth, WorkerId, WorkerStore}; +use codra_runtime::{ + PairingFingerprint, StoredPairing, TrustLevel, WorkerHealth, WorkerId, WorkerStore, +}; use codra_tools::design::load_design_system; use codra_tools::registry::builtin_tool_definitions; use std::env; -use std::io::{self, Write}; +use std::io::{self, BufRead, Write}; use std::path::PathBuf; fn main() { @@ -119,13 +121,19 @@ fn worker_command(args: &[String]) -> Result<(), String> { "add" => worker_add(&args[1..]), "check" | "probe" => worker_check(&args[1..]), "list" => worker_list(), - "remove" => worker_remove(&args[1..]), + "pair" => worker_pair(&args[1..]), + "remove" | "unpair" => worker_remove(&args[1..]), + "trust" => worker_trust(&args[1..]), _ => { println!("codra worker "); println!(" add --fingerprint Register a remote worker"); println!(" check|probe Probe worker health endpoint"); println!(" list List registered workers"); - println!(" remove Remove a registered worker"); + println!(" pair Interactive pair and verify"); + println!( + " remove|unpair Remove/unpair a registered worker" + ); + println!(" trust Update trust level"); Ok(()) } } @@ -250,6 +258,147 @@ fn worker_remove(args: &[String]) -> Result<(), String> { Ok(()) } +fn worker_pair(args: &[String]) -> Result<(), String> { + let url = args + .first() + .ok_or_else(|| "Usage: codra worker pair [--trust ]".to_string())?; + + let trust_override = args + .iter() + .position(|a| a == "--trust") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse::().ok()); + + let health_url = format!("{}/api/workers/health", url.trim_end_matches('/')); + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let resp = client + .get(&health_url) + .send() + .map_err(|e| format!("Failed to reach worker at {}: {}", health_url, e))?; + + if !resp.status().is_success() { + return Err(format!( + "Worker at {} returned HTTP {}", + health_url, + resp.status() + )); + } + + let health: WorkerHealth = resp + .json() + .map_err(|e| format!("Malformed health response: {}", e))?; + + // Derive provisional fingerprint from stable fields + let fp = PairingFingerprint::from_bytes(&health.provisional_fingerprint_bytes()); + let pin = fp.pin(); + + // Display pairing preview + println!("Pairing preview"); + println!(" worker id: {}", health.worker_id.0); + println!(" URL: {}", url.trim_end_matches('/')); + println!(" hostname: {}", health.hostname); + println!(" os / arch: {} / {}", health.os, health.arch); + println!(" daemon version: {}", health.version); + println!( + " protocol: {}", + health.remote_worker_protocol_version + ); + println!(" capabilities:"); + println!( + " task_execution: {}", + yesno(health.capabilities.task_execution) + ); + println!( + " event_streaming: {}", + yesno(health.capabilities.event_streaming) + ); + println!( + " remote_pairing: {}", + yesno(health.capabilities.remote_pairing) + ); + println!( + " approval_fwd: {}", + yesno(health.capabilities.approval_forwarding) + ); + println!( + " mdns_discovery: {}", + yesno(health.capabilities.mdns_discovery) + ); + println!(" fingerprint: {}", fp); + println!(" PIN: {}", pin); + + // Prompt for confirmation + print!("Pair this worker? Type the PIN to confirm: "); + io::stdout().flush().map_err(|e| e.to_string())?; + + let mut input = String::new(); + io::stdin() + .lock() + .read_line(&mut input) + .map_err(|e| e.to_string())?; + let input = input.trim().to_string(); + + if input != pin.as_str() { + return Err("PIN does not match — pairing rejected.".to_string()); + } + + println!("PIN verified ✓"); + + let url_trimmed = url.trim_end_matches('/'); + let (host, port) = parse_worker_url(url_trimmed)?; + let trust_level = trust_override.unwrap_or(TrustLevel::Standard); + + let worker = StoredPairing { + worker_id: WorkerId(health.worker_id.0.clone()), + worker_label: health.hostname.clone(), + pin_sha256: fp.as_hex().to_string(), + worker_host: host, + worker_port: port, + trust_level: trust_level.clone(), + paired_at: chrono::Utc::now().to_rfc3339(), + last_seen: chrono::Utc::now().to_rfc3339(), + }; + + let store = WorkerStore::new_global(); + store + .add_worker(worker) + .map_err(|e| format!("Failed to register worker: {}", e))?; + + println!("Worker paired successfully."); + println!(" ID: {}", health.worker_id.0); + println!(" URL: {}", url_trimmed); + println!(" Trust: {}", trust_level); + println!(" Store: {}", store.file_path().display()); + + Ok(()) +} + +fn worker_trust(args: &[String]) -> Result<(), String> { + let worker_id = args + .first() + .ok_or_else(|| "Usage: codra worker trust ".to_string())?; + let level_str = args + .get(1) + .ok_or_else(|| "Usage: codra worker trust ".to_string())?; + + let level: TrustLevel = level_str.parse().map_err(|e: String| e)?; + let store = WorkerStore::new_global(); + let updated = store + .update_trust_level(&WorkerId(worker_id.clone()), level.clone()) + .map_err(|e| format!("Failed to update trust level: {}", e))?; + + if updated { + println!("Worker '{}' trust level set to '{}'.", worker_id, level); + } else { + println!("Worker '{}' not found.", worker_id); + } + Ok(()) +} + fn worker_check(args: &[String]) -> Result<(), String> { let worker_id = args .first() @@ -374,7 +523,9 @@ fn help() -> Result<(), String> { println!(" worker add Register a remote worker"); println!(" worker check Probe a registered worker's health endpoint"); println!(" worker list List registered workers"); - println!(" worker remove Remove a registered worker"); + println!(" worker pair Interactive pair and verify a remote worker"); + println!(" worker trust Update a worker's trust level"); + println!(" worker remove Remove/unpair a registered worker"); println!(" headless Run a dry-run headless planning surface"); println!(" mcp-server Print MCP-compatible server/tool metadata"); Ok(()) diff --git a/crates/codra-runtime/src/types.rs b/crates/codra-runtime/src/types.rs index 4341d47..967755b 100644 --- a/crates/codra-runtime/src/types.rs +++ b/crates/codra-runtime/src/types.rs @@ -480,6 +480,32 @@ pub enum TrustLevel { Full, } +impl std::fmt::Display for TrustLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Untrusted => write!(f, "untrusted"), + Self::Limited => write!(f, "limited"), + Self::Standard => write!(f, "standard"), + Self::Elevated => write!(f, "elevated"), + Self::Full => write!(f, "full"), + } + } +} + +impl std::str::FromStr for TrustLevel { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "untrusted" => Ok(Self::Untrusted), + "limited" => Ok(Self::Limited), + "standard" => Ok(Self::Standard), + "elevated" => Ok(Self::Elevated), + "full" => Ok(Self::Full), + _ => Err(format!("Unknown trust level '{}'; expected one of: untrusted, limited, standard, elevated, full", s)), + } + } +} + /// The current state of a pairing request between controller and worker. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum PairingStatus { @@ -545,6 +571,20 @@ pub struct WorkerHealth { pub capabilities: WorkerCapabilities, } +impl WorkerHealth { + /// Provisional identity bytes for deriving a fingerprint when the + /// daemon does not yet expose a public key. Concatenates the + /// most stable, identifying fields so the fingerprint is + /// deterministic unless the worker identity or platform changes. + pub fn provisional_fingerprint_bytes(&self) -> Vec { + format!( + "{}|{}|{}|{}", + self.worker_id.0, self.remote_worker_protocol_version, self.os, self.arch, + ) + .into_bytes() + } +} + impl Default for SafetyConfig { fn default() -> Self { Self { @@ -706,4 +746,51 @@ mod tests { .unwrap()); assert!(!json["capabilities"]["mdns_discovery"].as_bool().unwrap()); } + + #[test] + fn trust_level_display_and_parse() { + for (level_str, variant) in [ + ("untrusted", TrustLevel::Untrusted), + ("limited", TrustLevel::Limited), + ("standard", TrustLevel::Standard), + ("elevated", TrustLevel::Elevated), + ("full", TrustLevel::Full), + ] { + assert_eq!(variant.to_string(), level_str); + assert_eq!(level_str.parse::().unwrap(), variant); + } + assert!("unknown".parse::().is_err()); + } + + #[test] + fn provisional_fingerprint_bytes_is_deterministic() { + let health = WorkerHealth { + status: "ok".to_string(), + worker_id: WorkerId("wkr-001".to_string()), + version: "0.1.0".to_string(), + hostname: "build-server".to_string(), + os: "linux".to_string(), + arch: "aarch64".to_string(), + uptime_seconds: 86400, + supported_runtime_kinds: vec![], + available_runtimes: vec![], + workspace_mode: "local_only".to_string(), + remote_worker_protocol_version: "0.1".to_string(), + capabilities: WorkerCapabilities { + task_execution: true, + event_streaming: true, + approval_forwarding: false, + remote_pairing: false, + mdns_discovery: false, + }, + }; + let bytes1 = health.provisional_fingerprint_bytes(); + let bytes2 = health.provisional_fingerprint_bytes(); + assert_eq!(bytes1, bytes2); + let encoded = String::from_utf8_lossy(&bytes1); + assert!(encoded.contains("wkr-001")); + assert!(encoded.contains("linux")); + assert!(encoded.contains("aarch64")); + assert!(encoded.contains("0.1")); + } } diff --git a/crates/codra-runtime/src/worker_store.rs b/crates/codra-runtime/src/worker_store.rs index 90c700b..f5e0601 100644 --- a/crates/codra-runtime/src/worker_store.rs +++ b/crates/codra-runtime/src/worker_store.rs @@ -1,4 +1,4 @@ -use crate::types::{StoredPairing, WorkerId}; +use crate::types::{StoredPairing, TrustLevel, WorkerId}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -101,6 +101,22 @@ impl WorkerStore { } } + /// Update the trust level for a registered worker. + pub fn update_trust_level( + &self, + worker_id: &WorkerId, + trust_level: TrustLevel, + ) -> Result { + let mut wf = self.load(); + if let Some(worker) = wf.workers.iter_mut().find(|w| w.worker_id == *worker_id) { + worker.trust_level = trust_level; + self.save(&wf)?; + Ok(true) + } else { + Ok(false) + } + } + /// Returns the file path used by this store. pub fn file_path(&self) -> &PathBuf { &self.file_path @@ -241,4 +257,25 @@ mod tests { assert_eq!(workers[1].worker_id.0, "wkr-002"); } } + + #[test] + fn update_trust_level_works() { + let (store, _dir) = test_store(); + store.add_worker(test_worker("wkr-001")).unwrap(); + let updated = store + .update_trust_level(&WorkerId("wkr-001".to_string()), TrustLevel::Elevated) + .unwrap(); + assert!(updated); + let worker = store.get_worker(&WorkerId("wkr-001".to_string())).unwrap(); + assert_eq!(worker.trust_level, TrustLevel::Elevated); + } + + #[test] + fn update_trust_level_nonexistent_returns_false() { + let (store, _dir) = test_store(); + let updated = store + .update_trust_level(&WorkerId("ghost".to_string()), TrustLevel::Elevated) + .unwrap(); + assert!(!updated); + } }