feat: add GitLab Duo and CodeBuddy support, update observability settings

This commit is contained in:
decolua
2026-03-30 11:28:07 +07:00
parent 11e6004fcb
commit abbf8ec86f
21 changed files with 779 additions and 141 deletions
+87 -49
View File
@@ -40,6 +40,11 @@ if (!isCloud && !fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// Seed db.json with defaults on first run so proper-lockfile never hits ENOENT
if (!isCloud && DB_FILE && !fs.existsSync(DB_FILE)) {
fs.writeFileSync(DB_FILE, JSON.stringify(defaultData, null, 2));
}
// Default data structure
const defaultData = {
providerConnections: [],
@@ -58,7 +63,7 @@ const defaultData = {
comboStrategy: "fallback",
comboStrategies: {},
requireLogin: true,
observabilityEnabled: true,
enableObservability: false,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
observabilityFlushIntervalMs: 5000,
@@ -88,7 +93,7 @@ function cloneDefaultData() {
comboStrategy: "fallback",
comboStrategies: {},
requireLogin: true,
observabilityEnabled: true,
enableObservability: false,
observabilityMaxRecords: 1000,
observabilityBatchSize: 20,
observabilityFlushIntervalMs: 5000,
@@ -162,6 +167,19 @@ function ensureDbShape(data) {
// Singleton instance
let dbInstance = null;
// In-memory read cache to avoid redundant disk reads under high load
const DB_CACHE_TTL = 500; // ms
let dbCache = { data: null, ts: 0 };
// Serialize all DB operations (reads on cache-miss + writes) to prevent race conditions
let dbQueue = Promise.resolve();
function withDbLock(fn) {
const next = dbQueue.then(fn, fn);
dbQueue = next.catch(() => {});
return next;
}
// Lock options for proper-lockfile
const LOCK_OPTIONS = {
retries: {
@@ -204,7 +222,8 @@ async function safeRead(db) {
}
/**
* Safely write database with file locking
* Safely write database with file locking.
* Always invalidates read cache so next read reflects the latest state.
*/
async function safeWrite(db) {
if (isCloud) {
@@ -214,9 +233,10 @@ async function safeWrite(db) {
let release = null;
try {
// Acquire lock before writing
release = await lockfile.lock(DB_FILE, LOCK_OPTIONS);
await db.write();
// Invalidate cache immediately after a successful write
dbCache.ts = 0;
} catch (error) {
if (error.code === "ELOCKED") {
console.warn("[DB] File is locked, retrying write...");
@@ -235,11 +255,14 @@ async function safeWrite(db) {
}
/**
* Get database instance (singleton)
* Get database instance (singleton).
*
* Hot path: if cache is fresh, return immediately without any I/O or queuing.
* Cold path: serialize via withDbLock to prevent concurrent reads from racing
* against in-flight writes (eliminates lost-update race condition).
*/
export async function getDb() {
if (isCloud) {
// Return in-memory DB for Workers
if (!dbInstance) {
const data = cloneDefaultData();
dbInstance = new Low({ read: async () => {}, write: async () => {} }, data);
@@ -248,37 +271,57 @@ export async function getDb() {
return dbInstance;
}
if (!dbInstance) {
const adapter = new JSONFile(DB_FILE);
dbInstance = new Low(adapter, cloneDefaultData());
// Hot path: cache hit — no lock, no disk I/O
if (dbCache.data && Date.now() - dbCache.ts < DB_CACHE_TTL) {
if (!dbInstance) {
const adapter = new JSONFile(DB_FILE);
dbInstance = new Low(adapter, dbCache.data);
}
dbInstance.data = dbCache.data;
return dbInstance;
}
// Always read latest disk state to avoid stale singleton data across route workers.
try {
await safeRead(dbInstance);
} catch (error) {
if (error instanceof SyntaxError) {
console.warn('[DB] Corrupt JSON detected, resetting to defaults...');
// Cold path: serialize with writes to prevent race conditions
return withDbLock(async () => {
// Re-check cache inside lock — another queued task may have already loaded it
if (dbCache.data && Date.now() - dbCache.ts < DB_CACHE_TTL) {
dbInstance.data = dbCache.data;
return dbInstance;
}
if (!dbInstance) {
const adapter = new JSONFile(DB_FILE);
dbInstance = new Low(adapter, cloneDefaultData());
}
try {
await safeRead(dbInstance);
} catch (error) {
if (error instanceof SyntaxError) {
console.warn("[DB] Corrupt JSON detected, resetting to defaults...");
dbInstance.data = cloneDefaultData();
await safeWrite(dbInstance);
} else {
throw error;
}
}
// Initialize/migrate missing keys for older DB schema versions
if (!dbInstance.data) {
dbInstance.data = cloneDefaultData();
await safeWrite(dbInstance);
} else {
throw error;
const { data, changed } = ensureDbShape(dbInstance.data);
dbInstance.data = data;
if (changed) await safeWrite(dbInstance);
}
}
// Initialize/migrate missing keys for older DB schema versions.
if (!dbInstance.data) {
dbInstance.data = cloneDefaultData();
await safeWrite(dbInstance);
} else {
const { data, changed } = ensureDbShape(dbInstance.data);
dbInstance.data = data;
if (changed) {
await safeWrite(dbInstance);
}
}
// Update cache after successful read
dbCache.data = dbInstance.data;
dbCache.ts = Date.now();
return dbInstance;
return dbInstance;
});
}
// ============ Provider Connections ============
@@ -608,10 +651,7 @@ export async function createProviderConnection(data) {
}
db.data.providerConnections.push(connection);
await safeWrite(db);
// Reorder to ensure consistency
await reorderProviderConnections(data.provider);
await reorderProviderConnections(data.provider, db);
return connection;
}
@@ -633,11 +673,11 @@ export async function updateProviderConnection(id, data) {
updatedAt: new Date().toISOString(),
};
await safeWrite(db);
// Reorder if priority was changed
// Reorder if priority was changed, reuse same db instance to avoid double-read
if (data.priority !== undefined) {
await reorderProviderConnections(providerId);
await reorderProviderConnections(providerId, db);
} else {
await safeWrite(db);
}
return db.data.providerConnections[index];
@@ -655,37 +695,35 @@ export async function deleteProviderConnection(id) {
const providerId = db.data.providerConnections[index].provider;
db.data.providerConnections.splice(index, 1);
await safeWrite(db);
// Reorder to fill gaps
await reorderProviderConnections(providerId);
// Reorder to fill gaps, reuse same db instance to avoid double-read
await reorderProviderConnections(providerId, db);
return true;
}
/**
* Reorder provider connections to ensure unique, sequential priorities
* Reorder provider connections to ensure unique, sequential priorities.
* Accepts an existing db instance to avoid redundant getDb() calls and
* prevent double-read race conditions within the same write operation.
*/
export async function reorderProviderConnections(providerId) {
const db = await getDb();
if (!db.data.providerConnections) return;
export async function reorderProviderConnections(providerId, db) {
const instance = db || (await getDb());
if (!instance.data.providerConnections) return;
const providerConnections = db.data.providerConnections
const providerConnections = instance.data.providerConnections
.filter(c => c.provider === providerId)
.sort((a, b) => {
// Sort by priority first
const pDiff = (a.priority || 0) - (b.priority || 0);
if (pDiff !== 0) return pDiff;
// Use updatedAt as tie-breaker (newer first)
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
});
// Re-assign sequential priorities
providerConnections.forEach((conn, index) => {
conn.priority = index + 1;
});
await safeWrite(db);
await safeWrite(instance);
}
// ============ Model Aliases ============
+27
View File
@@ -216,6 +216,31 @@ export const CLINE_CONFIG = {
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
};
// GitLab Duo OAuth Configuration (Authorization Code Flow with PKCE)
// Supports both OAuth (PKCE) and Personal Access Token (PAT) modes
export const GITLAB_CONFIG = {
defaultBaseUrl: "https://gitlab.com",
authorizeUrlPath: "/oauth/authorize",
tokenUrlPath: "/oauth/token",
userInfoUrlPath: "/api/v4/user",
scope: "api read_user",
codeChallengeMethod: "S256",
};
// CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow)
// Step 1: POST /v2/plugin/auth/state?platform=CLI → get { state, authUrl }
// Step 2: Open authUrl in browser
// Step 3: Poll POST /v2/plugin/auth/token with state until success
export const CODEBUDDY_CONFIG = {
baseUrl: "https://copilot.tencent.com",
stateUrl: "https://copilot.tencent.com/v2/plugin/auth/state",
tokenUrl: "https://copilot.tencent.com/v2/plugin/auth/token",
refreshUrl: "https://copilot.tencent.com/v2/plugin/auth/token/refresh",
userAgent: "CLI/2.63.2 CodeBuddy/2.63.2",
platform: "CLI",
pollInterval: 5000,
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
@@ -234,4 +259,6 @@ export const PROVIDERS = {
KIMI_CODING: "kimi-coding",
KILOCODE: "kilocode",
CLINE: "cline",
GITLAB: "gitlab",
CODEBUDDY: "codebuddy",
};
+143 -5
View File
@@ -20,6 +20,8 @@ import {
KIMI_CODING_CONFIG,
KILOCODE_CONFIG,
CLINE_CONFIG,
GITLAB_CONFIG,
CODEBUDDY_CONFIG,
} from "./constants/oauth";
// Provider configurations
@@ -873,6 +875,140 @@ const PROVIDERS = {
providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName },
}),
},
// GitLab Duo - Authorization Code Flow with PKCE
// Supports two login modes via loginMode metadata: "oauth" (default) or "pat"
gitlab: {
config: GITLAB_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => {
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
const clientId = meta.clientId || "";
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
state,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
});
return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => {
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
const clientId = meta.clientId || "";
const clientSecret = meta.clientSecret || "";
const body = new URLSearchParams({
client_id: clientId,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
if (clientSecret) body.set("client_secret", clientSecret);
const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: body.toString(),
});
if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`);
const tokens = await response.json();
// Fetch user info
const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const user = userRes.ok ? await userRes.json() : {};
return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
providerSpecificData: {
username: tokens._user?.username || "",
email: tokens._user?.email || tokens._user?.public_email || "",
name: tokens._user?.name || "",
baseUrl: tokens._baseUrl,
clientId: tokens._clientId,
authKind: "oauth",
},
}),
},
// CodeBuddy (Tencent) - Browser OAuth Polling Flow
// 1. POST stateUrl → get { state, authUrl }
// 2. Open authUrl in browser
// 3. Poll tokenUrl with state until success (code 0) or timeout
codebuddy: {
config: CODEBUDDY_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": config.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "copilot.tencent.com",
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-Product": "SaaS",
},
body: "{}",
});
if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`);
const data = await response.json();
if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) {
throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`);
}
return {
device_code: data.data.state,
verification_uri: data.data.authUrl,
user_code: "",
interval: config.pollInterval / 1000,
_isCodeBuddy: true,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": config.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "copilot.tencent.com",
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-Product": "SaaS",
},
body: JSON.stringify({ state: deviceCode }),
});
if (!response.ok) return { ok: false, data: { error: "request_failed" } };
const data = await response.json();
// code 11217 = pending, code 0 = success
if (data.code === 0 && data.data?.accessToken) {
return {
ok: true,
data: {
access_token: data.data.accessToken,
refresh_token: data.data.refreshToken || "",
token_type: data.data.tokenType || "Bearer",
},
};
}
if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } };
return { ok: false, data: { error: data.msg || "unknown_error" } };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: 86400,
providerSpecificData: {},
}),
},
};
/**
@@ -895,8 +1031,9 @@ export function getProviderNames() {
/**
* Generate auth data for a provider
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
*/
export function generateAuthData(providerName, redirectUri) {
export function generateAuthData(providerName, redirectUri, meta) {
const provider = getProvider(providerName);
const { codeVerifier, codeChallenge, state } = generatePKCE();
@@ -905,9 +1042,9 @@ export function generateAuthData(providerName, redirectUri) {
// Device code flow doesn't have auth URL upfront
authUrl = null;
} else if (provider.flowType === "authorization_code_pkce") {
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge);
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge, meta || {});
} else {
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state);
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, undefined, meta || {});
}
return {
@@ -924,11 +1061,12 @@ export function generateAuthData(providerName, redirectUri) {
/**
* Exchange code for tokens
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
*/
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) {
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) {
const provider = getProvider(providerName);
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state);
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state, meta || {});
let extra = null;
if (provider.postExchange) {
+3 -3
View File
@@ -65,8 +65,8 @@ async function getObservabilityConfig() {
const { getSettings } = await import("@/lib/localDb");
const settings = await getSettings();
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
const enabled = typeof settings.observabilityEnabled === "boolean"
? settings.observabilityEnabled
const enabled = typeof settings.enableObservability === "boolean"
? settings.enableObservability
: envEnabled;
cachedConfig = {
@@ -78,7 +78,7 @@ async function getObservabilityConfig() {
};
} catch {
cachedConfig = {
enabled: true,
enabled: false,
maxRecords: DEFAULT_MAX_RECORDS,
batchSize: DEFAULT_BATCH_SIZE,
flushIntervalMs: DEFAULT_FLUSH_INTERVAL_MS,