Skip to main content

supercode_harness/
harness_auth.rs

1//! Native coding-harness authentication coordination.
2//!
3//! Supercode never reads, copies, or stores another harness's credentials. It
4//! discovers the native CLI's supported sign-in mechanisms, returns an
5//! explicit launch plan to the embedding host, and verifies the result through
6//! the harness's own status command.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use serde::{Deserialize, Serialize};
13
14use crate::HarnessId;
15
16/// Stable schema identifier shared by authentication reports and launch plans.
17pub const HARNESS_AUTHENTICATION_SCHEMA: &str = "supercode.harness-authentication.v1";
18
19/// Environment in which the native authentication interaction must work.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum HarnessAuthenticationEnvironment {
23    /// A local graphical browser may be opened by the native harness.
24    LocalBrowser,
25    /// No browser can be opened on the machine running the harness.
26    Headless,
27}
28
29/// Stable identifier for a harness-native authentication mechanism.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum HarnessAuthenticationMethodId {
33    /// The native CLI opens or directs the user to a browser sign-in.
34    Browser,
35    /// The native CLI prints a device code completed in another browser.
36    DeviceCode,
37}
38
39/// User interaction presented by an authentication method.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "snake_case")]
42pub enum HarnessAuthenticationInteraction {
43    /// A browser-based sign-in with progress retained in a terminal.
44    Browser,
45    /// A short-lived code entered on another device.
46    DeviceCode,
47}
48
49/// Whether the native CLI itself is expected to open a browser.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum HarnessBrowserBehavior {
53    /// The harness owns automatic browser opening and any redirect listener.
54    NativeAuto,
55    /// This method does not open a local browser.
56    None,
57}
58
59/// Credential readiness reported without exposing credential material.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum HarnessAuthenticationState {
63    /// The harness's native status command confirms an active sign-in.
64    Authenticated,
65    /// Credential evidence exists, but native status did not confirm it.
66    Configured,
67    /// No native sign-in or local credential evidence was found.
68    Required,
69    /// The harness is absent or has no verified Supercode auth adapter.
70    Unavailable,
71}
72
73/// One verified native authentication mechanism supported by a harness.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
75pub struct HarnessAuthenticationMethod {
76    /// Stable mechanism identifier.
77    pub id: HarnessAuthenticationMethodId,
78    /// Short user-facing label.
79    pub label: &'static str,
80    /// User-facing explanation of the native flow.
81    pub description: &'static str,
82    /// Interaction the host must present.
83    pub interaction: HarnessAuthenticationInteraction,
84    /// Browser-opening behavior owned by the native CLI.
85    pub browser_behavior: HarnessBrowserBehavior,
86    /// Whether the method is verified for machines without a local browser.
87    pub headless: bool,
88    /// Whether local interactive hosts should prefer this method.
89    pub recommended: bool,
90}
91
92/// Process launch that an embedding host must run in a user-visible terminal.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
94pub struct HarnessAuthenticationLaunch {
95    /// Working directory inherited by the native harness.
96    pub cwd: PathBuf,
97    /// Resolved native harness executable.
98    pub program: String,
99    /// Authentication arguments supported by that executable.
100    pub arguments: Vec<String>,
101    /// Deliberate environment additions; credential values are never included.
102    pub env: BTreeMap<String, String>,
103}
104
105/// Host-executable plan for one selected authentication method.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct HarnessAuthenticationPlan {
108    /// Wire schema identifier.
109    pub schema: &'static str,
110    /// Harness that owns the credentials and interaction.
111    pub harness: HarnessId,
112    /// Selected native method.
113    pub method: HarnessAuthenticationMethodId,
114    /// Interaction the host must surface.
115    pub interaction: HarnessAuthenticationInteraction,
116    /// Browser-opening behavior of the native CLI.
117    pub browser_behavior: HarnessBrowserBehavior,
118    /// Whether this plan is verified for a browserless machine.
119    pub headless: bool,
120    /// Native process to run in a visible, host-owned terminal.
121    pub launch: HarnessAuthenticationLaunch,
122    /// Concise instructions for the host to display alongside the terminal.
123    pub instructions: &'static str,
124}
125
126/// Redacted native authentication readiness and available methods.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128pub struct HarnessAuthenticationReport {
129    /// Wire schema identifier.
130    pub schema: &'static str,
131    /// Inspected harness.
132    pub harness: HarnessId,
133    /// Whether its native executable is installed.
134    pub installed: bool,
135    /// Resolved executable path, when installed.
136    pub executable: Option<String>,
137    /// Redacted credential readiness.
138    pub state: HarnessAuthenticationState,
139    /// Verified native methods Supercode can coordinate.
140    pub methods: Vec<HarnessAuthenticationMethod>,
141    /// Human-readable status explanation without native command output.
142    pub reason: Option<String>,
143}
144
145/// Failure to construct a truthful native authentication plan.
146#[derive(Debug, thiserror::Error)]
147pub enum HarnessAuthenticationError {
148    /// The harness id is not in Supercode's registry.
149    #[error("unknown harness `{0}`")]
150    UnknownHarness(String),
151    /// The registered native executable is absent.
152    #[error("{0} is not installed or its executable is not on PATH")]
153    NotInstalled(String),
154    /// The requested environment or method has no verified native adapter.
155    #[error("{0}")]
156    Unsupported(String),
157}
158
159/// Return the verified native authentication methods for a harness.
160pub fn harness_authentication_methods(
161    harness: &HarnessId,
162) -> Result<Vec<HarnessAuthenticationMethod>, HarnessAuthenticationError> {
163    let methods = match harness.as_str() {
164        HarnessId::CLAUDE_CODE => vec![HarnessAuthenticationMethod {
165            id: HarnessAuthenticationMethodId::Browser,
166            label: "Sign in with browser",
167            description: "Claude Code opens its native sign-in page and keeps the terminal available for status and fallback instructions.",
168            interaction: HarnessAuthenticationInteraction::Browser,
169            browser_behavior: HarnessBrowserBehavior::NativeAuto,
170            headless: false,
171            recommended: true,
172        }],
173        HarnessId::CODEX => vec![
174            HarnessAuthenticationMethod {
175                id: HarnessAuthenticationMethodId::Browser,
176                label: "Sign in with browser",
177                description: "Codex opens its native ChatGPT sign-in flow in the local browser.",
178                interaction: HarnessAuthenticationInteraction::Browser,
179                browser_behavior: HarnessBrowserBehavior::NativeAuto,
180                headless: false,
181                recommended: true,
182            },
183            HarnessAuthenticationMethod {
184                id: HarnessAuthenticationMethodId::DeviceCode,
185                label: "Use another device",
186                description: "Codex prints a short-lived code and verification address for a phone or another browser.",
187                interaction: HarnessAuthenticationInteraction::DeviceCode,
188                browser_behavior: HarnessBrowserBehavior::None,
189                headless: true,
190                recommended: false,
191            },
192        ],
193        value
194            if crate::harness_support_registry()
195                .harnesses
196                .iter()
197                .any(|descriptor| descriptor.id == *harness) =>
198        {
199            return Err(HarnessAuthenticationError::Unsupported(format!(
200                "Supercode does not yet have a verified native sign-in adapter for `{value}`"
201            )))
202        }
203        value => return Err(HarnessAuthenticationError::UnknownHarness(value.into())),
204    };
205    Ok(methods)
206}
207
208/// Select a method for the requested environment and create its host-owned launch plan.
209pub fn harness_authentication_plan(
210    harness: &HarnessId,
211    environment: HarnessAuthenticationEnvironment,
212    requested_method: Option<HarnessAuthenticationMethodId>,
213    cwd: &Path,
214) -> Result<HarnessAuthenticationPlan, HarnessAuthenticationError> {
215    let methods = harness_authentication_methods(harness)?;
216    let selected = requested_method
217        .and_then(|id| methods.iter().find(|method| method.id == id))
218        .or_else(|| match environment {
219            HarnessAuthenticationEnvironment::LocalBrowser => {
220                methods.iter().find(|method| method.recommended)
221            }
222            HarnessAuthenticationEnvironment::Headless => {
223                methods.iter().find(|method| method.headless)
224            }
225        })
226        .ok_or_else(|| {
227            HarnessAuthenticationError::Unsupported(format!(
228                "{} does not expose a verified {} sign-in flow in this Supercode version",
229                harness.as_str(),
230                match environment {
231                    HarnessAuthenticationEnvironment::LocalBrowser => "local-browser",
232                    HarnessAuthenticationEnvironment::Headless => "headless",
233                }
234            ))
235        })?;
236    if requested_method.is_some_and(|id| !methods.iter().any(|method| method.id == id)) {
237        return Err(HarnessAuthenticationError::Unsupported(format!(
238            "{} does not support the requested sign-in method",
239            harness.as_str()
240        )));
241    }
242    let program = default_auth_program(harness)
243        .ok_or_else(|| HarnessAuthenticationError::UnknownHarness(harness.as_str().into()))?;
244    let executable = find_executable(&program)
245        .ok_or_else(|| HarnessAuthenticationError::NotInstalled(harness.as_str().to_string()))?;
246    let arguments = match (harness.as_str(), selected.id) {
247        (HarnessId::CLAUDE_CODE, HarnessAuthenticationMethodId::Browser) => {
248            vec!["auth".into(), "login".into()]
249        }
250        (HarnessId::CODEX, HarnessAuthenticationMethodId::Browser) => vec!["login".into()],
251        (HarnessId::CODEX, HarnessAuthenticationMethodId::DeviceCode) => {
252            vec!["login".into(), "--device-auth".into()]
253        }
254        _ => {
255            return Err(HarnessAuthenticationError::Unsupported(format!(
256                "{} does not support the requested sign-in method",
257                harness.as_str()
258            )))
259        }
260    };
261    Ok(HarnessAuthenticationPlan {
262        schema: HARNESS_AUTHENTICATION_SCHEMA,
263        harness: harness.clone(),
264        method: selected.id,
265        interaction: selected.interaction,
266        browser_behavior: selected.browser_behavior,
267        headless: selected.headless,
268        launch: HarnessAuthenticationLaunch {
269            cwd: cwd.to_path_buf(),
270            program: executable.to_string_lossy().into_owned(),
271            arguments,
272            env: BTreeMap::new(),
273        },
274        instructions: match selected.interaction {
275            HarnessAuthenticationInteraction::Browser => {
276                "Keep the native sign-in terminal open until the harness confirms completion. If a browser cannot open, use any fallback instructions printed there."
277            }
278            HarnessAuthenticationInteraction::DeviceCode => {
279                "Keep the native sign-in terminal open, then visit the printed address on any device and enter the short-lived code."
280            }
281        },
282    })
283}
284
285/// Inspect redacted sign-in status using the harness's own bounded status command.
286pub async fn inspect_harness_authentication(harness: &HarnessId) -> HarnessAuthenticationReport {
287    let methods = harness_authentication_methods(harness);
288    let Some(program) = default_auth_program(harness) else {
289        return HarnessAuthenticationReport {
290            schema: HARNESS_AUTHENTICATION_SCHEMA,
291            harness: harness.clone(),
292            installed: false,
293            executable: None,
294            state: HarnessAuthenticationState::Unavailable,
295            methods: Vec::new(),
296            reason: Some("No native sign-in executable is registered.".into()),
297        };
298    };
299    let Some(executable) = find_executable(&program) else {
300        return HarnessAuthenticationReport {
301            schema: HARNESS_AUTHENTICATION_SCHEMA,
302            harness: harness.clone(),
303            installed: false,
304            executable: None,
305            state: HarnessAuthenticationState::Unavailable,
306            methods: methods.unwrap_or_default(),
307            reason: Some(format!("`{program}` was not found on PATH.")),
308        };
309    };
310    let methods = match methods {
311        Ok(methods) => methods,
312        Err(error) => {
313            return HarnessAuthenticationReport {
314                schema: HARNESS_AUTHENTICATION_SCHEMA,
315                harness: harness.clone(),
316                installed: true,
317                executable: Some(executable.to_string_lossy().into_owned()),
318                state: HarnessAuthenticationState::Unavailable,
319                methods: Vec::new(),
320                reason: Some(error.to_string()),
321            }
322        }
323    };
324    let verified = native_auth_status(harness, &executable).await;
325    let configured = super::harness_service::auth_evidence(harness.as_str());
326    let (state, reason) = if verified {
327        (
328            HarnessAuthenticationState::Authenticated,
329            Some("The native harness reports an active sign-in.".into()),
330        )
331    } else if configured {
332        (
333            HarnessAuthenticationState::Configured,
334            Some("Local credential evidence exists, but the native status command did not confirm an active sign-in.".into()),
335        )
336    } else {
337        (
338            HarnessAuthenticationState::Required,
339            Some("The native harness does not report an active sign-in.".into()),
340        )
341    };
342    HarnessAuthenticationReport {
343        schema: HARNESS_AUTHENTICATION_SCHEMA,
344        harness: harness.clone(),
345        installed: true,
346        executable: Some(executable.to_string_lossy().into_owned()),
347        state,
348        methods,
349        reason,
350    }
351}
352
353async fn native_auth_status(harness: &HarnessId, executable: &Path) -> bool {
354    let mut command = tokio::process::Command::new(executable);
355    match harness.as_str() {
356        HarnessId::CLAUDE_CODE => {
357            command.args(["auth", "status", "--json"]);
358        }
359        HarnessId::CODEX => {
360            command.args(["login", "status"]);
361        }
362        _ => return false,
363    }
364    command
365        .stdin(std::process::Stdio::null())
366        .stdout(std::process::Stdio::piped())
367        .stderr(std::process::Stdio::null())
368        .kill_on_drop(true);
369    let Ok(Ok(output)) = tokio::time::timeout(Duration::from_secs(3), command.output()).await
370    else {
371        return false;
372    };
373    if !output.status.success() {
374        return false;
375    }
376    if harness.as_str() == HarnessId::CLAUDE_CODE {
377        return serde_json::from_slice::<serde_json::Value>(&output.stdout)
378            .ok()
379            .and_then(|value| value.get("loggedIn").and_then(serde_json::Value::as_bool))
380            .unwrap_or(false);
381    }
382    true
383}
384
385fn default_auth_program(harness: &HarnessId) -> Option<String> {
386    crate::harness_support_registry()
387        .harnesses
388        .into_iter()
389        .find(|descriptor| descriptor.id == *harness)
390        .and_then(|descriptor| descriptor.runtime.default_launch)
391        .map(|launch| launch.program)
392}
393
394fn find_executable(program: &str) -> Option<PathBuf> {
395    let path = std::env::var_os("PATH")?;
396    std::env::split_paths(&path).find_map(|directory| {
397        let candidate = directory.join(program);
398        if candidate.is_file() {
399            return std::fs::canonicalize(&candidate).ok().or(Some(candidate));
400        }
401        #[cfg(windows)]
402        for extension in ["exe", "cmd", "bat"] {
403            let candidate = directory.join(format!("{program}.{extension}"));
404            if candidate.is_file() {
405                return std::fs::canonicalize(&candidate).ok().or(Some(candidate));
406            }
407        }
408        None
409    })
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn adapter_selection_never_substitutes_a_browser_flow_for_headless_login() {
418        let codex = HarnessId::new(HarnessId::CODEX);
419        let claude = HarnessId::new(HarnessId::CLAUDE_CODE);
420        let methods = harness_authentication_methods(&codex).unwrap();
421        assert_eq!(
422            methods
423                .iter()
424                .find(|method| method.headless)
425                .map(|method| method.id),
426            Some(HarnessAuthenticationMethodId::DeviceCode)
427        );
428        assert!(!harness_authentication_methods(&claude)
429            .unwrap()
430            .iter()
431            .any(|method| method.headless));
432    }
433}