重构git凭证托管,改为操作系统托管

This commit is contained in:
xintaofei
2026-03-21 18:00:05 +08:00
parent 44f812c8d2
commit 450b081e88
13 changed files with 180 additions and 19 deletions

46
src-tauri/Cargo.lock generated
View File

@@ -802,6 +802,7 @@ dependencies = [
"fix-path-env", "fix-path-env",
"flate2", "flate2",
"futures", "futures",
"keyring",
"kill_tree", "kill_tree",
"notify", "notify",
"portable-pty", "portable-pty",
@@ -1151,6 +1152,27 @@ dependencies = [
"syn 2.0.114", "syn 2.0.114",
] ]
[[package]]
name = "dbus"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"dbus",
"zeroize",
]
[[package]] [[package]]
name = "deflate64" name = "deflate64"
version = "0.1.10" version = "0.1.10"
@@ -2716,6 +2738,21 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"security-framework 2.11.1",
"security-framework 3.5.1",
"windows-sys 0.60.2",
"zeroize",
]
[[package]] [[package]]
name = "kill_tree" name = "kill_tree"
version = "0.2.4" version = "0.2.4"
@@ -2806,6 +2843,15 @@ version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.7.4" version = "0.7.4"

View File

@@ -49,6 +49,7 @@ base64 = "0.22"
agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_usage", "unstable_session_fork"] } agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_usage", "unstable_session_fork"] }
kill_tree = { version = "0.2", features = ["tokio"] } kill_tree = { version = "0.2", features = ["tokio"] }
which = "7" which = "7"
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"

View File

