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}