Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/engine/auth/keychain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,54 @@ pub fn agent_keychain_files(agent: &AgentName) -> Vec<AgentSecretFile> {

// ── Claude (env-var) ────────────────────────────────────────────────────────

/// Returns `true` when the harness already supplies its own Anthropic
/// credential, making keychain OAuth injection unnecessary and harmful.
/// Claude Code warns "auth may not work" when both `CLAUDE_CODE_OAUTH_TOKEN`
/// and `ANTHROPIC_API_KEY` are present in the same environment.
///
/// Triggers when:
/// - `ANTHROPIC_API_KEY` is set and non-empty (direct API-key auth), or
/// - `ANTHROPIC_BASE_URL` is set to a non-anthropic.com endpoint (local/omlx
/// harness pointing at a custom base URL).
///
/// `lookup_env` abstracts `std::env::var` so tests stay hermetic and never
/// mutate process-global state, mirroring the `lookup_env` pattern used by
/// `auto_auth_env_overlays`.
fn harness_supplies_anthropic_auth(lookup_env: impl Fn(&str) -> Option<String>) -> bool {
if lookup_env("ANTHROPIC_API_KEY")
.map(|v| !v.is_empty())
.unwrap_or(false)
{
return true;
}
if let Some(base_url) = lookup_env("ANTHROPIC_BASE_URL") {
if !base_url.is_empty() {
let lower = base_url.to_ascii_lowercase();
// Cloud endpoints: api.anthropic.com and *.anthropic.com are
// first-party — keychain OAuth still applies. Anything else (local
// address, omlx harness, custom proxy) means the harness owns auth.
let is_cloud = lower.contains("anthropic.com");
if !is_cloud {
return true;
}
}
}
false
}

/// macOS-only: look up the Claude Code OAuth credential and extract its
/// access token via the JSON path `claudeAiOauth.accessToken`.
///
/// Returns an empty list immediately (without touching the keychain) when the
/// harness already supplies its own Anthropic credential — see
/// [`harness_supplies_anthropic_auth`].
fn claude_keychain_credentials() -> Vec<(String, String)> {
if !cfg!(target_os = "macos") {
return Vec::new();
}
if harness_supplies_anthropic_auth(|key| std::env::var(key).ok()) {
return Vec::new();
}
let Some(raw) = run_macos_keychain_lookup("Claude Code-credentials", None) else {
return Vec::new();
};
Expand Down Expand Up @@ -255,4 +297,61 @@ mod tests {
let agent = AgentName::new("totallymadeup").unwrap();
assert!(agent_keychain_credentials(&agent).is_empty());
}

// ── harness_supplies_anthropic_auth ────────────────────────────────────

/// Helper: build a lookup closure from a static list of (key, value) pairs.
fn env_from<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |key: &str| {
pairs
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| (*v).to_string())
}
}

#[test]
fn harness_auth_true_when_api_key_set_nonempty() {
let lookup = env_from(&[("ANTHROPIC_API_KEY", "sk-ant-test123")]);
assert!(
harness_supplies_anthropic_auth(lookup),
"non-empty ANTHROPIC_API_KEY must signal harness auth"
);
}

#[test]
fn harness_auth_false_when_api_key_empty_string() {
let lookup = env_from(&[("ANTHROPIC_API_KEY", "")]);
assert!(
!harness_supplies_anthropic_auth(lookup),
"empty ANTHROPIC_API_KEY must not trigger the guard"
);
}

#[test]
fn harness_auth_true_when_base_url_is_non_cloud() {
let lookup = env_from(&[("ANTHROPIC_BASE_URL", "http://192.168.65.1:8000")]);
assert!(
harness_supplies_anthropic_auth(lookup),
"non-anthropic.com ANTHROPIC_BASE_URL must signal harness auth"
);
}

#[test]
fn harness_auth_false_when_base_url_is_anthropic_com() {
let lookup = env_from(&[("ANTHROPIC_BASE_URL", "https://api.anthropic.com")]);
assert!(
!harness_supplies_anthropic_auth(lookup),
"official anthropic.com base URL must not suppress keychain OAuth"
);
}

#[test]
fn harness_auth_false_when_nothing_set() {
let lookup = env_from(&[]);
assert!(
!harness_supplies_anthropic_auth(lookup),
"no env vars set must not trigger the guard"
);
}
}
2 changes: 1 addition & 1 deletion src/frontend/tui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1187,7 +1187,7 @@ fn render_dialog(dialog: &dialogs::Dialog, area: Rect, frame: &mut Frame) {
let visible = list_h as usize;
let start = selected
.saturating_sub(visible.saturating_sub(1))
.min(items.len().saturating_sub(visible).max(0));
.min(items.len().saturating_sub(visible));
let lines: Vec<Line> = items
.iter()
.enumerate()
Expand Down