Skip to content
Merged
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
166 changes: 146 additions & 20 deletions clients/agent-runtime/src/onboard/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ const MODEL_PREVIEW_LIMIT: usize = 20;
const MODEL_CACHE_FILE: &str = "models_cache.json";
const MODEL_CACHE_TTL_SECS: u64 = 12 * 60 * 60;
const CUSTOM_MODEL_SENTINEL: &str = "__custom_model__";
const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token";
const COPILOT_DEFAULT_API: &str = "https://api.githubcopilot.com";

const COPILOT_DISCOVERY_HEADERS: [(&str, &str); 4] = [
("Editor-Version", "vscode/1.85.1"),
("Editor-Plugin-Version", "copilot/1.155.0"),
("User-Agent", "GithubCopilot/1.155.0"),
("Accept", "application/json"),
];
Comment on lines +60 to +68
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

copilot_internal endpoint violates GitHub ToS and is an undocumented unstable API

COPILOT_TOKEN_URL points to https://api.github.com/copilot_internal/v2/token, which is not part of GitHub's public API contract. The endpoint is "intended solely for use through GitHub Copilot's officially supported clients" and using it in other applications "would violate GitHub's Terms of Service and the Copilot license agreement." This exposes users to risk of account suspension and the endpoint can change or disappear without notice.

Additionally, COPILOT_DISCOVERY_HEADERS hardcodes specific VS Code (1.85.1) and Copilot (1.155.0) version strings to impersonate a VS Code session — directly tied to the ToS concern. If GitHub ever enforces version freshness, discovery will silently fail for all users with no actionable error.

