fix: robustesse v1.1.6 — timeout runner/doctor, anti-deadlock, securite migrate, win32 is_running, websocket RFC6455, keyring JSON
- runner.rs: timeout 30s sur les commandes capturees (doctor ne pend plus) + pipes stdout/stderr draines dans des threads (anti-deadlock) ; run_untimed() pour les installations longues (npm/cargo/go) — sh() des installateurs l'utilise, les sondes gardent le timeout
- migrate_cmd.rs: rejet des chemins unsafe (path traversal) dans import_bundle
- process.rs: is_running Windows via OpenProcess/GetExitCodeProcess (fiable vs tasklist)
- probe.rs: drain des pipes en threads dans detect_external_version
- web.rs: boucle de traduction i18n (curseur, plus de boucle infinie si la traduction contient {{)
- serve.rs: frames WebSocket RFC 6455 avec vraies longueurs > 125 octets (126/127)
- secrets.rs: index keyring serialise en JSON (cles contenant des virgules)
- sandbox.rs: tests sans fuite de TempDir (plus de mem::forget)
- version 1.1.6, man pages et ROADMAP maj
This commit is contained in:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "1.1.5"
|
||||
version = "1.1.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "1.1.5"
|
||||
version = "1.1.6"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
|
||||
+10
@@ -119,6 +119,16 @@
|
||||
> — aichat ne filtre rien et échoue sur `.git/index`. Génération `--exec` :
|
||||
> `--code` au lieu de `--execute --dry-run` (aichat ≥ 0.30 : `--dry-run`
|
||||
> n'appelle plus l'API, il écho la requête).
|
||||
>
|
||||
> 🔧 **Maintenance v1.1.6 (2026-08-24)** : correctifs de robustesse —
|
||||
> timeout de 30 s sur les commandes capturées (doctor/runner ne pendent plus
|
||||
> si un agent ne répond pas, pipes vidés dans des threads pour éviter les
|
||||
> deadlocks), `is_running` Windows via l'API Win32 (OpenProcess /
|
||||
> GetExitCodeProcess, plus fiable que tasklist), rejet des chemins unsafe
|
||||
> (path traversal) dans `am migrate --import`, frames WebSocket RFC 6455
|
||||
> avec vraies longueurs > 125 octets, boucle de traduction i18n de la page
|
||||
> web corrigée (plus de boucle infinie), index du trousseau (keyring)
|
||||
> sérialisé en JSON (clés contenant des virgules).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.ie \n(.g .ds Aq \(aq
|
||||
.el .ds Aq '
|
||||
.TH am 1 "am 1.1.5"
|
||||
.TH am 1 "am 1.1.6"
|
||||
.SH NAME
|
||||
am \- agent\-manager (am) — manage local AI coding agents
|
||||
.SH SYNOPSIS
|
||||
@@ -250,4 +250,4 @@ Export the configuration and installation state (backup)
|
||||
am\-import(1)
|
||||
Import a previously exported configuration and state
|
||||
.SH VERSION
|
||||
v1.1.5
|
||||
v1.1.6
|
||||
|
||||
@@ -277,7 +277,11 @@ pub fn import_bundle(app: &App, bundle: &Path, confirm: bool) -> Result<Vec<Stri
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
dir.join(crate::config::CONFIG_FILE_NAME)
|
||||
} else {
|
||||
state_dir.join(rel)
|
||||
let t = state_dir.join(rel);
|
||||
if !t.starts_with(&state_dir) {
|
||||
anyhow::bail!("security error: bundle contains unsafe path '{}'", bf.path);
|
||||
}
|
||||
t
|
||||
};
|
||||
if let Some(parent) = target.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
|
||||
@@ -98,7 +98,9 @@ pub fn sh(
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<RunOutput> {
|
||||
let out = runner.run(cmd, args, env, cwd, true)?;
|
||||
// Installs are legitimately long (npm/cargo/go/... can take minutes):
|
||||
// no probe timeout here, only run() (doctor/version probes) is bounded.
|
||||
let out = runner.run_untimed(cmd, args, env, cwd, true)?;
|
||||
if out.code != 0 {
|
||||
let mut detail = out.stderr.trim().to_string();
|
||||
if detail.is_empty() {
|
||||
|
||||
+23
-14
@@ -30,7 +30,6 @@ impl ExternalInfo {
|
||||
/// Detect the version of an external binary (--version), bounded by a
|
||||
/// timeout so a misbehaving agent can never hang the whole listing.
|
||||
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
|
||||
let mut cmd = std::process::Command::new(&prog);
|
||||
cmd.args(&prefix)
|
||||
@@ -41,6 +40,24 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
let Ok(mut child) = cmd.spawn() else {
|
||||
return None;
|
||||
};
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
let stdout_handle = std::thread::spawn(move || {
|
||||
let mut out = String::new();
|
||||
if let Some(mut pipe) = stdout_pipe {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut out);
|
||||
}
|
||||
out
|
||||
});
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let mut err = String::new();
|
||||
if let Some(mut pipe) = stderr_pipe {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut err);
|
||||
}
|
||||
err
|
||||
});
|
||||
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
|
||||
Ok(Some(s)) => Some(s),
|
||||
@@ -51,22 +68,14 @@ pub fn detect_external_version(bin_path: &Path) -> Option<String> {
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let Some(status) = status else {
|
||||
return None;
|
||||
};
|
||||
let stdout = stdout_handle.join().unwrap_or_default();
|
||||
let stderr = stderr_handle.join().unwrap_or_default();
|
||||
|
||||
let status = status?;
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut stdout = String::new();
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut o) = child.stdout.take() {
|
||||
let _ = o.read_to_string(&mut stdout);
|
||||
}
|
||||
if let Some(mut e) = child.stderr.take() {
|
||||
let _ = e.read_to_string(&mut stderr);
|
||||
}
|
||||
let combined = format!("{stdout}
|
||||
{stderr}");
|
||||
let combined = format!("{stdout}\n{stderr}");
|
||||
crate::version::find_version(&combined)
|
||||
}
|
||||
|
||||
|
||||
+13
-8
@@ -17,14 +17,19 @@ pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf {
|
||||
pub fn is_running(pid: u32) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let out = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout)
|
||||
.split_whitespace()
|
||||
.any(|w| w == pid.to_string()),
|
||||
Err(_) => false,
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, FALSE, STILL_ACTIVE};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
if handle == 0 {
|
||||
return false;
|
||||
}
|
||||
let mut code: u32 = 0;
|
||||
let ok = GetExitCodeProcess(handle, &mut code);
|
||||
CloseHandle(handle);
|
||||
ok != 0 && code == STILL_ACTIVE as u32
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
|
||||
+118
-7
@@ -52,6 +52,20 @@ pub trait Runner {
|
||||
capture: bool,
|
||||
) -> Result<RunOutput>;
|
||||
|
||||
/// Run without the probe timeout: used for legitimately long
|
||||
/// operations (package installs, git clones, ...). The default
|
||||
/// implementation delegates to run().
|
||||
fn run_untimed(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run(cmd, args, env, cwd, capture)
|
||||
}
|
||||
|
||||
/// Locate a binary on the PATH (like the which command).
|
||||
fn which(&self, bin: &str) -> Option<PathBuf>;
|
||||
}
|
||||
@@ -73,14 +87,17 @@ impl<'a> SystemRunner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Runner for SystemRunner<'a> {
|
||||
fn run(
|
||||
impl<'a> SystemRunner<'a> {
|
||||
/// Shared implementation. The timeout applies only to captured
|
||||
/// probes; long-running installs pass None via run_untimed().
|
||||
fn run_inner(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
timeout: Option<std::time::Duration>,
|
||||
) -> Result<RunOutput> {
|
||||
let display = if args.is_empty() {
|
||||
cmd.to_string()
|
||||
@@ -102,12 +119,70 @@ impl<'a> Runner for SystemRunner<'a> {
|
||||
c.current_dir(dir);
|
||||
}
|
||||
if capture {
|
||||
let out = c
|
||||
.output()
|
||||
c.stdout(std::process::Stdio::piped());
|
||||
c.stderr(std::process::Stdio::piped());
|
||||
let mut child = c
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to execute: {display}"))?;
|
||||
let code = out.status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
|
||||
let stdout_handle = std::thread::spawn(move || {
|
||||
let mut out = Vec::new();
|
||||
if let Some(mut pipe) = stdout_pipe {
|
||||
let _ = std::io::Read::read_to_end(&mut pipe, &mut out);
|
||||
}
|
||||
out
|
||||
});
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let mut err = Vec::new();
|
||||
if let Some(mut pipe) = stderr_pipe {
|
||||
let _ = std::io::Read::read_to_end(&mut pipe, &mut err);
|
||||
}
|
||||
err
|
||||
});
|
||||
|
||||
let status = if let Some(timeout) = timeout {
|
||||
use wait_timeout::ChildExt;
|
||||
match child.wait_timeout(timeout) {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
self.log
|
||||
.warn(format!("command timed out after {timeout:?}: {display}"));
|
||||
let stdout = String::from_utf8_lossy(&stdout_handle.join().unwrap_or_default())
|
||||
.to_string();
|
||||
let stderr = String::from_utf8_lossy(&stderr_handle.join().unwrap_or_default())
|
||||
.to_string();
|
||||
return Ok(RunOutput {
|
||||
code: -1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Ok(RunOutput {
|
||||
code: -1,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
child
|
||||
.wait()
|
||||
.with_context(|| format!("failed to wait: {display}"))?
|
||||
};
|
||||
|
||||
let stdout_bytes = stdout_handle.join().unwrap_or_default();
|
||||
let stderr_bytes = stderr_handle.join().unwrap_or_default();
|
||||
let code = status.code().unwrap_or(-1);
|
||||
let stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
|
||||
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
|
||||
self.log.result(code, &stderr);
|
||||
Ok(RunOutput {
|
||||
code,
|
||||
@@ -126,6 +201,42 @@ impl<'a> Runner for SystemRunner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl<'a> Runner for SystemRunner<'a> {
|
||||
/// Probes are bounded by a 30 s timeout so a misbehaving process can
|
||||
/// never hang doctor/version detection.
|
||||
fn run(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run_inner(
|
||||
cmd,
|
||||
args,
|
||||
env,
|
||||
cwd,
|
||||
capture,
|
||||
Some(std::time::Duration::from_secs(30)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Installs and other legitimately long operations run without the
|
||||
/// probe timeout (npm/cargo/go installs can take minutes).
|
||||
fn run_untimed(
|
||||
&self,
|
||||
cmd: &str,
|
||||
args: &[String],
|
||||
env: &BTreeMap<String, String>,
|
||||
cwd: Option<&Path>,
|
||||
capture: bool,
|
||||
) -> Result<RunOutput> {
|
||||
self.run_inner(cmd, args, env, cwd, capture, None)
|
||||
}
|
||||
|
||||
fn which(&self, bin: &str) -> Option<PathBuf> {
|
||||
which::which(bin).ok()
|
||||
}
|
||||
|
||||
+8
-9
@@ -103,10 +103,9 @@ mod tests {
|
||||
use super::*;
|
||||
use clap::Parser;
|
||||
|
||||
fn test_app(sandbox_yaml: &str) -> App {
|
||||
fn test_app(sandbox_yaml: &str) -> (tempfile::TempDir, App) {
|
||||
let guard = tempfile::tempdir().unwrap();
|
||||
let dir = guard.path().to_path_buf();
|
||||
std::mem::forget(guard);
|
||||
let cfg = dir.join("config.yaml");
|
||||
std::fs::write(
|
||||
&cfg,
|
||||
@@ -116,7 +115,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
||||
crate::app::App::from_cli(cli).unwrap()
|
||||
(guard, crate::app::App::from_cli(cli).unwrap())
|
||||
}
|
||||
|
||||
fn agent(app: &App) -> &AgentDef {
|
||||
@@ -125,7 +124,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn no_profile_means_unsandboxed() {
|
||||
let app = test_app("");
|
||||
let (_guard, app) = test_app("");
|
||||
assert!(profile_of(agent(&app)).is_none());
|
||||
let mut env = BTreeMap::new();
|
||||
// No profile: everything allowed, nothing refused.
|
||||
@@ -135,7 +134,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn disallowed_command_is_refused_and_journalized() {
|
||||
let app = test_app(
|
||||
let (_guard, app) = test_app(
|
||||
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
@@ -161,7 +160,7 @@ mod tests {
|
||||
#[test]
|
||||
fn allowed_command_and_cwd_pass() {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let app = test_app(&format!(
|
||||
let (_guard, app) = test_app(&format!(
|
||||
"sandbox:\n enabled: true\n commands: [demo.exe]\n dirs: [\"{}\"]\n network: true\n",
|
||||
cwd.display().to_string().replace('\\', "\\\\")
|
||||
));
|
||||
@@ -172,7 +171,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cwd_outside_perimeter_is_blocked() {
|
||||
let app = test_app(
|
||||
let (_guard, app) = test_app(
|
||||
"sandbox:\n enabled: true\n commands: []\n dirs: [\"C:/definitely/not/here\"]\n network: true\n",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
@@ -182,7 +181,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn network_off_injects_blocking_proxy_env() {
|
||||
let app = test_app(
|
||||
let (_guard, app) = test_app(
|
||||
"sandbox:\n enabled: true\n commands: []\n dirs: []\n network: false\n",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
@@ -194,7 +193,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn no_sandbox_flag_bypasses_the_profile() {
|
||||
let app = test_app(
|
||||
let (_guard, app) = test_app(
|
||||
"sandbox:\n enabled: true\n commands: [python]\n dirs: []\n network: true\n",
|
||||
);
|
||||
let mut env = BTreeMap::new();
|
||||
|
||||
+13
-3
@@ -25,8 +25,9 @@ impl SecretStore for KeyringStore {
|
||||
if !idx.iter().any(|k| k == key) {
|
||||
idx.push(key.to_string());
|
||||
}
|
||||
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
let _ = index.set_password(&idx.join(","));
|
||||
let _ = index.set_password(&json_idx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -51,8 +52,9 @@ impl SecretStore for KeyringStore {
|
||||
let mut idx = self.list().unwrap_or_default();
|
||||
if idx.iter().any(|k| k == key) {
|
||||
idx.retain(|k| k != key);
|
||||
let json_idx = serde_json::to_string(&idx).unwrap_or_else(|_| idx.join("\0"));
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
let _ = index.set_password(&idx.join(","));
|
||||
let _ = index.set_password(&json_idx);
|
||||
}
|
||||
Ok(existed)
|
||||
}
|
||||
@@ -60,7 +62,15 @@ impl SecretStore for KeyringStore {
|
||||
fn list(&self) -> Result<Vec<String>> {
|
||||
let index = keyring::Entry::new(SERVICE, INDEX_KEY)?;
|
||||
match index.get_password() {
|
||||
Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()),
|
||||
Ok(v) => {
|
||||
if let Ok(vec) = serde_json::from_str::<Vec<String>>(&v) {
|
||||
Ok(vec)
|
||||
} else if v.contains('\0') {
|
||||
Ok(v.split('\0').filter(|s| !s.is_empty()).map(String::from).collect())
|
||||
} else {
|
||||
Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect())
|
||||
}
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
||||
Err(e) => Err(anyhow!("keyring: {e}")),
|
||||
}
|
||||
|
||||
+11
-2
@@ -370,9 +370,18 @@ fn ws_accept(key: &str) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Write a single server frame (unmasked): opcode + 7-bit length + payload.
|
||||
/// Write a single server frame (unmasked): opcode + RFC 6455 length + payload.
|
||||
fn write_frame<W: Write + ?Sized>(w: &mut W, opcode: u8, payload: &[u8]) -> Result<()> {
|
||||
w.write_all(&[0x80 | opcode, payload.len() as u8])?;
|
||||
let len = payload.len();
|
||||
if len <= 125 {
|
||||
w.write_all(&[0x80 | opcode, len as u8])?;
|
||||
} else if len <= 65535 {
|
||||
w.write_all(&[0x80 | opcode, 126])?;
|
||||
w.write_all(&(len as u16).to_be_bytes())?;
|
||||
} else {
|
||||
w.write_all(&[0x80 | opcode, 127])?;
|
||||
w.write_all(&(len as u64).to_be_bytes())?;
|
||||
}
|
||||
w.write_all(payload)?;
|
||||
w.flush()?;
|
||||
Ok(())
|
||||
|
||||
+4
-1
@@ -134,7 +134,9 @@ fn index_page(app: &App) -> HttpResult {
|
||||
let mut html = INDEX_HTML.replace("{{i18n_bundle}}", &bundle);
|
||||
// Then every {{label}} token: replaced by the localized label (the
|
||||
// French text is both the key and the fallback, so nothing survives).
|
||||
while let Some(start) = html.find("{{") {
|
||||
let mut cursor = 0;
|
||||
while let Some(rel_start) = html[cursor..].find("{{") {
|
||||
let start = cursor + rel_start;
|
||||
let Some(rel_end) = html[start + 2..].find("}}") else {
|
||||
break;
|
||||
};
|
||||
@@ -142,6 +144,7 @@ fn index_page(app: &App) -> HttpResult {
|
||||
let key = html[start + 2..end].to_string();
|
||||
let translated = crate::i18n::tr_in(lang, &key);
|
||||
html.replace_range(start..end + 2, translated);
|
||||
cursor = start + translated.len();
|
||||
}
|
||||
HttpResult {
|
||||
status: 200,
|
||||
|
||||
Reference in New Issue
Block a user