Skip to main content

winx_code_agent/tools/
herdr.rs

1//! Optional, explicitly bound access to an operator-configured Herdr server.
2//!
3//! No action discovers servers or implicitly selects the focused/current terminal.
4//! The public server derives the trusted principal and prompt authority separately.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use std::time::Duration;
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use tokio::io::{AsyncRead, AsyncReadExt};
16use tokio::process::Command;
17use tokio::time::Instant;
18
19const CONFIG_LIMIT: usize = 256 * 1024;
20const STDOUT_LIMIT: usize = 64 * 1024;
21const STDERR_LIMIT: usize = 16 * 1024;
22const ARGV_LIMIT: usize = 32 * 1024;
23const PROMPT_LIMIT: usize = 16 * 1024;
24const TARGET_LIMIT: usize = 32;
25
26#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum HerdrAction {
29    #[default]
30    List,
31    Get,
32    Explain,
33    Read,
34    Prompt,
35    Wait,
36}
37
38#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum HerdrStatus {
41    Idle,
42    Working,
43    Blocked,
44    Done,
45    Unknown,
46}
47
48impl HerdrStatus {
49    fn as_str(self) -> &'static str {
50        match self {
51            Self::Idle => "idle",
52            Self::Working => "working",
53            Self::Blocked => "blocked",
54            Self::Done => "done",
55            Self::Unknown => "unknown",
56        }
57    }
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
61#[serde(deny_unknown_fields)]
62pub struct HerdrRequest {
63    pub action: HerdrAction,
64    pub target: Option<String>,
65    pub text: Option<String>,
66    pub lines: Option<u32>,
67    pub timeout_ms: Option<u64>,
68    #[serde(default)]
69    pub until: Vec<HerdrStatus>,
70}
71
72#[derive(Debug, Clone)]
73pub struct HerdrContext {
74    pub principal_key: String,
75    pub workspace_root: PathBuf,
76    pub allow_prompt: bool,
77}
78
79#[derive(Debug, thiserror::Error)]
80pub enum HerdrError {
81    #[error("Herdr is disabled; an operator must configure WINX_HERDR_CONFIG")]
82    Disabled,
83    #[error("invalid Herdr configuration: {0}")]
84    Configuration(String),
85    #[error("Herdr access denied: {0}")]
86    Denied(String),
87    #[error("Herdr unavailable: {0}")]
88    Unavailable(String),
89    #[error("invalid Herdr request: {0}")]
90    InvalidRequest(String),
91    #[error("Herdr command failed: {0}")]
92    Failed(String),
93    #[error("invalid Herdr response: {0}")]
94    InvalidResponse(String),
95    #[error("Herdr output limit exceeded; do not automatically retry prompts")]
96    OutputLimit,
97    #[error("Herdr prompt delivery is uncertain: {0}; do not automatically retry")]
98    DeliveryUncertain(String),
99    #[error("Herdr deadline expired; prompt delivery uncertain: {uncertain}; do not automatically retry")]
100    Timeout { uncertain: bool },
101}
102
103#[derive(Debug, Clone, Deserialize)]
104#[serde(deny_unknown_fields)]
105struct HerdrConfig {
106    executable: PathBuf,
107    socket_path: PathBuf,
108    #[serde(default = "default_deadline")]
109    deadline_ms: u64,
110    bindings: Vec<Binding>,
111}
112
113const fn default_deadline() -> u64 {
114    5000
115}
116
117#[derive(Debug, Clone, Deserialize)]
118#[serde(deny_unknown_fields)]
119struct Binding {
120    principal_key: String,
121    workspace_root: PathBuf,
122    targets: BTreeMap<String, String>,
123}
124
125#[derive(Debug, Clone)]
126pub struct HerdrAdapter {
127    config: Option<HerdrConfig>,
128}
129
130impl HerdrAdapter {
131    pub fn disabled() -> Self {
132        Self { config: None }
133    }
134
135    /// A configuration hint, not a health probe or a grant of target authority.
136    /// Do not open the configured path or start a process during discovery.
137    pub(crate) fn configuration_hint() -> Value {
138        let present = std::env::var_os("WINX_HERDR_CONFIG").is_some();
139        json!({
140            "configuration_present": present,
141            "availability": if present { "unknown" } else { "disabled" },
142            "reason": if present { "configuration_not_validated" } else { "configuration_missing" },
143        })
144    }
145
146    pub fn from_env() -> Result<Self, HerdrError> {
147        match std::env::var_os("WINX_HERDR_CONFIG") {
148            None => Ok(Self::disabled()),
149            Some(path) => Self::from_config_path(Path::new(&path)),
150        }
151    }
152
153    pub fn from_config_path(path: &Path) -> Result<Self, HerdrError> {
154        if !path.is_absolute() {
155            return Err(configuration("configuration path must be absolute"));
156        }
157        let file =
158            std::fs::File::open(path).map_err(|_| configuration("cannot open configuration"))?;
159        let metadata =
160            file.metadata().map_err(|_| configuration("cannot inspect configuration"))?;
161        if !metadata.is_file() {
162            return Err(configuration("configuration must be a regular file"));
163        }
164        #[cfg(unix)]
165        {
166            use std::os::unix::fs::PermissionsExt;
167            if metadata.permissions().mode() & 0o077 != 0 {
168                return Err(configuration("configuration must be private (chmod 600)"));
169            }
170        }
171        let mut bytes = Vec::new();
172        file.take((CONFIG_LIMIT + 1) as u64)
173            .read_to_end(&mut bytes)
174            .map_err(|_| configuration("cannot read configuration"))?;
175        if bytes.len() > CONFIG_LIMIT {
176            return Err(configuration("configuration exceeds 256 KiB"));
177        }
178        let mut config: HerdrConfig = serde_json::from_slice(&bytes)
179            .map_err(|error| configuration(&format!("malformed JSON: {error}")))?;
180        config.validate()?;
181        Ok(Self { config: Some(config) })
182    }
183
184    pub async fn run(
185        &self,
186        context: &HerdrContext,
187        request: HerdrRequest,
188    ) -> Result<Value, HerdrError> {
189        let config = self.config.as_ref().ok_or(HerdrError::Disabled)?;
190        let binding = config.binding(context)?;
191        validate_request(&request, context.allow_prompt)?;
192        let deadline = Instant::now() + Duration::from_millis(config.deadline_ms);
193        if request.action == HerdrAction::List {
194            let mut agents = Vec::new();
195            let mut seen = BTreeSet::new();
196            let mut aggregate_bytes = 16;
197            for id in binding.targets.values() {
198                if seen.insert(id) {
199                    let agent = config.get(context, id, deadline).await?;
200                    aggregate_bytes += serde_json::to_vec(&agent)
201                        .map_err(|_| invalid_response("cannot encode agent"))?
202                        .len()
203                        + 1;
204                    if aggregate_bytes > STDOUT_LIMIT {
205                        return Err(HerdrError::OutputLimit);
206                    }
207                    agents.push(agent);
208                }
209            }
210            let result = json!({ "agents": agents });
211            if serde_json::to_vec(&result)
212                .map_err(|_| invalid_response("cannot encode agents"))?
213                .len()
214                > STDOUT_LIMIT
215            {
216                return Err(HerdrError::OutputLimit);
217            }
218            return Ok(result);
219        }
220        let target = request
221            .target
222            .as_deref()
223            .ok_or_else(|| invalid_request("an explicit configured target is required"))?;
224        let id = binding
225            .targets
226            .get(target)
227            .or_else(|| binding.targets.values().find(|id| id.as_str() == target))
228            .ok_or_else(|| {
229                HerdrError::Denied("target is not configured for this principal/workspace".into())
230            })?;
231
232        if request.action == HerdrAction::Get {
233            return Ok(json!({ "agent": config.get(context, id, deadline).await? }));
234        }
235        // Explain and read do not return terminal identity. Verify the stable ID
236        // before invoking them, and also before delivering a prompt.
237        if matches!(request.action, HerdrAction::Read | HerdrAction::Explain | HerdrAction::Prompt)
238        {
239            config.get(context, id, deadline).await?;
240        }
241        let action = request.action;
242        let (args, expected_type) = command_args(id, request);
243        let output =
244            execute_cli(config, context, &args, deadline, action == HerdrAction::Prompt).await?;
245        parse_output(action, output, id, expected_type)
246    }
247}
248
249impl HerdrConfig {
250    fn validate(&mut self) -> Result<(), HerdrError> {
251        if !self.executable.is_absolute() || !self.socket_path.is_absolute() {
252            return Err(configuration("executable and socket_path must be absolute"));
253        }
254        if self.socket_path.as_os_str().len() > 4096
255            || self.socket_path.to_string_lossy().contains('\0')
256        {
257            return Err(configuration("socket_path is too long or contains NUL"));
258        }
259        self.executable = std::fs::canonicalize(&self.executable)
260            .map_err(|_| HerdrError::Unavailable("configured executable does not exist".into()))?;
261        let metadata = std::fs::metadata(&self.executable)
262            .map_err(|_| HerdrError::Unavailable("cannot inspect configured executable".into()))?;
263        if !metadata.is_file() {
264            return Err(HerdrError::Unavailable(
265                "configured executable is not a regular file".into(),
266            ));
267        }
268        #[cfg(unix)]
269        {
270            use std::os::unix::fs::PermissionsExt;
271            if metadata.permissions().mode() & 0o111 == 0 {
272                return Err(HerdrError::Unavailable("configured file is not executable".into()));
273            }
274        }
275        if !(1..=30_000).contains(&self.deadline_ms) {
276            return Err(configuration("deadline_ms must be between 1 and 30000"));
277        }
278        let mut scopes = BTreeSet::new();
279        for binding in &self.bindings {
280            if binding.principal_key.is_empty() || binding.principal_key.len() > 256 {
281                return Err(configuration("principal_key must be nonempty and bounded"));
282            }
283            if !binding.workspace_root.is_absolute()
284                || std::fs::canonicalize(&binding.workspace_root).ok().as_ref()
285                    != Some(&binding.workspace_root)
286            {
287                return Err(configuration(
288                    "workspace_root must be an existing canonical absolute path",
289                ));
290            }
291            if !scopes.insert((&binding.principal_key, &binding.workspace_root)) {
292                return Err(configuration("duplicate principal/workspace binding"));
293            }
294            if binding.targets.is_empty() || binding.targets.len() > TARGET_LIMIT {
295                return Err(configuration("each binding must have 1 through 32 targets"));
296            }
297            for (alias, id) in &binding.targets {
298                if !valid_target(alias) || !valid_target(id) {
299                    return Err(configuration(
300                        "targets require explicit stable IDs and safe aliases",
301                    ));
302                }
303                if binding.targets.get(id).is_some_and(|mapped| mapped != id) {
304                    return Err(configuration(
305                        "an alias must not shadow another configured terminal ID",
306                    ));
307                }
308            }
309        }
310        Ok(())
311    }
312
313    fn binding(&self, context: &HerdrContext) -> Result<&Binding, HerdrError> {
314        if std::fs::canonicalize(&context.workspace_root).ok().as_ref()
315            != Some(&context.workspace_root)
316        {
317            return Err(HerdrError::Denied("workspace must be canonical".into()));
318        }
319        self.bindings
320            .iter()
321            .find(|binding| {
322                binding.principal_key == context.principal_key
323                    && binding.workspace_root == context.workspace_root
324            })
325            .ok_or_else(|| HerdrError::Denied("principal/workspace binding is absent".into()))
326    }
327
328    async fn get(
329        &self,
330        context: &HerdrContext,
331        id: &str,
332        deadline: Instant,
333    ) -> Result<Value, HerdrError> {
334        let args = vec!["agent".into(), "get".into(), id.into()];
335        let output = execute_cli(self, context, &args, deadline, false).await?;
336        parse_agent(&output, id, "agent_info")
337    }
338}
339
340async fn execute_cli(
341    config: &HerdrConfig,
342    context: &HerdrContext,
343    args: &[String],
344    deadline: Instant,
345    prompt: bool,
346) -> Result<Vec<u8>, HerdrError> {
347    if config.executable.as_os_str().len() + 1 + args.iter().map(|arg| arg.len() + 1).sum::<usize>()
348        > ARGV_LIMIT
349        || args.iter().any(|arg| arg.contains('\0'))
350    {
351        return Err(invalid_request("argv exceeds its byte bound or contains NUL"));
352    }
353    if Instant::now() >= deadline {
354        return Err(HerdrError::Timeout { uncertain: false });
355    }
356    let mut command = Command::new(&config.executable);
357    command
358        .args(args)
359        .current_dir(&context.workspace_root)
360        .stdin(Stdio::null())
361        .stdout(Stdio::piped())
362        .stderr(Stdio::piped())
363        .kill_on_drop(true);
364    // Herdr routing may otherwise inherit session/current-pane/plugin state.
365    for (key, _) in std::env::vars_os() {
366        if key.to_string_lossy().starts_with("HERDR_") {
367            command.env_remove(key);
368        }
369    }
370    command.env("HERDR_SOCKET_PATH", &config.socket_path);
371    let mut child = command
372        .spawn()
373        .map_err(|_| HerdrError::Unavailable("cannot spawn configured executable".into()))?;
374    let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) else {
375        let _ = child.kill().await;
376        let _ = child.wait().await;
377        return Err(after_spawn_error(
378            HerdrError::Failed("output pipe unavailable".into()),
379            prompt,
380        ));
381    };
382    let result = tokio::time::timeout_at(deadline, async {
383        tokio::try_join!(
384            async {
385                child
386                    .wait()
387                    .await
388                    .map_err(|_| HerdrError::Failed("cannot wait for subprocess".into()))
389            },
390            read_bounded(stdout, STDOUT_LIMIT),
391            read_bounded(stderr, STDERR_LIMIT)
392        )
393    })
394    .await;
395    match result {
396        Ok(Ok((status, stdout, _stderr))) if status.success() => Ok(stdout),
397        Ok(Ok((status, _, _))) => {
398            if prompt {
399                Err(HerdrError::DeliveryUncertain(format!("CLI exited with {status}")))
400            } else {
401                Err(HerdrError::Failed(format!("CLI exited with {status}")))
402            }
403        }
404        failed => {
405            // Cancellation or a full output pipe must not leave the CLI alive.
406            let _ = child.kill().await;
407            let _ = child.wait().await;
408            match failed {
409                Ok(Err(error)) => Err(after_spawn_error(error, prompt)),
410                Err(_) => Err(HerdrError::Timeout { uncertain: prompt }),
411                Ok(Ok(_)) => unreachable!("successful command handled above"),
412            }
413        }
414    }
415}
416
417async fn read_bounded(
418    mut pipe: impl AsyncRead + Unpin,
419    limit: usize,
420) -> Result<Vec<u8>, HerdrError> {
421    let mut bytes = Vec::new();
422    let mut buffer = [0u8; 4096];
423    loop {
424        let length = pipe
425            .read(&mut buffer)
426            .await
427            .map_err(|_| HerdrError::Failed("cannot read subprocess output".into()))?;
428        if length == 0 {
429            return Ok(bytes);
430        }
431        if bytes.len() + length > limit {
432            return Err(HerdrError::OutputLimit);
433        }
434        bytes.extend_from_slice(&buffer[..length]);
435    }
436}
437
438fn parse_agent(output: &[u8], id: &str, expected_type: &str) -> Result<Value, HerdrError> {
439    let response: Value = serde_json::from_slice(output)
440        .map_err(|_| invalid_response("stdout is not a JSON success envelope"))?;
441    if !response["id"].is_string()
442        || response.get("error").is_some()
443        || response["result"]["type"] != expected_type
444    {
445        return Err(invalid_response("unexpected response envelope/type"));
446    }
447    let agent = &response["result"]["agent"];
448    if agent["terminal_id"] != id {
449        return Err(invalid_response(
450            "returned terminal identity does not match the configured stable ID",
451        ));
452    }
453    Ok(agent.clone())
454}
455
456fn parse_output(
457    action: HerdrAction,
458    output: Vec<u8>,
459    id: &str,
460    expected_type: &str,
461) -> Result<Value, HerdrError> {
462    match action {
463        HerdrAction::Read => {
464            let text = String::from_utf8(output)
465                .map_err(|_| invalid_response("read output is not UTF-8"))?;
466            Ok(json!({ "terminal_id": id, "source": "recent", "text": text }))
467        }
468        HerdrAction::Explain => {
469            let explain: Value = serde_json::from_slice(&output)
470                .map_err(|_| invalid_response("explain output is not JSON"))?;
471            if !explain.is_object()
472                || !explain["state"].is_string()
473                || explain.get("error").is_some()
474            {
475                return Err(invalid_response("explain output is not an explanation object"));
476            }
477            Ok(json!({ "terminal_id": id, "explain": explain }))
478        }
479        HerdrAction::Prompt | HerdrAction::Wait => {
480            let agent = parse_agent(&output, id, expected_type)
481                .map_err(|error| after_spawn_error(error, action == HerdrAction::Prompt))?;
482            Ok(json!({ "agent": agent }))
483        }
484        HerdrAction::List | HerdrAction::Get => Err(invalid_request("unexpected response action")),
485    }
486}
487
488fn command_args(id: &str, request: HerdrRequest) -> (Vec<String>, &'static str) {
489    let mut args = vec!["agent".into()];
490    let expected_type = match request.action {
491        HerdrAction::Explain => {
492            args.extend(["explain".into(), id.into(), "--json".into()]);
493            ""
494        }
495        HerdrAction::Read => {
496            args.extend([
497                "read".into(),
498                id.into(),
499                "--source".into(),
500                "recent".into(),
501                "--lines".into(),
502                request.lines.unwrap_or(100).to_string(),
503                "--format".into(),
504                "text".into(),
505            ]);
506            ""
507        }
508        HerdrAction::Prompt => {
509            // Stop session routing before both positionals. A separator in TEXT's
510            // original argv slot is escaped by Herdr and becomes literal text.
511            let mut text = request.text.unwrap_or_default();
512            // Herdr strips one leading verbatim escape even after `--`.
513            if text.starts_with('\u{1}') {
514                text.insert(0, '\u{1}');
515            }
516            args.extend(["prompt".into(), "--".into(), id.into(), text]);
517            "agent_prompted"
518        }
519        HerdrAction::Wait => {
520            args.extend([
521                "wait".into(),
522                id.into(),
523                "--timeout".into(),
524                request.timeout_ms.unwrap_or(1000).to_string(),
525            ]);
526            for status in &request.until {
527                args.extend(["--until".into(), status.as_str().into()]);
528            }
529            "agent_info"
530        }
531        HerdrAction::List | HerdrAction::Get => "",
532    };
533    (args, expected_type)
534}
535
536fn validate_request(request: &HerdrRequest, allow_prompt: bool) -> Result<(), HerdrError> {
537    if request.text.is_some() && request.action != HerdrAction::Prompt {
538        return Err(invalid_request("text is only supported for prompt"));
539    }
540    if request.lines.is_some() && request.action != HerdrAction::Read {
541        return Err(invalid_request("lines is only supported for read"));
542    }
543    if (request.timeout_ms.is_some() || !request.until.is_empty())
544        && request.action != HerdrAction::Wait
545    {
546        return Err(invalid_request(
547            "timeout_ms and until are only supported for wait; prompt is immediate submission",
548        ));
549    }
550    if request.action == HerdrAction::List && request.target.is_some() {
551        return Err(invalid_request("list does not accept a target"));
552    }
553    if request.action == HerdrAction::Prompt {
554        if !allow_prompt {
555            return Err(HerdrError::Denied(
556                "prompt requires wcgw mode and BashCommand authority".into(),
557            ));
558        }
559        let text =
560            request.text.as_deref().ok_or_else(|| invalid_request("prompt text is required"))?;
561        if text.is_empty() || text.len() > PROMPT_LIMIT || text.contains('\0') {
562            return Err(invalid_request(
563                "prompt must be nonempty, at most 16 KiB and contain no NUL",
564            ));
565        }
566    }
567    if request.lines.is_some_and(|lines| !(1..=1000).contains(&lines)) {
568        return Err(invalid_request("lines must be between 1 and 1000"));
569    }
570    if request.timeout_ms.is_some_and(|timeout| !(1..=30_000).contains(&timeout))
571        || request.until.len() > 5
572    {
573        return Err(invalid_request(
574            "timeout_ms must be between 1 and 30000 and until has at most five states",
575        ));
576    }
577    Ok(())
578}
579
580fn valid_target(value: &str) -> bool {
581    !value.is_empty()
582        && value.len() <= 256
583        && !value.starts_with('-')
584        && !matches!(value, "current" | "focused" | "focus")
585        && value
586            .bytes()
587            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.'))
588}
589fn configuration(message: &str) -> HerdrError {
590    HerdrError::Configuration(message.into())
591}
592fn invalid_request(message: &str) -> HerdrError {
593    HerdrError::InvalidRequest(message.into())
594}
595fn invalid_response(message: &str) -> HerdrError {
596    HerdrError::InvalidResponse(message.into())
597}
598fn after_spawn_error(error: HerdrError, prompt: bool) -> HerdrError {
599    if prompt {
600        HerdrError::DeliveryUncertain(error.to_string())
601    } else {
602        error
603    }
604}