Skip to main content

agentbridge/adapters/
profile.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Profile-driven [`CliAdapter`]: argv/env come from JSON, not a new `*.rs`.
5
6use 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
30/// IDs shipped under `crates/agentbridge/profiles/`.
31pub 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
54/// Load `{id}.json` from `AGENTBRIDGE_PROFILES_DIR`, then a bundled profile.
55pub 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
81/// `{id}` or `{id}:{workdir}` → [`ProfileAdapter`] when a profile exists.
82pub 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    /// Claude-only: move a long execute prompt into `--system-prompt`.
129    /// Copilot/cursor treat that flag as missing or as a file path.
130    #[serde(default)]
131    pub split_large_prompt: bool,
132    #[serde(default)]
133    pub extra_args_env: Option<String>,
134    /// When `{env_flags}` appears in `execute.args`, each set host env var
135    /// becomes `flag value` (omitted when unset or empty).
136    #[serde(default)]
137    pub env_flags: Vec<EnvFlag>,
138    /// Host env var names copied onto the child (values never logged).
139    #[serde(default)]
140    pub pass_env: Vec<String>,
141    /// Copy every host env var whose name starts with one of these prefixes.
142    #[serde(default)]
143    pub pass_env_prefixes: Vec<String>,
144    /// Copy every host env var whose name ends with one of these suffixes.
145    #[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
211/// [`CliAdapter`] driven by a [`CliProfile`].
212pub 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
348/// Host env vars this profile should copy onto the child. Values are not logged.
349fn 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
411/// Render argv from a profile. `{prompt}` / `{workdir}` / `{system}` /
412/// `{session}` / `{workdir_flags}` / `{extra_args}` / `{env_flags}` are expanded.
413pub 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    /// `load_profile` / `render_argv` read process env. Parallel tests must
646    /// not interleave `set_var` with those lookups.
647    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        // Directory named *.json: read_to_string fails; skip that entry.
823        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}