Skip to main content

csd/
backend.rs

1//! Backend abstraction. `csd` drives `claude` today and is designed to drive `codex` later
2//! (decision #49: keep the dispatch backend swappable so a forced move off the interactive REPL is
3//! a config flip, not a rewrite). Everything release-dependent — the spawn command and the
4//! capture-pane gate markers — lives behind this trait in ONE place (PoC gotcha #4).
5
6use crate::error::{Error, Result};
7
8/// Options needed to build a spawn command. cwd is handled by tmux (`-c`), not the command itself.
9#[derive(Debug, Clone)]
10pub struct SpawnOpts {
11    pub session_id: String,
12    pub permission_mode: Option<String>,
13    /// Pass claude's standalone `--dangerously-skip-permissions` (the `--yolo` posture).
14    pub dangerous: bool,
15}
16
17/// A driveable agent CLI.
18pub trait Backend {
19    /// Stable identifier persisted in the sidecar (`claude`, `codex`).
20    fn name(&self) -> &'static str;
21
22    /// The shell command tmux execs in the new session (PoC §2.1 for claude).
23    fn spawn_command(&self, opts: &SpawnOpts) -> String;
24
25    /// capture-pane substrings that indicate a plan-approval gate is on screen (PoC §3.3).
26    fn plan_markers(&self) -> &'static [&'static str];
27
28    /// capture-pane substrings that indicate a tool-permission gate is on screen.
29    fn permission_markers(&self) -> &'static [&'static str];
30
31    /// capture-pane substrings for the one-time "trust this folder?" startup gate that `claude`
32    /// shows on a directory it has not seen before. Blocks the session until answered.
33    fn trust_markers(&self) -> &'static [&'static str];
34
35    /// Backend versions the gate markers were live-verified on. Exact pins only — patch releases
36    /// can change the TUI wording the markers match against.
37    fn verified_versions(&self) -> &'static [&'static str];
38
39    /// Version of the backend CLI currently on PATH, or `None` if it can't be determined. Probed
40    /// once at spawn (the binary is pinned for the session's lifetime) — note this is the CLI
41    /// `csd` resolves, which tmux normally execs too, but a divergent tmux environment could
42    /// technically run a different copy.
43    fn installed_version(&self) -> Option<String>;
44}
45
46/// Warning to attach to spawn/state/ps output when the session's backend version is not in the
47/// marker-verified set, so a backend upgrade turns gate-detection drift from a silent stall into
48/// a machine-readable signal. `CSD_VERIFIED_VERSIONS` (comma-separated) extends the built-in set
49/// without a csd release once the user re-verifies the markers by hand.
50pub fn marker_warning(backend: &dyn Backend, installed: Option<&str>) -> Option<String> {
51    let extra = std::env::var("CSD_VERIFIED_VERSIONS").unwrap_or_default();
52    marker_warning_with(backend.name(), backend.verified_versions(), installed, &extra)
53}
54
55fn marker_warning_with(backend_name: &str, verified: &[&str], installed: Option<&str>, extra: &str) -> Option<String> {
56    let Some(installed) = installed else {
57        return Some(format!(
58            "could not determine the installed {backend_name} version; gate markers were \
59             verified on {verified:?} and may not match — gate detection \
60             (plan/permission/trust) can silently miss prompts"
61        ));
62    };
63    let extra_match = extra.split(',').map(str::trim).any(|v| !v.is_empty() && v == installed);
64    if verified.contains(&installed) || extra_match {
65        return None;
66    }
67    Some(format!(
68        "{backend_name} {installed} is not marker-verified (verified: {verified:?}); gate \
69         detection (plan/permission/trust) may silently miss prompts. If gates still work on \
70         {installed}, set CSD_VERIFIED_VERSIONS={installed} to silence this warning"
71    ))
72}
73
74/// First whitespace token of `<cli> --version` stdout, if it looks like a version
75/// (`"2.1.173 (Claude Code)"` → `"2.1.173"`).
76fn parse_version_output(stdout: &str) -> Option<String> {
77    let token = stdout.split_whitespace().next()?;
78    token
79        .starts_with(|c: char| c.is_ascii_digit())
80        .then(|| token.to_string())
81}
82
83/// Permission modes accepted by `claude --permission-mode` (PoC §2.1).
84pub const CLAUDE_PERMISSION_MODES: &[&str] =
85    &["plan", "acceptEdits", "auto", "bypassPermissions", "default", "dontAsk"];
86
87pub struct Claude;
88
89impl Backend for Claude {
90    fn name(&self) -> &'static str {
91        "claude"
92    }
93
94    fn spawn_command(&self, opts: &SpawnOpts) -> String {
95        // Strip the nested-CLI markers so the child doesn't think it runs inside another Claude
96        // session, then pin the session id so the transcript path is known up front.
97        let mut cmd = format!(
98            "env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT claude --session-id {}",
99            opts.session_id
100        );
101        if let Some(mode) = &opts.permission_mode {
102            cmd.push_str(" --permission-mode ");
103            cmd.push_str(mode);
104        }
105        if opts.dangerous {
106            cmd.push_str(" --dangerously-skip-permissions");
107        }
108        cmd
109    }
110
111    fn plan_markers(&self) -> &'static [&'static str] {
112        &["Here is Claude's plan", "Would you like to proceed?"]
113    }
114
115    fn permission_markers(&self) -> &'static [&'static str] {
116        // "Do you want to proceed?" verified live on v2.1.158 (Bash/tool gate) — distinct from the
117        // plan gate's "Would you like to proceed?". The edit-gate variant is the historical wording,
118        // kept as a fallback (couldn't be exercised here — Write/Edit were allowlisted).
119        &["Do you want to proceed?", "Do you want to make this edit?"]
120    }
121
122    fn trust_markers(&self) -> &'static [&'static str] {
123        &[
124            "Is this a project you created or one you trust?",
125            "Yes, I trust this folder",
126        ]
127    }
128
129    fn verified_versions(&self) -> &'static [&'static str] {
130        // Extend only after a live `scripts/e2e.sh` run proves all three gates on that release.
131        &["2.1.158", "2.1.173"]
132    }
133
134    fn installed_version(&self) -> Option<String> {
135        let output = std::process::Command::new("claude").arg("--version").output().ok()?;
136        if !output.status.success() {
137            return None;
138        }
139        parse_version_output(&String::from_utf8_lossy(&output.stdout))
140    }
141}
142
143/// Resolve a backend by name.
144pub fn resolve(name: &str) -> Result<Box<dyn Backend>> {
145    match name {
146        "claude" => Ok(Box::new(Claude)),
147        other => Err(Error::UnknownBackend(other.to_string())),
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn parses_claude_version_output() {
157        assert_eq!(
158            parse_version_output("2.1.173 (Claude Code)\n"),
159            Some("2.1.173".to_string())
160        );
161        assert_eq!(parse_version_output("2.1.158"), Some("2.1.158".to_string()));
162        assert_eq!(parse_version_output("Claude Code 2.1.173"), None);
163        assert_eq!(parse_version_output(""), None);
164        assert_eq!(parse_version_output("   \n"), None);
165    }
166
167    #[test]
168    fn no_warning_on_verified_version() {
169        assert_eq!(marker_warning_with("claude", &["2.1.158"], Some("2.1.158"), ""), None);
170    }
171
172    #[test]
173    fn warns_on_unverified_version() {
174        let warning = marker_warning_with("claude", &["2.1.158"], Some("2.1.173"), "").unwrap();
175        assert!(warning.contains("2.1.173"));
176        assert!(warning.contains("2.1.158"));
177        assert!(warning.contains("CSD_VERIFIED_VERSIONS=2.1.173"));
178    }
179
180    #[test]
181    fn warns_on_unknown_version() {
182        let warning = marker_warning_with("claude", &["2.1.158"], None, "").unwrap();
183        assert!(warning.contains("could not determine"));
184    }
185
186    #[test]
187    fn env_override_extends_verified_set() {
188        // Entries are trimmed, so a human-typed "a, b" list matches.
189        let extra = "2.1.170, 2.1.173";
190        assert_eq!(
191            marker_warning_with("claude", &["2.1.158"], Some("2.1.173"), extra),
192            None
193        );
194        assert_eq!(
195            marker_warning_with("claude", &["2.1.158"], Some("2.1.170"), extra),
196            None
197        );
198        assert!(marker_warning_with("claude", &["2.1.158"], Some("2.1.171"), extra).is_some());
199        // An empty entry never matches (e.g. trailing comma, or empty env var).
200        assert!(marker_warning_with("claude", &["2.1.158"], Some(""), ",").is_some());
201    }
202}