diff --git a/CHANGELOG.md b/CHANGELOG.md index 9669b07..5bc7811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- `forge launch ` composes coding-tool launches through ordered middleware (`pxpipe`, `otel`, `presidio`, `squid`, `docker`, `tmux`) plus external `forge-launch-mw-*` script middleware. It supports configured default chains and tool base-url env mappings, `--with a,b,c`, legacy `--pxpipe`/`--direct`, `--tmux[=name]`, scoped child env injection, best-effort proxy preflight, and `--dry-run` plan output. - The `agentskills` provider installs Agent Skills-compatible `SKILL.md` files under `.agents/skills//SKILL.md`, with `agents` as an alias for `--provider agents` and an Agent Skills frontmatter whitelist. - `forge adopt ` fetches an upstream HTTPS artifact (or `file://` fixture), applies the `align` transform into a module skill or companion file, and writes an `adopt/v1` provenance sidecar with the upstream digest pin. - `forge find ""` scans local modules, discovered repos, and already-cached watchlist sources for skills, agents, and rules, ranking matches by name, trigger text, and description with optional JSON output. @@ -29,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Deferred +- Porting the shell `forge project` spine remains a separate environment-coupled follow-up. - Line-level annotation export from the TUI Code tab remains follow-up work. ### Fixed diff --git a/docs/decisions/CLI-0018 Launch Middleware Chain.md b/docs/decisions/CLI-0018 Launch Middleware Chain.md new file mode 100644 index 0000000..e7642b6 --- /dev/null +++ b/docs/decisions/CLI-0018 Launch Middleware Chain.md @@ -0,0 +1,73 @@ +--- +title: "Launch Middleware Chain" +description: "forge launch composes tool environments through ordered middleware and script extension points" +type: adr +category: cli +tags: + - cli + - launch + - middleware + - dispatch + - config +status: accepted +created: 2026-07-09 +updated: 2026-07-09 +author: "@N4M3Z" +project: forge-cli +related: + - "CLI-0013 Unified Config and Ontology" + - "CLI-0014 Exec Runtime Contract" + - "CLI-0015 Git-Style External Command Dispatch" + - "RUST-0006 Synchronous Core" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: [] +informed: [] +upstream: [] +--- + +# Launch Middleware Chain + +## Context and Problem Statement + +Coding-tool launch wrappers need to combine local proxies, observability, redaction, containers, and terminal session managers without turning Forge into a provider-specific process supervisor. A shell wrapper can cover one workstation's defaults, but it cannot provide typed config, testable plan composition, or a stable script extension contract for modules. Forge already has a minimal-kernel command-dispatch model, so launch behavior should reuse that shape instead of hard-coding every future environment transform in Rust. + +## Decision Drivers + +- Preserve scoped child-process environment changes without mutating Forge's own environment +- Keep middleware order explicit and deterministic +- Let modules and extensions add middleware without recompiling Forge +- Reuse configured extension directories and `PATH` search semantics +- Keep launch synchronous and directly return the child process exit code +- Make plan composition testable without spawning real coding tools or terminal wrappers + +## Considered Options + +1. **Keep launch behavior only in shell functions.** This preserves local flexibility, but leaves no typed config, no portable tests, and no extension contract. +2. **Add first-class Rust flags for every proxy and wrapper.** This is easy to document, but each new environment transform expands the kernel. +3. **Model launch as an ordered middleware chain with built-ins plus script middleware.** Rust owns plan composition, dry-runs, and stable built-ins; external scripts patch the plan through JSON. + +## Decision Outcome + +Chosen option: **Option 3**, because middleware chains keep launch policy composable while preserving Forge's minimal-kernel extension model. + +`forge launch ` resolves tool configuration from the unified config and builds a `LaunchPlan` containing child environment entries, an optional API `base_url`, command wrappers, and best-effort preflight steps. Middleware runs in the order named by `--with a,b,c`; conflicting `base_url` or environment keys warn and use the later value. `--tmux[=name]` appends the `tmux` middleware as the outer wrapper, so it encloses any earlier wrappers. + +Built-in middleware covers `pxpipe`, `otel`, `presidio`, `squid`, `docker`, and `tmux`. Unknown middleware named `foo` resolves to `forge-launch-mw-foo` using the same module `commands/`, configured extension directories, and `PATH` search order as external forge verbs. Forge sends the current plan as JSON on stdin and merges the JSON patch from stdout. If no built-in or script exists, launch fails with a message listing the known middleware. + +The child receives only the composed environment. A plan `base_url` is exported through the selected tool's configured base URL environment variable; the default `claude` mapping uses `ANTHROPIC_BASE_URL`. `--dry-run` prints the resolved plan and final argv without spawning preflight steps or the tool. + +## Consequences + +- Shell-only launch behavior can move into a tested native command without retiring local functions. +- New middleware can be delivered as scripts and config rather than Rust enum variants. +- Middleware authors get a small JSON patch contract instead of direct access to Forge internals. +- Wrappers that need shell command strings, such as `tmux new-session`, remain launch-kernel concerns. +- Best-effort preflight steps can warn without blocking direct tool launches when optional local services are unavailable. + +## More Information + +- [CLI-0013 Unified Config and Ontology](CLI-0013%20Unified%20Config%20and%20Ontology.md) +- [CLI-0014 Exec Runtime Contract](CLI-0014%20Exec%20Runtime%20Contract.md) +- [CLI-0015 Git-Style External Command Dispatch](CLI-0015%20Git-Style%20External%20Command%20Dispatch.md) +- [RUST-0006 Synchronous Core](RUST-0006%20Synchronous%20Core.md) diff --git a/src/cli/dispatch.rs b/src/cli/dispatch.rs index d045357..2da890e 100644 --- a/src/cli/dispatch.rs +++ b/src/cli/dispatch.rs @@ -65,7 +65,11 @@ fn absolutize(path: &Path) -> Result { .map_err(|error| error.to_string()) } -fn resolve_external(command_name: &str, root: &Path, extensions: &[PathBuf]) -> Option { +pub(crate) fn resolve_external( + command_name: &str, + root: &Path, + extensions: &[PathBuf], +) -> Option { let local = root.join("commands").join(command_name); if local.is_file() { return Some(local); diff --git a/src/cli/launch/mod.rs b/src/cli/launch/mod.rs new file mode 100644 index 0000000..71902f4 --- /dev/null +++ b/src/cli/launch/mod.rs @@ -0,0 +1,755 @@ +use crate::cli::dispatch; +use commands::ontology::{self, DockerConfig, Launch}; +use serde::{Deserialize, Serialize}; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command as ProcessCommand, Stdio}; +use std::time::Duration; + +const BUILT_INS: &[&str] = &["pxpipe", "otel", "presidio", "squid", "docker", "tmux"]; +const MIDDLEWARE_STDOUT_LIMIT_BYTES: usize = 1024 * 1024; +const MIDDLEWARE_STDOUT_READ_LIMIT_BYTES: u64 = 1024 * 1024 + 1; +const SENSITIVE_ENV_KEYS: &[&str] = &[ + "PATH", + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", +]; + +pub fn execute_cli(tool: &str, rest: &[OsString]) -> Result { + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + let root = dispatch::forge_root_from(&cwd)?; + let config = ontology::load().map_err(|error| error.to_string())?; + let options = parse_cli_tail(rest, &config.launch)?; + let context = LaunchContext { cwd, root, config }; + let tool = resolve_tool(tool, &context.config.launch); + let mut plan = compose_plan(&options.middleware, options.tmux.as_deref(), &context)?; + let argv = build_argv(&tool, &options.args, &plan); + if options.dry_run { + println!("{}", format_dry_run(&tool, &argv, &plan)); + return Ok(0); + } + for warning in &plan.warnings { + eprintln!("warning: {warning}"); + } + run_pre_steps(&mut plan); + run_child(&argv, &process_env(&tool, &plan)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedLaunchTail { + middleware: Vec, + tmux: Option, + dry_run: bool, + args: Vec, +} + +#[derive(Debug)] +struct LaunchContext { + cwd: PathBuf, + root: PathBuf, + config: ontology::ResolvedConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ResolvedTool { + name: String, + binary: OsString, + base_url_env: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct LaunchPlan { + env: Vec<(OsString, OsString)>, + base_url: Option, + wrap: Vec>, + pre: Vec, + warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct PreStep { + name: String, + host: String, + port: u16, + command: Vec, + log_path: Option, + optional: bool, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct PlanPatch { + env: Vec<(String, String)>, + base_url: Option, + wrap: Vec>, + pre: Vec, +} + +#[derive(Debug, Serialize)] +struct SerializablePlan { + env: Vec<(String, String)>, + base_url: Option, + wrap: Vec>, + pre: Vec, +} + +fn parse_cli_tail(rest: &[OsString], launch: &Launch) -> Result { + let mut parsed = ParsedLaunchTail { + middleware: launch.default_with.clone(), + tmux: None, + dry_run: false, + args: Vec::new(), + }; + let mut saw_explicit_chain = false; + let mut index = 0; + while index < rest.len() { + let item = rest[index].to_string_lossy(); + match item.as_ref() { + "--" => { + parsed.args.extend(rest[index + 1..].iter().cloned()); + return Ok(parsed); + } + "--dry-run" => parsed.dry_run = true, + "--pxpipe" => { + clear_default_chain(&mut parsed.middleware, &mut saw_explicit_chain); + parsed.middleware.push("pxpipe".to_string()); + } + "--direct" => { + parsed.middleware.clear(); + saw_explicit_chain = true; + } + "--with" => { + index += 1; + let Some(value) = rest.get(index) else { + return Err("--with requires a comma-separated value".to_string()); + }; + clear_default_chain(&mut parsed.middleware, &mut saw_explicit_chain); + parsed.middleware.extend(parse_middleware_list(value)); + } + "--tmux" => { + parsed.tmux = Some(String::new()); + } + value if value.starts_with("--with=") => { + clear_default_chain(&mut parsed.middleware, &mut saw_explicit_chain); + parsed + .middleware + .extend(split_middleware_list(value.trim_start_matches("--with="))); + } + value if value.starts_with("--tmux=") => { + parsed.tmux = Some(value.trim_start_matches("--tmux=").to_string()); + } + _ => { + parsed.args.extend(rest[index..].iter().cloned()); + return Ok(parsed); + } + } + index += 1; + } + Ok(parsed) +} + +fn clear_default_chain(chain: &mut Vec, saw_explicit_chain: &mut bool) { + if !*saw_explicit_chain { + chain.clear(); + *saw_explicit_chain = true; + } +} + +fn parse_middleware_list(value: &OsString) -> Vec { + split_middleware_list(&value.to_string_lossy()) +} + +fn split_middleware_list(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn resolve_tool(name: &str, launch: &Launch) -> ResolvedTool { + let configured = launch.tools.get(name); + let default_base_env = (name == "claude").then(|| "ANTHROPIC_BASE_URL".to_string()); + let binary = configured + .and_then(|tool| tool.binary.as_ref()) + .map_or_else(|| name.to_string(), Clone::clone); + let base_url_env = configured + .and_then(|tool| tool.base_url_env.as_ref()) + .cloned() + .or(default_base_env) + .map(OsString::from); + ResolvedTool { + name: name.to_string(), + binary: OsString::from(binary), + base_url_env, + } +} + +fn compose_plan( + middleware: &[String], + tmux_name: Option<&str>, + context: &LaunchContext, +) -> Result { + let mut plan = LaunchPlan::default(); + for name in middleware { + apply_middleware(&mut plan, name, context)?; + } + if let Some(name) = tmux_name { + let session = if name.is_empty() { + context + .cwd + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("forge") + .to_string() + } else { + name.to_string() + }; + apply_builtin(&mut plan, "tmux", Some(&session), &context.config.launch)?; + } + Ok(plan) +} + +fn apply_middleware( + plan: &mut LaunchPlan, + name: &str, + context: &LaunchContext, +) -> Result<(), String> { + if BUILT_INS.contains(&name) { + return apply_builtin(plan, name, None, &context.config.launch); + } + apply_external(plan, name, context) +} + +fn apply_builtin( + plan: &mut LaunchPlan, + name: &str, + argument: Option<&str>, + launch: &Launch, +) -> Result<(), String> { + match name { + "pxpipe" => { + let config = &launch.middleware.pxpipe; + set_base_url(plan, name, config.base_url.clone()); + plan.pre.push(PreStep { + name: "pxpipe".to_string(), + host: config.host.clone(), + port: config.port, + command: split_pre_step_command(&config.command), + log_path: Some(config.log_path.clone()), + optional: true, + }); + } + "otel" => { + let config = &launch.middleware.otel; + set_env( + plan, + "otel", + "OTEL_EXPORTER_OTLP_ENDPOINT", + &config.endpoint, + ); + set_env(plan, "otel", "OTEL_SERVICE_NAME", &config.service_name); + } + "presidio" => { + let config = &launch.middleware.presidio; + set_base_url(plan, name, config.base_url.clone()); + plan.pre.push(PreStep { + name: "presidio".to_string(), + host: config.host.clone(), + port: config.port, + command: Vec::new(), + log_path: None, + optional: true, + }); + } + "squid" => { + let config = &launch.middleware.squid; + set_env(plan, "squid", "HTTP_PROXY", &config.http_proxy); + set_env(plan, "squid", "HTTPS_PROXY", &config.https_proxy); + } + "docker" => plan.wrap.push(docker_wrap(&launch.middleware.docker)), + "tmux" => { + let session = argument.unwrap_or("forge"); + plan.wrap.push(vec![ + OsString::from("tmux"), + OsString::from("new-session"), + OsString::from("-A"), + OsString::from("-s"), + OsString::from(session), + ]); + } + _ => { + return Err(format!( + "unknown middleware '{name}'; known middleware: {}", + BUILT_INS.join(", ") + )); + } + } + Ok(()) +} + +fn docker_wrap(config: &DockerConfig) -> Vec { + let mut wrap = vec![OsString::from("docker"), OsString::from("run")]; + wrap.extend(config.args.iter().map(OsString::from)); + wrap.push(OsString::from(&config.image)); + wrap +} + +fn split_pre_step_command(command: &str) -> Vec { + command + .split_whitespace() + .map(ToString::to_string) + .collect() +} + +fn set_env(plan: &mut LaunchPlan, middleware: &str, key: &str, value: &str) { + let key_os = OsString::from(key); + let value_os = OsString::from(value); + if let Some((_, existing)) = plan + .env + .iter_mut() + .find(|(candidate, _)| candidate == &key_os) + { + if existing != &value_os { + plan.warnings.push(format!( + "middleware '{middleware}' overrides env {key} from '{}' to '{value}'", + existing.to_string_lossy() + )); + } + *existing = value_os; + return; + } + plan.env.push((key_os, value_os)); +} + +fn set_base_url(plan: &mut LaunchPlan, middleware: &str, value: String) { + if let Some(existing) = &plan.base_url + && existing != &value + { + plan.warnings.push(format!( + "middleware '{middleware}' overrides base_url from '{existing}' to '{value}'" + )); + } + plan.base_url = Some(value); +} + +fn set_external_base_url(plan: &mut LaunchPlan, middleware: &str, value: String) { + if let Some(existing) = &plan.base_url + && existing != &value + { + plan.warnings.push(format!( + "external middleware '{middleware}' sets base_url to '{value}', overriding '{existing}'" + )); + } else { + plan.warnings.push(format!( + "external middleware '{middleware}' sets base_url to '{value}'; verify this API endpoint is trusted" + )); + } + plan.base_url = Some(value); +} + +fn apply_external( + plan: &mut LaunchPlan, + name: &str, + context: &LaunchContext, +) -> Result<(), String> { + if name.contains(['/', '\\']) { + return Err("middleware name must not contain path separators".to_string()); + } + let command_name = format!("forge-launch-mw-{name}"); + let Some(executable) = + dispatch::resolve_external(&command_name, &context.root, &context.config.extensions) + else { + return Err(format!( + "unknown middleware '{name}'; known middleware: {}", + BUILT_INS.join(", ") + )); + }; + let patch = run_external_middleware(&executable, plan)?; + merge_patch(plan, name, patch); + Ok(()) +} + +fn run_external_middleware(executable: &Path, plan: &LaunchPlan) -> Result { + let input = serde_json::to_string(&serializable_plan(plan)) + .map_err(|error| format!("cannot serialize launch plan: {error}"))?; + let mut child = ProcessCommand::new(executable) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("cannot run {}: {error}", executable.display()))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "cannot open middleware stdin".to_string())?; + stdin + .write_all(input.as_bytes()) + .map_err(|error| format!("cannot write middleware stdin: {error}"))?; + drop(stdin); + let stdout = child + .stdout + .take() + .ok_or_else(|| "cannot open middleware stdout".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "cannot open middleware stderr".to_string())?; + let executable_name = executable.display().to_string(); + let stdout_handle = std::thread::spawn(move || read_limited_stdout(stdout, &executable_name)); + let stderr_handle = std::thread::spawn(move || read_stderr(stderr)); + let stdout = stdout_handle + .join() + .map_err(|_| "middleware stdout reader panicked".to_string())?; + let stdout = match stdout { + Ok(output) => output, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = stderr_handle.join(); + return Err(error); + } + }; + let status = child + .wait() + .map_err(|error| format!("cannot read middleware output: {error}"))?; + let stderr = stderr_handle + .join() + .map_err(|_| "middleware stderr reader panicked".to_string())? + .map_err(|error| format!("cannot read middleware stderr: {error}"))?; + if !status.success() { + return Err(format!( + "{} exited with {}; {}", + executable.display(), + status.code().unwrap_or(1), + stderr.trim() + )); + } + serde_json::from_str(&stdout) + .map_err(|error| format!("{} emitted invalid JSON: {error}", executable.display())) +} + +fn read_limited_stdout(mut stdout: impl Read, executable: &str) -> Result { + let mut output = String::new(); + let bytes_read = stdout + .by_ref() + .take(MIDDLEWARE_STDOUT_READ_LIMIT_BYTES) + .read_to_string(&mut output) + .map_err(|error| format!("cannot read {executable} stdout: {error}"))?; + if bytes_read > MIDDLEWARE_STDOUT_LIMIT_BYTES { + return Err(format!( + "{executable} stdout exceeded {MIDDLEWARE_STDOUT_LIMIT_BYTES} byte limit" + )); + } + Ok(output) +} + +fn read_stderr(mut stderr: impl Read) -> Result { + let mut output = Vec::new(); + stderr.read_to_end(&mut output)?; + Ok(String::from_utf8_lossy(&output).into_owned()) +} + +fn merge_patch(plan: &mut LaunchPlan, middleware: &str, patch: PlanPatch) { + for (key, value) in patch.env { + if is_sensitive_env_key(&key) { + plan.warnings.push(format!( + "middleware '{middleware}' attempted to set sensitive env {key}; dropping it" + )); + } else { + set_env(plan, middleware, &key, &value); + } + } + if let Some(base_url) = patch.base_url { + set_external_base_url(plan, middleware, base_url); + } + plan.wrap.extend(patch.wrap.into_iter().map(|wrap| { + wrap.into_iter() + .map(OsString::from) + .collect::>() + })); + plan.pre.extend(patch.pre); +} + +fn is_sensitive_env_key(key: &str) -> bool { + SENSITIVE_ENV_KEYS + .iter() + .any(|sensitive| sensitive.eq_ignore_ascii_case(key)) +} + +fn build_argv(tool: &ResolvedTool, child_args: &[OsString], plan: &LaunchPlan) -> Vec { + let mut command_line = vec![tool.binary.clone()]; + command_line.extend(child_args.iter().cloned()); + let env = final_env(tool, plan); + for wrap in &plan.wrap { + command_line = apply_wrap(wrap, &command_line, &env); + } + command_line +} + +fn apply_wrap( + wrap: &[OsString], + inner: &[OsString], + env: &[(OsString, OsString)], +) -> Vec { + let mut argv = wrap.to_vec(); + if wrap.first().is_some_and(|command| command == "tmux") { + argv.push(OsString::from(shell_join(&env_command(inner, env)))); + } else if wrap.first().is_some_and(|command| command == "docker") { + argv = docker_argv(wrap, inner, env); + } else { + argv.extend(inner.iter().cloned()); + } + argv +} + +fn is_explicit_env_wrapper(wrap: &[OsString]) -> bool { + wrap.first() + .is_some_and(|command| command == "docker" || command == "tmux") +} + +fn env_command(inner: &[OsString], env: &[(OsString, OsString)]) -> Vec { + if env.is_empty() { + return inner.to_vec(); + } + let mut argv = vec![OsString::from("env")]; + argv.extend(env.iter().map(env_assignment)); + argv.extend(inner.iter().cloned()); + argv +} + +fn docker_argv( + wrap: &[OsString], + inner: &[OsString], + env: &[(OsString, OsString)], +) -> Vec { + let Some((image, docker_prefix)) = wrap.split_last() else { + return inner.to_vec(); + }; + let mut argv = docker_prefix.to_vec(); + for assignment in env.iter().map(env_assignment) { + argv.push(OsString::from("-e")); + argv.push(assignment); + } + argv.push(image.clone()); + argv.extend(inner.iter().cloned()); + argv +} + +fn env_assignment((key, value): &(OsString, OsString)) -> OsString { + OsString::from(format!( + "{}={}", + key.to_string_lossy(), + value.to_string_lossy() + )) +} + +fn final_env(tool: &ResolvedTool, plan: &LaunchPlan) -> Vec<(OsString, OsString)> { + let mut env = plan.env.clone(); + if let (Some(base_url), Some(env_key)) = (&plan.base_url, &tool.base_url_env) { + env.push((env_key.clone(), OsString::from(base_url))); + } + env +} + +fn process_env(tool: &ResolvedTool, plan: &LaunchPlan) -> Vec<(OsString, OsString)> { + if plan.wrap.iter().any(|wrap| is_explicit_env_wrapper(wrap)) { + return Vec::new(); + } + final_env(tool, plan) +} + +fn run_child(argv: &[OsString], env: &[(OsString, OsString)]) -> Result { + let Some(command_name) = argv.first() else { + return Err("launch plan produced an empty argv".to_string()); + }; + let mut command = ProcessCommand::new(command_name); + command.args(&argv[1..]); + dispatch::apply_env(&mut command, env); + let status = command + .status() + .map_err(|error| format!("cannot run child process: {error}"))?; + Ok(status.code().unwrap_or(1)) +} + +fn run_pre_steps(plan: &mut LaunchPlan) { + for step in &plan.pre { + if is_listening(&step.host, step.port) { + continue; + } + if step.command.is_empty() { + eprintln!( + "warning: middleware '{}' is not listening on {}:{}", + step.name, step.host, step.port + ); + continue; + } + if let Err(error) = start_pre_step(step) { + eprintln!("warning: {error}"); + } + std::thread::sleep(Duration::from_millis(700)); + if !is_listening(&step.host, step.port) && step.optional { + eprintln!( + "warning: middleware '{}' did not become ready on {}:{}", + step.name, step.host, step.port + ); + } + } +} + +fn start_pre_step(step: &PreStep) -> Result<(), String> { + let Some(mut process) = build_pre_step_command(step)? else { + return Ok(()); + }; + process + .spawn() + .map(|_| ()) + .map_err(|error| format!("cannot start middleware '{}': {error}", step.name)) +} + +fn build_pre_step_command(step: &PreStep) -> Result, String> { + let Some(command) = step.command.first() else { + return Ok(None); + }; + let mut process = ProcessCommand::new(command); + process.args(&step.command[1..]); + if step.name == "pxpipe" { + configure_pxpipe_stdio(&mut process, step.log_path.as_ref())?; + } else { + process.stdout(Stdio::null()).stderr(Stdio::null()); + } + Ok(Some(process)) +} + +fn configure_pxpipe_stdio( + process: &mut ProcessCommand, + log_path: Option<&String>, +) -> Result<(), String> { + let Some(log_path) = log_path else { + process.stdout(Stdio::null()).stderr(Stdio::null()); + return Ok(()); + }; + let path = ontology::expand_tilde(log_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("cannot create {}: {error}", parent.display()))?; + } + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| format!("cannot open {}: {error}", path.display()))?; + let stderr = stdout + .try_clone() + .map_err(|error| format!("cannot clone {}: {error}", path.display()))?; + process + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + Ok(()) +} + +fn is_listening(host: &str, port: u16) -> bool { + let Ok(address) = format!("{host}:{port}").parse::() else { + return false; + }; + TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok() +} + +fn format_dry_run(tool: &ResolvedTool, argv: &[OsString], plan: &LaunchPlan) -> String { + let mut lines = vec![ + format!("tool: {}", tool.name), + format!("argv: {}", display_argv(argv)), + "env:".to_string(), + ]; + lines.extend( + final_env(tool, plan) + .iter() + .map(|(key, value)| format!(" {}={}", key.to_string_lossy(), value.to_string_lossy())), + ); + lines.push(format!( + "base_url: {}", + plan.base_url.as_deref().unwrap_or("") + )); + lines.push("wrap:".to_string()); + lines.extend( + plan.wrap + .iter() + .map(|wrap| format!(" {}", display_argv(wrap))), + ); + lines.push("pre:".to_string()); + lines.extend(plan.pre.iter().map(|step| { + let command = if step.command.is_empty() { + "".to_string() + } else { + step.command.join(" ") + }; + format!(" {} {}:{} {}", step.name, step.host, step.port, command) + })); + if !plan.warnings.is_empty() { + lines.push("warnings:".to_string()); + lines.extend(plan.warnings.iter().map(|warning| format!(" {warning}"))); + } + lines.join("\n") +} + +fn display_argv(argv: &[OsString]) -> String { + argv.iter() + .map(|part| part.to_string_lossy()) + .collect::>() + .join(" ") +} + +fn shell_join(argv: &[OsString]) -> String { + argv.iter() + .map(|part| shell_quote(&part.to_string_lossy())) + .collect::>() + .join(" ") +} + +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + if value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_./:=@".contains(character)) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn serializable_plan(plan: &LaunchPlan) -> SerializablePlan { + SerializablePlan { + env: plan + .env + .iter() + .map(|(key, value)| { + ( + key.to_string_lossy().to_string(), + value.to_string_lossy().to_string(), + ) + }) + .collect(), + base_url: plan.base_url.clone(), + wrap: plan + .wrap + .iter() + .map(|wrap| { + wrap.iter() + .map(|part| part.to_string_lossy().to_string()) + .collect() + }) + .collect(), + pre: plan.pre.clone(), + } +} + +#[cfg(test)] +mod tests; diff --git a/src/cli/launch/tests.rs b/src/cli/launch/tests.rs new file mode 100644 index 0000000..621cb24 --- /dev/null +++ b/src/cli/launch/tests.rs @@ -0,0 +1,348 @@ +use super::*; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +fn context(root: &Path, launch: Launch, extensions: Vec) -> LaunchContext { + LaunchContext { + cwd: root.to_path_buf(), + root: root.to_path_buf(), + config: ontology::ResolvedConfig { + ontology: ontology::ResolvedOntology::default(), + extensions, + launch, + }, + } +} + +fn names(values: &[(&str, &str)]) -> Vec<(OsString, OsString)> { + values + .iter() + .map(|(key, value)| (OsString::from(key), OsString::from(value))) + .collect() +} + +#[cfg(unix)] +fn write_executable(path: &Path, content: &str) { + std::fs::write(path, content).expect("write script"); + let mut permissions = std::fs::metadata(path).expect("metadata").permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("permissions"); +} + +#[test] +fn shell_join_preserves_empty_args_and_quotes_values() { + let argv = vec![ + OsString::from("a"), + OsString::from(""), + OsString::from("b"), + OsString::from("two words"), + ]; + + assert_eq!(shell_join(&argv), "a '' b 'two words'"); +} + +#[test] +fn plan_composes_otel_then_pxpipe() { + let dir = tempfile::tempdir().expect("tempdir"); + let launch = Launch::default(); + let middleware = vec!["otel".to_string(), "pxpipe".to_string()]; + + let plan = + compose_plan(&middleware, None, &context(dir.path(), launch, Vec::new())).expect("compose"); + + assert_eq!( + plan.env, + names(&[ + ("OTEL_EXPORTER_OTLP_ENDPOINT", "http://127.0.0.1:4318"), + ("OTEL_SERVICE_NAME", "forge-launch"), + ]) + ); + assert_eq!(plan.base_url.as_deref(), Some("http://127.0.0.1:47821")); + assert_eq!(plan.pre.len(), 1); + assert_eq!(plan.pre[0].name, "pxpipe"); + assert_eq!(plan.pre[0].command, vec!["pxpipe"]); +} + +#[test] +fn base_url_conflict_warns_and_last_wins() { + let dir = tempfile::tempdir().expect("tempdir"); + let middleware = vec!["presidio".to_string(), "pxpipe".to_string()]; + + let plan = compose_plan( + &middleware, + None, + &context(dir.path(), Launch::default(), Vec::new()), + ) + .expect("compose"); + + assert_eq!(plan.base_url.as_deref(), Some("http://127.0.0.1:47821")); + assert_eq!(plan.warnings.len(), 1); + assert!(plan.warnings[0].contains("overrides base_url")); + assert!(plan.warnings[0].contains("pxpipe")); +} + +#[test] +fn tmux_flag_adds_outer_wrap() { + let dir = tempfile::tempdir().expect("tempdir"); + let plan = compose_plan( + &["otel".to_string()], + Some("work"), + &context(dir.path(), Launch::default(), Vec::new()), + ) + .expect("compose"); + let tool = resolve_tool("claude", &Launch::default()); + let argv = build_argv( + &tool, + &[OsString::from("--model"), OsString::from("opus")], + &plan, + ); + + assert_eq!(plan.wrap.len(), 1); + assert_eq!( + plan.wrap[0], + vec![ + OsString::from("tmux"), + OsString::from("new-session"), + OsString::from("-A"), + OsString::from("-s"), + OsString::from("work"), + ] + ); + assert_eq!(argv[0], OsString::from("tmux")); + assert!( + argv.last() + .expect("tmux shell command") + .to_string_lossy() + .contains("claude --model opus") + ); +} + +#[test] +fn docker_wrap_forwards_final_env_to_container() { + let dir = tempfile::tempdir().expect("tempdir"); + let middleware = vec!["docker".to_string(), "pxpipe".to_string()]; + let plan = compose_plan( + &middleware, + None, + &context(dir.path(), Launch::default(), Vec::new()), + ) + .expect("compose"); + let tool = resolve_tool("claude", &Launch::default()); + let argv = build_argv(&tool, &[], &plan); + + let rendered = display_argv(&argv); + assert!(rendered.contains("-e ANTHROPIC_BASE_URL=http://127.0.0.1:47821")); + assert!(rendered.contains("ghcr.io/n4m3z/forge-coding-tool:latest claude")); +} + +#[test] +fn tmux_wrap_forwards_final_env_to_inner_command() { + let dir = tempfile::tempdir().expect("tempdir"); + let plan = compose_plan( + &["pxpipe".to_string()], + Some("work"), + &context(dir.path(), Launch::default(), Vec::new()), + ) + .expect("compose"); + let tool = resolve_tool("claude", &Launch::default()); + let argv = build_argv(&tool, &[OsString::from("--version")], &plan); + let inner = argv.last().expect("tmux shell command").to_string_lossy(); + + assert!(inner.contains("env ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude --version")); +} + +#[test] +fn unwrapped_launch_keeps_env_out_of_argv() { + let mut plan = LaunchPlan { + base_url: Some("http://127.0.0.1:47821".to_string()), + ..LaunchPlan::default() + }; + plan.env.push(( + OsString::from("OTEL_SERVICE_NAME"), + OsString::from("forge-launch"), + )); + let tool = resolve_tool("claude", &Launch::default()); + let argv = build_argv(&tool, &[OsString::from("--version")], &plan); + + assert_eq!( + argv, + vec![OsString::from("claude"), OsString::from("--version")] + ); + assert_eq!( + process_env(&tool, &plan), + names(&[ + ("OTEL_SERVICE_NAME", "forge-launch"), + ("ANTHROPIC_BASE_URL", "http://127.0.0.1:47821"), + ]) + ); +} + +#[test] +fn wrapped_launch_does_not_apply_env_to_wrapper_process() { + let dir = tempfile::tempdir().expect("tempdir"); + let plan = compose_plan( + &["docker".to_string(), "pxpipe".to_string()], + None, + &context(dir.path(), Launch::default(), Vec::new()), + ) + .expect("compose"); + let tool = resolve_tool("claude", &Launch::default()); + + assert!(process_env(&tool, &plan).is_empty()); +} + +#[test] +fn dry_run_prints_plan_without_launching() { + let dir = tempfile::tempdir().expect("tempdir"); + let middleware = vec!["otel".to_string(), "pxpipe".to_string()]; + let plan = compose_plan( + &middleware, + Some("x"), + &context(dir.path(), Launch::default(), Vec::new()), + ) + .expect("compose"); + let tool = resolve_tool("claude", &Launch::default()); + let argv = build_argv( + &tool, + &[OsString::from("--model"), OsString::from("opus")], + &plan, + ); + let output = format_dry_run(&tool, &argv, &plan); + + assert!(output.contains("tool: claude")); + assert!(output.contains("OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318")); + assert!(output.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:47821")); + assert!(output.contains("base_url: http://127.0.0.1:47821")); + assert!(output.contains("tmux new-session -A -s x")); + assert!(output.contains("pxpipe 127.0.0.1:47821")); +} + +#[test] +fn merge_patch_rejects_sensitive_env_and_keeps_normal_env() { + let mut plan = LaunchPlan::default(); + merge_patch( + &mut plan, + "external", + PlanPatch { + env: vec![ + ("LD_PRELOAD".to_string(), "/tmp/hijack.dylib".to_string()), + ( + "OTEL_RESOURCE_ATTRIBUTES".to_string(), + "service.name=forge".to_string(), + ), + ], + ..PlanPatch::default() + }, + ); + + assert_eq!( + plan.env, + names(&[("OTEL_RESOURCE_ATTRIBUTES", "service.name=forge")]) + ); + assert_eq!(plan.warnings.len(), 1); + assert!(plan.warnings[0].contains("sensitive env LD_PRELOAD")); + assert!(plan.warnings[0].contains("dropping")); +} + +#[test] +fn external_base_url_from_none_warns() { + let mut plan = LaunchPlan::default(); + merge_patch( + &mut plan, + "external", + PlanPatch { + base_url: Some("https://api.example.invalid".to_string()), + ..PlanPatch::default() + }, + ); + + assert_eq!( + plan.base_url.as_deref(), + Some("https://api.example.invalid") + ); + assert_eq!(plan.warnings.len(), 1); + assert!(plan.warnings[0].contains("external middleware 'external'")); + assert!(plan.warnings[0].contains("https://api.example.invalid")); +} + +#[test] +fn unknown_middleware_without_script_errors_with_known_list() { + let dir = tempfile::tempdir().expect("tempdir"); + let error = compose_plan( + &["missing".to_string()], + None, + &context(dir.path(), Launch::default(), Vec::new()), + ) + .expect_err("unknown middleware"); + + assert!(error.contains("unknown middleware 'missing'")); + assert!(error.contains("pxpipe, otel, presidio, squid, docker, tmux")); +} + +#[cfg(unix)] +#[test] +fn external_middleware_patch_is_merged_from_extension() { + let dir = tempfile::tempdir().expect("tempdir"); + let extension = tempfile::tempdir().expect("extension"); + let script = extension.path().join("forge-launch-mw-test"); + write_executable( + &script, + "#!/usr/bin/env bash\ncat >/dev/null\nprintf '%s\\n' '{\"env\":[[\"TEST_MW\",\"enabled\"]]}'\n", + ); + + let plan = compose_plan( + &["test".to_string()], + None, + &context( + dir.path(), + Launch::default(), + vec![extension.path().to_path_buf()], + ), + ) + .expect("compose external"); + + assert_eq!(plan.env, names(&[("TEST_MW", "enabled")])); +} + +#[cfg(unix)] +#[test] +fn external_middleware_oversized_stdout_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + let script = dir.path().join("forge-launch-mw-large"); + write_executable( + &script, + "#!/usr/bin/env bash\ncat >/dev/null\nprintf '%1048577s' ''\n", + ); + + let error = + run_external_middleware(&script, &LaunchPlan::default()).expect_err("oversized stdout"); + + assert!(error.contains("stdout exceeded 1048576 byte limit")); +} + +#[test] +fn pxpipe_pre_step_command_with_args_builds_argv() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut launch = Launch::default(); + launch.middleware.pxpipe.command = "pxpipe --port 47821".to_string(); + launch.middleware.pxpipe.log_path = dir.path().join("pxpipe.log").to_string_lossy().to_string(); + let plan = compose_plan( + &["pxpipe".to_string()], + None, + &context(dir.path(), launch, Vec::new()), + ) + .expect("compose"); + + assert_eq!(plan.pre[0].command, vec!["pxpipe", "--port", "47821"]); + let command = build_pre_step_command(&plan.pre[0]) + .expect("pre-step command") + .expect("configured command"); + let args = command.get_args().map(OsString::from).collect::>(); + + assert_eq!(command.get_program(), OsStr::new("pxpipe")); + assert_eq!( + args, + vec![OsString::from("--port"), OsString::from("47821")] + ); +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index eb12792..4675b71 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,6 +12,7 @@ mod exec; mod find; mod init; mod install; +mod launch; mod ontology; mod output; mod provenance; @@ -283,6 +284,19 @@ enum Command { rest: Vec, }, + /// Launch a coding tool with composable environment middleware + #[command( + after_help = "LAUNCH OPTIONS:\n --with Middleware chain to apply in order\n --pxpipe Legacy sugar for --with pxpipe\n --direct Clear the configured/default middleware chain\n --tmux[=NAME] Wrap the launch in a tmux session\n --dry-run Print the resolved launch plan without spawning\n -- ARGS... Arguments passed to the launched tool" + )] + Launch { + /// Coding tool to launch, such as `claude`. + tool: String, + + /// Launch options and tool args after `--`. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + rest: Vec, + }, + /// Launch a read-only web dashboard showing artifact state, provenance, /// and deployment status across all providers #[cfg(feature = "dashboard")] @@ -461,6 +475,9 @@ pub fn run() -> i32 { Command::Exec { skill, rest } => { return exit_code(exec::execute_cli(&skill, args.json, &rest)); } + Command::Launch { tool, rest } => { + return exit_code(launch::execute_cli(&tool, &rest)); + } #[cfg(feature = "dashboard")] Command::Dashboard { root, port } => return exit_code(dashboard::execute(&root, port)), Command::Release { source, embed } => (release::execute(&source, embed), "released"), diff --git a/src/ontology.rs b/src/ontology.rs index d8e5eab..163a940 100644 --- a/src/ontology.rs +++ b/src/ontology.rs @@ -1,5 +1,6 @@ use crate::error::{Error, ErrorKind}; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -32,9 +33,120 @@ pub struct Ontology { pub domain: Option, } -#[derive(Debug, Clone, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct Launch { + #[serde(alias = "default-with")] + pub default_with: Vec, + pub tools: HashMap, + pub middleware: LaunchMiddleware, +} + +#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct LaunchTool { + pub binary: Option, + #[serde(alias = "base-url-env")] + pub base_url_env: Option, +} + +#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] +#[serde(default)] +pub struct LaunchMiddleware { + pub pxpipe: PxpipeConfig, + pub otel: OtelConfig, + pub presidio: PresidioConfig, + pub squid: SquidConfig, + pub docker: DockerConfig, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct PxpipeConfig { + pub base_url: String, + pub host: String, + pub port: u16, + pub command: String, + pub log_path: String, +} + +impl Default for PxpipeConfig { + fn default() -> Self { + Self { + base_url: "http://127.0.0.1:47821".to_string(), + host: "127.0.0.1".to_string(), + port: 47_821, + command: "pxpipe".to_string(), + log_path: "~/.pxpipe/proxy.log".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct OtelConfig { + pub endpoint: String, + pub service_name: String, +} + +impl Default for OtelConfig { + fn default() -> Self { + Self { + endpoint: "http://127.0.0.1:4318".to_string(), + service_name: "forge-launch".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct PresidioConfig { + pub base_url: String, + pub host: String, + pub port: u16, +} + +impl Default for PresidioConfig { + fn default() -> Self { + Self { + base_url: "http://127.0.0.1:47822".to_string(), + host: "127.0.0.1".to_string(), + port: 47_822, + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct SquidConfig { + pub http_proxy: String, + pub https_proxy: String, +} + +impl Default for SquidConfig { + fn default() -> Self { + Self { + http_proxy: "http://127.0.0.1:3128".to_string(), + https_proxy: "http://127.0.0.1:3128".to_string(), + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(default)] -pub struct Launch {} +pub struct DockerConfig { + pub image: String, + pub args: Vec, +} + +impl Default for DockerConfig { + fn default() -> Self { + Self { + image: "ghcr.io/n4m3z/forge-coding-tool:latest".to_string(), + args: Vec::new(), + } + } +} #[derive(Debug, Clone, Deserialize, Default)] #[serde(default)] @@ -81,6 +193,8 @@ pub struct ResolvedValue { pub struct ResolvedConfig { pub ontology: ResolvedOntology, pub extensions: Vec, + #[serde(skip)] + pub launch: Launch, } #[derive(Debug, Clone, Copy)] @@ -323,6 +437,7 @@ fn resolve_config(config: &Config, env: &dyn Fn(&str) -> Option) -> Reso ResolvedConfig { ontology, extensions, + launch: config.launch.clone(), } }