@@ -203,6 +203,38 @@ pub async fn update_github_accounts(
Ok(settings) Ok(settings)
} }
// ---------------------------------------------------------------------------
// Keyring token management
// ---------------------------------------------------------------------------
#[tauri::command]
pub async fn save_account_token(
account_id: String,
token: String,
) -> Result<(), AppCommandError> {
crate::keyring_store::set_token(&account_id, &token)
.map_err(|e| AppCommandError::io_error("Failed to save token to keyring").with_detail(e))
}
#[tauri::command]
pub async fn get_account_token(
account_id: String,
) -> Result<Option<String>, AppCommandError> {
Ok(crate::keyring_store::get_token(&account_id))
}
#[tauri::command]
pub async fn delete_account_token(
account_id: String,
) -> Result<(), AppCommandError> {
crate::keyring_store::delete_token(&account_id)
.map_err(|e| AppCommandError::io_error("Failed to delete token from keyring").with_detail(e))
}
// ---------------------------------------------------------------------------
// GitHub token validation
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GitHubUserResponse { struct GitHubUserResponse {
login: String, login: String,

View File

@@ -123,8 +123,10 @@ pub fn run_credential_helper() {
let remote_url = format!("https://{}", host); let remote_url = format!("https://{}", host);
if let Some(account) = find_matching_account(&settings.accounts, &remote_url) { if let Some(account) = find_matching_account(&settings.accounts, &remote_url) {
println!("username={}", account.username); if let Some(token) = crate::keyring_store::get_token(&account.id) {
println!("password={}", account.token); println!("username={}", account.username);
println!("password={}", token);
}
} }
}); });
} }
@@ -387,11 +389,19 @@ pub async fn try_inject_for_repo(
} }
}; };
let token = match crate::keyring_store::get_token(&account.id) {
Some(t) => t,
None => {
eprintln!("[GIT_CRED] no token in keyring for account {}", account.id);
return false;
}
};
eprintln!( eprintln!(
"[GIT_CRED] injecting credentials for {} (user: {})", "[GIT_CRED] injecting credentials for {} (user: {})",
remote_url, account.username remote_url, account.username
); );
inject_credentials(cmd, &account.username, &account.token, &askpass); inject_credentials(cmd, &account.username, &token, &askpass);
true true
} }
@@ -417,6 +427,14 @@ pub async fn try_inject_for_url(
None => return false, None => return false,
}; };
let token = match crate::keyring_store::get_token(&account.id) {
Some(t) => t,
None => {
eprintln!("[GIT_CRED] no token in keyring for account {}", account.id);
return false;
}
};
let askpass = match ensure_askpass_script(app_data_dir) { let askpass = match ensure_askpass_script(app_data_dir) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
@@ -425,7 +443,7 @@ pub async fn try_inject_for_url(
} }
}; };
inject_credentials(cmd, &account.username, &account.token, &askpass); inject_credentials(cmd, &account.username, &token, &askpass);
true true
} }
@@ -464,7 +482,6 @@ mod tests {
id: "1".into(), id: "1".into(),
server_url: "https://github.com".into(), server_url: "https://github.com".into(),
username: "user1".into(), username: "user1".into(),
token: "tok1".into(),
scopes: vec![], scopes: vec![],
avatar_url: None, avatar_url: None,
is_default: false, is_default: false,
@@ -474,7 +491,6 @@ mod tests {
id: "2".into(), id: "2".into(),
server_url: "https://gitlab.example.com".into(), server_url: "https://gitlab.example.com".into(),
username: "user2".into(), username: "user2".into(),
token: "tok2".into(),
scopes: vec![], scopes: vec![],
avatar_url: None, avatar_url: None,
is_default: true, is_default: true,
@@ -500,7 +516,6 @@ mod tests {
id: "1".into(), id: "1".into(),
server_url: "https://github.com".into(), server_url: "https://github.com".into(),
username: "personal".into(), username: "personal".into(),
token: "tok1".into(),
scopes: vec![], scopes: vec![],
avatar_url: None, avatar_url: None,
is_default: false, is_default: false,
@@ -510,7 +525,6 @@ mod tests {
id: "2".into(), id: "2".into(),
server_url: "https://github.com".into(), server_url: "https://github.com".into(),
username: "work".into(), username: "work".into(),
token: "tok2".into(),
scopes: vec![], scopes: vec![],
avatar_url: None, avatar_url: None,
is_default: true, is_default: true,

View File

@@ -0,0 +1,30 @@
use keyring::Entry;
const SERVICE_NAME: &str = "codeg";
fn token_key(account_id: &str) -> String {
format!("github-token:{}", account_id)
}
pub fn set_token(account_id: &str, token: &str) -> Result<(), String> {
let entry = Entry::new(SERVICE_NAME, &token_key(account_id))
.map_err(|e| format!("keyring init error: {e}"))?;
entry
.set_password(token)
.map_err(|e| format!("keyring set error: {e}"))
}
pub fn get_token(account_id: &str) -> Option<String> {
let entry = Entry::new(SERVICE_NAME, &token_key(account_id)).ok()?;
entry.get_password().ok()
}
pub fn delete_token(account_id: &str) -> Result<(), String> {
let entry = Entry::new(SERVICE_NAME, &token_key(account_id))
.map_err(|e| format!("keyring init error: {e}"))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("keyring delete error: {e}")),
}
}

View File

@@ -3,6 +3,7 @@ mod app_error;
mod commands; mod commands;
mod db; mod db;
pub mod git_credential; pub mod git_credential;
pub mod keyring_store;
mod models; mod models;
mod network; mod network;
mod parsers; mod parsers;
@@ -272,6 +273,9 @@ pub fn run() {
version_control::get_github_accounts, version_control::get_github_accounts,
version_control::validate_github_token, version_control::validate_github_token,
version_control::update_github_accounts, version_control::update_github_accounts,
version_control::save_account_token,
version_control::get_account_token,
version_control::delete_account_token,
acp_commands::acp_preflight, acp_commands::acp_preflight,
acp_commands::acp_connect, acp_commands::acp_connect,
acp_commands::acp_prompt, acp_commands::acp_prompt,

View File

@@ -64,7 +64,6 @@ pub struct GitHubAccount {
pub id: String, pub id: String,
pub server_url: String, pub server_url: String,
pub username: String, pub username: String,
pub token: String,
pub scopes: Vec<String>, pub scopes: Vec<String>,
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub is_default: bool, pub is_default: bool,

View File

@@ -13,6 +13,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { saveAccountToken } from "@/lib/tauri"
import type { GitHubAccount } from "@/lib/types" import type { GitHubAccount } from "@/lib/types"
interface AddGitAccountDialogProps { interface AddGitAccountDialogProps {
@@ -52,7 +53,7 @@ export function AddGitAccountDialog({
[onOpenChange, resetForm] [onOpenChange, resetForm]
) )
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(async () => {
const trimmedUrl = serverUrl.trim() const trimmedUrl = serverUrl.trim()
const trimmedUser = username.trim() const trimmedUser = username.trim()
const trimmedPass = password.trim() const trimmedPass = password.trim()
@@ -74,13 +75,19 @@ export function AddGitAccountDialog({
id: crypto.randomUUID(), id: crypto.randomUUID(),
server_url: trimmedUrl, server_url: trimmedUrl,
username: trimmedUser, username: trimmedUser,
token: trimmedPass,
scopes: [], scopes: [],
avatar_url: null, avatar_url: null,
is_default: isFirstAccount, is_default: isFirstAccount,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} }
try {
await saveAccountToken(account.id, trimmedPass)
} catch {
setError(t("gitAccount.passwordRequired"))
return
}
onAccountAdded(account) onAccountAdded(account)
handleOpenChange(false) handleOpenChange(false)
}, [ }, [

View File

@@ -14,7 +14,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { validateGitHubToken } from "@/lib/tauri" import { validateGitHubToken, saveAccountToken } from "@/lib/tauri"
import type { GitHubAccount } from "@/lib/types" import type { GitHubAccount } from "@/lib/types"
interface AddGitHubAccountDialogProps { interface AddGitHubAccountDialogProps {
@@ -94,13 +94,13 @@ export function AddGitHubAccountDialog({
id: crypto.randomUUID(), id: crypto.randomUUID(),
server_url: serverUrl.trim() || "https://github.com", server_url: serverUrl.trim() || "https://github.com",
username: result.username ?? "unknown", username: result.username ?? "unknown",
token: trimmedToken,
scopes: result.scopes, scopes: result.scopes,
avatar_url: result.avatar_url, avatar_url: result.avatar_url,
is_default: isFirstAccount, is_default: isFirstAccount,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} }
await saveAccountToken(account.id, trimmedToken)
onAccountAdded(account) onAccountAdded(account)
handleOpenChange(false) handleOpenChange(false)
} catch (err) { } catch (err) {

View File

@@ -33,6 +33,8 @@ import {
getGitHubAccounts, getGitHubAccounts,
updateGitHubAccounts, updateGitHubAccounts,
validateGitHubToken, validateGitHubToken,
getAccountToken,
deleteAccountToken,
} from "@/lib/tauri" } from "@/lib/tauri"
import type { import type {
GitDetectResult, GitDetectResult,
@@ -260,10 +262,15 @@ export function VersionControlSettings() {
async (account: GitHubAccount) => { async (account: GitHubAccount) => {
setTestingAccountId(account.id) setTestingAccountId(account.id)
try { try {
const token = await getAccountToken(account.id)
if (!token) {
toast.error(t("connectionFailed", { message: "Token not found" }))
return
}
if (isGitHubAccount(account)) { if (isGitHubAccount(account)) {
const result = await validateGitHubToken( const result = await validateGitHubToken(
account.server_url, account.server_url,
account.token token
) )
if (result.success) { if (result.success) {
toast.success(t("connectionSuccess")) toast.success(t("connectionSuccess"))
@@ -276,7 +283,7 @@ export function VersionControlSettings() {
} }
} else { } else {
// For non-GitHub accounts we can't validate via API, // For non-GitHub accounts we can't validate via API,
// just confirm the account is stored. // just confirm the token exists in keyring.
toast.success(t("connectionSuccess")) toast.success(t("connectionSuccess"))
} }
} catch (err) { } catch (err) {
@@ -315,6 +322,7 @@ export function VersionControlSettings() {
accounts: accounts.accounts.filter((a) => a.id !== removeTarget.id), accounts: accounts.accounts.filter((a) => a.id !== removeTarget.id),
} }
try { try {
await deleteAccountToken(removeTarget.id)
const saved = await updateGitHubAccounts(updated) const saved = await updateGitHubAccounts(updated)
setAccounts(saved) setAccounts(saved)
toast.success(t("removeSuccess")) toast.success(t("removeSuccess"))

View File

@@ -36,6 +36,7 @@ import {
validateGitHubToken, validateGitHubToken,
getGitHubAccounts, getGitHubAccounts,
updateGitHubAccounts, updateGitHubAccounts,
saveAccountToken,
} from "@/lib/tauri" } from "@/lib/tauri"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -146,14 +147,15 @@ async function saveGenericAccount(
(a) => a.username === creds.username && extractHost(a.server_url) === host (a) => a.username === creds.username && extractHost(a.server_url) === host
) )
if (!isDuplicate) { if (!isDuplicate) {
const newId = crypto.randomUUID()
await saveAccountToken(newId, creds.password)
await updateGitHubAccounts({ await updateGitHubAccounts({
accounts: [ accounts: [
...existing.accounts, ...existing.accounts,
{ {
id: crypto.randomUUID(), id: newId,
server_url: serverUrl, server_url: serverUrl,
username: creds.username, username: creds.username,
token: creds.password,
scopes: [], scopes: [],
avatar_url: null, avatar_url: null,
is_default: existing.accounts.length === 0, is_default: existing.accounts.length === 0,
@@ -284,12 +286,12 @@ export function GitCredentialProvider({ children }: { children: ReactNode }) {
id: crypto.randomUUID(), id: crypto.randomUUID(),
server_url: serverUrl, server_url: serverUrl,
username: result.username ?? "unknown", username: result.username ?? "unknown",
token: trimmedToken,
scopes: result.scopes, scopes: result.scopes,
avatar_url: result.avatar_url, avatar_url: result.avatar_url,
is_default: existing.accounts.length === 0, is_default: existing.accounts.length === 0,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
} }
await saveAccountToken(newAccount.id, trimmedToken)
await updateGitHubAccounts({ await updateGitHubAccounts({
accounts: [...existing.accounts, newAccount], accounts: [...existing.accounts, newAccount],
}) })

View File

@@ -344,6 +344,25 @@ export async function updateGitHubAccounts(
return invoke("update_github_accounts", { settings }) return invoke("update_github_accounts", { settings })
} }
export async function saveAccountToken(
accountId: string,
token: string
): Promise<void> {
return invoke("save_account_token", { accountId, token })
}
export async function getAccountToken(
accountId: string
): Promise<string | null> {
return invoke("get_account_token", { accountId })
}
export async function deleteAccountToken(
accountId: string
): Promise<void> {
return invoke("delete_account_token", { accountId })
}
export async function mcpScanLocal(): Promise<LocalMcpServer[]> { export async function mcpScanLocal(): Promise<LocalMcpServer[]> {
return invoke("mcp_scan_local") return invoke("mcp_scan_local")
} }

View File

@@ -544,7 +544,6 @@ export interface GitHubAccount {
id: string id: string
server_url: string server_url: string
username: string username: string
token: string
scopes: string[] scopes: string[]
avatar_url: string | null avatar_url: string | null
is_default: boolean is_default: boolean