From 66b151e25326666ac2112c01cd41019c88ab985a Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 24 Jul 2026 14:18:59 -0700 Subject: [PATCH 1/3] Default cargo run to WebUI serve --- Cargo.toml | 1 + crates/ironclaw_reborn_cli/src/cli.rs | 8 ++- .../ironclaw_reborn_cli/src/commands/serve.rs | 2 +- .../src/commands/traces/tests.rs | 2 +- crates/ironclaw_reborn_cli/tests/smoke.rs | 37 ++++++++++ crates/ironclaw_webui/build.rs | 68 ++++++++++++++++--- crates/ironclaw_webui/frontend/README.md | 4 +- 7 files changed, 109 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cc75734cb2a..6e387b8aeb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [".", "crates/ironclaw_common", "crates/ironclaw_observability", "crates/ironclaw_host_api", "crates/ironclaw_host_ingress", "crates/ironclaw_filesystem", "crates/ironclaw_attachments", "crates/ironclaw_extractors", "crates/ironclaw_memory", "crates/ironclaw_memory_native", "crates/ironclaw_memory_mem0", "crates/ironclaw_events", "crates/ironclaw_event_projections", "crates/ironclaw_event_streams", "crates/ironclaw_reborn_event_store", "crates/ironclaw_extensions", "crates/ironclaw_extension_host", "crates/ironclaw_processes", "crates/ironclaw_dispatcher", "crates/ironclaw_scripts", "crates/ironclaw_process_sandbox", "crates/ironclaw_mcp", "crates/ironclaw_wasm", "crates/ironclaw_wasm_limiter", "crates/ironclaw_capabilities", "crates/ironclaw_secrets", "crates/ironclaw_network", "crates/ironclaw_host_runtime", "crates/ironclaw_runtime_policy", "crates/ironclaw_authorization", "crates/ironclaw_run_state", "crates/ironclaw_approvals", "crates/ironclaw_resources", "crates/ironclaw_auth", "crates/ironclaw_trust", "crates/ironclaw_turns", "crates/ironclaw_agent_loop", "crates/ironclaw_threads", "crates/ironclaw_prompt_envelope", "crates/ironclaw_hooks", "crates/ironclaw_loop_host", "crates/ironclaw_runner", "crates/ironclaw_reborn_config", "crates/ironclaw_operator", "crates/ironclaw_reborn_composition", "crates/ironclaw_reborn_identity", "crates/ironclaw_first_party_extensions", "crates/ironclaw_reborn_cli", "crates/ironclaw_reborn_traces", "crates/ironclaw_webui", "crates/ironclaw_reborn_openai_compat", "crates/ironclaw_conversations", "crates/ironclaw_product", "crates/ironclaw_telegram_extension", "crates/ironclaw_telegram_v2_adapter", "crates/ironclaw_slack_extension", "crates/ironclaw_outbound", "crates/ironclaw_triggers", "crates/ironclaw_projects", "crates/ironclaw_architecture", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_llm", "crates/ironclaw_embeddings", "tools/ironclaw_stress"] +default-members = ["crates/ironclaw_reborn_cli"] exclude = [ "crates/ironclaw_silk_decoder", "fuzz", diff --git a/crates/ironclaw_reborn_cli/src/cli.rs b/crates/ironclaw_reborn_cli/src/cli.rs index f2af1a6d5be..fca40d9d31b 100644 --- a/crates/ironclaw_reborn_cli/src/cli.rs +++ b/crates/ironclaw_reborn_cli/src/cli.rs @@ -5,8 +5,11 @@ use crate::commands::Command; #[derive(Debug, Parser)] #[command(name = "ironclaw", about = "IronClaw agent runtime", version)] pub(crate) struct Cli { + #[command(flatten)] + pub(crate) serve: crate::commands::serve::ServeCommand, + #[command(subcommand)] - pub(crate) command: Command, + pub(crate) command: Option, } pub(crate) fn command() -> clap::Command { @@ -14,5 +17,6 @@ pub(crate) fn command() -> clap::Command { } pub(crate) fn run() -> anyhow::Result<()> { - Cli::parse().command.execute() + let cli = Cli::parse(); + cli.command.unwrap_or(Command::Serve(cli.serve)).execute() } diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index 8fbd06dbdc0..14eeeca759f 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -86,7 +86,7 @@ impl ironclaw_reborn_composition::AdminApiTokenMinter for SignedSessionTokenMint } } -#[derive(Debug, Args)] +#[derive(Debug, Default, Args)] pub(crate) struct ServeCommand { /// Host interface for the Reborn WebChat v2 HTTP listener. /// Overrides `[webui].listen_host` from the boot config file. diff --git a/crates/ironclaw_reborn_cli/src/commands/traces/tests.rs b/crates/ironclaw_reborn_cli/src/commands/traces/tests.rs index d0500597a47..def6d0022a9 100644 --- a/crates/ironclaw_reborn_cli/src/commands/traces/tests.rs +++ b/crates/ironclaw_reborn_cli/src/commands/traces/tests.rs @@ -10,7 +10,7 @@ use ironclaw_reborn_traces::contribution::{ }; fn unwrap_traces_command(cli: Cli) -> TracesSubcommand { - let Command::Traces(command) = cli.command else { + let Some(Command::Traces(command)) = cli.command else { panic!("expected traces command"); }; command.command diff --git a/crates/ironclaw_reborn_cli/tests/smoke.rs b/crates/ironclaw_reborn_cli/tests/smoke.rs index 66ee460e84a..e2a56a60bbb 100644 --- a/crates/ironclaw_reborn_cli/tests/smoke.rs +++ b/crates/ironclaw_reborn_cli/tests/smoke.rs @@ -1010,6 +1010,16 @@ fn help_mentions_reborn_commands() { ); } +#[test] +fn workspace_default_cargo_run_targets_reborn_cli() { + let manifest = + std::fs::read_to_string(workspace_root().join("Cargo.toml")).expect("workspace Cargo.toml"); + assert!( + manifest.contains("default-members = [\"crates/ironclaw_reborn_cli\"]"), + "workspace `cargo run` must target the shipping Reborn CLI package: {manifest}" + ); +} + #[test] fn service_help_lists_all_verbs() { let output = Command::new(reborn_bin()) @@ -3055,6 +3065,33 @@ fn run_reports_runtime_readiness_snapshot_without_touching_v1_state() { ); } +#[test] +fn no_subcommand_defaults_to_serve_command() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let home_dir = temp.path().join("home"); + + let output = reborn_command() + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env("HOME", &home_dir) + .env_remove("IRONCLAW_REBORN_PROFILE") + .env_remove("IRONCLAW_REBORN_WEBUI_TOKEN") + .env_remove("IRONCLAW_REBORN_WEBUI_USER_ID") + .output() + .expect("ironclaw-reborn should default to serve"); + + assert!( + !output.status.success(), + "default serve must fail closed without a WebUI token; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("IRONCLAW_REBORN_WEBUI_TOKEN must be set"), + "stderr should match the `serve` auth gate: {stderr}" + ); +} + #[test] fn doctor_uses_reborn_home_override_without_touching_v1_state() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/ironclaw_webui/build.rs b/crates/ironclaw_webui/build.rs index d3522c69837..7e2bf41fc21 100644 --- a/crates/ironclaw_webui/build.rs +++ b/crates/ironclaw_webui/build.rs @@ -10,9 +10,12 @@ use std::env; use std::fs; +use std::io; use std::path::{Path, PathBuf}; use std::process::Command; +const PINNED_PNPM_PACKAGE: &str = "pnpm@11.7.0"; + fn main() -> Result<(), Box> { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); // safety: build script — Cargo always sets this let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // safety: build script — Cargo always sets this @@ -130,18 +133,22 @@ fn required_frontend_dist_files(dist_dir: &Path) -> [PathBuf; 3] { ] } -fn run_command(command: &str, args: &[&str], cwd: &Path) -> Result<(), Box> { - let status = Command::new(command).args(args).current_dir(cwd).status()?; +fn run_command>( + command: &str, + args: &[S], + cwd: &Path, +) -> Result<(), Box> { + let rendered = render_command(command, args); + let status = Command::new(command) + .args(args.iter().map(AsRef::as_ref)) + .current_dir(cwd) + .status()?; if status.success() { return Ok(()); } Err(std::io::Error::other(format!( - "`{}` failed in {} with status {status}", - std::iter::once(command) - .chain(args.iter().copied()) - .collect::>() - .join(" "), + "`{rendered}` failed in {} with status {status}", cwd.display(), )) .into()) @@ -151,7 +158,35 @@ fn run_pnpm(args: &[&str], cwd: &Path) -> Result<(), Box> let mut corepack_args = Vec::with_capacity(args.len() + 1); corepack_args.push("pnpm"); corepack_args.extend_from_slice(args); - run_command(corepack_command(), &corepack_args, cwd) + match run_command(corepack_command(), &corepack_args, cwd) { + Ok(()) => Ok(()), + Err(error) if is_not_found_error(error.as_ref()) => { + eprintln!( + "`{}` was not found; falling back to `npm exec --yes --package={}`", + corepack_command(), + PINNED_PNPM_PACKAGE, + ); + let mut npm_args = Vec::with_capacity(args.len() + 5); + npm_args.push("exec".to_string()); + npm_args.push("--yes".to_string()); + npm_args.push(format!("--package={PINNED_PNPM_PACKAGE}")); + npm_args.push("--".to_string()); + npm_args.push("pnpm".to_string()); + npm_args.extend(args.iter().map(|arg| (*arg).to_string())); + run_command(npm_command(), &npm_args, cwd).map_err(|fallback_error| { + io::Error::other(format!( + "failed to run pnpm through `{}` or `{}`. Install Corepack/pnpm \ + (`corepack enable pnpm`) or set SKIP_FRONTEND_BUILD=1 for a \ + backend-only Rust build. corepack error: {error}; npm fallback \ + error: {fallback_error}", + corepack_command(), + npm_command(), + )) + .into() + }) + } + Err(error) => Err(error), + } } fn corepack_command() -> &'static str { @@ -162,6 +197,23 @@ fn corepack_command() -> &'static str { } } +fn npm_command() -> &'static str { + if cfg!(windows) { "npm.cmd" } else { "npm" } +} + +fn is_not_found_error(error: &(dyn std::error::Error + 'static)) -> bool { + error + .downcast_ref::() + .is_some_and(|error| error.kind() == io::ErrorKind::NotFound) +} + +fn render_command>(command: &str, args: &[S]) -> String { + std::iter::once(command) + .chain(args.iter().map(AsRef::as_ref)) + .collect::>() + .join(" ") +} + fn collect( root: &Path, dir: &Path, diff --git a/crates/ironclaw_webui/frontend/README.md b/crates/ironclaw_webui/frontend/README.md index df0136edd40..b7e88e7ba8a 100644 --- a/crates/ironclaw_webui/frontend/README.md +++ b/crates/ironclaw_webui/frontend/README.md @@ -25,7 +25,9 @@ and generated JavaScript asset names are outside this module-import rule. `frontend/dist/`. Cargo does not embed that local preview directory. `crates/ironclaw_webui/build.rs` runs `corepack pnpm install --frozen-lockfile` and a Vite production build into -Cargo's `OUT_DIR`, then embeds that generated output into the Rust binary. +Cargo's `OUT_DIR`, then embeds that generated output into the Rust binary. If +`corepack` is not on `PATH`, the build script falls back to +`npm exec --yes --package=pnpm@11.7.0 -- pnpm ...`. `./build.sh` is the one-shot local refresh helper. It vendors pinned browser assets, installs dependencies with Corepack, and runs the Vite production build. From 81149b623c0f65492ee22ae20d52900d4be00e67 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 24 Jul 2026 14:51:40 -0700 Subject: [PATCH 2/3] Fix default serve CI integration --- .github/workflows/reborn-tests.yml | 2 +- crates/ironclaw_reborn_cli/src/cli.rs | 63 +++++++++++++++++++++-- crates/ironclaw_reborn_cli/tests/smoke.rs | 10 ---- scripts/ci/quality_gate.sh | 2 +- scripts/ci/quality_gate_strict.sh | 2 +- scripts/ci/run-reborn-group-tests.sh | 2 +- scripts/ci/run-reborn-root-partition.sh | 2 +- 7 files changed, 63 insertions(+), 20 deletions(-) diff --git a/.github/workflows/reborn-tests.yml b/.github/workflows/reborn-tests.yml index d884a53d0ab..a690bd5566a 100644 --- a/.github/workflows/reborn-tests.yml +++ b/.github/workflows/reborn-tests.yml @@ -807,7 +807,7 @@ jobs: run: scripts/ci/check-reborn-qa-fixtures.sh - name: Run Reborn QA fixture contracts and replay - run: cargo test --test reborn_qa_recorded_behavior -- --nocapture + run: cargo test -p ironclaw_reborn_integration_tests --test reborn_qa_recorded_behavior -- --nocapture reborn-tests: name: Tests (Reborn) diff --git a/crates/ironclaw_reborn_cli/src/cli.rs b/crates/ironclaw_reborn_cli/src/cli.rs index fca40d9d31b..044cc53de07 100644 --- a/crates/ironclaw_reborn_cli/src/cli.rs +++ b/crates/ironclaw_reborn_cli/src/cli.rs @@ -1,3 +1,5 @@ +use std::ffi::OsString; + use clap::{CommandFactory, Parser}; use crate::commands::Command; @@ -5,9 +7,6 @@ use crate::commands::Command; #[derive(Debug, Parser)] #[command(name = "ironclaw", about = "IronClaw agent runtime", version)] pub(crate) struct Cli { - #[command(flatten)] - pub(crate) serve: crate::commands::serve::ServeCommand, - #[command(subcommand)] pub(crate) command: Option, } @@ -17,6 +16,60 @@ pub(crate) fn command() -> clap::Command { } pub(crate) fn run() -> anyhow::Result<()> { - let cli = Cli::parse(); - cli.command.unwrap_or(Command::Serve(cli.serve)).execute() + let cli = Cli::parse_from(args_with_default_serve(std::env::args_os())); + cli.command + .unwrap_or(Command::Serve( + crate::commands::serve::ServeCommand::default(), + )) + .execute() +} + +fn args_with_default_serve(mut args: impl Iterator) -> Vec { + let program = args.next().unwrap_or_else(|| OsString::from("ironclaw")); + let mut rest: Vec<_> = args.collect(); + + let has_explicit_command = rest + .first() + .and_then(|arg| arg.to_str()) + .is_some_and(|arg| Cli::command().find_subcommand(arg).is_some()); + let is_help_or_version = rest + .iter() + .any(|arg| matches!(arg.to_str(), Some("--help" | "-h" | "--version" | "-V"))); + + if !has_explicit_command && !is_help_or_version { + rest.insert(0, OsString::from("serve")); + } + + std::iter::once(program).chain(rest).collect() +} + +#[cfg(test)] +mod tests { + use super::{Cli, args_with_default_serve}; + use crate::commands::Command; + use clap::Parser; + use std::ffi::OsString; + + #[test] + fn missing_subcommand_inserts_serve() { + let args = [OsString::from("ironclaw")]; + let cli = Cli::try_parse_from(args_with_default_serve(args.into_iter())) + .expect("default serve command should parse"); + + assert!(matches!(cli.command, Some(Command::Serve(_)))); + } + + #[test] + fn serve_options_before_another_subcommand_are_rejected() { + let args = [ + OsString::from("ironclaw"), + OsString::from("--host"), + OsString::from("127.0.0.1"), + OsString::from("status"), + ]; + let error = Cli::try_parse_from(args_with_default_serve(args.into_iter())) + .expect_err("serve options must not be silently ignored"); + + assert!(error.to_string().contains("unexpected argument 'status'")); + } } diff --git a/crates/ironclaw_reborn_cli/tests/smoke.rs b/crates/ironclaw_reborn_cli/tests/smoke.rs index e2a56a60bbb..e6ed397bc8d 100644 --- a/crates/ironclaw_reborn_cli/tests/smoke.rs +++ b/crates/ironclaw_reborn_cli/tests/smoke.rs @@ -1010,16 +1010,6 @@ fn help_mentions_reborn_commands() { ); } -#[test] -fn workspace_default_cargo_run_targets_reborn_cli() { - let manifest = - std::fs::read_to_string(workspace_root().join("Cargo.toml")).expect("workspace Cargo.toml"); - assert!( - manifest.contains("default-members = [\"crates/ironclaw_reborn_cli\"]"), - "workspace `cargo run` must target the shipping Reborn CLI package: {manifest}" - ); -} - #[test] fn service_help_lists_all_verbs() { let output = Command::new(reborn_bin()) diff --git a/scripts/ci/quality_gate.sh b/scripts/ci/quality_gate.sh index 83a62e02908..e24f7945050 100755 --- a/scripts/ci/quality_gate.sh +++ b/scripts/ci/quality_gate.sh @@ -9,5 +9,5 @@ cargo clippy --locked --all-targets -- -D clippy::correctness if [ "${IRONCLAW_PREPUSH_TEST:-1}" = "1" ]; then echo "==> tests (skip with IRONCLAW_PREPUSH_TEST=0)" - cargo test --locked --lib + cargo test --locked --workspace --lib fi diff --git a/scripts/ci/quality_gate_strict.sh b/scripts/ci/quality_gate_strict.sh index ce3ea43ecd0..869824f42d0 100755 --- a/scripts/ci/quality_gate_strict.sh +++ b/scripts/ci/quality_gate_strict.sh @@ -56,4 +56,4 @@ require_command cargo-deny "install with: cargo install cargo-deny" cargo deny check echo "==> tests" -cargo test --locked +cargo test --locked --workspace diff --git a/scripts/ci/run-reborn-group-tests.sh b/scripts/ci/run-reborn-group-tests.sh index 8ac2fc02b90..67353d5d9a1 100755 --- a/scripts/ci/run-reborn-group-tests.sh +++ b/scripts/ci/run-reborn-group-tests.sh @@ -35,6 +35,6 @@ fi for test_name in "${test_names[@]}"; do echo "::group::cargo test --test ${test_name}" timeout --signal=INT --kill-after=30s "${test_timeout}" \ - cargo test --test "${test_name}" -- --nocapture + cargo test -p ironclaw_reborn_integration_tests --test "${test_name}" -- --nocapture echo "::endgroup::" done diff --git a/scripts/ci/run-reborn-root-partition.sh b/scripts/ci/run-reborn-root-partition.sh index f3bac87cb6b..a38507b77f2 100755 --- a/scripts/ci/run-reborn-root-partition.sh +++ b/scripts/ci/run-reborn-root-partition.sh @@ -50,7 +50,7 @@ for index in "${!test_names[@]}"; do test_name="${test_names[$index]}" echo "::group::cargo test --test ${test_name}" timeout --signal=INT --kill-after=30s "${test_timeout}" \ - cargo test --test "${test_name}" -- --nocapture + cargo test -p ironclaw_reborn_integration_tests --test "${test_name}" -- --nocapture echo "::endgroup::" done From f0d93897f6e985b0e4024fde83da5c39a6dbad00 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Fri, 24 Jul 2026 14:54:05 -0700 Subject: [PATCH 3/3] Lint the full workspace in quality gate --- scripts/ci/quality_gate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/quality_gate.sh b/scripts/ci/quality_gate.sh index e24f7945050..cd85d340df0 100755 --- a/scripts/ci/quality_gate.sh +++ b/scripts/ci/quality_gate.sh @@ -5,7 +5,7 @@ echo "==> fmt check" cargo fmt --all -- --check echo "==> clippy (correctness)" -cargo clippy --locked --all-targets -- -D clippy::correctness +cargo clippy --locked --workspace --all-targets -- -D clippy::correctness if [ "${IRONCLAW_PREPUSH_TEST:-1}" = "1" ]; then echo "==> tests (skip with IRONCLAW_PREPUSH_TEST=0)"