fix(copilot): add proper OAuth device-flow authentication

The existing Copilot provider passes a static Bearer token, but the
Copilot API requires short-lived session tokens obtained via GitHub's
OAuth device code flow, plus mandatory editor headers.

This replaces the stub with a dedicated CopilotProvider that:

- Runs the OAuth device code flow on first use (same client ID as VS Code)
- Exchanges the OAuth token for a Copilot API key via
  api.github.com/copilot_internal/v2/token
- Sends required Editor-Version/Editor-Plugin-Version headers
- Caches tokens to disk (~/.config/zeroclaw/copilot/) with auto-refresh
- Uses Mutex to prevent concurrent refresh races / duplicate device prompts
- Writes token files with 0600 permissions (owner-only)
- Respects GitHub's polling interval and code expiry from device flow
- Sanitizes error messages to prevent token leakage
- Uses async filesystem I/O (tokio::fs) throughout
- Optionally accepts a pre-supplied GitHub token via config api_key

Fixes: 403 'Access to this endpoint is forbidden'
Fixes: 400 'missing Editor-Version header for IDE auth'
This commit is contained in:
Khoi Tran 2026-02-16 08:42:20 -08:00 committed by Chummy
parent a2f29838b4
commit 3c62b59a72
2 changed files with 748 additions and 5 deletions

View file

@ -1,5 +1,6 @@
pub mod anthropic;
pub mod compatible;
pub mod copilot;
pub mod gemini;
pub mod ollama;
pub mod openai;
@ -37,9 +38,18 @@ fn token_end(input: &str, from: usize) -> usize {
/// Scrub known secret-like token prefixes from provider error strings.
///
/// Redacts tokens with prefixes like `sk-`, `xoxb-`, and `xoxp-`.
/// Redacts tokens with prefixes like `sk-`, `xoxb-`, `xoxp-`, `ghp_`, `gho_`,
/// `ghu_`, and `github_pat_`.
pub fn scrub_secret_patterns(input: &str) -> String {
const PREFIXES: [&str; 3] = ["sk-", "xoxb-", "xoxp-"];
const PREFIXES: [&str; 7] = [
"sk-",
"xoxb-",
"xoxp-",
"ghp_",
"gho_",
"ghu_",
"github_pat_",
];
let mut scrubbed = input.to_string();
@ -290,9 +300,9 @@ pub fn create_provider_with_url(
"cohere" => Ok(Box::new(OpenAiCompatibleProvider::new(
"Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer,
))),
"copilot" | "github-copilot" => Ok(Box::new(OpenAiCompatibleProvider::new(
"GitHub Copilot", "https://api.githubcopilot.com", key, AuthStyle::Bearer,
))),
"copilot" | "github-copilot" => {
Ok(Box::new(copilot::CopilotProvider::new(api_key)))
},
"lmstudio" | "lm-studio" => {
let lm_studio_key = api_key
.map(str::trim)
@ -967,4 +977,32 @@ mod tests {
let result = sanitize_api_error(input);
assert_eq!(result, input);
}
#[test]
fn scrub_github_personal_access_token() {
let input = "auth failed with token ghp_abc123def456";
let result = scrub_secret_patterns(input);
assert_eq!(result, "auth failed with token [REDACTED]");
}
#[test]
fn scrub_github_oauth_token() {
let input = "Bearer gho_1234567890abcdef";
let result = scrub_secret_patterns(input);
assert_eq!(result, "Bearer [REDACTED]");
}
#[test]
fn scrub_github_user_token() {
let input = "token ghu_sessiontoken123";
let result = scrub_secret_patterns(input);
assert_eq!(result, "token [REDACTED]");
}
#[test]
fn scrub_github_fine_grained_pat() {
let input = "failed: github_pat_11AABBC_xyzzy789";
let result = scrub_secret_patterns(input);
assert_eq!(result, "failed: [REDACTED]");
}
}