1use 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(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 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 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 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 let mut text = request.text.unwrap_or_default();
512 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}