Skip to content

Commit 4340cbe

Browse files
authored
Merge pull request #502 from Milhouszhang/fix/501-pr495-followups
fix(providers): PR #495 follow-ups — copilot 401 retry, real SSE streaming, device-flow errors, gemini env keys (#501)
2 parents 4d1e681 + 5022e65 commit 4340cbe

8 files changed

Lines changed: 1244 additions & 161 deletions

File tree

crates/puffer-cli/src/copilot_login.rs

Lines changed: 101 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
//! provider credential; the runtime later exchanges it for a short-lived
88
//! Copilot bearer (see `puffer-core/runtime/copilot.rs`).
99
10-
use anyhow::{bail, Context, Result};
10+
use anyhow::{anyhow, bail, Context, Result};
1111
use puffer_provider_registry::COPILOT_USER_AGENT;
1212
use serde::Deserialize;
1313
use std::time::Duration;
@@ -76,6 +76,7 @@ pub(crate) fn start_device_flow() -> Result<DeviceFlowStart> {
7676
}
7777

7878
/// Outcome of a single poll of the device-flow token endpoint.
79+
#[derive(Debug)]
7980
pub(crate) enum DeviceFlowPoll {
8081
/// User has not authorized yet — keep polling.
8182
Pending,
@@ -87,39 +88,32 @@ pub(crate) enum DeviceFlowPoll {
8788
Failed(String),
8889
}
8990

90-
/// Polls the token endpoint once with the device code.
91-
pub(crate) fn poll_device_flow(device_code: &str) -> Result<DeviceFlowPoll> {
92-
#[derive(Deserialize)]
93-
struct Resp {
94-
#[serde(default)]
95-
access_token: Option<String>,
96-
#[serde(default)]
97-
error: Option<String>,
91+
struct DevicePollHttpResponse {
92+
status: reqwest::StatusCode,
93+
body: String,
94+
}
95+
96+
#[derive(Deserialize)]
97+
struct DevicePollResponse {
98+
#[serde(default)]
99+
access_token: Option<String>,
100+
#[serde(default)]
101+
error: Option<String>,
102+
}
103+
104+
fn classify_device_flow_poll_response(
105+
response: Result<DevicePollHttpResponse>,
106+
) -> Result<DeviceFlowPoll> {
107+
let response = response?;
108+
if !response.status.is_success() {
109+
bail!(
110+
"GitHub device-flow token poll failed ({}): {}",
111+
response.status,
112+
response.body
113+
);
98114
}
99-
let client = http_client()?;
100-
// A single poll must not abort the whole login on a transient blip. Network
101-
// errors and non-JSON/unknown bodies (e.g. a 5xx HTML error page from an
102-
// infra hiccup) are treated as Pending so the caller keeps polling until the
103-
// device code genuinely expires; only GitHub's documented terminal device-
104-
// flow errors end the flow.
105-
let response = match client
106-
.post(ACCESS_TOKEN_URL)
107-
.header("Accept", "application/json")
108-
.header("User-Agent", COPILOT_USER_AGENT)
109-
.form(&[
110-
("client_id", COPILOT_CLIENT_ID),
111-
("device_code", device_code),
112-
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
113-
])
114-
.send()
115-
{
116-
Ok(response) => response,
117-
Err(_) => return Ok(DeviceFlowPoll::Pending),
118-
};
119-
let body = response.text().unwrap_or_default();
120-
let Ok(parsed) = serde_json::from_str::<Resp>(&body) else {
121-
return Ok(DeviceFlowPoll::Pending);
122-
};
115+
let parsed: DevicePollResponse =
116+
serde_json::from_str(&response.body).context("parsing GitHub device-flow poll response")?;
123117
if let Some(token) = parsed.access_token.filter(|t| !t.is_empty()) {
124118
return Ok(DeviceFlowPoll::Done(token));
125119
}
@@ -136,7 +130,80 @@ pub(crate) fn poll_device_flow(device_code: &str) -> Result<DeviceFlowPoll> {
136130
| "incorrect_device_code"
137131
| "device_flow_disabled"),
138132
) => Ok(DeviceFlowPoll::Failed(err.to_string())),
139-
// Unknown error code — treat as transient rather than aborting.
133+
// Unknown GitHub error code — treat as transient rather than aborting.
140134
Some(_) => Ok(DeviceFlowPoll::Pending),
141135
}
142136
}
137+
138+
/// Polls the token endpoint once with the device code.
139+
pub(crate) fn poll_device_flow(device_code: &str) -> Result<DeviceFlowPoll> {
140+
let client = http_client()?;
141+
// GitHub's device-flow protocol has explicit non-terminal states
142+
// (`authorization_pending`, `slow_down`). Transport failures are not one of
143+
// them: surface those as poll errors so desktop callers can use their
144+
// consecutive-error guard instead of waiting until device-code expiry.
145+
let response = match client
146+
.post(ACCESS_TOKEN_URL)
147+
.header("Accept", "application/json")
148+
.header("User-Agent", COPILOT_USER_AGENT)
149+
.form(&[
150+
("client_id", COPILOT_CLIENT_ID),
151+
("device_code", device_code),
152+
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
153+
])
154+
.send()
155+
{
156+
Ok(response) => {
157+
let status = response.status();
158+
let body = response.text().unwrap_or_default();
159+
Ok(DevicePollHttpResponse { status, body })
160+
}
161+
Err(error) => Err(anyhow!("GitHub device-flow poll network error: {error}")),
162+
};
163+
classify_device_flow_poll_response(response)
164+
}
165+
166+
#[cfg(test)]
167+
mod tests {
168+
use super::*;
169+
use anyhow::anyhow;
170+
use reqwest::StatusCode;
171+
172+
fn classify_ok(body: &str) -> Result<DeviceFlowPoll> {
173+
classify_device_flow_poll_response(Ok(DevicePollHttpResponse {
174+
status: StatusCode::OK,
175+
body: body.to_string(),
176+
}))
177+
}
178+
179+
#[test]
180+
fn authorization_pending_remains_pending() {
181+
let result = classify_ok(r#"{"error":"authorization_pending"}"#).unwrap();
182+
assert!(matches!(result, DeviceFlowPoll::Pending));
183+
}
184+
185+
#[test]
186+
fn slow_down_remains_slow_down() {
187+
let result = classify_ok(r#"{"error":"slow_down"}"#).unwrap();
188+
assert!(matches!(result, DeviceFlowPoll::SlowDown));
189+
}
190+
191+
#[test]
192+
fn transport_errors_are_not_mapped_to_pending() {
193+
let error = classify_device_flow_poll_response(Err(anyhow!("connection refused")))
194+
.expect_err("transport failures must reject the poll RPC");
195+
assert!(error.to_string().contains("connection refused"));
196+
}
197+
198+
#[test]
199+
fn malformed_poll_response_is_not_mapped_to_pending() {
200+
let error = classify_ok("<html>bad gateway</html>")
201+
.expect_err("malformed poll responses are not protocol pending states");
202+
assert!(
203+
error
204+
.to_string()
205+
.contains("parsing GitHub device-flow poll response"),
206+
"{error:#}"
207+
);
208+
}
209+
}

crates/puffer-cli/src/non_interactive.rs

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -600,14 +600,20 @@ fn compose_prompt(
600600
}
601601

602602
fn hydrate_env_auth(auth_store: &mut AuthStore) {
603-
for (provider, env_name) in [
604-
("openai", "OPENAI_API_KEY"),
605-
("anthropic", "ANTHROPIC_API_KEY"),
606-
] {
607-
if let Ok(value) = std::env::var(env_name) {
608-
let trimmed = value.trim();
609-
if !trimmed.is_empty() {
610-
auth_store.set_api_key(provider, trimmed.to_string());
603+
let mappings: &[(&str, &[&str])] = &[
604+
("openai", &["OPENAI_API_KEY"]),
605+
("anthropic", &["ANTHROPIC_API_KEY"]),
606+
("google", &["GEMINI_API_KEY", "GOOGLE_API_KEY"]),
607+
];
608+
609+
for (provider, env_names) in mappings {
610+
for env_name in *env_names {
611+
if let Ok(value) = std::env::var(env_name) {
612+
let trimmed = value.trim();
613+
if !trimmed.is_empty() {
614+
auth_store.set_api_key(*provider, trimmed.to_string());
615+
break;
616+
}
611617
}
612618
}
613619
}
@@ -798,6 +804,47 @@ impl ReplayArtifact {
798804
mod tests {
799805
use super::*;
800806
use puffer_provider_registry::ProviderDescriptor;
807+
use std::sync::{Mutex, OnceLock};
808+
809+
fn env_lock() -> &'static Mutex<()> {
810+
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
811+
LOCK.get_or_init(|| Mutex::new(()))
812+
}
813+
814+
struct EnvGuard {
815+
name: &'static str,
816+
prior: Option<String>,
817+
}
818+
819+
impl EnvGuard {
820+
fn set(name: &'static str, value: &str) -> Self {
821+
let prior = std::env::var(name).ok();
822+
std::env::set_var(name, value);
823+
Self { name, prior }
824+
}
825+
826+
fn remove(name: &'static str) -> Self {
827+
let prior = std::env::var(name).ok();
828+
std::env::remove_var(name);
829+
Self { name, prior }
830+
}
831+
}
832+
833+
impl Drop for EnvGuard {
834+
fn drop(&mut self) {
835+
match self.prior.take() {
836+
Some(value) => std::env::set_var(self.name, value),
837+
None => std::env::remove_var(self.name),
838+
}
839+
}
840+
}
841+
842+
fn api_key(auth_store: &AuthStore, provider: &str) -> Option<String> {
843+
match auth_store.get(provider) {
844+
Some(StoredCredential::ApiKey { key }) => Some(key.clone()),
845+
_ => None,
846+
}
847+
}
801848

802849
#[test]
803850
fn compose_prompt_includes_transcript_and_skill() {
@@ -833,6 +880,44 @@ mod tests {
833880
assert!(!looks_like_jsonl_transcript(r#"{"role":"user"}"#));
834881
}
835882

883+
#[test]
884+
fn hydrate_env_auth_uses_gemini_api_key_for_google() {
885+
let _lock = env_lock()
886+
.lock()
887+
.unwrap_or_else(|poison| poison.into_inner());
888+
let _openai = EnvGuard::remove("OPENAI_API_KEY");
889+
let _anthropic = EnvGuard::remove("ANTHROPIC_API_KEY");
890+
let _google = EnvGuard::remove("GOOGLE_API_KEY");
891+
let _gemini = EnvGuard::set("GEMINI_API_KEY", " gemini-key ");
892+
let mut auth_store = AuthStore::default();
893+
894+
hydrate_env_auth(&mut auth_store);
895+
896+
assert_eq!(
897+
api_key(&auth_store, "google").as_deref(),
898+
Some("gemini-key")
899+
);
900+
}
901+
902+
#[test]
903+
fn hydrate_env_auth_uses_google_api_key_alias_for_google() {
904+
let _lock = env_lock()
905+
.lock()
906+
.unwrap_or_else(|poison| poison.into_inner());
907+
let _openai = EnvGuard::remove("OPENAI_API_KEY");
908+
let _anthropic = EnvGuard::remove("ANTHROPIC_API_KEY");
909+
let _gemini = EnvGuard::remove("GEMINI_API_KEY");
910+
let _google = EnvGuard::set("GOOGLE_API_KEY", " google-key ");
911+
let mut auth_store = AuthStore::default();
912+
913+
hydrate_env_auth(&mut auth_store);
914+
915+
assert_eq!(
916+
api_key(&auth_store, "google").as_deref(),
917+
Some("google-key")
918+
);
919+
}
920+
836921
#[test]
837922
fn custom_model_selector_registers_unknown_provider_model() {
838923
let mut providers = ProviderRegistry::new();

crates/puffer-cli/tests/tmux_agent_loop.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -366,11 +366,11 @@ fn tmux_agent_loop_renders_assistant_reply_from_mock_anthropic() {
366366
"sh",
367367
&[
368368
"-lc",
369-
// HOME=workspace makes `.puffer/` in the workspace be the
370-
// workspace config dir. Tracing PUFFER_HTTP_TRACE_PATH lets
369+
// PUFFER_HOME=workspace makes `.puffer/` in the workspace be the
370+
// user config dir. Tracing PUFFER_HTTP_TRACE_PATH lets
371371
// post-mortem inspection see the wire bytes if the test fails.
372372
&format!(
373-
"HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
373+
"PUFFER_HOME='{ws}' HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
374374
ws = workspace.display(),
375375
bin = binary
376376
),
@@ -440,7 +440,7 @@ fn tmux_agent_loop_accepts_codex_default_provider_alias() {
440440
&[
441441
"-lc",
442442
&format!(
443-
"HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
443+
"PUFFER_HOME='{ws}' HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
444444
ws = workspace.display(),
445445
bin = binary
446446
),
@@ -548,7 +548,7 @@ fn tmux_agent_loop_drives_tool_round_trip_in_tui() {
548548
&[
549549
"-lc",
550550
&format!(
551-
"HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
551+
"PUFFER_HOME='{ws}' HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
552552
ws = workspace.display(),
553553
bin = binary
554554
),
@@ -641,7 +641,7 @@ fn tmux_agent_loop_validates_workflow_shorthand_in_tui() {
641641
&[
642642
"-lc",
643643
&format!(
644-
"HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
644+
"PUFFER_HOME='{ws}' HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
645645
ws = workspace.display(),
646646
bin = binary
647647
),
@@ -743,7 +743,7 @@ fn tmux_agent_loop_answers_ask_user_question_with_keyboard_selection() {
743743
&[
744744
"-lc",
745745
&format!(
746-
"HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
746+
"PUFFER_HOME='{ws}' HOME='{ws}' PUFFER_HTTP_TRACE_PATH='{ws}/wire.log' '{bin}'",
747747
ws = workspace.display(),
748748
bin = binary
749749
),

crates/puffer-core/runtime/copilot.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use anyhow::{bail, Context, Result};
1111
use puffer_provider_registry::{apply_copilot_client_identity, COPILOT_TOKEN_URL};
1212
use reqwest::blocking::Client;
1313
use std::collections::HashMap;
14+
#[cfg(test)]
15+
use std::sync::Arc;
1416
use std::sync::{Mutex, OnceLock};
1517
use std::time::{Duration, SystemTime, UNIX_EPOCH};
1618

@@ -79,6 +81,34 @@ fn cache() -> &'static Mutex<HashMap<String, CachedToken>> {
7981
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
8082
}
8183

84+
#[cfg(test)]
85+
type TestBearerExchange = Arc<dyn Fn(&str) -> Result<CopilotAuth> + Send + Sync>;
86+
87+
#[cfg(test)]
88+
fn test_bearer_exchange() -> &'static Mutex<Option<TestBearerExchange>> {
89+
static EXCHANGE: OnceLock<Mutex<Option<TestBearerExchange>>> = OnceLock::new();
90+
EXCHANGE.get_or_init(|| Mutex::new(None))
91+
}
92+
93+
#[cfg(test)]
94+
pub(super) struct TestBearerExchangeGuard;
95+
96+
#[cfg(test)]
97+
impl Drop for TestBearerExchangeGuard {
98+
fn drop(&mut self) {
99+
*test_bearer_exchange().lock().unwrap() = None;
100+
}
101+
}
102+
103+
#[cfg(test)]
104+
pub(super) fn install_test_bearer_exchange<F>(exchange: F) -> TestBearerExchangeGuard
105+
where
106+
F: Fn(&str) -> Result<CopilotAuth> + Send + Sync + 'static,
107+
{
108+
*test_bearer_exchange().lock().unwrap() = Some(Arc::new(exchange));
109+
TestBearerExchangeGuard
110+
}
111+
82112
/// Drops the cached exchanged bearer for a GitHub token. Called when the chat
83113
/// endpoint rejects the bearer with 401 (e.g. it was invalidated before its
84114
/// cached expiry — revoked Copilot seat, server-side rotation), and on
@@ -109,6 +139,11 @@ fn now_secs() -> u64 {
109139
/// small burst at first use / expiry; a shared in-flight map would add locking
110140
/// complexity for little gain, so we accept it.
111141
pub(crate) fn copilot_bearer_token(github_token: &str) -> Result<CopilotAuth> {
142+
#[cfg(test)]
143+
if let Some(exchange) = test_bearer_exchange().lock().unwrap().clone() {
144+
return exchange(github_token);
145+
}
146+
112147
let now = now_secs();
113148
if let Some(cached) = cache().lock().unwrap().get(github_token) {
114149
if cached.expires_at_secs > now + EXPIRY_SKEW_SECS {

0 commit comments

Comments
 (0)