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