use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use anyhow::{anyhow, Context};
use crate::pm::PmProviderKind;
use crate::provider_account::{
encode_forwarded_profile_bundle, local_forwarded_profile_bundle, ForwardedProfileBundle,
FORWARDED_PROFILE_BUNDLE_ENV, FORWARDED_PROFILE_STORE_ENV,
};
use crate::provider_auth::{extract_claude_token, extract_codex_access_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 codex_token: Option<String>,
pub profile_bundle: Option<ForwardedProfileBundle>,
pub pm_token: Option<String>,
pub pm_provider: Option<String>,
pub secrets: Vec<(String, String)>,
}
pub fn run(
dest: &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 {dest} -- lf pr open`"
));
}
run_with_env(dest, None, repo, secret_names, forward_agent, cmd, &[])
}
pub fn run_routed(
dest: &str,
port: Option<u16>,
repo: Option<&str>,
cmd: &[String],
) -> anyhow::Result<()> {
run_with_env(
dest,
port,
repo,
&[],
false,
cmd,
&[(crate::engine::wave_home::HOME_ROUTED_ENV, "1")],
)
}
pub fn capture_routed(
dest: &str,
port: Option<u16>,
cmd: &[String],
) -> Result<String, SshCaptureError> {
let repo = DEFAULT_REPO;
let runtime = tokio::runtime::Runtime::new()
.map_err(|error| SshCaptureError::Local(error.to_string()))?;
let credentials = runtime
.block_on(resolve_credentials(&[]))
.map_err(|error| SshCaptureError::Local(error.to_string()))?;
reject_detached_profile_forwarding(credentials.profile_bundle.is_some(), cmd)
.map_err(|error| SshCaptureError::Local(error.to_string()))?;
let preamble = build_preamble(
&credentials,
dest,
repo,
cmd,
&[(crate::engine::wave_home::HOME_ROUTED_ENV, "1")],
);
run_ssh_capture(dest, port, &preamble)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SshCaptureError {
Unreachable(String),
Command { code: i32, stderr: String },
Local(String),
}
fn run_with_env(
dest: &str,
port: Option<u16>,
repo: Option<&str>,
secret_names: &[String],
forward_agent: bool,
cmd: &[String],
extra_env: &[(&str, &str)],
) -> anyhow::Result<()> {
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))?;
reject_detached_profile_forwarding(credentials.profile_bundle.is_some(), cmd)?;
let preamble = build_preamble(&credentials, dest, repo, cmd, extra_env);
run_ssh(dest, port, forward_agent, &preamble)
}
fn reject_detached_profile_forwarding(
has_profile_bundle: bool,
cmd: &[String],
) -> anyhow::Result<()> {
if has_profile_bundle && cmd.first().is_some_and(|program| program == "tmux") {
return Err(anyhow!(
"cannot forward ephemeral provider profiles into a remote tmux command; \
run the remote command in the foreground or authenticate on the remote host"
));
}
Ok(())
}
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)?));
}
let profile_bundle = match crate::store::open_existing_store().await {
Some(store) => local_forwarded_profile_bundle(&std::sync::Arc::new(store)).await?,
None => None,
};
let (claude_token, codex_token) = if profile_bundle.is_some() {
(None, None)
} else {
(
extract_claude_token(&home).map(|token| token.access_token),
extract_codex_access_token(&home),
)
};
Ok(Credentials {
gh_token: resolve_gh_token(),
claude_token,
codex_token,
profile_bundle,
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],
extra_env: &[(&str, &str)],
) -> 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(),
);
for (name, value) in extra_env {
lines.push(format!("export {name}={}", sh_quote(value)));
}
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.codex_token) {
lines.push(format!("export CODEX_ACCESS_TOKEN={}", sh_quote(token)));
}
if let Some(profile_bundle) = &credentials.profile_bundle {
match encode_forwarded_profile_bundle(profile_bundle) {
Ok(bundle) => lines.push(format!(
"export {FORWARDED_PROFILE_BUNDLE_ENV}={}",
sh_quote(&bundle)
)),
Err(error) => lines.push(format!(
"echo {} >&2; exit 1",
sh_quote(&format!("could not encode provider accounts: {error}"))
)),
}
lines.push(
"LF_PROFILE_LEASE_DIR=$(mktemp -d \"${TMPDIR:-/tmp}/lf-profile.XXXXXX\") || exit 1"
.to_string(),
);
lines.push("export LF_PROFILE_LEASE_DIR".to_string());
lines.push(format!(
"export {FORWARDED_PROFILE_STORE_ENV}=\"$LF_PROFILE_LEASE_DIR/router.db\""
));
lines.push(
"trap 'status=$?; trap - EXIT; rm -rf -- \"$LF_PROFILE_LEASE_DIR\"; exit \"$status\"' EXIT"
.to_string(),
);
lines.push("trap 'exit 129' HUP".to_string());
lines.push("trap 'exit 130' INT".to_string());
lines.push("trap 'exit 143' TERM".to_string());
}
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(if credentials.profile_bundle.is_some() {
remote_cmd
} else {
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
}
const CONNECT_TIMEOUT_SECS: u32 = 10;
const SERVER_ALIVE_INTERVAL_SECS: u32 = 10;
const SERVER_ALIVE_COUNT_MAX: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SshOutcome {
Success,
ConnectionFailure,
CommandFailure(i32),
}
fn ssh_args(dest: &str, port: Option<u16>, forward_agent: bool) -> Vec<String> {
let mut args: Vec<String> = Vec::new();
if forward_agent {
args.push("-A".to_string());
}
if let Some(port) = port {
args.push("-p".to_string());
args.push(port.to_string());
}
args.push("-o".to_string());
args.push("BatchMode=yes".to_string());
args.push("-o".to_string());
args.push(format!("ConnectTimeout={CONNECT_TIMEOUT_SECS}"));
args.push("-o".to_string());
args.push(format!("ServerAliveInterval={SERVER_ALIVE_INTERVAL_SECS}"));
args.push("-o".to_string());
args.push(format!("ServerAliveCountMax={SERVER_ALIVE_COUNT_MAX}"));
args.push(dest.to_string());
args.push("bash -s".to_string());
args
}
fn classify_exit(code: Option<i32>) -> SshOutcome {
match code {
Some(0) => SshOutcome::Success,
Some(255) | None => SshOutcome::ConnectionFailure,
Some(other) => SshOutcome::CommandFailure(other),
}
}
fn connection_error(host: &str) -> anyhow::Error {
anyhow!(
"lf ssh could not reach '{host}': ssh failed during connection/transport \
(bounded by BatchMode + ConnectTimeout={CONNECT_TIMEOUT_SECS}s). See the ssh \
error above; check the host is reachable, its key is known, and key auth works."
)
}
fn run_ssh(
dest: &str,
port: Option<u16>,
forward_agent: bool,
preamble: &str,
) -> anyhow::Result<()> {
let mut child = Command::new("ssh")
.args(ssh_args(dest, port, forward_agent))
.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")?;
match classify_exit(status.code()) {
SshOutcome::Success => Ok(()),
SshOutcome::ConnectionFailure => Err(connection_error(dest)),
SshOutcome::CommandFailure(code) => std::process::exit(code),
}
}
fn run_ssh_capture(
dest: &str,
port: Option<u16>,
preamble: &str,
) -> Result<String, SshCaptureError> {
let mut child = Command::new("ssh")
.args(ssh_args(dest, port, false))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| SshCaptureError::Local(format!("failed to spawn ssh: {error}")))?;
child
.stdin
.take()
.ok_or_else(|| SshCaptureError::Local("ssh stdin unavailable".to_string()))?
.write_all(preamble.as_bytes())
.map_err(|error| SshCaptureError::Local(format!("failed to write preamble: {error}")))?;
let output = child
.wait_with_output()
.map_err(|error| SshCaptureError::Local(format!("ssh did not complete: {error}")))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
match classify_exit(output.status.code()) {
SshOutcome::Success => Ok(String::from_utf8_lossy(&output.stdout).into_owned()),
SshOutcome::ConnectionFailure => Err(SshCaptureError::Unreachable(
connection_error(dest).to_string(),
)),
SshOutcome::CommandFailure(code) => Err(SshCaptureError::Command { code, stderr }),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn full_bundle() -> Credentials {
let primary = crate::profile::ProfileId::parse("primary@example.com").unwrap();
let engineering = crate::profile::ProfileId::parse("engineering@example.com").unwrap();
let personal = crate::profile::ProfileId::parse("personal@example.com").unwrap();
let claude = crate::provider_account::parse_account_id("primary").unwrap();
let codex = crate::provider_account::parse_account_id("reserve").unwrap();
Credentials {
gh_token: Some("gh-secret".to_string()),
claude_token: None,
codex_token: None,
profile_bundle: Some(ForwardedProfileBundle::new(
crate::repository::RepoId::parse("loopflowstudio/loopflow").unwrap(),
primary.clone(),
vec![engineering.clone(), personal.clone()],
vec![
crate::provider_account::ForwardedProfileProviderAccount {
profile_id: primary,
provider: crate::provider_auth::Provider::Claude,
account_id: claude.clone(),
},
crate::provider_account::ForwardedProfileProviderAccount {
profile_id: engineering.clone(),
provider: crate::provider_auth::Provider::Claude,
account_id: claude.clone(),
},
crate::provider_account::ForwardedProfileProviderAccount {
profile_id: engineering,
provider: crate::provider_auth::Provider::Codex,
account_id: codex.clone(),
},
crate::provider_account::ForwardedProfileProviderAccount {
profile_id: personal,
provider: crate::provider_auth::Provider::Claude,
account_id: claude.clone(),
},
],
vec![
crate::provider_account::ForwardedProviderAccount {
provider: crate::provider_auth::Provider::Claude,
account_id: claude.clone(),
login_email: Some(
crate::profile::EmailAddress::parse("personal@example.com").unwrap(),
),
credential_state: crate::store::CredentialState::Connected,
routing_state: crate::store::RoutingState::Automatic,
plan: Some("max".to_string()),
paid_through: None,
utilization_percent: None,
cooldown_until: None,
cooldown_reason: None,
},
crate::provider_account::ForwardedProviderAccount {
provider: crate::provider_auth::Provider::Codex,
account_id: codex.clone(),
login_email: Some(
crate::profile::EmailAddress::parse("engineering@example.com").unwrap(),
),
credential_state: crate::store::CredentialState::Connected,
routing_state: crate::store::RoutingState::Automatic,
plan: Some("max".to_string()),
paid_through: None,
utilization_percent: None,
cooldown_until: None,
cooldown_reason: None,
},
],
vec![
crate::provider_account::ForwardedProviderCredential::new(
crate::provider_auth::Provider::Claude,
claude,
"claude-primary".to_string(),
),
crate::provider_account::ForwardedProviderCredential::new(
crate::provider_auth::Provider::Codex,
codex,
"codex-reserve".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 remote_tmux_rejects_ephemeral_profile_forwarding() {
let cmd = vec!["tmux".to_string(), "list-sessions".to_string()];
assert!(reject_detached_profile_forwarding(false, &cmd).is_ok());
assert!(reject_detached_profile_forwarding(true, &cmd)
.unwrap_err()
.to_string()
.contains("remote tmux"));
}
#[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="));
assert!(!preamble.contains("export CODEX_ACCESS_TOKEN="));
let encoded =
encode_forwarded_profile_bundle(full_bundle().profile_bundle.as_ref().unwrap())
.unwrap();
assert!(preamble.contains(&format!(
"export {FORWARDED_PROFILE_BUNDLE_ENV}='{}'",
encoded
)));
assert!(!preamble.contains("claude-primary"));
assert!(!preamble.contains("codex-reserve"));
assert!(!preamble.contains("refresh_token"));
assert!(!preamble.contains("/accounts/"));
assert!(preamble.contains("LF_PROFILE_LEASE_DIR=$(mktemp -d"));
assert!(preamble.contains(&format!("export {FORWARDED_PROFILE_STORE_ENV}=")));
assert!(preamble.contains("trap - EXIT; rm -rf --"));
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("'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"));
assert!(!preamble.contains("LF_HOME_ROUTED"));
}
#[test]
fn routed_preamble_marks_the_home_hop_to_break_the_forward_loop() {
let cmd = vec!["lf".to_string(), "pr".to_string(), "open".to_string()];
let preamble = build_preamble(
&Credentials::default(),
"mini-heart",
"src/loopflow",
&cmd,
&[("LF_HOME_ROUTED", "1")],
);
assert!(preamble.contains("export LF_HOME_ROUTED='1'"));
assert!(preamble.trim_end().ends_with("exec 'lf' 'pr' 'open'"));
}
#[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 ssh_args_bound_the_connection() {
let args = ssh_args("jack@mini-heart", None, false);
assert!(args.iter().any(|a| a == "BatchMode=yes"));
assert!(args.iter().any(|a| a == "ConnectTimeout=10"));
assert!(args.iter().any(|a| a == "ServerAliveInterval=10"));
assert!(args.iter().any(|a| a == "ServerAliveCountMax=3"));
assert!(args.iter().any(|a| a == "jack@mini-heart"));
assert_eq!(args.last().unwrap(), "bash -s");
assert!(!args.iter().any(|a| a == "-A"));
assert!(!args.iter().any(|a| a == "-p"));
}
#[test]
fn ssh_args_pass_an_explicit_port() {
let args = ssh_args("jack@host", Some(2222), false);
let p = args.iter().position(|a| a == "-p").expect("-p present");
assert_eq!(args[p + 1], "2222");
}
#[test]
fn ssh_args_opt_in_agent_forwarding() {
let args = ssh_args("host", None, true);
assert_eq!(args.first().unwrap(), "-A");
}
#[test]
fn classify_exit_separates_transport_from_command_failure() {
assert_eq!(classify_exit(Some(0)), SshOutcome::Success);
assert_eq!(classify_exit(Some(255)), SshOutcome::ConnectionFailure);
assert_eq!(classify_exit(None), SshOutcome::ConnectionFailure);
assert_eq!(classify_exit(Some(1)), SshOutcome::CommandFailure(1));
assert_eq!(classify_exit(Some(42)), SshOutcome::CommandFailure(42));
}
#[test]
fn connection_error_names_host_without_leaking_credentials() {
let err = connection_error("mini-heart").to_string();
assert!(err.contains("mini-heart"));
assert!(err.contains("connection/transport"));
assert!(!err.contains("TOKEN"));
assert!(!err.contains("password"));
}
#[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(""));
}
}