1use serde::Deserialize;
7use shadi_mas::AgentId;
8use std::{
9 collections::BTreeMap,
10 path::{Path, PathBuf},
11 process::{Command, Stdio},
12 sync::Mutex,
13};
14
15use crate::{
16 adapter::{CliAdapter, CliAdapterError},
17 context::{ArtifactPayload, ContextPacket, ConversationMessage},
18 subprocess::TrackedSubprocess,
19};
20
21use super::workdir::pin_tmpdir;
22
23const BUNDLED_CLAUDE: &str = include_str!("../../profiles/claude-code.json");
24const BUNDLED_COPILOT: &str = include_str!("../../profiles/copilot.json");
25const BUNDLED_CODEX: &str = include_str!("../../profiles/codex.json");
26const BUNDLED_CURSOR: &str = include_str!("../../profiles/cursor-agent.json");
27const BUNDLED_GOOSE: &str = include_str!("../../profiles/goose.json");
28const BUNDLED_OPENCODE: &str = include_str!("../../profiles/opencode.json");
29
30pub fn bundled_profile_ids() -> &'static [&'static str] {
32 &[
33 "claude-code",
34 "copilot",
35 "codex",
36 "cursor-agent",
37 "goose",
38 "opencode",
39 ]
40}
41
42fn bundled_json(id: &str) -> Option<&'static str> {
43 match id {
44 "claude-code" => Some(BUNDLED_CLAUDE),
45 "copilot" => Some(BUNDLED_COPILOT),
46 "codex" => Some(BUNDLED_CODEX),
47 "cursor-agent" => Some(BUNDLED_CURSOR),
48 "goose" => Some(BUNDLED_GOOSE),
49 "opencode" => Some(BUNDLED_OPENCODE),
50 _ => None,
51 }
52}
53
54pub fn load_profile(id: &str) -> Result<Option<CliProfile>, CliAdapterError> {
56 if id == "generic-stdio" {
57 return Ok(None);
58 }
59 if let Ok(dir) = std::env::var("AGENTBRIDGE_PROFILES_DIR") {
60 if let Some(profile) = load_profile_file(&Path::new(&dir).join(format!("{id}.json")))? {
61 return Ok(Some(profile));
62 }
63 }
64 match bundled_json(id) {
65 Some(raw) => Ok(Some(parse_profile(raw)?)),
66 None => Ok(None),
67 }
68}
69
70fn load_profile_file(path: &Path) -> Result<Option<CliProfile>, CliAdapterError> {
71 if !path.is_file() {
72 return Ok(None);
73 }
74 Ok(Some(parse_profile(&std::fs::read_to_string(path)?)?))
75}
76
77fn parse_profile(raw: &str) -> Result<CliProfile, CliAdapterError> {
78 serde_json::from_str(raw).map_err(CliAdapterError::from)
79}
80
81pub fn open_profile_adapter(
83 spec: &str,
84) -> Result<Option<(String, ProfileAdapter)>, CliAdapterError> {
85 let (id, work_dir) = match spec.split_once(':') {
86 Some((id, rest)) if !id.is_empty() && id != "generic-stdio" && id != "slim" => {
87 (id, PathBuf::from(rest))
88 }
89 None if !spec.is_empty() && spec != "generic-stdio" => (
90 spec,
91 std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
92 ),
93 _ => return Ok(None),
94 };
95 match load_profile(id)? {
96 Some(profile) => {
97 let label = profile.id.clone();
98 Ok(Some((label, ProfileAdapter::new(profile, work_dir))))
99 }
100 None => Ok(None),
101 }
102}
103
104fn default_true() -> bool {
105 true
106}
107
108#[derive(Debug, Clone, Deserialize)]
109pub struct CliProfile {
110 pub id: String,
111 pub bin: String,
112 #[serde(default)]
113 pub pin_tmpdir: bool,
114 #[serde(default)]
115 pub env: BTreeMap<String, String>,
116 #[serde(default)]
117 pub workdir_flags: Vec<String>,
118 #[serde(default = "default_true")]
119 pub workdir_flags_on_execute: bool,
120 #[serde(default = "default_true")]
121 pub workdir_flags_on_snapshot: bool,
122 #[serde(default = "default_true")]
123 pub workdir_flags_on_inject: bool,
124 #[serde(default = "default_true")]
125 pub current_dir_workdir: bool,
126 #[serde(default)]
127 pub system_flag: Option<String>,
128 #[serde(default)]
131 pub split_large_prompt: bool,
132 #[serde(default)]
133 pub extra_args_env: Option<String>,
134 #[serde(default)]
137 pub env_flags: Vec<EnvFlag>,
138 #[serde(default)]
140 pub pass_env: Vec<String>,
141 #[serde(default)]
143 pub pass_env_prefixes: Vec<String>,
144 #[serde(default)]
146 pub pass_env_suffixes: Vec<String>,
147 #[serde(default)]
148 pub stdin_null: bool,
149 pub execute: ExecuteSpec,
150 #[serde(default)]
151 pub session: Option<SessionSpec>,
152 #[serde(default)]
153 pub result: ResultSpec,
154}
155
156#[derive(Debug, Clone, Deserialize)]
157pub struct EnvFlag {
158 pub flag: String,
159 pub env: String,
160}
161
162#[derive(Debug, Clone, Deserialize)]
163pub struct ExecuteSpec {
164 pub args: Vec<String>,
165}
166
167#[derive(Debug, Clone, Deserialize)]
168pub struct SessionSpec {
169 pub json_field: String,
170 pub flag: String,
171 #[serde(default)]
172 pub retry_stderr_contains: Option<String>,
173}
174
175#[derive(Debug, Clone, Deserialize)]
176pub struct ResultSpec {
177 pub kind: ResultKind,
178 #[serde(default)]
179 pub field: Option<String>,
180 #[serde(default)]
181 pub prefix: Option<String>,
182 #[serde(default)]
183 pub line: Option<String>,
184}
185
186impl Default for ResultSpec {
187 fn default() -> Self {
188 Self {
189 kind: ResultKind::Stdout,
190 field: None,
191 prefix: None,
192 line: None,
193 }
194 }
195}
196
197#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
198#[serde(rename_all = "snake_case")]
199pub enum ResultKind {
200 #[default]
201 Stdout,
202 JsonField,
203 StripSuffix,
204 StripAfterFirstLine,
205}
206
207struct State {
208 session_id: Option<String>,
209}
210
211pub struct ProfileAdapter {
213 id: AgentId,
214 work_dir: PathBuf,
215 profile: CliProfile,
216 state: Mutex<State>,
217 subprocess: TrackedSubprocess,
218}
219
220impl ProfileAdapter {
221 pub fn new(profile: CliProfile, work_dir: impl Into<PathBuf>) -> Self {
222 let work_dir = work_dir.into();
223 Self {
224 id: AgentId(profile.id.clone()),
225 work_dir,
226 profile,
227 state: Mutex::new(State { session_id: None }),
228 subprocess: TrackedSubprocess::new(),
229 }
230 }
231
232 fn session_id(&self) -> Result<Option<String>, CliAdapterError> {
233 self.state
234 .lock()
235 .map(|s| s.session_id.clone())
236 .map_err(|_| CliAdapterError::Subprocess("lock poisoned".to_string()))
237 }
238
239 fn store_session(&self, session_id: Option<String>) {
240 if let Ok(mut state) = self.state.lock() {
241 if session_id.is_some() {
242 state.session_id = session_id;
243 }
244 }
245 }
246
247 fn clear_session(&self) {
248 if let Ok(mut state) = self.state.lock() {
249 state.session_id = None;
250 }
251 }
252
253 fn run(
254 &self,
255 prompt: &str,
256 system: Option<&str>,
257 include_workdir_flags: bool,
258 use_session: bool,
259 ) -> Result<String, CliAdapterError> {
260 let session = if use_session {
261 self.session_id()?
262 } else {
263 None
264 };
265 let (effective_system, effective_prompt) = split_large_prompt(
266 prompt,
267 system,
268 self.profile.split_large_prompt && self.profile.system_flag.is_some(),
269 );
270 let argv = render_argv(
271 &self.profile,
272 &self.work_dir.to_string_lossy(),
273 &effective_prompt,
274 effective_system.as_deref().or(system),
275 session.as_deref(),
276 include_workdir_flags,
277 );
278
279 let mut cmd = Command::new(&self.profile.bin);
280 if self.profile.pin_tmpdir {
281 pin_tmpdir(&mut cmd, &self.work_dir);
282 }
283 let workdir = self.work_dir.to_string_lossy();
284 for (key, value) in &self.profile.env {
285 cmd.env(key, value.replace("{workdir}", workdir.as_ref()));
286 }
287 for (key, value) in collect_pass_env(&self.profile) {
288 cmd.env(key, value);
289 }
290 if self.profile.current_dir_workdir {
291 cmd.current_dir(&self.work_dir);
292 }
293 cmd.args(&argv)
294 .stdout(Stdio::piped())
295 .stderr(Stdio::piped());
296 if self.profile.stdin_null {
297 cmd.stdin(Stdio::null());
298 }
299
300 let output = self.subprocess.output(&mut cmd).map_err(|e| {
301 CliAdapterError::Subprocess(format!("failed to run {}: {e}", self.profile.bin))
302 })?;
303 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
304 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
305
306 if !output.status.success() && stdout.is_empty() {
307 return Err(CliAdapterError::Subprocess(format!(
308 "{} exited with {}: {stderr}",
309 self.profile.bin, output.status
310 )));
311 }
312
313 if let Some(spec) = &self.profile.session {
314 if let Some(needle) = &spec.retry_stderr_contains {
315 if session.is_some() && (stderr.contains(needle) || stdout.contains(needle)) {
316 return Err(CliAdapterError::Subprocess(needle.clone()));
317 }
318 }
319 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&stdout) {
320 if let Some(sid) = value.get(&spec.json_field).and_then(|v| v.as_str()) {
321 self.store_session(Some(sid.to_string()));
322 }
323 }
324 }
325
326 extract_result(&self.profile, &stdout)
327 }
328}
329
330const MAX_INLINE_BYTES: usize = 512;
331
332fn split_large_prompt(
333 prompt: &str,
334 system: Option<&str>,
335 can_use_system: bool,
336) -> (Option<String>, String) {
337 if system.is_some() || !can_use_system || prompt.len() <= MAX_INLINE_BYTES {
338 return (system.map(str::to_string), prompt.to_string());
339 }
340 let split_at = prompt[..MAX_INLINE_BYTES]
341 .rfind("\n\n")
342 .unwrap_or(MAX_INLINE_BYTES);
343 let context_part = prompt[..split_at].to_string();
344 let question_part = prompt[split_at..].trim().to_string();
345 (Some(context_part), question_part)
346}
347
348fn collect_pass_env(profile: &CliProfile) -> BTreeMap<String, String> {
350 let mut names: Vec<String> = profile.pass_env.clone();
351 if profile.id == "goose" {
352 names.extend(goose_provider_api_key_env_names());
353 }
354 let mut out = BTreeMap::new();
355 for name in names {
356 if let Ok(value) = std::env::var(&name) {
357 if !value.is_empty() {
358 out.insert(name, value);
359 }
360 }
361 }
362 for (key, value) in std::env::vars() {
363 if value.is_empty() {
364 continue;
365 }
366 let prefix = profile
367 .pass_env_prefixes
368 .iter()
369 .any(|prefix| key.starts_with(prefix));
370 let suffix = profile
371 .pass_env_suffixes
372 .iter()
373 .any(|suffix| key.ends_with(suffix));
374 if prefix || suffix {
375 out.insert(key, value);
376 }
377 }
378 out
379}
380
381fn goose_provider_api_key_env_names() -> Vec<String> {
382 let Some(home) = std::env::var_os("HOME") else {
383 return Vec::new();
384 };
385 let dir = PathBuf::from(home).join(".config/goose/custom_providers");
386 let Ok(entries) = std::fs::read_dir(dir) else {
387 return Vec::new();
388 };
389 let mut names = Vec::new();
390 for entry in entries.flatten() {
391 let path = entry.path();
392 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
393 continue;
394 }
395 let Ok(raw) = std::fs::read_to_string(&path) else {
396 continue;
397 };
398 let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) else {
399 continue;
400 };
401 if let Some(name) = value.get("api_key_env").and_then(|v| v.as_str()) {
402 let name = name.trim();
403 if !name.is_empty() && !names.iter().any(|existing| existing == name) {
404 names.push(name.to_string());
405 }
406 }
407 }
408 names
409}
410
411pub fn render_argv(
414 profile: &CliProfile,
415 workdir: &str,
416 prompt: &str,
417 system: Option<&str>,
418 session_id: Option<&str>,
419 include_workdir_flags: bool,
420) -> Vec<String> {
421 let extra: Vec<String> = profile
422 .extra_args_env
423 .as_deref()
424 .and_then(|key| std::env::var(key).ok())
425 .map(|v| v.split_whitespace().map(str::to_string).collect())
426 .unwrap_or_default();
427
428 let mut out = Vec::new();
429 for token in &profile.execute.args {
430 match token.as_str() {
431 "{workdir_flags}" => {
432 if include_workdir_flags {
433 for flag in &profile.workdir_flags {
434 out.push(flag.replace("{workdir}", workdir));
435 }
436 }
437 }
438 "{system}" => {
439 if let (Some(flag), Some(sp)) = (profile.system_flag.as_deref(), system) {
440 out.push(flag.to_string());
441 out.push(sp.to_string());
442 }
443 }
444 "{session}" => {
445 if let (Some(spec), Some(sid)) = (&profile.session, session_id) {
446 out.push(spec.flag.clone());
447 out.push(sid.to_string());
448 }
449 }
450 "{extra_args}" => out.extend(extra.iter().cloned()),
451 "{env_flags}" => {
452 for spec in &profile.env_flags {
453 if let Ok(value) = std::env::var(&spec.env) {
454 let value = value.trim();
455 if !value.is_empty() {
456 out.push(spec.flag.clone());
457 out.push(value.to_string());
458 }
459 }
460 }
461 }
462 "{prompt}" => out.push(prompt.to_string()),
463 other => out.push(
464 other
465 .replace("{workdir}", workdir)
466 .replace("{prompt}", prompt),
467 ),
468 }
469 }
470 out
471}
472
473fn extract_result(profile: &CliProfile, stdout: &str) -> Result<String, CliAdapterError> {
474 match profile.result.kind {
475 ResultKind::Stdout => Ok(stdout.trim().to_string()),
476 ResultKind::JsonField => {
477 let field = profile.result.field.as_deref().ok_or_else(|| {
478 CliAdapterError::Protocol("json_field result missing field".into())
479 })?;
480 let value: serde_json::Value = serde_json::from_str(stdout)
481 .map_err(|e| CliAdapterError::Protocol(format!("unexpected json output: {e}")))?;
482 if value
483 .get("is_error")
484 .and_then(|v| v.as_bool())
485 .unwrap_or(false)
486 {
487 let msg = value
488 .get(field)
489 .and_then(|v| v.as_str())
490 .unwrap_or("cli reported an error")
491 .to_string();
492 return Err(CliAdapterError::Subprocess(msg));
493 }
494 Ok(value
495 .get(field)
496 .and_then(|v| v.as_str())
497 .unwrap_or("")
498 .to_string())
499 }
500 ResultKind::StripSuffix => {
501 let prefix = profile.result.prefix.as_deref().unwrap_or("Changes");
502 Ok(strip_trailing_prefix_lines(stdout, prefix))
503 }
504 ResultKind::StripAfterFirstLine => {
505 let line = profile.result.line.as_deref().unwrap_or("--------");
506 Ok(strip_after_first_line(stdout, line).trim().to_string())
507 }
508 }
509}
510
511fn strip_trailing_prefix_lines(output: &str, prefix: &str) -> String {
512 let mut lines: Vec<&str> = output.lines().collect();
513 while let Some(last) = lines.last() {
514 let t = last.trim();
515 if t.is_empty() || t.starts_with(prefix) {
516 lines.pop();
517 } else {
518 break;
519 }
520 }
521 lines.join("\n")
522}
523
524fn strip_after_first_line<'a>(output: &'a str, separator: &str) -> &'a str {
525 let mut found = false;
526 let mut byte_pos = 0usize;
527 for line in output.lines() {
528 if line.trim() == separator {
529 found = true;
530 byte_pos += line.len() + 1;
531 break;
532 }
533 byte_pos += line.len() + 1;
534 }
535 if found && byte_pos <= output.len() {
536 &output[byte_pos..]
537 } else {
538 output
539 }
540}
541
542fn snapshot_prompt() -> &'static str {
543 "Summarize this session for handoff: current goal, key decisions, files changed, what remains."
544}
545
546fn inject_system(ctx: &ContextPacket) -> String {
547 let mut system = format!(
548 "You are continuing a coding session originally started in {}.\n\n",
549 ctx.source_agent
550 );
551 if !ctx.conversation.is_empty() {
552 system.push_str("## Prior conversation\n");
553 for msg in &ctx.conversation {
554 system.push_str(&format!("[{}]: {}\n\n", msg.role, msg.content));
555 }
556 }
557 if !ctx.code_context.files.is_empty() {
558 system.push_str("## Files from prior session\n");
559 for f in &ctx.code_context.files {
560 system.push_str(&format!("### {}\n```\n{}\n```\n\n", f.path, f.content));
561 }
562 }
563 if let Some(diff) = &ctx.code_context.git_diff {
564 system.push_str(&format!("## Git diff\n```diff\n{diff}\n```\n\n"));
565 }
566 if !ctx.artifacts.is_empty() {
567 system.push_str("## Generated artifacts\n");
568 for art in &ctx.artifacts {
569 system.push_str(&format!("### {}\n```\n{}\n```\n\n", art.name, art.content));
570 }
571 }
572 system
573}
574
575impl CliAdapter for ProfileAdapter {
576 fn agent_id(&self) -> &AgentId {
577 &self.id
578 }
579
580 fn snapshot_context(&self) -> Result<ContextPacket, CliAdapterError> {
581 let summary = self.run(
582 snapshot_prompt(),
583 None,
584 self.profile.workdir_flags_on_snapshot,
585 true,
586 )?;
587 let mut pkt = ContextPacket::new(self.id.0.clone());
588 pkt.conversation.push(ConversationMessage {
589 role: "assistant".to_string(),
590 content: summary.clone(),
591 });
592 pkt.code_context.project_root = Some(self.work_dir.to_string_lossy().into_owned());
593 pkt.artifacts.push(ArtifactPayload {
594 name: "session_summary.md".to_string(),
595 content: summary,
596 media_type: "text/markdown".to_string(),
597 });
598 Ok(pkt)
599 }
600
601 fn inject_context(&self, ctx: &ContextPacket) -> Result<(), CliAdapterError> {
602 if self.profile.system_flag.is_some() {
603 self.run(
604 "Acknowledge you have received the handoff context and are ready to continue.",
605 Some(&inject_system(ctx)),
606 self.profile.workdir_flags_on_inject,
607 false,
608 )?;
609 } else {
610 let mut prompt = inject_system(ctx);
611 prompt.push_str("Acknowledge you have received the handoff context.");
612 self.run(&prompt, None, self.profile.workdir_flags_on_inject, false)?;
613 }
614 Ok(())
615 }
616
617 fn execute_prompt(&self, prompt: &str) -> Result<String, CliAdapterError> {
618 let session = self.session_id()?;
619 match self.run(prompt, None, self.profile.workdir_flags_on_execute, true) {
620 Err(err)
621 if session.is_some()
622 && self
623 .profile
624 .session
625 .as_ref()
626 .and_then(|s| s.retry_stderr_contains.as_deref())
627 .is_some_and(|needle| err.to_string().contains(needle)) =>
628 {
629 self.clear_session();
630 self.run(prompt, None, self.profile.workdir_flags_on_execute, false)
631 }
632 other => other,
633 }
634 }
635
636 fn kill_in_flight(&self) {
637 self.subprocess.kill();
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 static ENV_LOCK: Mutex<()> = Mutex::new(());
648
649 fn load_bundled(id: &str) -> CliProfile {
650 load_profile(id)
651 .expect("profile parse")
652 .unwrap_or_else(|| panic!("missing bundled profile {id}"))
653 }
654
655 #[test]
656 fn bundled_profiles_parse() {
657 for id in bundled_profile_ids() {
658 let p = load_bundled(id);
659 assert_eq!(p.id, *id);
660 assert!(!p.bin.is_empty());
661 assert!(!p.execute.args.is_empty());
662 }
663 }
664
665 #[test]
666 fn claude_argv_matches_native_order() {
667 let p = load_bundled("claude-code");
668 let argv = render_argv(
669 &p,
670 "/var/workspace",
671 "hello",
672 Some("sys"),
673 Some("sid-1"),
674 true,
675 );
676 assert_eq!(
677 argv,
678 vec![
679 "--print",
680 "--output-format",
681 "json",
682 "--add-dir",
683 "/var/workspace",
684 "--system-prompt",
685 "sys",
686 "--session-id",
687 "sid-1",
688 "hello",
689 ]
690 );
691 }
692
693 #[test]
694 fn claude_inject_omits_add_dir() {
695 let p = load_bundled("claude-code");
696 let argv = render_argv(&p, "/var/workspace", "ack", Some("sys"), None, false);
697 assert!(!argv.iter().any(|a| a == "--add-dir"));
698 assert_eq!(argv.last().map(String::as_str), Some("ack"));
699 }
700
701 #[test]
702 fn only_claude_splits_large_execute_prompts() {
703 assert!(load_bundled("claude-code").split_large_prompt);
704 assert!(!load_bundled("copilot").split_large_prompt);
705 assert!(!load_bundled("cursor-agent").split_large_prompt);
706 assert!(!load_bundled("goose").split_large_prompt);
707 assert!(!load_bundled("opencode").split_large_prompt);
708 let long = "x".repeat(600);
709 assert!(split_large_prompt(&long, None, false).0.is_none());
710 assert!(split_large_prompt(&long, None, true).0.is_some());
711 }
712
713 #[test]
714 fn copilot_argv_places_prompt_before_workdir() {
715 let p = load_bundled("copilot");
716 let argv = render_argv(&p, "/ws", "do it", None, None, true);
717 assert_eq!(
718 argv,
719 vec![
720 "--prompt",
721 "do it",
722 "--allow-all-tools",
723 "-C",
724 "/ws",
725 "--add-dir",
726 "/ws",
727 ]
728 );
729 }
730
731 #[test]
732 fn codex_argv_includes_exec_flags() {
733 let p = load_bundled("codex");
734 let argv = render_argv(&p, "/ws", "implement fifo", None, None, true);
735 assert_eq!(
736 argv,
737 vec![
738 "exec",
739 "implement fifo",
740 "--dangerously-bypass-approvals-and-sandbox",
741 "--skip-git-repo-check",
742 "--ephemeral",
743 ]
744 );
745 }
746
747 #[test]
748 fn cursor_argv_sets_workspace_and_trust() {
749 let p = load_bundled("cursor-agent");
750 let argv = render_argv(&p, "/ws", "hi", None, None, true);
751 assert_eq!(
752 argv,
753 vec![
754 "--print",
755 "--output-format",
756 "text",
757 "--workspace",
758 "/ws",
759 "--trust",
760 "--sandbox",
761 "disabled",
762 "hi",
763 ]
764 );
765 }
766
767 #[test]
768 fn goose_argv_is_noninteractive_run() {
769 let p = load_bundled("goose");
770 let _guard = ENV_LOCK.lock().unwrap();
771 std::env::remove_var("GOOSE_PROVIDER");
772 std::env::remove_var("GOOSE_MODEL");
773 let argv = render_argv(&p, "/ws", "summarize src/", Some("be terse"), None, true);
774 assert_eq!(
775 argv,
776 vec![
777 "run",
778 "--text",
779 "summarize src/",
780 "--no-session",
781 "--quiet",
782 "--system",
783 "be terse",
784 ]
785 );
786 }
787
788 #[test]
789 fn collect_pass_env_copies_api_key_suffix_from_host() {
790 let p = load_bundled("goose");
791 let _guard = ENV_LOCK.lock().unwrap();
792 std::env::set_var("AGENTBRIDGE_FAKE_API_KEY", "test-key-not-a-secret");
793 let env = collect_pass_env(&p);
794 std::env::remove_var("AGENTBRIDGE_FAKE_API_KEY");
795 assert_eq!(
796 env.get("AGENTBRIDGE_FAKE_API_KEY").map(String::as_str),
797 Some("test-key-not-a-secret")
798 );
799 }
800
801 #[test]
802 fn goose_pass_env_harvests_api_key_env_from_custom_providers() {
803 let p = load_bundled("goose");
804 let _guard = ENV_LOCK.lock().unwrap();
805 let home = tempfile::tempdir().unwrap();
806 let providers = home.path().join(".config/goose/custom_providers");
807 std::fs::create_dir_all(&providers).unwrap();
808 std::fs::write(providers.join("notes.txt"), "ignore").unwrap();
809 std::fs::write(providers.join("bad.json"), "{not-json").unwrap();
810 std::fs::write(providers.join("empty.json"), r#"{"api_key_env":" "}"#).unwrap();
811 std::fs::write(providers.join("none.json"), "{}").unwrap();
812 std::fs::write(
813 providers.join("ok.json"),
814 r#"{"api_key_env":"AGENTBRIDGE_TEST_GOOSE_KEY"}"#,
815 )
816 .unwrap();
817 std::fs::write(
818 providers.join("dup.json"),
819 r#"{"api_key_env":"AGENTBRIDGE_TEST_GOOSE_KEY"}"#,
820 )
821 .unwrap();
822 std::fs::create_dir(providers.join("dir.json")).unwrap();
824
825 let old_home = std::env::var_os("HOME");
826 std::env::set_var("HOME", home.path());
827 std::env::set_var("AGENTBRIDGE_TEST_GOOSE_KEY", "test-key-not-a-secret");
828 std::env::set_var("GOOSE_DEMO_FLAG", "1");
829 std::env::set_var("GOOSE_EMPTY", "");
830 let names = goose_provider_api_key_env_names();
831 let env = collect_pass_env(&p);
832 std::env::remove_var("AGENTBRIDGE_TEST_GOOSE_KEY");
833 std::env::remove_var("GOOSE_DEMO_FLAG");
834 std::env::remove_var("GOOSE_EMPTY");
835 match old_home {
836 Some(v) => std::env::set_var("HOME", v),
837 None => std::env::remove_var("HOME"),
838 }
839
840 assert_eq!(names, vec!["AGENTBRIDGE_TEST_GOOSE_KEY".to_string()]);
841 assert_eq!(
842 env.get("AGENTBRIDGE_TEST_GOOSE_KEY").map(String::as_str),
843 Some("test-key-not-a-secret")
844 );
845 assert_eq!(env.get("GOOSE_DEMO_FLAG").map(String::as_str), Some("1"));
846 assert!(!env.contains_key("GOOSE_EMPTY"));
847 }
848
849 #[test]
850 fn goose_provider_api_key_env_names_skips_missing_home_and_dir() {
851 let _guard = ENV_LOCK.lock().unwrap();
852 let old_home = std::env::var_os("HOME");
853 std::env::remove_var("HOME");
854 assert!(goose_provider_api_key_env_names().is_empty());
855 let home = tempfile::tempdir().unwrap();
856 std::env::set_var("HOME", home.path());
857 assert!(goose_provider_api_key_env_names().is_empty());
858 match old_home {
859 Some(v) => std::env::set_var("HOME", v),
860 None => std::env::remove_var("HOME"),
861 }
862 }
863
864 #[test]
865 fn collect_pass_env_copies_explicit_pass_env_names() {
866 let mut p = load_bundled("opencode");
867 p.pass_env = vec!["AGENTBRIDGE_TEST_PASS".into()];
868 let _guard = ENV_LOCK.lock().unwrap();
869 std::env::set_var("AGENTBRIDGE_TEST_PASS", "test-key-not-a-secret");
870 let env = collect_pass_env(&p);
871 std::env::remove_var("AGENTBRIDGE_TEST_PASS");
872 assert_eq!(
873 env.get("AGENTBRIDGE_TEST_PASS").map(String::as_str),
874 Some("test-key-not-a-secret")
875 );
876 }
877
878 #[test]
879 fn goose_env_flags_omit_blank_provider() {
880 let p = load_bundled("goose");
881 let _guard = ENV_LOCK.lock().unwrap();
882 std::env::set_var("GOOSE_PROVIDER", " ");
883 std::env::remove_var("GOOSE_MODEL");
884 let argv = render_argv(&p, "/ws", "hi", None, None, true);
885 std::env::remove_var("GOOSE_PROVIDER");
886 assert_eq!(
887 argv,
888 vec!["run", "--text", "hi", "--no-session", "--quiet"]
889 );
890 }
891
892 #[test]
893 fn goose_argv_passes_provider_and_model_from_host_env() {
894 let p = load_bundled("goose");
895 let _guard = ENV_LOCK.lock().unwrap();
896 std::env::set_var("GOOSE_PROVIDER", "openai");
897 std::env::set_var("GOOSE_MODEL", "test-model");
898 let argv = render_argv(&p, "/ws", "hi", None, None, true);
899 std::env::remove_var("GOOSE_PROVIDER");
900 std::env::remove_var("GOOSE_MODEL");
901 assert_eq!(
902 argv,
903 vec![
904 "run",
905 "--text",
906 "hi",
907 "--no-session",
908 "--quiet",
909 "--provider",
910 "openai",
911 "--model",
912 "test-model",
913 ]
914 );
915 }
916
917 #[test]
918 fn opencode_argv_uses_auto_and_dir() {
919 let p = load_bundled("opencode");
920 let argv = render_argv(&p, "/ws", "fix the tests", None, None, true);
921 assert_eq!(
922 argv,
923 vec!["run", "--auto", "--dir", "/ws", "fix the tests"]
924 );
925 }
926
927 #[test]
928 fn extract_json_field_and_error() {
929 let p = load_bundled("claude-code");
930 let ok = extract_result(
931 &p,
932 r#"{"result":"fn x() {}","session_id":"abc","is_error":false}"#,
933 )
934 .unwrap();
935 assert_eq!(ok, "fn x() {}");
936 let err = extract_result(&p, r#"{"result":"rate limit","is_error":true}"#);
937 assert!(err.is_err());
938 }
939
940 #[test]
941 fn extract_copilot_strip_and_codex_header() {
942 let copilot = load_bundled("copilot");
943 assert_eq!(
944 extract_result(&copilot, "hello\n\nChanges +1 -0\n").unwrap(),
945 "hello"
946 );
947 let codex = load_bundled("codex");
948 let raw = "Reading extra\n--------\nworkdir: /ws\nmodel: x\nfn ok() {}\n";
949 assert!(extract_result(&codex, raw).unwrap().contains("fn ok() {}"));
950 }
951
952 #[test]
953 fn load_profile_skips_generic_stdio() {
954 let _guard = ENV_LOCK.lock().unwrap();
955 assert!(load_profile("generic-stdio").unwrap().is_none());
956 assert!(load_profile("no-such-profile").unwrap().is_none());
957 }
958
959 #[test]
960 fn open_profile_adapter_reads_id_and_workdir() {
961 let _guard = ENV_LOCK.lock().unwrap();
962 let (id, adapter) = open_profile_adapter("claude-code:/var/ws")
963 .unwrap()
964 .expect("bundled");
965 assert_eq!(id, "claude-code");
966 assert_eq!(adapter.agent_id().0, "claude-code");
967 assert!(open_profile_adapter("generic-stdio:echo")
968 .unwrap()
969 .is_none());
970 assert!(open_profile_adapter("slim:peer").unwrap().is_none());
971 assert!(open_profile_adapter("no-such-profile").unwrap().is_none());
972 }
973
974 #[test]
975 fn load_profile_file_reads_override_json() {
976 let dir = tempfile::tempdir().unwrap();
977 let path = dir.path().join("gemini.json");
978 std::fs::write(
979 &path,
980 r#"{
981 "id": "gemini",
982 "bin": "gemini",
983 "execute": { "args": ["{prompt}"] }
984 }"#,
985 )
986 .unwrap();
987 let loaded = load_profile_file(&path).unwrap().expect("override profile");
988 assert_eq!(loaded.id, "gemini");
989 assert_eq!(loaded.bin, "gemini");
990 assert_eq!(
991 render_argv(&loaded, "/ws", "hi", None, None, true),
992 vec!["hi"]
993 );
994 }
995
996 #[test]
997 fn profile_adapter_constructs() {
998 let adapter = ProfileAdapter::new(load_bundled("claude-code"), "/tmp");
999 assert_eq!(adapter.agent_id().0, "claude-code");
1000 }
1001
1002 fn echo_profile(extra: &str) -> CliProfile {
1003 parse_profile(&format!(
1004 r#"{{
1005 "id": "echo",
1006 "bin": "echo",
1007 "current_dir_workdir": false,
1008 "execute": {{ "args": ["{{prompt}}"] }},
1009 "result": {{ "kind": "stdout" }}
1010 {extra}
1011 }}"#
1012 ))
1013 .unwrap()
1014 }
1015
1016 fn write_session_retry_bin() -> (tempfile::TempDir, PathBuf) {
1017 let dir = tempfile::tempdir().unwrap();
1018 #[cfg(windows)]
1019 {
1020 let path = dir.path().join("retry.cmd");
1021 std::fs::write(
1022 &path,
1023 r#"@echo off
1024echo %* | findstr /C:"--session-id" >nul
1025if not errorlevel 1 (
1026 echo already in use 1>&2
1027 exit /b 1
1028)
1029echo {"result":"ok","session_id":"sid-9"}
1030"#,
1031 )
1032 .unwrap();
1033 (dir, path)
1034 }
1035 #[cfg(not(windows))]
1036 {
1037 let path = dir.path().join("retry.sh");
1038 std::fs::write(
1039 &path,
1040 r#"#!/bin/sh
1041has_session=0
1042for a in "$@"; do
1043 [ "$a" = "--session-id" ] && has_session=1
1044done
1045if [ "$has_session" = 1 ]; then
1046 echo "already in use" >&2
1047 exit 1
1048fi
1049echo '{"result":"ok","session_id":"sid-9"}'
1050"#,
1051 )
1052 .unwrap();
1053 use std::os::unix::fs::PermissionsExt;
1054 let mut perms = std::fs::metadata(&path).unwrap().permissions();
1055 perms.set_mode(0o755);
1056 std::fs::set_permissions(&path, perms).unwrap();
1057 (dir, path)
1058 }
1059 }
1060
1061 #[test]
1062 fn profile_execute_prompt_runs_echo() {
1063 let adapter = ProfileAdapter::new(echo_profile(""), std::env::temp_dir());
1064 let out = adapter.execute_prompt("hello-profile").unwrap();
1065 assert!(
1066 out.contains("hello-profile"),
1067 "execute output should echo the prompt"
1068 );
1069 adapter.kill_in_flight();
1070 }
1071
1072 #[test]
1073 fn snapshot_and_inject_drive_run() {
1074 let adapter = ProfileAdapter::new(
1075 echo_profile(r#", "system_flag": "--system-prompt""#),
1076 std::env::temp_dir(),
1077 );
1078 let snap = adapter.snapshot_context().unwrap();
1079 assert_eq!(snap.source_agent, "echo");
1080 assert!(!snap.artifacts.is_empty());
1081 assert!(snap.code_context.project_root.is_some());
1082
1083 let mut ctx = ContextPacket::new("peer");
1084 ctx.conversation.push(ConversationMessage {
1085 role: "user".to_string(),
1086 content: "hi".to_string(),
1087 });
1088 ctx.code_context.files.push(crate::context::FileSnapshot {
1089 path: "src/lib.rs".to_string(),
1090 content: "fn x() {}".to_string(),
1091 });
1092 ctx.code_context.git_diff = Some("-a\n+b".to_string());
1093 ctx.artifacts.push(ArtifactPayload {
1094 name: "note.md".to_string(),
1095 content: "n".to_string(),
1096 media_type: "text/markdown".to_string(),
1097 });
1098 adapter.inject_context(&ctx).unwrap();
1099 }
1100
1101 #[test]
1102 fn inject_without_system_flag_prepends_context() {
1103 let adapter = ProfileAdapter::new(echo_profile(""), std::env::temp_dir());
1104 let ctx = ContextPacket::new("peer");
1105 adapter.inject_context(&ctx).unwrap();
1106 }
1107
1108 #[test]
1109 fn run_pins_tmpdir_and_nulls_stdin() {
1110 let profile = parse_profile(
1111 r#"{
1112 "id": "echo",
1113 "bin": "echo",
1114 "pin_tmpdir": true,
1115 "stdin_null": true,
1116 "current_dir_workdir": true,
1117 "env": { "AGENTBRIDGE_TEST_TMP": "{workdir}" },
1118 "workdir_flags": ["-n"],
1119 "execute": { "args": ["{workdir_flags}", "{prompt}"] },
1120 "result": { "kind": "stdout" }
1121 }"#,
1122 )
1123 .unwrap();
1124 let dir = tempfile::tempdir().unwrap();
1125 let adapter = ProfileAdapter::new(profile, dir.path());
1126 let out = adapter.execute_prompt("pinned").unwrap();
1127 assert!(out.contains("pinned"), "execute output should echo the prompt");
1128 }
1129
1130 #[test]
1131 fn execute_missing_bin_is_subprocess_error() {
1132 let profile = parse_profile(
1133 r#"{
1134 "id": "missing",
1135 "bin": "agentbridge-profile-bin-does-not-exist",
1136 "execute": { "args": ["{prompt}"] }
1137 }"#,
1138 )
1139 .unwrap();
1140 let adapter = ProfileAdapter::new(profile, std::env::temp_dir());
1141 assert!(adapter.execute_prompt("x").is_err());
1142 }
1143
1144 #[test]
1145 fn execute_failed_command_with_empty_stdout_errors() {
1146 let profile = parse_profile(
1147 r#"{
1148 "id": "false",
1149 "bin": "false",
1150 "execute": { "args": [] }
1151 }"#,
1152 )
1153 .unwrap();
1154 let adapter = ProfileAdapter::new(profile, std::env::temp_dir());
1155 let err = adapter.execute_prompt("x").unwrap_err();
1156 assert!(err.to_string().contains("exited"), "{err}");
1157 }
1158
1159 #[test]
1160 fn session_is_stored_and_retried_on_stderr_needle() {
1161 let (_dir, script) = write_session_retry_bin();
1162 let profile = parse_profile(
1163 &serde_json::json!({
1164 "id": "session",
1165 "bin": script.to_string_lossy(),
1166 "current_dir_workdir": false,
1167 "execute": { "args": ["{session}", "{prompt}"] },
1168 "session": {
1169 "json_field": "session_id",
1170 "flag": "--session-id",
1171 "retry_stderr_contains": "already in use"
1172 },
1173 "result": { "kind": "json_field", "field": "result" }
1174 })
1175 .to_string(),
1176 )
1177 .unwrap();
1178 let adapter = ProfileAdapter::new(profile, std::env::temp_dir());
1179 assert_eq!(adapter.execute_prompt("one").unwrap(), "ok");
1180 assert_eq!(adapter.session_id().unwrap().as_deref(), Some("sid-9"));
1181 assert_eq!(adapter.execute_prompt("two").unwrap(), "ok");
1182 adapter.clear_session();
1183 assert!(adapter.session_id().unwrap().is_none());
1184 }
1185
1186 #[test]
1187 fn execute_splits_large_prompt_when_profile_allows() {
1188 let profile = parse_profile(
1189 r#"{
1190 "id": "echo",
1191 "bin": "echo",
1192 "current_dir_workdir": false,
1193 "split_large_prompt": true,
1194 "system_flag": "--system-prompt",
1195 "execute": { "args": ["{system}", "{prompt}"] },
1196 "result": { "kind": "stdout" }
1197 }"#,
1198 )
1199 .unwrap();
1200 let adapter = ProfileAdapter::new(profile, std::env::temp_dir());
1201 let prompt = format!("context\n\n{}", "q".repeat(600));
1202 let out = adapter.execute_prompt(&prompt).unwrap();
1203 assert!(out.contains('q'), "split execute should keep the question");
1204 }
1205
1206 #[test]
1207 fn extract_result_edge_cases() {
1208 let mut p = load_bundled("claude-code");
1209 p.result.field = None;
1210 assert!(extract_result(&p, r#"{"result":"x"}"#).is_err());
1211 p.result.field = Some("result".to_string());
1212 assert!(extract_result(&p, "not-json").is_err());
1213 let err = extract_result(&p, r#"{"is_error":true}"#).unwrap_err();
1214 assert!(err.to_string().contains("cli reported an error"));
1215 assert_eq!(extract_result(&p, r#"{}"#).unwrap(), "");
1216
1217 let mut copilot = load_bundled("copilot");
1218 copilot.result.prefix = None;
1219 assert_eq!(
1220 extract_result(&copilot, "keep\nChanges 1\n").unwrap(),
1221 "keep"
1222 );
1223
1224 let mut codex = load_bundled("codex");
1225 codex.result.line = None;
1226 assert_eq!(extract_result(&codex, "only body").unwrap(), "only body");
1227 assert!(extract_result(&codex, "head\n--------\ntail")
1228 .unwrap()
1229 .contains("tail"));
1230 }
1231
1232 #[test]
1233 fn split_large_prompt_uses_blank_line_and_keeps_system() {
1234 let prompt = format!("{}\n\n{}", "c".repeat(200), "q".repeat(400));
1235 let (sys, rest) = split_large_prompt(&prompt, None, true);
1236 assert!(sys.unwrap().contains('c'));
1237 assert!(rest.contains('q'));
1238 let (sys, rest) = split_large_prompt("short", Some("keep"), true);
1239 assert_eq!(sys.as_deref(), Some("keep"));
1240 assert_eq!(rest, "short");
1241 }
1242
1243 #[test]
1244 fn render_argv_expands_extra_args_and_inline_prompt() {
1245 let p = parse_profile(
1246 r#"{
1247 "id": "x",
1248 "bin": "x",
1249 "extra_args_env": "AGENTBRIDGE_TEST_EXTRA_ARGS",
1250 "execute": { "args": ["pre-{prompt}", "{extra_args}", "{prompt}"] }
1251 }"#,
1252 )
1253 .unwrap();
1254 let _guard = ENV_LOCK.lock().unwrap();
1255 std::env::set_var("AGENTBRIDGE_TEST_EXTRA_ARGS", "--flag value");
1256 let argv = render_argv(&p, "/ws", "hi", None, None, true);
1257 std::env::remove_var("AGENTBRIDGE_TEST_EXTRA_ARGS");
1258 assert_eq!(argv, vec!["pre-hi", "--flag", "value", "hi"]);
1259 }
1260
1261 #[test]
1262 fn load_profile_reads_override_dir() {
1263 let _guard = ENV_LOCK.lock().unwrap();
1264 let dir = tempfile::tempdir().unwrap();
1265 std::fs::write(
1266 dir.path().join("override-probe.json"),
1267 r#"{"id":"override-probe","bin":"probe","execute":{"args":["{prompt}"]}}"#,
1268 )
1269 .unwrap();
1270 let prev = std::env::var("AGENTBRIDGE_PROFILES_DIR").ok();
1271 std::env::set_var("AGENTBRIDGE_PROFILES_DIR", dir.path());
1272 let loaded = load_profile("override-probe").unwrap().expect("override");
1273 match prev {
1274 Some(v) => std::env::set_var("AGENTBRIDGE_PROFILES_DIR", v),
1275 None => std::env::remove_var("AGENTBRIDGE_PROFILES_DIR"),
1276 }
1277 assert_eq!(loaded.id, "override-probe");
1278 assert!(load_profile_file(&dir.path().join("missing.json"))
1279 .unwrap()
1280 .is_none());
1281 assert!(parse_profile("{").is_err());
1282 assert!(open_profile_adapter("").unwrap().is_none());
1283 assert!(open_profile_adapter("generic-stdio").unwrap().is_none());
1284 }
1285
1286 #[test]
1287 fn result_spec_and_kind_defaults() {
1288 assert!(matches!(ResultSpec::default().kind, ResultKind::Stdout));
1289 let stdout = parse_profile(r#"{"id":"t","bin":"t","execute":{"args":[]}}"#).unwrap();
1290 assert!(matches!(stdout.result.kind, ResultKind::Stdout));
1291 }
1292}