These are architectural concerns that should be validated before shipping:

  1. Confirm the legal/ToS posture of using copilot_internal in a third-party CLI agent.
  2. Document why the version strings are pinned and at what cadence they will be updated.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/agent-runtime/src/onboard/wizard.rs` around lines 60 - 68, The code
currently uses an undocumented/unsupported endpoint and hardcoded impersonation
headers: remove or gate usage of COPILOT_TOKEN_URL
("https://api.github.com/copilot_internal/v2/token") and the pinned
COPILOT_DISCOVERY_HEADERS, and instead default to the public COPILOT_DEFAULT_API
flow or a configurable, documented backend; update the code around
COPILOT_TOKEN_URL, COPILOT_DEFAULT_API, and COPILOT_DISCOVERY_HEADERS to (a)
stop calling the internal endpoint unless a clearly named opt-in config flag is
set and documented with legal/ToS approval, (b) replace hardcoded VSCode/Copilot
version strings with either dynamic user-agent construction or a configurable
header value with clear update cadence, and (c) add runtime checks/log messages
that surface when the internal endpoint is disabled so callers fall back
gracefully to the public API.


#[derive(Debug, Deserialize)]
struct CopilotApiEndpoints {
api: Option<String>,
}

#[derive(Deserialize)]
struct CopilotApiKeyInfo {
token: String,
#[serde(default)]
endpoints: Option<CopilotApiEndpoints>,
}

// ── Main wizard entry point ──────────────────────────────────────

Expand Down Expand Up @@ -814,6 +835,7 @@ fn supports_live_model_fetch(provider_name: &str) -> bool {
| "together-ai"
| "gemini"
| "ollama"
| "copilot"
| "astrai"
)
}
Expand Down Expand Up @@ -1001,24 +1023,76 @@ fn fetch_ollama_models() -> Result<Vec<String>> {
Ok(parse_ollama_model_ids(&payload))
}

fn resolve_live_model_api_key(provider_name: &str, api_key: &str) -> Option<String> {
if !api_key.trim().is_empty() {
return Some(api_key.trim().to_string());
}

std::env::var(provider_env_var(provider_name))
.ok()
.or_else(|| {
// Anthropic also accepts OAuth setup-tokens via ANTHROPIC_OAUTH_TOKEN
if provider_name == "anthropic" {
std::env::var("ANTHROPIC_OAUTH_TOKEN").ok()
} else if provider_name == "copilot" {
// GitHub CLI commonly exposes GH_TOKEN
std::env::var("GH_TOKEN").ok()
} else {
None
}
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn fetch_copilot_models(api_key: Option<&str>) -> Result<Vec<String>> {
let github_token = api_key
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("GitHub token is required for Copilot model discovery"))?;

let client = build_model_fetch_client()?;

let mut token_request = client
.get(COPILOT_TOKEN_URL)
.header("Authorization", format!("token {github_token}"));
for (header, value) in COPILOT_DISCOVERY_HEADERS {
token_request = token_request.header(header, value);
}

let api_key_info: CopilotApiKeyInfo = token_request
.send()
.and_then(reqwest::blocking::Response::error_for_status)
.context("model fetch failed: GET Copilot API token")?
.json()
.context("failed to parse Copilot API token response")?;

let endpoint = api_key_info
.endpoints
.and_then(|endpoints| endpoints.api)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| COPILOT_DEFAULT_API.to_string());

let mut model_request = client
.get(format!("{}/models", endpoint.trim_end_matches('/')))
.header("Authorization", format!("Bearer {}", api_key_info.token));
for (header, value) in COPILOT_DISCOVERY_HEADERS {
model_request = model_request.header(header, value);
}

let payload: Value = model_request
.send()
.and_then(reqwest::blocking::Response::error_for_status)
.context("model fetch failed: GET Copilot models")?
.json()
.context("failed to parse Copilot model list response")?;

Ok(parse_openai_compatible_model_ids(&payload))
}

fn fetch_live_models_for_provider(provider_name: &str, api_key: &str) -> Result<Vec<String>> {
let provider_name = canonical_provider_name(provider_name);
let api_key = if api_key.trim().is_empty() {
std::env::var(provider_env_var(provider_name))
.ok()
.or_else(|| {
// Anthropic also accepts OAuth setup-tokens via ANTHROPIC_OAUTH_TOKEN
if provider_name == "anthropic" {
std::env::var("ANTHROPIC_OAUTH_TOKEN").ok()
} else {
None
}
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
} else {
Some(api_key.trim().to_string())
};
let api_key = resolve_live_model_api_key(provider_name, api_key);

let models = match provider_name {
"openrouter" => fetch_openrouter_models(api_key.as_deref())?,
Expand Down Expand Up @@ -1065,6 +1139,7 @@ fn fetch_live_models_for_provider(provider_name: &str, api_key: &str) -> Result<
"astrai" => {
fetch_openai_compatible_models("https://as-trai.com/v1/models", api_key.as_deref())?
}
"copilot" => fetch_copilot_models(api_key.as_deref())?,
_ => Vec::new(),
};

Expand Down Expand Up @@ -1914,10 +1989,7 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio

if supports_live_model_fetch(provider_name) {
let can_fetch_without_key = matches!(provider_name, "openrouter" | "ollama");
let has_api_key = !api_key.trim().is_empty()
|| std::env::var(provider_env_var(provider_name))
.ok()
.is_some_and(|value| !value.trim().is_empty());
let has_api_key = resolve_live_model_api_key(provider_name, &api_key).is_some();

if can_fetch_without_key || has_api_key {
if let Some(cached) =
Expand Down Expand Up @@ -4515,8 +4587,38 @@ fn print_summary(config: &Config) {
mod tests {
use super::*;
use serde_json::json;
use std::sync::{Mutex, OnceLock};
use tempfile::TempDir;

fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}

struct EnvRestore {
key: &'static str,
value: Option<String>,
}

impl EnvRestore {
fn capture(key: &'static str) -> Self {
Self {
key,
value: std::env::var(key).ok(),
}
}
}

impl Drop for EnvRestore {
fn drop(&mut self) {
if let Some(value) = &self.value {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}

// ── ProjectContext defaults ──────────────────────────────────

#[test]
Expand Down Expand Up @@ -4998,10 +5100,21 @@ mod tests {
assert!(supports_live_model_fetch("grok"));
assert!(supports_live_model_fetch("together"));
assert!(supports_live_model_fetch("ollama"));
assert!(supports_live_model_fetch("copilot"));
assert!(supports_live_model_fetch("github-copilot"));
assert!(supports_live_model_fetch("astrai"));
assert!(!supports_live_model_fetch("venice"));
}

#[test]
fn fetch_copilot_models_requires_non_empty_token() {
let missing = fetch_copilot_models(None).unwrap_err().to_string();
assert!(missing.contains("GitHub token is required"));

let empty = fetch_copilot_models(Some(" ")).unwrap_err().to_string();
assert!(empty.contains("GitHub token is required"));
}

#[test]
fn curated_models_provider_aliases_share_same_catalog() {
assert_eq!(
Expand Down Expand Up @@ -5215,6 +5328,19 @@ mod tests {
assert_eq!(provider_env_var("some-new-provider"), "API_KEY");
}

#[test]
fn resolve_live_model_api_key_uses_gh_token_for_copilot() {
let _guard = env_lock().lock().unwrap_or_else(|error| error.into_inner());
let _restore_github_token = EnvRestore::capture("GITHUB_TOKEN");
let _restore_gh_token = EnvRestore::capture("GH_TOKEN");

std::env::remove_var("GITHUB_TOKEN");
std::env::set_var("GH_TOKEN", " ghp-from-gh-cli ");

let resolved = resolve_live_model_api_key("copilot", "");
assert_eq!(resolved.as_deref(), Some("ghp-from-gh-cli"));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[test]
fn backend_key_from_choice_maps_supported_backends() {
assert_eq!(backend_key_from_choice(0), "sqlite");
Expand Down
Loading