feat(antigravity): add Google Antigravity auth plugin with auto-rotation, model catalog and multi-turn support

This commit is contained in:
masudranaxpert
2026-09-01 18:23:55 +06:00
parent 8c6c415a84
commit d7578bcc15
30 changed files with 3327 additions and 91 deletions
+1
View File
@@ -125,6 +125,7 @@ impl App {
self.registry.web_cache().set_service_addr(address);
self.harness.set_backend_addr(address);
tracing::info!(%address, "cursor server listening");
tokio::spawn(crate::plugin::oauth_callback::start_oauth_callback_server(shutdown.clone()));
let registry = self.registry;
let harness = self.harness;
let graceful = shutdown.clone();
+8
View File
@@ -138,6 +138,14 @@ pub fn api_router(service: ControlService) -> Router {
.route("/__byok-api__/api/llm-calls", get(calls::list))
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
.route("/__byok-api__/api/plugins", get(plugins::list))
.route(
"/__byok-api__/api/plugins/disabled-models",
get(plugins::get_disabled_models).put(plugins::set_disabled_models),
)
.route(
"/__byok-api__/api/plugins/disabled-accounts",
get(plugins::get_disabled_accounts).put(plugins::set_disabled_accounts),
)
.route(
"/__byok-api__/api/plugins/runtime",
get(plugins::runtime_status)
+43
View File
@@ -4,6 +4,7 @@ use axum::{
http::StatusCode,
Json,
};
use serde::Deserialize;
use crate::{
plugin::{
@@ -122,3 +123,45 @@ pub async fn cancel_runtime_initialization(
) -> Result<Json<PluginRuntimeStatus>> {
Ok(Json(service.cancel_plugin_runtime_initialization()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetDisabledModelsInput {
pub model_ids: Vec<String>,
}
pub async fn get_disabled_models(
State(service): State<ControlService>,
) -> Result<Json<Vec<String>>> {
Ok(Json(service.disabled_plugin_models().await?))
}
pub async fn set_disabled_models(
State(service): State<ControlService>,
Json(input): Json<SetDisabledModelsInput>,
) -> Result<Json<Vec<String>>> {
service.set_disabled_plugin_models(input.model_ids).await?;
Ok(Json(service.disabled_plugin_models().await?))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetDisabledAccountsInput {
pub account_ids: Vec<String>,
}
pub async fn get_disabled_accounts(
State(service): State<ControlService>,
) -> Result<Json<Vec<String>>> {
Ok(Json(service.disabled_plugin_accounts().await?))
}
pub async fn set_disabled_accounts(
State(service): State<ControlService>,
Json(input): Json<SetDisabledAccountsInput>,
) -> Result<Json<Vec<String>>> {
service
.set_disabled_plugin_accounts(input.account_ids)
.await?;
Ok(Json(service.disabled_plugin_accounts().await?))
}
+22
View File
@@ -251,6 +251,28 @@ impl ControlService {
self.plugin_runtime.cancel_initialization()
}
pub async fn disabled_plugin_models(&self) -> Result<Vec<String>> {
let mut list: Vec<_> = self.store.disabled_plugin_models().await?.into_iter().collect();
list.sort();
Ok(list)
}
pub async fn set_disabled_plugin_models(&self, model_ids: Vec<String>) -> Result<()> {
let set = model_ids.into_iter().collect();
self.store.set_disabled_plugin_models(&set).await
}
pub async fn disabled_plugin_accounts(&self) -> Result<Vec<String>> {
let mut list: Vec<_> = self.store.disabled_plugin_accounts().await?.into_iter().collect();
list.sort();
Ok(list)
}
pub async fn set_disabled_plugin_accounts(&self, account_ids: Vec<String>) -> Result<()> {
let set = account_ids.into_iter().collect();
self.store.set_disabled_plugin_accounts(&set).await
}
pub(super) async fn ads(
&self,
disabled_ad_ids: Option<&str>,
+57 -1
View File
@@ -109,7 +109,63 @@ const GROK_AUTH: &[(&str, &str)] = &[
),
];
const PLUGINS: &[(&str, &[(&str, &str)])] = &[("codex-auth", CODEX_AUTH), ("grok-auth", GROK_AUTH)];
const ANTIGRAVITY_AUTH: &[(&str, &str)] = &[
(
"plugin.json",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/plugin.json"
)),
),
(
"main.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/main.ts"
)),
),
(
"provider.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/provider.ts"
)),
),
(
"models.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/models.ts"
)),
),
(
"oauth.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/oauth.ts"
)),
),
(
"resources.ts",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/resources.ts"
)),
),
(
"assets/antigravity.svg",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/plugins/build-in/antigravity-auth/assets/antigravity.svg"
)),
),
];
const PLUGINS: &[(&str, &[(&str, &str)])] = &[
("antigravity-auth", ANTIGRAVITY_AUTH),
("codex-auth", CODEX_AUTH),
("grok-auth", GROK_AUTH),
];
/// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键:
/// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。
+4 -4
View File
@@ -203,20 +203,20 @@ fn validate_component(value: &str, label: &str) -> Result<()> {
Ok(())
}
fn set_directory_permissions(path: &Path) -> Result<()> {
fn set_directory_permissions(_path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn set_file_permissions(path: &Path) -> Result<()> {
fn set_file_permissions(_path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
+1
View File
@@ -7,6 +7,7 @@ mod definition;
mod descriptor;
mod installation;
mod manifest;
pub mod oauth_callback;
mod protocol;
mod registry;
mod runtime;
+98
View File
@@ -0,0 +1,98 @@
//! Lightweight local OAuth callback server for Google / Antigravity OAuth redirect flows.
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
use axum::{
extract::{Query, State},
response::Html,
routing::get,
Json, Router,
};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
#[derive(Default, Clone)]
pub struct OAuthCallbackState {
codes: Arc<RwLock<HashMap<String, String>>>,
}
#[derive(Deserialize)]
pub struct CallbackQuery {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
}
#[derive(Deserialize)]
pub struct StatusQuery {
pub state: Option<String>,
}
#[derive(Serialize)]
pub struct StatusResponse {
pub code: Option<String>,
}
pub async fn start_oauth_callback_server(shutdown: tokio_util::sync::CancellationToken) {
let port = 51121;
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let state = OAuthCallbackState::default();
let router = Router::new()
.route("/oauth-callback", get(handle_callback))
.route("/auth-status", get(handle_status))
.with_state(state);
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(err) => {
tracing::warn!(%addr, %err, "OAuth callback port 51121 unavailable or already bound");
return;
}
};
tracing::info!(%addr, "OAuth callback server listening");
let server = axum::serve(listener, router).with_graceful_shutdown(async move {
shutdown.cancelled().await;
});
if let Err(err) = server.await {
tracing::debug!(%err, "OAuth callback server stopped");
}
}
async fn handle_callback(
State(state): State<OAuthCallbackState>,
Query(query): Query<CallbackQuery>,
) -> Html<&'static str> {
if let (Some(code), Some(st)) = (query.code, query.state) {
state.codes.write().insert(st, code);
}
Html(r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Antigravity Authorization Successful</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #18181b; color: #f4f4f5; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: #27272a; border: 1px solid #3f3f46; padding: 36px; border-radius: 16px; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.5); max-width: 440px; }
.icon { font-size: 40px; color: #4285f4; margin-bottom: 16px; }
h1 { color: #ffffff; font-size: 20px; margin: 0 0 12px 0; }
p { color: #a1a1aa; font-size: 14px; line-height: 1.6; margin: 0; }
</style>
</head>
<body>
<div class="card">
<div class="icon">✓</div>
<h1>Authorization Successful</h1>
<p>Your Google Antigravity account has been authorized. You can close this browser tab and return to <strong>Cursor BYOK</strong>.</p>
</div>
</body>
</html>"#)
}
async fn handle_status(
State(state): State<OAuthCallbackState>,
Query(query): Query<StatusQuery>,
) -> Json<StatusResponse> {
let code = query.state.and_then(|st| state.codes.read().get(&st).cloned());
Json(StatusResponse { code })
}
+83 -18
View File
@@ -40,6 +40,7 @@ struct RegistryInner {
entries: RwLock<Option<Vec<PluginEntry>>>,
workers: Mutex<HashMap<String, Arc<PluginWorker>>>,
oauth_sessions: Mutex<HashMap<String, OAuthSession>>,
rr_counter: std::sync::atomic::AtomicUsize,
}
struct OAuthSession {
@@ -108,6 +109,7 @@ impl PluginRegistry {
entries: RwLock::new(None),
workers: Mutex::new(HashMap::new()),
oauth_sessions: Mutex::new(HashMap::new()),
rr_counter: std::sync::atomic::AtomicUsize::new(0),
}),
})
}
@@ -142,6 +144,12 @@ impl PluginRegistry {
let Some(executable) = self.inner.runtime.executable() else {
return Vec::new();
};
let disabled_models = self
.inner
.store
.disabled_plugin_models()
.await
.unwrap_or_default();
let mut models = Vec::new();
for entry in self.entries(&executable).await {
for provider in &entry.definition.providers {
@@ -154,14 +162,19 @@ impl PluginRegistry {
.models(&entry.manifest.id, &provider.id)
.await
.unwrap_or_default();
models.extend(stored.iter().map(|model| {
PluginModelDescriptor::new(
models.extend(stored.iter().filter_map(|model| {
let descriptor = PluginModelDescriptor::new(
&entry.manifest.id,
&entry.manifest.name,
&entry.icon,
provider,
model,
)
);
if disabled_models.contains(&descriptor.id) {
None
} else {
Some(descriptor)
}
}));
}
}
@@ -192,6 +205,15 @@ impl PluginRegistry {
}
pub async fn plan_model(&self, model_id: &str) -> Result<PluginInvocationPlan> {
let disabled_models = self
.inner
.store
.disabled_plugin_models()
.await
.unwrap_or_default();
if disabled_models.contains(model_id) {
return Err(Error::Provider(format!("plugin model '{model_id}' is disabled")));
}
let model = self.model_descriptor(model_id).await?;
let request_url = format!("plugin://{}/{}", model.plugin_id, model.provider_id);
Ok(PluginInvocationPlan { model, request_url })
@@ -704,13 +726,21 @@ impl PluginRegistry {
}
}
match &provider.resource_type {
Some(resource_type) => !self
.inner
.state
.resources(plugin_id, resource_type)
.await
.unwrap_or_default()
.is_empty(),
Some(resource_type) => {
let disabled_accounts = self
.inner
.store
.disabled_plugin_accounts()
.await
.unwrap_or_default();
let resources = self
.inner
.state
.resources(plugin_id, resource_type)
.await
.unwrap_or_default();
resources.iter().any(|r| !disabled_accounts.contains(&r.id))
}
None => true,
}
}
@@ -796,19 +826,54 @@ impl PluginRegistry {
plugin_id: &str,
resource_type: &str,
) -> Result<ResourceRecord> {
let disabled_accounts = self
.inner
.store
.disabled_plugin_accounts()
.await
.unwrap_or_default();
let records = self.inner.state.resources(plugin_id, resource_type).await?;
if records.is_empty() {
let active_records: Vec<_> = records
.into_iter()
.filter(|record| !disabled_accounts.contains(&record.id))
.collect();
if active_records.is_empty() {
return Err(Error::Provider(format!(
"plugin '{plugin_id}' has no '{resource_type}' resource; add one first"
"plugin '{plugin_id}' has no enabled '{resource_type}' resource; enable or add one first"
)));
}
let now = now_ms();
records
.iter()
.find(|record| record.state.is_ready(now))
.or_else(|| records.first())
.cloned()
.ok_or_else(|| Error::Provider("no plugin resource is available".into()))
let mut ready_records: Vec<_> = active_records
.into_iter()
.filter(|record| record.state.is_ready(now))
.collect();
if ready_records.is_empty() {
return Err(Error::Provider(format!(
"all enabled accounts for plugin '{plugin_id}' are currently cooling or rate-limited"
)));
}
let get_priority = |r: &ResourceRecord| -> u8 {
let label = r.private_data.get("quota").and_then(|q| q.get("planLabel")).and_then(|l| l.as_str()).unwrap_or("");
let lower = label.to_lowercase();
if label.contains("🔥") || lower.contains("pro") || lower.contains("ultra") || lower.contains("premium") || lower.contains("advanced") {
0
} else {
1
}
};
ready_records.sort_by_key(|r| get_priority(r));
if let Some(best_prio) = ready_records.first().map(|r| get_priority(r)) {
ready_records.retain(|r| get_priority(r) == best_prio);
}
let index = self
.inner
.rr_counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
% ready_records.len();
Ok(ready_records[index].clone())
}
async fn find_record(
@@ -102,7 +102,6 @@ export function buildChatBody(call: OpenAiChatCall): Record<string, JsonValue> {
body.reasoning_effort = call.request.reasoning.effort;
}
if (call.request.latency === "fast") body.service_tier = "fast";
if (call.request.cacheKey !== null) body.prompt_cache_key = call.request.cacheKey;
return { ...body, ...call.extraBody };
}
+9 -2
View File
@@ -425,9 +425,16 @@ impl HostContext {
let raw_url = required_string(params, "url")?;
let url = url::Url::parse(raw_url)
.map_err(|error| Error::Config(format!("invalid plugin network URL: {error}")))?;
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
let is_loopback = url
.host_str()
.map(|h| h == "127.0.0.1" || h == "localhost")
.unwrap_or(false);
if (url.scheme() != "https" && (!is_loopback || url.scheme() != "http"))
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(Error::Config(
"plugin network URL must be HTTPS without credentials".into(),
"plugin network URL must be HTTPS without credentials (or loopback HTTP)".into(),
));
}
let host = url
+56
View File
@@ -1,4 +1,6 @@
//! Persists application settings.
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::Result;
@@ -10,6 +12,8 @@ const PROXY_SETTINGS_KEY: &str = "outbound_proxy";
const TAB_SETTINGS_KEY: &str = "cursor_tab";
const INSTALLATION_ID_KEY: &str = "installation_id";
const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle";
const DISABLED_PLUGIN_MODELS_KEY: &str = "disabled_plugin_models";
const DISABLED_PLUGIN_ACCOUNTS_KEY: &str = "disabled_plugin_accounts";
pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn";
@@ -299,4 +303,56 @@ impl Store {
.await?;
Ok(())
}
pub async fn disabled_plugin_models(&self) -> Result<HashSet<String>> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
)
.bind(DISABLED_PLUGIN_MODELS_KEY)
.fetch_optional(&self.pool)
.await?;
value
.map(|value| serde_json::from_str(&value).map_err(Into::into))
.unwrap_or_else(|| Ok(HashSet::new()))
}
pub async fn set_disabled_plugin_models(&self, model_ids: &HashSet<String>) -> Result<()> {
let value_json = serde_json::to_string(model_ids)?;
let _write = self.writes.lock().await;
sqlx::query(
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
)
.bind(DISABLED_PLUGIN_MODELS_KEY)
.bind(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn disabled_plugin_accounts(&self) -> Result<HashSet<String>> {
let value = sqlx::query_scalar::<_, String>(
"SELECT value_json FROM service_settings WHERE setting_key = ?",
)
.bind(DISABLED_PLUGIN_ACCOUNTS_KEY)
.fetch_optional(&self.pool)
.await?;
value
.map(|value| serde_json::from_str(&value).map_err(Into::into))
.unwrap_or_else(|| Ok(HashSet::new()))
}
pub async fn set_disabled_plugin_accounts(&self, account_ids: &HashSet<String>) -> Result<()> {
let value_json = serde_json::to_string(account_ids)?;
let _write = self.writes.lock().await;
sqlx::query(
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
)
.bind(DISABLED_PLUGIN_ACCOUNTS_KEY)
.bind(value_json)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
}