From d0219f65f536fc50763adbebf04b1b59d4d84f1b Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Fri, 2 Oct 2026 19:07:24 -0300 Subject: [PATCH] =?UTF-8?q?fix(cli):=20make=20a=20parked=20one-shot=20run?= =?UTF-8?q?=20resumable=20=E2=80=94=20accept=20/continue=20on=20stdin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-shot `neurosploit run|whitebox|greybox ` path dropped the pause/resume handles and never read stdin, so when the pool parked on quota/auth exhaustion it printed "type /continue …" into a void — nothing accepted it and the process hung forever on the parked task; only Ctrl-C worked. This is exactly the "can't /continue, it's stuck" a run that exhausts during recon hits. run_mode now, at a real terminal, reads stdin and accepts: /continue [provider:model] resume (optionally switching model) /model provider:model switch provider/model, then resume Ctrl-C still stops and offers a partial report; it also clears the pause so a parked task can't swallow the interrupt. Over a pipe (CI/web) stdin is skipped. Co-Authored-By: Claude Opus 4.8 --- neurosploit-rs/app/src/main.rs | 76 +++++++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index dbc1186..bb67ae8 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -1348,21 +1348,77 @@ pub(crate) fn finalize_run(mut out: RunOutput, workdir: &Path) -> RunOutput { async fn run_mode(base: &Path, cfg: RunConfig, mcp: bool, mode: Mode) -> anyhow::Result { subscription_preflight(&cfg).await; - let Spawned { mut task, mut rx, cancel, workdir, .. } = spawn_engagement(base, cfg, mcp, mode); + // Keep the pause/resume handles: a one-shot run that parks on quota/auth + // exhaustion must still be resumable. Without reading stdin here the pool's + // "type /continue" notice would be a dead end — nothing would accept it, and + // the process would hang forever on the parked task. + let Spawned { mut task, mut rx, cancel, paused, resume, fallback, workdir, .. } = + spawn_engagement(base, cfg, mcp, mode); let printer = tokio::spawn(async move { while let Some(line) = rx.recv().await { render_line(&line); } }); + // Read operator input ONLY at a real terminal: `/continue [provider:model]` + // to resume a parked run, `/model provider:model` to switch then resume. + // Over a pipe (CI, web) there is no one to type, so we skip it and rely on + // the auto-resume / Ctrl-C paths instead. + use std::io::IsTerminal as _; + let interactive = std::io::stdin().is_terminal(); + let mut stdin_lines = interactive.then(|| { + use tokio::io::AsyncBufReadExt as _; + tokio::io::BufReader::new(tokio::io::stdin()).lines() + }); + + let resume_run = |arg: &str| { + let arg = arg.trim(); + if !arg.is_empty() { + let m = ModelRef::parse(arg); + println!(" \x1b[1;35m▶ resuming with\x1b[0m {}:{}", m.provider, m.model); + if let Ok(mut fb) = fallback.lock() { fb.push(m); } + } else { + println!(" \x1b[1;35m▶ resuming\x1b[0m — retrying with the current model(s)."); + } + paused.store(false, std::sync::atomic::Ordering::Relaxed); + resume.notify_waiters(); + }; + let mut cancelled = false; - let out: RunOutput = tokio::select! { - r = &mut task => r.unwrap_or_default(), - _ = tokio::signal::ctrl_c() => { - cancelled = true; - cancel.store(true, std::sync::atomic::Ordering::Relaxed); - println!("\n \x1b[33m⏸ stopping — finishing in-flight work… (Ctrl-C again to abort now)\x1b[0m"); - tokio::select! { - r = &mut task => r.unwrap_or_default(), - _ = tokio::signal::ctrl_c() => { task.abort(); println!(" \x1b[31m✗ aborted.\x1b[0m"); RunOutput::default() } + let out: RunOutput = loop { + tokio::select! { + r = &mut task => break r.unwrap_or_default(), + _ = tokio::signal::ctrl_c() => { + cancelled = true; + cancel.store(true, std::sync::atomic::Ordering::Relaxed); + paused.store(false, std::sync::atomic::Ordering::Relaxed); // don't let a parked task hold the Ctrl-C + resume.notify_waiters(); + println!("\n \x1b[33m⏸ stopping — finishing in-flight work… (Ctrl-C again to abort now)\x1b[0m"); + break tokio::select! { + r = &mut task => r.unwrap_or_default(), + _ = tokio::signal::ctrl_c() => { task.abort(); println!(" \x1b[31m✗ aborted.\x1b[0m"); RunOutput::default() } + }; + } + line = async { match stdin_lines.as_mut() { Some(l) => l.next_line().await, None => Ok(None) } }, if stdin_lines.is_some() => { + match line { + Ok(Some(l)) => { + let t = l.trim(); + let (cmd, arg) = t.split_once(char::is_whitespace).unwrap_or((t, "")); + match cmd { + "/continue" | "/resume" => { + if paused.load(std::sync::atomic::Ordering::Relaxed) { resume_run(arg); } + else { println!(" run is not paused — it's still working."); } + } + "/model" => { + // Switch provider/model and resume if parked. + if arg.trim().is_empty() { println!(" usage: /model "); } + else { resume_run(arg); } + } + "" => {} + other => println!(" while a run is active only /continue [provider:model], /model and Ctrl-C are accepted (got {other})"), + } + } + Ok(None) => { stdin_lines = None; } // EOF — stop polling stdin + Err(_) => { stdin_lines = None; } + } } } };