Skip to main content

devflow_core/agents/
pi.rs

1//! Pi coding-agent harness adapter.
2//!
3//! Launches `pi -p "<prompt>"` in non-interactive print mode. The prompt is
4//! POSITIONAL — `-p` is a boolean flag (not the prompt carrier, and not stdin
5//! transport like Claude's stream-json). `--no-approve` is always passed:
6//! `--approve` trusts project-local extensions/skills/settings that execute
7//! UNSANDBOXED (Pi ships no sandbox), and a fresh per-phase worktree establishes
8//! no trust decision — that is a security boundary, not a convenience.
9//!
10//! No `--model`/`--provider` wiring in the launch argv: model/provider
11//! selection is Pi's own. The `health` check probes the provider a launch will
12//! actually use — `settings.json`'s `defaultProvider` (this machine:
13//! `litellm`), falling back to Pi's built-in `--provider` default (`google`)
14//! when unset — never a hardcoded provider and never "any ready provider in
15//! `models.json`".
16//!
17//! Note: Pi has NO `--` end-of-options convention — passing `--` is rejected as
18//! an unknown option, so the prompt is passed raw. DevFlow's own stage prompts
19//! never begin with `-`, so the leading-dash hazard (a markdown `- [ ]` list) is
20//! a Phase 37 concern, not something a `--` can guard here.
21
22use super::AgentDriver;
23use crate::phase_id::PhaseId;
24use std::path::PathBuf;
25
26/// The modular driver for Pi (37-03): print-mode `-p` launch, `pi auth check`
27/// health, and the de-Claude-ified workflow-reference prompt. NO JSON unwrapper
28/// or monitor/`CloseRule` integration here — that is 37.1/38 (CONTEXT D-04).
29pub struct PiDriver;
30
31impl AgentDriver for PiDriver {
32    fn name(&self) -> &'static str {
33        "Pi"
34    }
35
36    fn render_prompt(&self, intent: &crate::prompt::StageIntent) -> String {
37        crate::prompt::render_workflow_style(intent, &self.workflow_root())
38    }
39
40    /// Pi installs its GSD workflows under `~/.pi/agent/gsd-core/workflows/`,
41    /// NOT the Codex install the default points at (code-review finding #5).
42    fn workflow_root(&self) -> String {
43        "$HOME/.pi/agent/gsd-core/workflows".to_string()
44    }
45
46    /// Pi declares subagent-dispatch capability when a subagent extension is
47    /// installed in the user profile (see [`pi_subagent_dispatch_available`]).
48    fn capabilities(&self) -> super::DriverCapabilities {
49        super::DriverCapabilities {
50            subagent_dispatch: pi_subagent_dispatch_available(),
51        }
52    }
53
54    fn build_command(
55        &self,
56        _phase: PhaseId,
57        prompt: &str,
58        _extra_writable_roots: &[PathBuf],
59    ) -> (&'static str, Vec<String>) {
60        (
61            "pi",
62            vec!["-p".into(), "--no-approve".into(), prompt.to_string()],
63        )
64    }
65
66    fn health(&self, _state: &crate::state::State) -> Result<(), String> {
67        // Credential readiness via `pi auth check` — Pi's own verb — rather
68        // than env-var sniffing (see `classify_auth_check`). `--no-refresh`
69        // prevents a stalled OAuth token refresh from hanging preflight
70        // (code-review finding #8).
71        //
72        // Probe the provider a launch will ACTUALLY use: `settings.json`'s
73        // `defaultProvider`. `build_command` passes no `--provider`, so the run
74        // selects the default — probing "any ready provider in `models.json`"
75        // false-greens a credential the run never touches, and refusing when
76        // `models.json` is absent false-rejects every standard install
77        // (built-in providers are credentialled from env vars / `auth.json`,
78        // never listed in `models.json`). Fall back to Pi's `--provider`
79        // default (`google`) when `settings.json` carries no `defaultProvider`.
80        let provider = configured_pi_provider().unwrap_or_else(|| "google".to_string());
81        let output = std::process::Command::new("pi")
82            .args([
83                "auth",
84                "check",
85                "--json",
86                "--provider",
87                &provider,
88                "--no-refresh",
89            ])
90            .output()
91            .map_err(|e| format!("could not run `pi auth check`: {e}"))?;
92        classify_auth_check(&String::from_utf8_lossy(&output.stdout), output.status.success())
93            .map_err(|reason| {
94                format!(
95                    "{reason} for provider `{provider}` — `pi auth check --json --provider {provider}` reports it not ready"
96                )
97            })
98    }
99}
100
101/// Map `pi auth check --json` output to a readiness verdict. Split out so the
102/// classification is unit-testable without spawning a process. Parses the JSON
103/// rather than substring-matching, so whitespace formatting can't defeat it.
104fn classify_auth_check(stdout: &str, success: bool) -> Result<(), String> {
105    // A successful exit code alone is not enough: a credentialless check still
106    // prints `{"status":"not_ready",...}`. Require the `ready` status AND exit 0.
107    let ready = success
108        && serde_json::from_str::<serde_json::Value>(stdout)
109            .ok()
110            .and_then(|v| v.get("status").and_then(|s| s.as_str()).map(str::to_owned))
111            .is_some_and(|s| s == "ready");
112    if ready {
113        Ok(())
114    } else {
115        Err("no provider credential resolves".to_string())
116    }
117}
118
119/// The provider a `pi -p` launch actually uses: `settings.json`'s
120/// `defaultProvider`. `None` when the file is missing/unparseable or carries no
121/// `defaultProvider` — the caller falls back to Pi's built-in `--provider`
122/// default (`google`, per `pi --help`).
123///
124/// `models.json` is NOT the provider configuration: it is the custom-model
125/// CATALOG (LiteLLM/vLLM endpoints). A standard Pi install (built-in provider
126/// credentialled from `ANTHROPIC_API_KEY`/`OPENAI_API_KEY`/OAuth in
127/// `auth.json`) has no `models.json` at all — reading it here both
128/// hard-refuses default installs and false-greens a provider the run never
129/// selects (phase-39 code review, finding 1).
130fn configured_pi_provider() -> Option<String> {
131    let base = pi_config_dir()?;
132    let path = base.join("settings.json");
133    let text = std::fs::read_to_string(&path).ok()?;
134    let json = serde_json::from_str::<serde_json::Value>(&text).ok()?;
135    json.get("defaultProvider")
136        .and_then(serde_json::Value::as_str)
137        .map(str::to_owned)
138}
139
140/// Resolve Pi's config dir: `PI_CODING_AGENT_DIR` if set, else `~/.pi/agent`.
141/// A leading `~` is expanded the way Pi's own `getAgentDir` does, so a tilde
142/// value resolves instead of yielding an unreadable literal path (phase-39
143/// code review, claude LOW #7).
144fn pi_config_dir() -> Option<std::path::PathBuf> {
145    let raw = std::env::var_os("PI_CODING_AGENT_DIR")
146        .map(std::path::PathBuf::from)
147        .or_else(|| {
148            std::env::var_os("HOME")
149                .map(|home| std::path::PathBuf::from(home).join(".pi").join("agent"))
150        })?;
151    let raw_str = raw.to_string_lossy();
152    if let Some(rest) = raw_str.strip_prefix("~/")
153        && let Some(home) = std::env::var_os("HOME")
154    {
155        return Some(std::path::PathBuf::from(home).join(rest));
156    }
157    Some(raw)
158}
159
160/// Whether Pi's profile has the vetted `@bacnh85/pi-subagent` dispatch
161/// extension installed. Probed via `pi list --no-approve` — Pi exposes no
162/// `pi tools` command, so the installed-package name is the only cheap,
163/// non-interactive signal. The match is the specific vetted package, NOT a
164/// bare `*subagent*` substring: other `subagent`-named packages (`@mystilleef`,
165/// `@dreki-gg`, `@smoose`) are unsafe/deferred and must not be reported
166/// available (phase-39 code review, finding 2).
167///
168/// **Honest limit:** name-based, not a tool-registry proof — it does not
169/// confirm the extension registers a working dispatch tool. Any probe failure
170/// returns `false`, so an undetectable profile fails closed to the baseline
171/// single-agent path rather than refusing a working run.
172fn pi_subagent_dispatch_available() -> bool {
173    let Ok(output) = std::process::Command::new("pi")
174        .args(["list", "--no-approve"])
175        .output()
176    else {
177        return false;
178    };
179    output.status.success()
180        && String::from_utf8_lossy(&output.stdout)
181            .to_lowercase()
182            .contains("@bacnh85/pi-subagent")
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::mode::Mode;
189    use crate::state::{AgentKind, State};
190    use std::sync::Mutex;
191
192    /// Serializes tests that mutate the process-global `PATH` (`set_var` is
193    /// process-wide; `cargo test` runs tests in parallel).
194    static ENV_MUTEX: Mutex<()> = Mutex::new(());
195
196    #[test]
197    fn exec_command_shape() {
198        let (program, args) = PiDriver.build_command(PhaseId::new(1), "do the thing", &[]);
199        assert_eq!(program, "pi");
200        assert_eq!(args, vec!["-p", "--no-approve", "do the thing"]);
201    }
202
203    #[test]
204    fn classify_auth_check_rejects_not_ready() {
205        assert!(classify_auth_check(
206            r#"{"status":"not_ready","provider":"google","reason":"credentials_not_configured"}"#,
207            false,
208        )
209        .is_err());
210    }
211
212    #[test]
213    fn classify_auth_check_accepts_ready() {
214        assert!(
215            classify_auth_check(
216                r#"{"status":"ready","provider":"google","authType":"api_key"}"#,
217                true,
218            )
219            .is_ok()
220        );
221    }
222
223    #[test]
224    fn classify_auth_check_tolerates_formatted_json() {
225        // Pretty-printed / whitespace-padded JSON must not defeat the parse.
226        assert!(classify_auth_check("{\n  \"status\": \"ready\"\n}", true).is_ok());
227    }
228
229    #[test]
230    fn classify_auth_check_rejects_ready_text_with_failed_exit() {
231        // A failed exit must not be read as ready even if the body says "ready".
232        assert!(classify_auth_check(r#"{"status":"ready"}"#, false).is_err());
233    }
234
235    /// A `State` value for `preflight`, which ignores it (`_state`) —
236    /// constructed only to satisfy the trait signature.
237    fn test_state() -> State {
238        State::new(
239            PhaseId::new(36),
240            AgentKind::Pi,
241            Mode::Auto,
242            std::path::PathBuf::from("/tmp"),
243        )
244    }
245
246    /// Writes an executable `pi` stub into a fresh tempdir. The stub records its
247    /// arguments (`"$@"`, one per line) to `args.txt` in the same dir, prints
248    /// `body` to stdout, and exits with `exit_code`. The returned tempdir is the
249    /// only entry the test puts on `PATH`, so the operator's live `pi` is never
250    /// consulted.
251    fn stub_pi_on_path(body: &str, exit_code: i32) -> tempfile::TempDir {
252        let dir = tempfile::tempdir().expect("create stub dir");
253        let stub = dir.path().join("pi");
254        let script = format!(
255            "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{args}'\necho '{body}'\nexit {exit_code}\n",
256            args = dir.path().join("args.txt").display(),
257            body = body,
258            exit_code = exit_code,
259        );
260        std::fs::write(&stub, script).expect("write pi stub");
261        #[cfg(unix)]
262        {
263            use std::os::unix::fs::PermissionsExt;
264            let mut perms = std::fs::metadata(&stub).expect("stat stub").permissions();
265            perms.set_mode(0o755);
266            std::fs::set_permissions(&stub, perms).expect("chmod +x stub");
267        }
268        dir
269    }
270
271    /// Like [`stub_pi_on_path`], plus a `settings.json` naming the provider a
272    /// launch would use — so the health check probes the configured provider
273    /// instead of a hardcoded `google`.
274    fn stub_pi_with_provider(body: &str, exit_code: i32, provider: &str) -> tempfile::TempDir {
275        let dir = stub_pi_on_path(body, exit_code);
276        std::fs::write(
277            dir.path().join("settings.json"),
278            format!(r#"{{"defaultProvider":"{provider}"}}"#),
279        )
280        .expect("write settings.json");
281        dir
282    }
283
284    /// RAII guard that replaces `PATH` with `path` and restores the previous
285    /// value on `Drop` — including the panic path, so a failing test never
286    /// hands the next test a mutated `PATH`.
287    struct PathGuard {
288        original: Option<std::ffi::OsString>,
289    }
290
291    impl PathGuard {
292        fn set(path: &std::path::Path) -> Self {
293            let original = std::env::var_os("PATH");
294            // SAFETY: held under ENV_MUTEX; no other thread reads/writes PATH.
295            unsafe { std::env::set_var("PATH", path) };
296            Self { original }
297        }
298    }
299
300    impl Drop for PathGuard {
301        fn drop(&mut self) {
302            match &self.original {
303                Some(prev) => unsafe { std::env::set_var("PATH", prev) },
304                None => unsafe { std::env::remove_var("PATH") },
305            }
306        }
307    }
308
309    /// RAII guard that sets an environment variable to `value` and restores it
310    /// on `Drop` — the same panic-safe pattern as [`PathGuard`].
311    struct EnvGuard {
312        name: &'static str,
313        original: Option<std::ffi::OsString>,
314    }
315
316    impl EnvGuard {
317        fn set(name: &'static str, value: &std::path::Path) -> Self {
318            let original = std::env::var_os(name);
319            // SAFETY: held under ENV_MUTEX; no other thread reads/writes this var.
320            unsafe { std::env::set_var(name, value) };
321            Self { name, original }
322        }
323    }
324
325    impl Drop for EnvGuard {
326        fn drop(&mut self) {
327            match &self.original {
328                Some(prev) => unsafe { std::env::set_var(self.name, prev) },
329                None => unsafe { std::env::remove_var(self.name) },
330            }
331        }
332    }
333
334    /// The shell-out must actually spawn `pi auth check --json --provider
335    /// <configured>` — not just classify a pre-parsed string. The stub records
336    /// its argv, proving the wiring end to end, and that the provider is read
337    /// from `settings.json` (`litellm`), not hardcoded.
338    #[test]
339    fn preflight_invokes_pi_auth_check_and_accepts_ready() {
340        let _guard = ENV_MUTEX.lock().unwrap();
341        let stub_dir = stub_pi_with_provider(r#"{"status":"ready"}"#, 0, "litellm");
342        let _path = PathGuard::set(stub_dir.path());
343        let _cfgdir = EnvGuard::set("PI_CODING_AGENT_DIR", stub_dir.path());
344
345        PiDriver
346            .health(&test_state())
347            .expect("a `ready` stub should pass preflight");
348
349        let argv = std::fs::read_to_string(stub_dir.path().join("args.txt")).unwrap();
350        assert_eq!(
351            argv,
352            "auth\ncheck\n--json\n--provider\nlitellm\n--no-refresh\n"
353        );
354    }
355
356    /// The negative control AC #1 requires: a `pi` binary that reports
357    /// `not_ready` must yield the credentialless `Err`, proving the predicate
358    /// tests credential readiness, not env-var presence.
359    #[test]
360    fn preflight_reports_credentialless_when_auth_check_says_not_ready() {
361        let _guard = ENV_MUTEX.lock().unwrap();
362        let stub_dir = stub_pi_with_provider(
363            r#"{"status":"not_ready","reason":"credentials_not_configured"}"#,
364            0,
365            "litellm",
366        );
367        let _path = PathGuard::set(stub_dir.path());
368        let _cfgdir = EnvGuard::set("PI_CODING_AGENT_DIR", stub_dir.path());
369
370        let err = PiDriver
371            .health(&test_state())
372            .expect_err("a `not_ready` stub should fail preflight");
373        assert!(
374            err.contains("no provider credential resolves"),
375            "unexpected error: {err}"
376        );
377    }
378
379    /// The exit code must be honored through the shell-out path, not just the
380    /// pure classifier: a `ready` body with a failed exit is still a failure.
381    #[test]
382    fn preflight_rejects_ready_body_with_failed_exit() {
383        let _guard = ENV_MUTEX.lock().unwrap();
384        let stub_dir = stub_pi_with_provider(r#"{"status":"ready"}"#, 1, "litellm");
385        let _path = PathGuard::set(stub_dir.path());
386        let _cfgdir = EnvGuard::set("PI_CODING_AGENT_DIR", stub_dir.path());
387
388        assert!(
389            PiDriver.health(&test_state()).is_err(),
390            "a failed exit must not be read as ready even when the body says ready"
391        );
392    }
393
394    /// No `settings.json` (a standard install: built-in provider from env vars
395    /// / `auth.json`, no custom `models.json`) must NOT be hard-refused — the
396    /// health check falls back to Pi's `--provider` default (`google`) and lets
397    /// `pi auth check` report readiness (phase-39 code review, finding 1a).
398    #[test]
399    fn preflight_falls_back_to_google_when_no_default_provider() {
400        let _guard = ENV_MUTEX.lock().unwrap();
401        let stub_dir = stub_pi_on_path(r#"{"status":"ready"}"#, 0);
402        let _path = PathGuard::set(stub_dir.path());
403        let _cfgdir = EnvGuard::set("PI_CODING_AGENT_DIR", stub_dir.path());
404
405        PiDriver
406            .health(&test_state())
407            .expect("a default-provider stub must pass preflight via the google fallback");
408
409        let argv = std::fs::read_to_string(stub_dir.path().join("args.txt")).unwrap();
410        assert_eq!(
411            argv,
412            "auth\ncheck\n--json\n--provider\ngoogle\n--no-refresh\n"
413        );
414    }
415
416    /// The capability probe shells out to `pi list --no-approve` and matches on
417    /// the installed-package name (there is no `pi tools` command). A stub
418    /// reporting an installed subagent package flips the capability on, and the
419    /// argv proves the probe is exactly `pi list --no-approve`.
420    #[test]
421    fn pi_capabilities_detect_subagent_dispatch() {
422        let _guard = ENV_MUTEX.lock().unwrap();
423        let stub_dir = stub_pi_on_path("npm:@bacnh85/pi-subagent@0.15.1 (user)", 0);
424        let _path = PathGuard::set(stub_dir.path());
425
426        assert!(PiDriver.capabilities().subagent_dispatch);
427
428        let argv = std::fs::read_to_string(stub_dir.path().join("args.txt")).unwrap();
429        assert_eq!(argv, "list\n--no-approve\n");
430    }
431
432    /// A `subagent`-named package that is NOT the vetted `@bacnh85/pi-subagent`
433    /// (e.g. the unsafe/deferred `@mystilleef`) must NOT flip the capability on
434    /// — the name-match is the specific package, not `*subagent*` (phase-39
435    /// code review, finding 2).
436    #[test]
437    fn pi_capabilities_exclude_unvetted_subagent_packages() {
438        let _guard = ENV_MUTEX.lock().unwrap();
439        let stub_dir = stub_pi_on_path("npm:@mystilleef/pi-subagent@2.0.0 (user)", 0);
440        let _path = PathGuard::set(stub_dir.path());
441
442        assert!(!PiDriver.capabilities().subagent_dispatch);
443    }
444
445    /// No subagent package in `pi list` → capability stays off (baseline path).
446    #[test]
447    fn pi_capabilities_fail_closed_when_no_subagent() {
448        let _guard = ENV_MUTEX.lock().unwrap();
449        let stub_dir = stub_pi_on_path("No packages installed.", 0);
450        let _path = PathGuard::set(stub_dir.path());
451
452        assert!(!PiDriver.capabilities().subagent_dispatch);
453    }
454
455    /// A failing probe (non-zero exit) fails closed to baseline, never refuses.
456    #[test]
457    fn pi_capabilities_fail_closed_when_probe_fails() {
458        let _guard = ENV_MUTEX.lock().unwrap();
459        let stub_dir = stub_pi_on_path("", 1);
460        let _path = PathGuard::set(stub_dir.path());
461
462        assert!(!PiDriver.capabilities().subagent_dispatch);
463    }
464}