use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use anyhow::{anyhow, Context};
use crate::pm::PmProviderKind;
use crate::provider_auth::extract_claude_token;
pub const DEFAULT_REPO: &str = "src/loopflow";
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Credentials {
pub gh_token: Option<String>,
pub claude_token: Option<String>,
pub pm_token: Option<String>,
pub pm_provider: Option<String>,
pub secrets: Vec<(String, String)>,
}
pub fn run(
host: &str,
repo: Option<&str>,
secret_names: &[String],
forward_agent: bool,
cmd: &[String],
) -> anyhow::Result<()> {
if cmd.is_empty() {
return Err(anyhow!(
"lf ssh needs a command after `--`, e.g. `lf ssh {host} -- lf pr open`"
));
}
let repo = repo.unwrap_or(DEFAULT_REPO);
let runtime = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
let credentials = runtime.block_on(resolve_credentials(secret_names))?;
let preamble = build_preamble(&credentials, host, repo, cmd);
run_ssh(host, forward_agent, &preamble)
}
async fn resolve_credentials(secret_names: &[String]) -> anyhow::Result<Credentials> {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let mut secrets = Vec::with_capacity(secret_names.len());
for name in secret_names {
secrets.push((name.clone(), resolve_doppler_secret(name)?));
}
Ok(Credentials {
gh_token: resolve_gh_token(),
claude_token: extract_claude_token(&home).map(|token| token.access_token),
pm_token: resolve_pm_token().await,
pm_provider: Some(PmProviderKind::Linear.as_str().to_string()),
secrets,
})
}
fn resolve_gh_token() -> Option<String> {
let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
if !output.status.success() {
return None;
}
let token = String::from_utf8(output.stdout).ok()?.trim().to_string();
(!token.is_empty()).then_some(token)
}
fn resolve_doppler_secret(name: &str) -> anyhow::Result<String> {
if !is_valid_env_name(name) {
return Err(anyhow!(
"invalid --secret name '{name}': expected an environment variable identifier"
));
}
let output = Command::new("doppler")
.args(["secrets", "get", name, "--plain"])
.output()
.with_context(|| format!("failed to run doppler for secret '{name}'"))?;
if !output.status.success() {
return Err(anyhow!(
"doppler could not resolve secret '{name}' (is it set in the active config?)"
));
}
let value = String::from_utf8(output.stdout)
.with_context(|| format!("doppler returned non-UTF8 value for '{name}'"))?
.trim_end_matches(['\n', '\r'])
.to_string();
if value.is_empty() {
return Err(anyhow!(
"doppler returned an empty value for secret '{name}'"
));
}
Ok(value)
}
async fn resolve_pm_token() -> Option<String> {
let cfg = crate::store::storage_config_from_env().ok()?;
let store = crate::store::open_store(&cfg).await.ok()?;
let token = store
.get_provider_token(PmProviderKind::Linear.as_str())
.await
.ok()??;
Some(token.access_token)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn is_valid_env_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
_ => return false,
}
chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
fn build_preamble(credentials: &Credentials, host: &str, repo: &str, cmd: &[String]) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push(
"export PATH=\"$HOME/.cargo/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\""
.to_string(),
);
if let Some(token) = nonempty(&credentials.gh_token) {
lines.push(format!("export GH_TOKEN={}", sh_quote(token)));
lines.push("export GIT_CONFIG_COUNT=2".to_string());
lines.push(format!(
"export GIT_CONFIG_KEY_0={}",
sh_quote("credential.helper")
));
lines.push("export GIT_CONFIG_VALUE_0=''".to_string());
lines.push(format!(
"export GIT_CONFIG_KEY_1={}",
sh_quote("credential.https://github.com.helper")
));
lines.push(format!(
"export GIT_CONFIG_VALUE_1={}",
sh_quote("!f(){ echo username=x-access-token; echo \"password=$GH_TOKEN\"; };f")
));
}
if let Some(token) = nonempty(&credentials.claude_token) {
lines.push(format!(
"export CLAUDE_CODE_OAUTH_TOKEN={}",
sh_quote(token)
));
}
if let Some(token) = nonempty(&credentials.pm_token) {
lines.push(format!("export LF_FORWARDED_PM_TOKEN={}", sh_quote(token)));
if let Some(provider) = nonempty(&credentials.pm_provider) {
lines.push(format!(
"export LF_FORWARDED_PM_PROVIDER={}",
sh_quote(provider)
));
}
}
for (name, value) in &credentials.secrets {
lines.push(format!("export {name}={}", sh_quote(value)));
}
lines.push(format!(
"cd \"$HOME\"/{} || {{ echo {} >&2; exit 1; }}",
sh_quote(repo),
sh_quote(&format!("no repo ~/{repo} on {host}"))
));
let remote_cmd = cmd
.iter()
.map(|arg| sh_quote(arg))
.collect::<Vec<_>>()
.join(" ");
lines.push(format!("exec {remote_cmd}"));
let mut preamble = lines.join("\n");
preamble.push('\n');
preamble
}
fn nonempty(value: &Option<String>) -> Option<&str> {
value.as_deref().filter(|value| !value.trim().is_empty())
}
fn sh_quote(value: &str) -> String {
let mut quoted = String::with_capacity(value.len() + 2);
quoted.push('\'');
for ch in value.chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
fn run_ssh(host: &str, forward_agent: bool, preamble: &str) -> anyhow::Result<()> {
let mut command = Command::new("ssh");
if forward_agent {
command.arg("-A");
}
let mut child = command
.args([host, "bash -s"])
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.context("failed to spawn ssh")?;
child
.stdin
.take()
.ok_or_else(|| anyhow!("ssh stdin unavailable"))?
.write_all(preamble.as_bytes())
.context("failed to write preamble to ssh")?;
let status = child.wait().context("ssh did not complete")?;
if status.success() {
Ok(())
} else {
std::process::exit(status.code().unwrap_or(1));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn full_bundle() -> Credentials {
Credentials {
gh_token: Some("gh-secret".to_string()),
claude_token: Some("claude-secret".to_string()),
pm_token: Some("linear-secret".to_string()),
pm_provider: Some("linear".to_string()),
secrets: vec![("STRIPE_KEY".to_string(), "sk-live-123".to_string())],
}
}
#[test]
fn preamble_exports_every_credential_and_execs_command() {
let cmd = vec!["lf".to_string(), "op".to_string(), "pr".to_string()];
let preamble = build_preamble(&full_bundle(), "mini-heart", "src/loopflow", &cmd);
assert!(preamble.contains("export GH_TOKEN='gh-secret'"));
assert!(preamble.contains("export CLAUDE_CODE_OAUTH_TOKEN='claude-secret'"));
assert!(preamble.contains("export LF_FORWARDED_PM_TOKEN='linear-secret'"));
assert!(preamble.contains("export LF_FORWARDED_PM_PROVIDER='linear'"));
assert!(preamble.contains("export STRIPE_KEY='sk-live-123'"));
assert!(!preamble.contains("DOPPLER_TOKEN"));
assert!(preamble.contains("export GIT_CONFIG_COUNT=2"));
assert!(preamble.contains("export GIT_CONFIG_KEY_0='credential.helper'"));
assert!(preamble.contains("export GIT_CONFIG_VALUE_0=''"));
assert!(preamble.contains("export GIT_CONFIG_KEY_1='credential.https://github.com.helper'"));
assert!(preamble.contains("password=$GH_TOKEN"));
assert!(preamble.contains("cd \"$HOME\"/'src/loopflow'"));
assert!(preamble.trim_end().ends_with("exec 'lf' 'op' 'pr'"));
}
#[test]
fn preamble_omits_absent_credentials() {
let creds = Credentials {
claude_token: Some("only-claude".to_string()),
..Credentials::default()
};
let cmd = vec!["lf".to_string(), "runs".to_string()];
let preamble = build_preamble(&creds, "host", "src/loopflow", &cmd);
assert!(preamble.contains("export CLAUDE_CODE_OAUTH_TOKEN='only-claude'"));
assert!(!preamble.contains("GH_TOKEN"));
assert!(!preamble.contains("LF_FORWARDED_PM_TOKEN"));
assert!(!preamble.contains("GIT_CONFIG_COUNT"));
assert!(!preamble.contains("credential.helper"));
}
#[test]
fn preamble_never_leaks_a_secret_to_argv_form() {
let creds = Credentials {
gh_token: Some("a'b; rm -rf ~ #".to_string()),
..Credentials::default()
};
let cmd = vec!["lf".to_string()];
let preamble = build_preamble(&creds, "host", "src/loopflow", &cmd);
assert!(preamble.contains(r#"export GH_TOKEN='a'\''b; rm -rf ~ #'"#));
assert!(!preamble.contains("\nrm -rf"));
}
#[test]
fn sh_quote_escapes_embedded_single_quotes() {
assert_eq!(sh_quote("plain"), "'plain'");
assert_eq!(sh_quote("a'b"), r#"'a'\''b'"#);
}
#[test]
fn env_name_validation_rejects_injection() {
assert!(is_valid_env_name("STRIPE_KEY"));
assert!(is_valid_env_name("_x1"));
assert!(!is_valid_env_name("1BAD"));
assert!(!is_valid_env_name("A B"));
assert!(!is_valid_env_name("A=B; rm -rf ~"));
assert!(!is_valid_env_name(""));
}
}