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    pub fn from_env() -> Result<Self, HerdrError> {
136        match std::env::var_os("WINX_HERDR_CONFIG") {
137            None => Ok(Self::disabled()),
138            Some(path) => Self::from_config_path(Path::new(&path)),
139        }
140    }
141
142    pub fn from_config_path(path: &Path) -> Result<Self, HerdrError> {
143        if !path.is_absolute() {
144            return Err(configuration("configuration path must be absolute"));
145        }
146        let file =
147            std::fs::File::open(path).map_err(|_| configuration("cannot open configuration"))?;
148        let metadata =
149            file.metadata().map_err(|_| configuration("cannot inspect configuration"))?;
150        if !metadata.is_file() {
151            return Err(configuration("configuration must be a regular file"));
152        }
153        #[cfg(unix)]
154        {
155            use std::os::unix::fs::PermissionsExt;
156            if metadata.permissions().mode() & 0o077 != 0 {
157                return Err(configuration("configuration must be private (chmod 600)"));
158            }
159        }
160        let mut bytes = Vec::new();
161        file.take((CONFIG_LIMIT + 1) as u64)
162            .read_to_end(&mut bytes)
163            .map_err(|_| configuration("cannot read configuration"))?;
164        if bytes.len() > CONFIG_LIMIT {
165            return Err(configuration("configuration exceeds 256 KiB"));
166        }
167        let mut config: HerdrConfig = serde_json::from_slice(&bytes)
168            .map_err(|error| configuration(&format!("malformed JSON: {error}")))?;
169        config.validate()?;
170        Ok(Self { config: Some(config) })
171    }
172
173    pub async fn run(
174        &self,
175        context: &HerdrContext,
176        request: HerdrRequest,
177    ) -> Result<Value, HerdrError> {
178        let config = self.config.as_ref().ok_or(HerdrError::Disabled)?;
179        let binding = config.binding(context)?;
180        validate_request(&request, context.allow_prompt)?;
181        let deadline = Instant::now() + Duration::from_millis(config.deadline_ms);
182        if request.action == HerdrAction::List {
183            let mut agents = Vec::new();
184            let mut seen = BTreeSet::new();
185            let mut aggregate_bytes = 16;
186            for id in binding.targets.values() {
187                if seen.insert(id) {
188                    let agent = config.get(context, id, deadline).await?;
189                    aggregate_bytes += serde_json::to_vec(&agent)
190                        .map_err(|_| invalid_response("cannot encode agent"))?
191                        .len()
192                        + 1;
193                    if aggregate_bytes > STDOUT_LIMIT {
194                        return Err(HerdrError::OutputLimit);
195                    }
196                    agents.push(agent);
197                }
198            }
199            let result = json!({ "agents": agents });
200            if serde_json::to_vec(&result)
201                .map_err(|_| invalid_response("cannot encode agents"))?
202                .len()
203                > STDOUT_LIMIT
204            {
205                return Err(HerdrError::OutputLimit);
206            }
207            return Ok(result);
208        }
209        let target = request
210            .target
211            .as_deref()
212            .ok_or_else(|| invalid_request("an explicit configured target is required"))?;
213        let id = binding
214            .targets
215            .get(target)
216            .or_else(|| binding.targets.values().find(|id| id.as_str() == target))
217            .ok_or_else(|| {
218                HerdrError::Denied("target is not configured for this principal/workspace".into())
219            })?;
220
221        if request.action == HerdrAction::Get {
222            return Ok(json!({ "agent": config.get(context, id, deadline).await? }));
223        }
224        // Explain and read do not return terminal identity. Verify the stable ID
225        // before invoking them, and also before delivering a prompt.
226        if matches!(request.action, HerdrAction::Read | HerdrAction::Explain | HerdrAction::Prompt)
227        {
228            config.get(context, id, deadline).await?;
229        }
230        let action = request.action;
231        let (args, expected_type) = command_args(id, request);
232        let output =
233            execute_cli(config, context, &args, deadline, action == HerdrAction::Prompt).await?;
234        parse_output(action, output, id, expected_type)
235    }
236}
237
238impl HerdrConfig {
239    fn validate(&mut self) -> Result<(), HerdrError> {
240        if !self.executable.is_absolute() || !self.socket_path.is_absolute() {
241            return Err(configuration("executable and socket_path must be absolute"));
242        }
243        if self.socket_path.as_os_str().len() > 4096
244            || self.socket_path.to_string_lossy().contains('\0')
245        {
246            return Err(configuration("socket_path is too long or contains NUL"));
247        }
248        self.executable = std::fs::canonicalize(&self.executable)
249            .map_err(|_| HerdrError::Unavailable("configured executable does not exist".into()))?;
250        let metadata = std::fs::metadata(&self.executable)
251            .map_err(|_| HerdrError::Unavailable("cannot inspect configured executable".into()))?;
252        if !metadata.is_file() {
253            return Err(HerdrError::Unavailable(
254                "configured executable is not a regular file".into(),
255            ));
256        }
257        #[cfg(unix)]
258        {
259            use std::os::unix::fs::PermissionsExt;
260            if metadata.permissions().mode() & 0o111 == 0 {
261                return Err(HerdrError::Unavailable("configured file is not executable".into()));
262            }
263        }
264        if !(1..=30_000).contains(&self.deadline_ms) {
265            return Err(configuration("deadline_ms must be between 1 and 30000"));
266        }
267        let mut scopes = BTreeSet::new();
268        for binding in &self.bindings {
269            if binding.principal_key.is_empty() || binding.principal_key.len() > 256 {
270                return Err(configuration("principal_key must be nonempty and bounded"));
271            }
272            if !binding.workspace_root.is_absolute()
273                || std::fs::canonicalize(&binding.workspace_root).ok().as_ref()
274                    != Some(&binding.workspace_root)
275            {
276                return Err(configuration(
277                    "workspace_root must be an existing canonical absolute path",
278                ));
279            }
280            if !scopes.insert((&binding.principal_key, &binding.workspace_root)) {
281                return Err(configuration("duplicate principal/workspace binding"));
282            }
283            if binding.targets.is_empty() || binding.targets.len() > TARGET_LIMIT {
284                return Err(configuration("each binding must have 1 through 32 targets"));
285            }
286            for (alias, id) in &binding.targets {
287                if !valid_target(alias) || !valid_target(id) {
288                    return Err(configuration(
289                        "targets require explicit stable IDs and safe aliases",
290                    ));
291                }
292                if binding.targets.get(id).is_some_and(|mapped| mapped != id) {
293                    return Err(configuration(
294                        "an alias must not shadow another configured terminal ID",
295                    ));
296                }
297            }
298        }
299        Ok(())
300    }
301
302    fn binding(&self, context: &HerdrContext) -> Result<&Binding, HerdrError> {
303        if std::fs::canonicalize(&context.workspace_root).ok().as_ref()
304            != Some(&context.workspace_root)
305        {
306            return Err(HerdrError::Denied("workspace must be canonical".into()));
307        }
308        self.bindings
309            .iter()
310            .find(|binding| {
311                binding.principal_key == context.principal_key
312                    && binding.workspace_root == context.workspace_root
313            })
314            .ok_or_else(|| HerdrError::Denied("principal/workspace binding is absent".into()))
315    }
316
317    async fn get(
318        &self,
319        context: &HerdrContext,
320        id: &str,
321        deadline: Instant,
322    ) -> Result<Value, HerdrError> {
323        let args = vec!["agent".into(), "get".into(), id.into()];
324        let output = execute_cli(self, context, &args, deadline, false).await?;
325        parse_agent(&output, id, "agent_info")
326    }
327}
328
329async fn execute_cli(
330    config: &HerdrConfig,
331    context: &HerdrContext,
332    args: &[String],
333    deadline: Instant,
334    prompt: bool,
335) -> Result<Vec<u8>, HerdrError> {
336    if config.executable.as_os_str().len() + 1 + args.iter().map(|arg| arg.len() + 1).sum::<usize>()
337        > ARGV_LIMIT
338        || args.iter().any(|arg| arg.contains('\0'))
339    {
340        return Err(invalid_request("argv exceeds its byte bound or contains NUL"));
341    }
342    if Instant::now() >= deadline {
343        return Err(HerdrError::Timeout { uncertain: false });
344    }
345    let mut command = Command::new(&config.executable);
346    command
347        .args(args)
348        .current_dir(&context.workspace_root)
349        .stdin(Stdio::null())
350        .stdout(Stdio::piped())
351        .stderr(Stdio::piped())
352        .kill_on_drop(true);
353    // Herdr routing may otherwise inherit session/current-pane/plugin state.
354    for (key, _) in std::env::vars_os() {
355        if key.to_string_lossy().starts_with("HERDR_") {
356            command.env_remove(key);
357        }
358    }
359    command.env("HERDR_SOCKET_PATH", &config.socket_path);
360    let mut child = command
361        .spawn()
362        .map_err(|_| HerdrError::Unavailable("cannot spawn configured executable".into()))?;
363    let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) else {
364        let _ = child.kill().await;
365        let _ = child.wait().await;
366        return Err(after_spawn_error(
367            HerdrError::Failed("output pipe unavailable".into()),
368            prompt,
369        ));
370    };
371    let result = tokio::time::timeout_at(deadline, async {
372        tokio::try_join!(
373            async {
374                child
375                    .wait()
376                    .await
377                    .map_err(|_| HerdrError::Failed("cannot wait for subprocess".into()))
378            },
379            read_bounded(stdout, STDOUT_LIMIT),
380            read_bounded(stderr, STDERR_LIMIT)
381        )
382    })
383    .await;
384    match result {
385        Ok(Ok((status, stdout, _stderr))) if status.success() => Ok(stdout),
386        Ok(Ok((status, _, _))) => {
387            if prompt {
388                Err(HerdrError::DeliveryUncertain(format!("CLI exited with {status}")))
389            } else {
390                Err(HerdrError::Failed(format!("CLI exited with {status}")))
391            }
392        }
393        failed => {
394            // Cancellation or a full output pipe must not leave the CLI alive.
395            let _ = child.kill().await;
396            let _ = child.wait().await;
397            match failed {
398                Ok(Err(error)) => Err(after_spawn_error(error, prompt)),
399                Err(_) => Err(HerdrError::Timeout { uncertain: prompt }),
400                Ok(Ok(_)) => unreachable!("successful command handled above"),
401            }
402        }
403    }
404}
405
406async fn read_bounded(
407    mut pipe: impl AsyncRead + Unpin,
408    limit: usize,
409) -> Result<Vec<u8>, HerdrError> {
410    let mut bytes = Vec::new();
411    let mut buffer = [0u8; 4096];
412    loop {
413        let length = pipe
414            .read(&mut buffer)
415            .await
416            .map_err(|_| HerdrError::Failed("cannot read subprocess output".into()))?;
417        if length == 0 {
418            return Ok(bytes);
419        }
420        if bytes.len() + length > limit {
421            return Err(HerdrError::OutputLimit);
422        }
423        bytes.extend_from_slice(&buffer[..length]);
424    }
425}
426
427fn parse_agent(output: &[u8], id: &str, expected_type: &str) -> Result<Value, HerdrError> {
428    let response: Value = serde_json::from_slice(output)
429        .map_err(|_| invalid_response("stdout is not a JSON success envelope"))?;
430    if !response["id"].is_string()
431        || response.get("error").is_some()
432        || response["result"]["type"] != expected_type
433    {
434        return Err(invalid_response("unexpected response envelope/type"));
435    }
436    let agent = &response["result"]["agent"];
437    if agent["terminal_id"] != id {
438        return Err(invalid_response(
439            "returned terminal identity does not match the configured stable ID",
440        ));
441    }
442    Ok(agent.clone())
443}
444
445fn parse_output(
446    action: HerdrAction,
447    output: Vec<u8>,
448    id: &str,
449    expected_type: &str,
450) -> Result<Value, HerdrError> {
451    match action {
452        HerdrAction::Read => {
453            let text = String::from_utf8(output)
454                .map_err(|_| invalid_response("read output is not UTF-8"))?;
455            Ok(json!({ "terminal_id": id, "source": "recent", "text": text }))
456        }
457        HerdrAction::Explain => {
458            let explain: Value = serde_json::from_slice(&output)
459                .map_err(|_| invalid_response("explain output is not JSON"))?;
460            if !explain.is_object()
461                || !explain["state"].is_string()
462                || explain.get("error").is_some()
463            {
464                return Err(invalid_response("explain output is not an explanation object"));
465            }
466            Ok(json!({ "terminal_id": id, "explain": explain }))
467        }
468        HerdrAction::Prompt | HerdrAction::Wait => {
469            let agent = parse_agent(&output, id, expected_type)
470                .map_err(|error| after_spawn_error(error, action == HerdrAction::Prompt))?;
471            Ok(json!({ "agent": agent }))
472        }
473        HerdrAction::List | HerdrAction::Get => Err(invalid_request("unexpected response action")),
474    }
475}
476
477fn command_args(id: &str, request: HerdrRequest) -> (Vec<String>, &'static str) {
478    let mut args = vec!["agent".into()];
479    let expected_type = match request.action {
480        HerdrAction::Explain => {
481            args.extend(["explain".into(), id.into(), "--json".into()]);
482            ""
483        }
484        HerdrAction::Read => {
485            args.extend([
486                "read".into(),
487                id.into(),
488                "--source".into(),
489                "recent".into(),
490                "--lines".into(),
491                request.lines.unwrap_or(100).to_string(),
492                "--format".into(),
493                "text".into(),
494            ]);
495            ""
496        }
497        HerdrAction::Prompt => {
498            // Stop session routing before both positionals. A separator in TEXT's
499            // original argv slot is escaped by Herdr and becomes literal text.
500            let mut text = request.text.unwrap_or_default();
501            // Herdr strips one leading verbatim escape even after `--`.
502            if text.starts_with('\u{1}') {
503                text.insert(0, '\u{1}');
504            }
505            args.extend(["prompt".into(), "--".into(), id.into(), text]);
506            "agent_prompted"
507        }
508        HerdrAction::Wait => {
509            args.extend([
510                "wait".into(),
511                id.into(),
512                "--timeout".into(),
513                request.timeout_ms.unwrap_or(1000).to_string(),
514            ]);
515            for status in &request.until {
516                args.extend(["--until".into(), status.as_str().into()]);
517            }
518            "agent_info"
519        }
520        HerdrAction::List | HerdrAction::Get => "",
521    };
522    (args, expected_type)
523}
524
525fn validate_request(request: &HerdrRequest, allow_prompt: bool) -> Result<(), HerdrError> {
526    if request.text.is_some() && request.action != HerdrAction::Prompt {
527        return Err(invalid_request("text is only supported for prompt"));
528    }
529    if request.lines.is_some() && request.action != HerdrAction::Read {
530        return Err(invalid_request("lines is only supported for read"));
531    }
532    if (request.timeout_ms.is_some() || !request.until.is_empty())
533        && request.action != HerdrAction::Wait
534    {
535        return Err(invalid_request(
536            "timeout_ms and until are only supported for wait; prompt is immediate submission",
537        ));
538    }
539    if request.action == HerdrAction::List && request.target.is_some() {
540        return Err(invalid_request("list does not accept a target"));
541    }
542    if request.action == HerdrAction::Prompt {
543        if !allow_prompt {
544            return Err(HerdrError::Denied(
545                "prompt requires wcgw mode and BashCommand authority".into(),
546            ));
547        }
548        let text =
549            request.text.as_deref().ok_or_else(|| invalid_request("prompt text is required"))?;
550        if text.is_empty() || text.len() > PROMPT_LIMIT || text.contains('\0') {
551            return Err(invalid_request(
552                "prompt must be nonempty, at most 16 KiB and contain no NUL",
553            ));
554        }
555    }
556    if request.lines.is_some_and(|lines| !(1..=1000).contains(&lines)) {
557        return Err(invalid_request("lines must be between 1 and 1000"));
558    }
559    if request.timeout_ms.is_some_and(|timeout| !(1..=30_000).contains(&timeout))
560        || request.until.len() > 5
561    {
562        return Err(invalid_request(
563            "timeout_ms must be between 1 and 30000 and until has at most five states",
564        ));
565    }
566    Ok(())
567}
568
569fn valid_target(value: &str) -> bool {
570    !value.is_empty()
571        && value.len() <= 256
572        && !value.starts_with('-')
573        && !matches!(value, "current" | "focused" | "focus")
574        && value
575            .bytes()
576            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':' | b'.'))
577}
578fn configuration(message: &str) -> HerdrError {
579    HerdrError::Configuration(message.into())
580}
581fn invalid_request(message: &str) -> HerdrError {
582    HerdrError::InvalidRequest(message.into())
583}
584fn invalid_response(message: &str) -> HerdrError {
585    HerdrError::InvalidResponse(message.into())
586}
587fn after_spawn_error(error: HerdrError, prompt: bool) -> HerdrError {
588    if prompt {
589        HerdrError::DeliveryUncertain(error.to_string())
590    } else {
591        error
592    }
593}