refactor(quota): secure managed provider credentials (#2160)
- add shared owner-only credential storage for OpenCode Go, Ollama Cloud, and Cursor - validate credentials before atomic writes using 0700 directories and 0600 files - replace provider-specific credential routes with an allowlisted lifecycle API - stop automatically reading Ollama's legacy cookie file - stop reading or modifying Cursor's database during regular quota requests - add explicit one-time Cursor credential import without mutating Cursor storage - persist refreshed Cursor credentials only in OpenChamber-managed storage - add Ollama Cloud and Cursor credential controls to provider settings - preserve OpenCode Go tracking through the shared credential flow - add VS Code credential management and Cursor quota parity - reject authentication redirects, enforce request timeouts, and fail on unparseable usage pages - mask stored secrets in API responses and extend quota security coverage - update quota provider documentation
This commit is contained in:
committed by
GitHub
parent
3b92d97795
commit
b09614fd68
@@ -6,7 +6,8 @@ import { randomUUID } from 'crypto';
|
||||
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
|
||||
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
||||
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
|
||||
import { deleteOpenCodeGoCredential, fetchOpenCodeGoUsage, getOpenCodeGoCredentialStatus, normalizeOpenCodeGoCredential, readOpenCodeGoCredential, writeOpenCodeGoCredential } from './opencodeGoQuota';
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials';
|
||||
import { getSessionActivitySnapshot } from './sessionActivityWatcher';
|
||||
import type { BridgeContext, BridgeResponse } from './bridge';
|
||||
|
||||
@@ -482,21 +483,30 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:quota:opencode-go-credentials': {
|
||||
const { method, credential: input } = (payload || {}) as { method?: string; credential?: unknown };
|
||||
case 'api:quota:credentials': {
|
||||
const { providerId, method, credential: input } = (payload || {}) as { providerId?: ManagedProvider; method?: string; credential?: unknown };
|
||||
try {
|
||||
if (method === 'GET') return { id, type, success: true, data: getOpenCodeGoCredentialStatus() };
|
||||
if (method === 'DELETE') { deleteOpenCodeGoCredential(); return { id, type, success: true, data: { configured: false } }; }
|
||||
if (!providerId || !['opencode-go', 'ollama-cloud', 'cursor'].includes(providerId)) return { id, type, success: false, error: 'Unsupported credential provider' };
|
||||
if (method === 'GET') return { id, type, success: true, data: credentialStatus(providerId) };
|
||||
if (method === 'DELETE') { deleteCredential(providerId); return { id, type, success: true, data: { configured: false } }; }
|
||||
if (method === 'IMPORT') {
|
||||
if (providerId !== 'cursor') return { id, type, success: false, error: 'Import unavailable' };
|
||||
const credential = importCursorCredential();
|
||||
await validateCredential(providerId, credential);
|
||||
return { id, type, success: true, data: writeCredential(providerId, credential) };
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
const credential = normalizeOpenCodeGoCredential(input);
|
||||
if (!credential) return { id, type, success: false, error: 'Workspace ID and auth cookie are required' };
|
||||
await fetchOpenCodeGoUsage(credential);
|
||||
return { id, type, success: true, data: writeOpenCodeGoCredential(credential) };
|
||||
const credential = normalizeCredential(providerId, input);
|
||||
if (!credential) return { id, type, success: false, error: 'Invalid credential' };
|
||||
if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string });
|
||||
else await validateCredential(providerId, credential);
|
||||
return { id, type, success: true, data: writeCredential(providerId, credential) };
|
||||
}
|
||||
if (method === 'VALIDATE') {
|
||||
const credential = readOpenCodeGoCredential();
|
||||
const credential = readCredential(providerId);
|
||||
if (!credential) return { id, type, success: false, error: 'Not configured' };
|
||||
await fetchOpenCodeGoUsage(credential);
|
||||
if (providerId === 'opencode-go') await fetchOpenCodeGoUsage(credential as { workspaceId: string; authCookie: string });
|
||||
else await validateCredential(providerId, credential);
|
||||
return { id, type, success: true, data: { valid: true } };
|
||||
}
|
||||
return { id, type, success: false, error: 'Unsupported method' };
|
||||
|
||||
@@ -1,53 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
type OpenCodeGoCredential = { workspaceId: string; authCookie: string };
|
||||
|
||||
const targetPath = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota', 'opencode-go.json');
|
||||
|
||||
export const normalizeOpenCodeGoCredential = (value: unknown): OpenCodeGoCredential | null => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const data = value as Record<string, unknown>;
|
||||
const workspaceId = typeof data.workspaceId === 'string' ? data.workspaceId.trim() : '';
|
||||
let authCookie = typeof data.authCookie === 'string' ? data.authCookie.trim() : '';
|
||||
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
|
||||
return workspaceId && authCookie && !/[\r\n]/.test(workspaceId + authCookie) ? { workspaceId, authCookie } : null;
|
||||
};
|
||||
|
||||
export const readOpenCodeGoCredential = (): OpenCodeGoCredential | null => {
|
||||
try {
|
||||
return normalizeOpenCodeGoCredential(JSON.parse(fs.readFileSync(targetPath(), 'utf8')));
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'ENOENT') {
|
||||
console.warn('Failed to read OpenCode Go credentials');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getOpenCodeGoCredentialStatus = () => {
|
||||
const value = readOpenCodeGoCredential();
|
||||
return value ? { configured: true, workspaceId: value.workspaceId, authCookieMasked: '••••••••' } : { configured: false };
|
||||
};
|
||||
|
||||
export const writeOpenCodeGoCredential = (value: OpenCodeGoCredential) => {
|
||||
const target = targetPath();
|
||||
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.chmodSync(temporary, 0o600);
|
||||
fs.renameSync(temporary, target);
|
||||
fs.chmodSync(target, 0o600);
|
||||
} finally {
|
||||
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
||||
}
|
||||
return getOpenCodeGoCredentialStatus();
|
||||
};
|
||||
|
||||
export const deleteOpenCodeGoCredential = () => { try { fs.unlinkSync(targetPath()); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } };
|
||||
|
||||
const toWindow = (usedPercent: number, resetInSec: number) => ({
|
||||
usedPercent: Math.min(100, Math.max(0, usedPercent)),
|
||||
remainingPercent: 100 - Math.min(100, Math.max(0, usedPercent)),
|
||||
@@ -59,8 +11,8 @@ const toWindow = (usedPercent: number, resetInSec: number) => ({
|
||||
});
|
||||
|
||||
export const fetchOpenCodeGoUsage = async (credential: OpenCodeGoCredential) => {
|
||||
const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, { headers: { Accept: 'text/html', Cookie: `auth=${credential.authCookie}` }, signal: AbortSignal.timeout(15_000) });
|
||||
if (response.status === 401 || response.status === 403 || (response.redirected && /\/auth(?:\/|$|\?)/.test(new URL(response.url).pathname))) throw new Error('OpenCode Go authentication failed');
|
||||
const response = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(credential.workspaceId)}/go`, { headers: { Accept: 'text/html', Cookie: `auth=${credential.authCookie}` }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
|
||||
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) throw new Error('OpenCode Go authentication failed');
|
||||
if (!response.ok) throw new Error(`OpenCode Go dashboard returned HTTP ${response.status}`);
|
||||
const html = (await response.text()).replaceAll('"', '"').replaceAll('"', '"').replaceAll('\\u0022', '"').replaceAll('\\"', '"');
|
||||
const windows: Record<string, ReturnType<typeof toWindow>> = {};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export type ManagedProvider = 'opencode-go' | 'ollama-cloud' | 'cursor';
|
||||
export type ManagedCredential = Record<string, string>;
|
||||
const providers = new Set<ManagedProvider>(['opencode-go', 'ollama-cloud', 'cursor']);
|
||||
const directory = () => path.join(process.env.OPENCHAMBER_DATA_DIR ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) : path.join(os.homedir(), '.config', 'openchamber'), 'quota');
|
||||
const target = (provider: ManagedProvider) => {
|
||||
if (!providers.has(provider)) throw new Error('Unsupported credential provider');
|
||||
return path.join(directory(), `${provider}.json`);
|
||||
};
|
||||
const clean = (value: unknown) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
|
||||
|
||||
export const normalizeCredential = (provider: ManagedProvider, value: unknown): ManagedCredential | null => {
|
||||
const data = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
if (provider === 'opencode-go') {
|
||||
const workspaceId = clean(data.workspaceId);
|
||||
let authCookie = clean(data.authCookie);
|
||||
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
|
||||
return workspaceId && authCookie ? { workspaceId, authCookie } : null;
|
||||
}
|
||||
if (provider === 'ollama-cloud') return clean(data.cookie) ? { cookie: clean(data.cookie) } : null;
|
||||
const accessToken = clean(data.accessToken);
|
||||
const refreshToken = clean(data.refreshToken);
|
||||
return accessToken || refreshToken ? { accessToken, refreshToken } : null;
|
||||
};
|
||||
|
||||
export const readCredential = (provider: ManagedProvider) => {
|
||||
try { return normalizeCredential(provider, JSON.parse(fs.readFileSync(target(provider), 'utf8'))); }
|
||||
catch (error) { if ((error as { code?: string }).code !== 'ENOENT') console.warn(`Failed to read ${provider} quota credentials`); return null; }
|
||||
};
|
||||
export const credentialStatus = (provider: ManagedProvider) => {
|
||||
const value = readCredential(provider);
|
||||
if (!value) return { configured: false };
|
||||
return { configured: true, ...(provider === 'opencode-go' ? { workspaceId: value.workspaceId } : {}), ...(provider === 'cursor' ? { hasRefreshToken: Boolean(value.refreshToken) } : {}), secretMasked: '••••••••' };
|
||||
};
|
||||
export const writeCredential = (provider: ManagedProvider, value: ManagedCredential) => {
|
||||
const dir = directory(); const file = target(provider); const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.chmodSync(dir, 0o700);
|
||||
try { fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); fs.renameSync(temp, file); fs.chmodSync(file, 0o600); }
|
||||
finally { if (fs.existsSync(temp)) fs.unlinkSync(temp); }
|
||||
return credentialStatus(provider);
|
||||
};
|
||||
export const deleteCredential = (provider: ManagedProvider) => { try { fs.unlinkSync(target(provider)); } catch (error) { if ((error as { code?: string }).code !== 'ENOENT') throw error; } };
|
||||
|
||||
export const importCursorCredential = () => {
|
||||
const db = path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
|
||||
if (process.platform !== 'darwin' || !fs.existsSync(db)) throw new Error('Cursor credential import is unavailable');
|
||||
const rows = JSON.parse(execFileSync('sqlite3', ['-json', db, "SELECT key,value FROM ItemTable WHERE key IN ('cursorAuth/accessToken','cursorAuth/refreshToken');"], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }) || '[]') as Array<{ key: string; value: string }>;
|
||||
const credential = normalizeCredential('cursor', { accessToken: rows.find((row) => row.key.endsWith('accessToken'))?.value, refreshToken: rows.find((row) => row.key.endsWith('refreshToken'))?.value });
|
||||
if (!credential) throw new Error('Cursor credentials are unavailable');
|
||||
return credential;
|
||||
};
|
||||
|
||||
export const validateCredential = async (provider: ManagedProvider, credential: ManagedCredential) => {
|
||||
if (provider === 'ollama-cloud') {
|
||||
const response = await fetch('https://ollama.com/settings', { headers: { Cookie: credential.cookie }, redirect: 'manual', signal: AbortSignal.timeout(15_000) });
|
||||
if (!response.ok || (response.status >= 300 && response.status < 400)) throw new Error('Ollama Cloud authentication failed');
|
||||
const html = await response.text();
|
||||
if (!/Session\s+usage|Weekly\s+usage|Premium[^0-9]*[0-9]+\s*\/\s*[0-9]+/i.test(html)) throw new Error('Ollama Cloud usage data could not be parsed');
|
||||
}
|
||||
if (provider === 'cursor') {
|
||||
if (!credential.accessToken && credential.refreshToken) {
|
||||
const refresh = await fetch('https://api2.cursor.sh/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', client_id: 'KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB', refresh_token: credential.refreshToken }), signal: AbortSignal.timeout(15_000) });
|
||||
const payload = await refresh.json().catch(() => null) as { access_token?: string } | null;
|
||||
if (!refresh.ok || !payload?.access_token) throw new Error('Cursor authentication failed');
|
||||
credential.accessToken = payload.access_token;
|
||||
}
|
||||
if (!credential.accessToken) throw new Error('Cursor access token is required');
|
||||
const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${credential.accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) });
|
||||
if (!response.ok) throw new Error('Cursor authentication failed');
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fetchOpenCodeGoUsage, readOpenCodeGoCredential } from './opencodeGoQuota';
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
import { readCredential } from './quotaCredentials';
|
||||
|
||||
type AuthEntry = Record<string, unknown> | string;
|
||||
type AuthFile = Record<string, AuthEntry>;
|
||||
@@ -124,7 +125,6 @@ export type ProviderResult = {
|
||||
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
|
||||
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
|
||||
const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json');
|
||||
const OLLAMA_CLOUD_COOKIE_PATH = path.join(os.homedir(), '.config', 'ollama-quota', 'cookie');
|
||||
|
||||
|
||||
const ANTIGRAVITY_ACCOUNTS_PATHS = [
|
||||
@@ -220,19 +220,6 @@ const readJsonFile = (filePath: string): Record<string, unknown> | null => {
|
||||
}
|
||||
};
|
||||
|
||||
const readTextFile = (filePath: string): string | null => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8').trim();
|
||||
return content || null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read text file: ${filePath}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthEntry = (auth: AuthFile, aliases: string[]) => {
|
||||
for (const alias of aliases) {
|
||||
if (auth[alias]) {
|
||||
@@ -389,7 +376,9 @@ const durationToSeconds = (duration?: number, unit?: string) => {
|
||||
export const listConfiguredQuotaProviders = () => {
|
||||
const auth = readAuthFile();
|
||||
const configured = new Set<string>();
|
||||
if (readOpenCodeGoCredential()) configured.add('opencode-go');
|
||||
if (readCredential('opencode-go')) configured.add('opencode-go');
|
||||
if (readCredential('ollama-cloud')) configured.add('ollama-cloud');
|
||||
if (readCredential('cursor')) configured.add('cursor');
|
||||
|
||||
const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude']));
|
||||
if (anthropicAuth && ((anthropicAuth as Record<string, unknown>).access || (anthropicAuth as Record<string, unknown>).token)) {
|
||||
@@ -446,9 +435,6 @@ export const listConfiguredQuotaProviders = () => {
|
||||
configured.add('github-copilot-addon');
|
||||
}
|
||||
|
||||
if (readTextFile(OLLAMA_CLOUD_COOKIE_PATH)) {
|
||||
configured.add('ollama-cloud');
|
||||
}
|
||||
|
||||
const waferAuth = normalizeAuthEntry(getAuthEntry(auth, ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai']));
|
||||
if (waferAuth && ((waferAuth as Record<string, unknown>).key || (waferAuth as Record<string, unknown>).token)) {
|
||||
@@ -1346,7 +1332,7 @@ const parseOllamaSettingsHtml = (html: string) => {
|
||||
};
|
||||
|
||||
const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
|
||||
const cookie = readTextFile(OLLAMA_CLOUD_COOKIE_PATH);
|
||||
const cookie = readCredential('ollama-cloud')?.cookie;
|
||||
|
||||
if (!cookie) {
|
||||
return buildResult({
|
||||
@@ -1395,6 +1381,19 @@ const fetchOllamaCloudQuota = async (): Promise<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCursorQuota = async (): Promise<ProviderResult> => {
|
||||
const accessToken = readCredential('cursor')?.accessToken;
|
||||
if (!accessToken) return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: false, error: 'Not configured' });
|
||||
try {
|
||||
const response = await fetch('https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'Connect-Protocol-Version': '1' }, body: '{}', signal: AbortSignal.timeout(15_000) });
|
||||
if (!response.ok) throw new Error(response.status === 401 ? 'Cursor session expired' : `API error: ${response.status}`);
|
||||
const payload = await response.json() as Record<string, unknown>;
|
||||
const plan = (payload.planUsage as Record<string, unknown> | undefined) ?? {};
|
||||
const usedPercent = toNumber(plan.totalPercentUsed);
|
||||
return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: true, configured: true, usage: { windows: { billing_cycle: toUsageWindow({ usedPercent, windowSeconds: null, resetAt: toTimestamp(payload.billingCycleEnd) }) } } });
|
||||
} catch (error) { return buildResult({ providerId: 'cursor', providerName: 'Cursor', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); }
|
||||
};
|
||||
|
||||
const fetchOpenRouterQuota = async (): Promise<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record<string, unknown> | null;
|
||||
@@ -1895,7 +1894,7 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
|
||||
case 'wafer':
|
||||
return fetchWaferQuota();
|
||||
case 'opencode-go': {
|
||||
const credential = readOpenCodeGoCredential();
|
||||
const credential = readCredential('opencode-go') as { workspaceId: string; authCookie: string } | null;
|
||||
if (!credential) return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: false, error: 'Not configured' });
|
||||
try {
|
||||
return buildResult({ providerId, providerName: 'OpenCode Go', ok: true, configured: true, usage: { windows: await fetchOpenCodeGoUsage(credential) } });
|
||||
@@ -1903,6 +1902,8 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
|
||||
return buildResult({ providerId, providerName: 'OpenCode Go', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' });
|
||||
}
|
||||
}
|
||||
case 'cursor':
|
||||
return fetchCursorQuota();
|
||||
default:
|
||||
return buildResult({
|
||||
providerId,
|
||||
|
||||
Reference in New Issue
Block a user