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
@@ -1,110 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
interface CredentialStatus {
|
||||
configured: boolean;
|
||||
workspaceId?: string;
|
||||
authCookieMasked?: string;
|
||||
}
|
||||
|
||||
export const OpenCodeGoCredentials: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = React.useState<CredentialStatus | null>(null);
|
||||
const [workspaceId, setWorkspaceId] = React.useState('');
|
||||
const [authCookie, setAuthCookie] = React.useState('');
|
||||
const [busy, setBusy] = React.useState<'save' | 'validate' | 'delete' | null>(null);
|
||||
const workspaceInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const cookieInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/quota/credentials/opencode-go');
|
||||
if (!response.ok) throw new Error('load failed');
|
||||
const next = await response.json() as CredentialStatus;
|
||||
setStatus(next);
|
||||
setWorkspaceId(next.workspaceId ?? '');
|
||||
} catch {
|
||||
setStatus({ configured: false });
|
||||
}
|
||||
};
|
||||
void loadStatus();
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
const submittedWorkspaceId = workspaceInputRef.current?.value.trim() ?? workspaceId.trim();
|
||||
const submittedAuthCookie = cookieInputRef.current?.value.trim() ?? authCookie.trim();
|
||||
setBusy('save');
|
||||
try {
|
||||
const response = await runtimeFetch('/api/quota/credentials/opencode-go', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId: submittedWorkspaceId, authCookie: submittedAuthCookie }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error);
|
||||
setStatus(payload);
|
||||
setAuthCookie('');
|
||||
toast.success(t('settings.providers.page.openCodeGo.saved'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
toast.error(message || t('settings.providers.page.openCodeGo.saveFailed'));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const validate = async () => {
|
||||
setBusy('validate');
|
||||
try {
|
||||
const response = await runtimeFetch('/api/quota/credentials/opencode-go/validate', { method: 'POST' });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error || t('settings.providers.page.openCodeGo.invalid'));
|
||||
toast.success(t('settings.providers.page.openCodeGo.valid'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
toast.error(message || t('settings.providers.page.openCodeGo.invalid'));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setBusy('delete');
|
||||
try {
|
||||
const response = await runtimeFetch('/api/quota/credentials/opencode-go', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('delete failed');
|
||||
setStatus({ configured: false });
|
||||
setWorkspaceId('');
|
||||
setAuthCookie('');
|
||||
toast.success(t('settings.providers.page.openCodeGo.deleted'));
|
||||
} catch {
|
||||
toast.error(t('settings.providers.page.openCodeGo.deleteFailed'));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-settings-item="providers.opencode-go-credentials" className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.providers.page.openCodeGo.title')}</h3>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.openCodeGo.description')}</p>
|
||||
</div>
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
<label className="block typography-ui-label text-foreground">
|
||||
{t('settings.providers.page.openCodeGo.workspaceId')}
|
||||
<Input ref={workspaceInputRef} className="mt-1 h-7 font-mono text-xs" value={workspaceId} onChange={(event) => setWorkspaceId(event.target.value)} placeholder="wrk_..." />
|
||||
</label>
|
||||
<label className="block typography-ui-label text-foreground">
|
||||
{t('settings.providers.page.openCodeGo.authCookie')}
|
||||
<Input ref={cookieInputRef} className="mt-1 h-7 font-mono text-xs" type="password" autoComplete="off" value={authCookie} onChange={(event) => setAuthCookie(event.target.value)} placeholder={status?.authCookieMasked ?? 'auth=...'} />
|
||||
</label>
|
||||
<p className="typography-meta text-muted-foreground">{t('settings.providers.page.openCodeGo.help')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="xs" onClick={save} disabled={Boolean(busy)}>{status?.configured ? t('settings.providers.page.openCodeGo.replace') : t('settings.providers.page.openCodeGo.save')}</Button>
|
||||
{status?.configured && <Button variant="outline" size="xs" onClick={validate} disabled={Boolean(busy)}>{t('settings.providers.page.openCodeGo.validate')}</Button>}
|
||||
{status?.configured && <Button variant="destructive" size="xs" onClick={remove} disabled={Boolean(busy)}>{t('settings.providers.page.openCodeGo.delete')}</Button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { shouldLoadAvailableProviders } from './providerAvailability';
|
||||
import { OpenCodeGoCredentials } from './OpenCodeGoCredentials';
|
||||
import { QuotaCredentials } from './QuotaCredentials';
|
||||
|
||||
const formatCompactNumber = (value: number) => new Intl.NumberFormat(getCurrentIntlLocale(), {
|
||||
notation: 'compact',
|
||||
@@ -947,7 +947,9 @@ export const ProvidersPage: React.FC = () => {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && <OpenCodeGoCredentials />}
|
||||
{(selectedProvider.id === 'opencode' || selectedProvider.id === 'opencode-go') && <QuotaCredentials providerId="opencode-go" providerName="OpenCode Go" />}
|
||||
{(selectedProvider.id === 'ollama' || selectedProvider.id === 'ollama-cloud') && <QuotaCredentials providerId="ollama-cloud" providerName="Ollama Cloud" />}
|
||||
{selectedProvider.id === 'cursor' && <QuotaCredentials providerId="cursor" providerName="Cursor" />}
|
||||
|
||||
{/* Connection Details */}
|
||||
<div data-settings-item="providers.connection-details" className="mb-8">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
type ProviderId = 'opencode-go' | 'ollama-cloud' | 'cursor';
|
||||
type Status = { configured: boolean; workspaceId?: string; secretMasked?: string };
|
||||
|
||||
export const QuotaCredentials: React.FC<{ providerId: ProviderId; providerName: string }> = ({ providerId, providerName }) => {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = React.useState<Status | null>(null);
|
||||
const [values, setValues] = React.useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const route = `/api/quota/credentials/${providerId}`;
|
||||
React.useEffect(() => { void runtimeFetch(route).then(async (response) => {
|
||||
if (!response.ok) throw new Error();
|
||||
const next = await response.json() as Status;
|
||||
setStatus(next); setValues(next.workspaceId ? { workspaceId: next.workspaceId } : {});
|
||||
}).catch(() => setStatus({ configured: false })); }, [route]);
|
||||
const request = async (path: string, method: string, body?: object) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await runtimeFetch(path, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payload?.error);
|
||||
if (payload?.configured !== undefined) setStatus(payload);
|
||||
setValues((current) => current.workspaceId ? { workspaceId: current.workspaceId } : {} as Record<string, string>);
|
||||
toast.success(t('settings.providers.page.openCodeGo.saved'));
|
||||
} catch (error) { toast.error(error instanceof Error && error.message ? error.message : t('settings.providers.page.openCodeGo.saveFailed')); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const field = (name: string, label: string, placeholder: string) => <label className="block typography-ui-label text-foreground">{label}<Input className="mt-1 h-7 font-mono text-xs" type={name === 'workspaceId' ? 'text' : 'password'} autoComplete="off" value={values[name] ?? ''} onChange={(event) => setValues((current) => ({ ...current, [name]: event.target.value }))} placeholder={status?.secretMasked ?? placeholder} /></label>;
|
||||
return <div data-settings-item={`providers.${providerId}-credentials`} className="mb-8">
|
||||
<div className="mb-1 px-1"><h3 className="typography-ui-header font-medium text-foreground">{providerName}</h3></div>
|
||||
<section className="space-y-3 px-2 pb-2 pt-0">
|
||||
{providerId === 'opencode-go' && field('workspaceId', t('settings.providers.page.openCodeGo.workspaceId'), 'wrk_...')}
|
||||
{providerId === 'opencode-go' && field('authCookie', t('settings.providers.page.openCodeGo.authCookie'), 'auth=...')}
|
||||
{providerId === 'ollama-cloud' && field('cookie', t('settings.providers.page.openCodeGo.authCookie'), 'session=...')}
|
||||
{providerId === 'cursor' && field('accessToken', t('settings.providers.page.auth.apiKeyLabel'), t('settings.providers.page.auth.apiKeyPlaceholder'))}
|
||||
{providerId === 'cursor' && field('refreshToken', t('settings.providers.page.auth.apiKeyLabel'), t('settings.providers.page.auth.apiKeyPlaceholder'))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="xs" disabled={busy} onClick={() => request(route, 'PUT', values)}>{status?.configured ? t('settings.providers.page.openCodeGo.replace') : t('settings.providers.page.openCodeGo.save')}</Button>
|
||||
{status?.configured && <Button variant="outline" size="xs" disabled={busy} onClick={() => request(`${route}/validate`, 'POST')}>{t('settings.providers.page.openCodeGo.validate')}</Button>}
|
||||
{providerId === 'cursor' && <Button variant="outline" size="xs" disabled={busy} onClick={() => request(`${route}/import`, 'POST')}>{t('settings.providers.page.actions.connect')}</Button>}
|
||||
{status?.configured && <Button variant="destructive" size="xs" disabled={busy} onClick={() => request(route, 'DELETE')}>{t('settings.providers.page.openCodeGo.delete')}</Button>}
|
||||
</div>
|
||||
</section>
|
||||
</div>;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -1041,11 +1041,12 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/api/quota/credentials/opencode-go' || pathname === '/api/quota/credentials/opencode-go/validate') {
|
||||
const quotaCredentialMatch = pathname.match(/^\/api\/quota\/credentials\/(opencode-go|ollama-cloud|cursor)(?:\/(validate|import))?$/);
|
||||
if (quotaCredentialMatch) {
|
||||
try {
|
||||
const body = method === 'PUT' ? await extractJsonBody(input, init, method) : undefined;
|
||||
const bridgeMethod = pathname.endsWith('/validate') ? 'VALIDATE' : method;
|
||||
const data = await sendBridgeMessage('api:quota:opencode-go-credentials', { method: bridgeMethod, credential: body });
|
||||
const bridgeMethod = quotaCredentialMatch[2]?.toUpperCase() || method;
|
||||
const data = await sendBridgeMessage('api:quota:credentials', { providerId: quotaCredentialMatch[1], method: bridgeMethod, credential: body });
|
||||
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -18,7 +18,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| --- | --- | --- | --- |
|
||||
| `claude` | Claude | `providers/claude.js` | `anthropic`, `claude` |
|
||||
| `codex` | Codex | `providers/codex.js` | `openai`, `codex`, `chatgpt` |
|
||||
| `cursor` | Cursor | `providers/cursor.js` | `CURSOR_TOKEN` / `CURSOR_ACCESS_TOKEN`, `CURSOR_REFRESH_TOKEN`, optional token files, or Cursor desktop SQLite DB |
|
||||
| `cursor` | Cursor | `providers/cursor.js` | Environment/token files, OpenChamber-managed credentials, or explicit one-time Cursor import |
|
||||
| `google` | Google | `providers/google/index.js` | `google`, `google.oauth`, Antigravity accounts file |
|
||||
| `github-copilot` | GitHub Copilot | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
| `github-copilot-addon` | GitHub Copilot Add-on | `providers/copilot.js` | `github-copilot`, `copilot` |
|
||||
@@ -29,7 +29,7 @@ These provider IDs are currently dispatchable via `fetchQuotaForProvider(provide
|
||||
| `zhipuai-coding-plan` | Zhipu AI Coding Plan | `providers/zhipuai-coding-plan.js` | `zhipuai-coding-plan`, `zhipuai`, `zhipu` |
|
||||
| `minimax-coding-plan` | MiniMax Coding Plan (minimax.io) | `providers/minimax-coding-plan.js` / `providers/minimax-shared.js` | `minimax-coding-plan` |
|
||||
| `minimax-cn-coding-plan` | MiniMax Coding Plan (minimaxi.com) | `providers/minimax-cn-coding-plan.js` / `providers/minimax-shared.js` | `minimax-cn-coding-plan` |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Cookie file at `~/.config/ollama-quota/cookie` (raw session cookie string) |
|
||||
| `ollama-cloud` | Ollama Cloud | `providers/ollama-cloud.js` | Manual cookie stored under `~/.config/openchamber/quota/` |
|
||||
| `wafer` | Wafer.ai | `providers/wafer.js` | `wafer`, `wafer-ai`, `wafer_ai`, `wafer.ai` |
|
||||
| `opencode-go` | OpenCode Go | `providers/opencode-go.js` | Manual workspace ID and auth cookie stored under `~/.config/openchamber/quota/` |
|
||||
|
||||
@@ -45,7 +45,7 @@ All providers should return results via shared helpers to preserve API shape:
|
||||
Provider modules must export `providerId`, `providerName`, `aliases`, `isConfigured(auth?)`, and `fetchQuota()`.
|
||||
`fetchQuota()` should return a quota result with `usage.windows` keyed by window name (for example `5h`, `7d`, `daily`) and optional provider-specific `usage.models` data.
|
||||
|
||||
OpenCode Go credentials are explicitly supplied by the user through Settings. OpenChamber never scans browser cookie stores. The server validates the credential before an atomic `0600` write and never returns the cookie through its API.
|
||||
OpenCode Go, Ollama Cloud, and Cursor credentials are explicitly managed through Settings. The server validates credentials before atomic `0600` writes and never returns secrets through its API. OpenChamber never scans browser cookie stores or automatically reads Cursor storage; Cursor import is an explicit one-time user action and never modifies Cursor's database.
|
||||
|
||||
## Add a new provider (quick steps)
|
||||
1. Choose module shape based on complexity:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from './store.js';
|
||||
|
||||
const clean = (value) => typeof value === 'string' && !/[\r\n]/.test(value) ? value.trim() : '';
|
||||
|
||||
export const normalizers = {
|
||||
'opencode-go': (value) => {
|
||||
const workspaceId = clean(value?.workspaceId);
|
||||
let authCookie = clean(value?.authCookie);
|
||||
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
|
||||
return workspaceId && authCookie ? { workspaceId, authCookie } : null;
|
||||
},
|
||||
'ollama-cloud': (value) => {
|
||||
const cookie = clean(value?.cookie);
|
||||
return cookie ? { cookie } : null;
|
||||
},
|
||||
cursor: (value) => {
|
||||
const accessToken = clean(value?.accessToken);
|
||||
const refreshToken = clean(value?.refreshToken);
|
||||
return accessToken || refreshToken ? { accessToken, refreshToken } : null;
|
||||
},
|
||||
};
|
||||
|
||||
export const readManagedCredential = (providerId) => {
|
||||
const normalize = normalizers[providerId];
|
||||
return normalize ? readQuotaCredential(providerId, normalize) : null;
|
||||
};
|
||||
|
||||
export const writeManagedCredential = (providerId, value) => {
|
||||
const credential = normalizers[providerId]?.(value);
|
||||
if (!credential) throw new Error('Invalid credential');
|
||||
writeQuotaCredential(providerId, credential);
|
||||
return getManagedCredentialStatus(providerId);
|
||||
};
|
||||
|
||||
export const getManagedCredentialStatus = (providerId) => {
|
||||
const credential = readManagedCredential(providerId);
|
||||
if (!credential) return { configured: false };
|
||||
if (providerId === 'opencode-go') return { configured: true, workspaceId: credential.workspaceId, secretMasked: '••••••••' };
|
||||
if (providerId === 'cursor') return { configured: true, hasRefreshToken: Boolean(credential.refreshToken), secretMasked: '••••••••' };
|
||||
return { configured: true, secretMasked: '••••••••' };
|
||||
};
|
||||
|
||||
export const deleteManagedCredential = (providerId) => deleteQuotaCredential(providerId);
|
||||
@@ -0,0 +1,48 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const MANAGED_QUOTA_PROVIDERS = new Set(['opencode-go', 'ollama-cloud', 'cursor']);
|
||||
|
||||
const credentialsDirectory = () => path.join(
|
||||
process.env.OPENCHAMBER_DATA_DIR
|
||||
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
||||
: path.join(os.homedir(), '.config', 'openchamber'),
|
||||
'quota',
|
||||
);
|
||||
|
||||
const credentialPath = (providerId) => {
|
||||
if (!MANAGED_QUOTA_PROVIDERS.has(providerId)) throw new Error('Unsupported credential provider');
|
||||
return path.join(credentialsDirectory(), `${providerId}.json`);
|
||||
};
|
||||
|
||||
export const readQuotaCredential = (providerId, normalize) => {
|
||||
try {
|
||||
return normalize(JSON.parse(fs.readFileSync(credentialPath(providerId), 'utf8')));
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') console.warn(`Failed to read ${providerId} quota credentials`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeQuotaCredential = (providerId, credential) => {
|
||||
const target = credentialPath(providerId);
|
||||
const directory = path.dirname(target);
|
||||
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
fs.chmodSync(directory, 0o700);
|
||||
try {
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.chmodSync(temporary, 0o600);
|
||||
fs.renameSync(temporary, target);
|
||||
fs.chmodSync(target, 0o600);
|
||||
} finally {
|
||||
try { fs.unlinkSync(temporary); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteQuotaCredential = (providerId) => {
|
||||
try { fs.unlinkSync(credentialPath(providerId)); } catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterAll, describe, expect, it } from 'bun:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { deleteQuotaCredential, readQuotaCredential, writeQuotaCredential } from './store.js';
|
||||
|
||||
const previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
|
||||
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-quota-store-'));
|
||||
process.env.OPENCHAMBER_DATA_DIR = temporaryDirectory;
|
||||
|
||||
describe('quota credential store', () => {
|
||||
it('uses owner-only permissions and rejects arbitrary provider paths', () => {
|
||||
writeQuotaCredential('ollama-cloud', { cookie: 'secret' });
|
||||
expect(fs.statSync(path.join(temporaryDirectory, 'quota')).mode & 0o777).toBe(0o700);
|
||||
expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'ollama-cloud.json')).mode & 0o777).toBe(0o600);
|
||||
expect(readQuotaCredential('ollama-cloud', (value) => value)).toEqual({ cookie: 'secret' });
|
||||
expect(() => writeQuotaCredential('../escape', {})).toThrow('Unsupported credential provider');
|
||||
deleteQuotaCredential('ollama-cloud');
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
|
||||
else process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
@@ -1,60 +1,11 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js';
|
||||
|
||||
const credentialsPath = () => 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 = normalizers['opencode-go'];
|
||||
|
||||
export const normalizeOpenCodeGoCredential = (value) => {
|
||||
const workspaceId = typeof value?.workspaceId === 'string' ? value.workspaceId.trim() : '';
|
||||
let authCookie = typeof value?.authCookie === 'string' ? value.authCookie.trim() : '';
|
||||
if (authCookie.startsWith('auth=')) authCookie = authCookie.slice(5).trim();
|
||||
if (!workspaceId || !authCookie || /[\r\n]/.test(workspaceId) || /[\r\n]/.test(authCookie)) {
|
||||
return null;
|
||||
}
|
||||
return { workspaceId, authCookie };
|
||||
};
|
||||
export const readOpenCodeGoCredential = () => readManagedCredential('opencode-go');
|
||||
|
||||
export const readOpenCodeGoCredential = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
|
||||
return normalizeOpenCodeGoCredential(parsed);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') console.warn('Failed to read OpenCode Go credentials');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
export const getOpenCodeGoCredentialStatus = () => getManagedCredentialStatus('opencode-go');
|
||||
|
||||
export const getOpenCodeGoCredentialStatus = () => {
|
||||
const credential = readOpenCodeGoCredential();
|
||||
return credential ? { configured: true, workspaceId: credential.workspaceId, authCookieMasked: '••••••••' } : { configured: false };
|
||||
};
|
||||
export const writeOpenCodeGoCredential = (value) => writeManagedCredential('opencode-go', value);
|
||||
|
||||
export const writeOpenCodeGoCredential = (value) => {
|
||||
const credential = normalizeOpenCodeGoCredential(value);
|
||||
if (!credential) throw new Error('Workspace ID and auth cookie are required');
|
||||
const target = credentialsPath();
|
||||
const directory = path.dirname(target);
|
||||
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
try {
|
||||
fs.writeFileSync(temporary, `${JSON.stringify(credential, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.chmodSync(temporary, 0o600);
|
||||
fs.renameSync(temporary, target);
|
||||
fs.chmodSync(target, 0o600);
|
||||
} finally {
|
||||
try { fs.unlinkSync(temporary); } catch {}
|
||||
}
|
||||
return getOpenCodeGoCredentialStatus();
|
||||
};
|
||||
|
||||
export const deleteOpenCodeGoCredential = () => {
|
||||
try { fs.unlinkSync(credentialsPath()); } catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
};
|
||||
export const deleteOpenCodeGoCredential = () => deleteManagedCredential('opencode-go');
|
||||
|
||||
@@ -13,7 +13,7 @@ afterEach(() => deleteOpenCodeGoCredential());
|
||||
describe('OpenCode Go credential store', () => {
|
||||
it('normalizes, masks, and stores credentials with owner-only permissions', () => {
|
||||
const status = writeOpenCodeGoCredential({ workspaceId: ' wrk_test ', authCookie: ' auth=secret ' });
|
||||
expect(status).toEqual({ configured: true, workspaceId: 'wrk_test', authCookieMasked: '••••••••' });
|
||||
expect(status).toEqual({ configured: true, workspaceId: 'wrk_test', secretMasked: '••••••••' });
|
||||
expect(readOpenCodeGoCredential()).toEqual({ workspaceId: 'wrk_test', authCookie: 'secret' });
|
||||
expect(fs.statSync(path.join(temporaryDirectory, 'quota', 'opencode-go.json')).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { readManagedCredential, writeManagedCredential } from '../credentials/providers.js';
|
||||
import {
|
||||
buildResult,
|
||||
formatMoney,
|
||||
@@ -54,25 +55,6 @@ const readStateValue = (key) => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeStateValue = (key, value) => {
|
||||
if (!existsSync(STATE_DB)) return false;
|
||||
try {
|
||||
const escaped = String(value).replace(/'/g, "''");
|
||||
const escapedKey = String(key).replace(/'/g, "''");
|
||||
execFileSync('sqlite3', [
|
||||
STATE_DB,
|
||||
`INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('${escapedKey}', '${escaped}');`
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'ignore', 'ignore']
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const readFileToken = (path) => {
|
||||
try {
|
||||
if (!path || !existsSync(path)) return null;
|
||||
@@ -83,16 +65,6 @@ const readFileToken = (path) => {
|
||||
}
|
||||
};
|
||||
|
||||
const writeFileToken = (path, value) => {
|
||||
try {
|
||||
if (!path) return false;
|
||||
writeFileSync(path, `${value}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAuthState = () => {
|
||||
const envAccessToken = process.env.CURSOR_TOKEN || process.env.CURSOR_ACCESS_TOKEN || null;
|
||||
const envRefreshToken = process.env.CURSOR_REFRESH_TOKEN || null;
|
||||
@@ -113,15 +85,15 @@ const loadAuthState = () => {
|
||||
return {
|
||||
accessToken: fileAccessToken,
|
||||
refreshToken: fileRefreshToken,
|
||||
source: 'file',
|
||||
accessTokenPath
|
||||
source: 'file'
|
||||
};
|
||||
}
|
||||
|
||||
const managed = readManagedCredential(providerId);
|
||||
return {
|
||||
accessToken: readStateValue('cursorAuth/accessToken'),
|
||||
refreshToken: readStateValue('cursorAuth/refreshToken'),
|
||||
source: 'sqlite'
|
||||
accessToken: managed?.accessToken || null,
|
||||
refreshToken: managed?.refreshToken || null,
|
||||
source: 'managed'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -133,8 +105,18 @@ const tokenNeedsRefresh = (token) => {
|
||||
};
|
||||
|
||||
const persistAccessToken = (auth, accessToken) => {
|
||||
if (auth.source === 'sqlite') writeStateValue('cursorAuth/accessToken', accessToken);
|
||||
if (auth.source === 'file') writeFileToken(auth.accessTokenPath, accessToken);
|
||||
if (auth.source === 'managed') writeManagedCredential(providerId, { accessToken, refreshToken: auth.refreshToken || '' });
|
||||
};
|
||||
|
||||
export const importCursorCredential = async () => {
|
||||
const credential = {
|
||||
accessToken: readStateValue('cursorAuth/accessToken') || '',
|
||||
refreshToken: readStateValue('cursorAuth/refreshToken') || '',
|
||||
};
|
||||
if (!credential.accessToken && !credential.refreshToken) throw new Error('Cursor credentials are unavailable');
|
||||
const accessToken = await resolveCredentialAccessToken({ ...credential, source: 'import' });
|
||||
if (!accessToken) throw new Error('Cursor credentials are invalid');
|
||||
return writeManagedCredential(providerId, { ...credential, accessToken });
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (auth) => {
|
||||
@@ -165,13 +147,20 @@ const refreshAccessToken = async (auth) => {
|
||||
return body.access_token;
|
||||
};
|
||||
|
||||
const resolveAccessToken = async () => {
|
||||
const auth = loadAuthState();
|
||||
const resolveCredentialAccessToken = async (auth) => {
|
||||
if (!auth.accessToken && !auth.refreshToken) return null;
|
||||
if (!tokenNeedsRefresh(auth.accessToken)) return auth.accessToken;
|
||||
return refreshAccessToken(auth);
|
||||
};
|
||||
|
||||
const resolveAccessToken = async () => resolveCredentialAccessToken(loadAuthState());
|
||||
|
||||
export const validateCursorCredential = async (credential) => {
|
||||
const accessToken = await resolveCredentialAccessToken({ ...credential, source: 'validation' });
|
||||
if (!accessToken) throw new Error('Cursor credentials are invalid');
|
||||
await connectPost(USAGE_URL, accessToken);
|
||||
};
|
||||
|
||||
const connectPost = async (url, accessToken) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,26 +1,11 @@
|
||||
import { homedir } from 'os';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { buildResult, toUsageWindow, toNumber } from '../utils/index.js';
|
||||
|
||||
const COOKIE_PATH = join(homedir(), '.config', 'ollama-quota', 'cookie');
|
||||
import { readManagedCredential } from '../credentials/providers.js';
|
||||
|
||||
export const providerId = 'ollama-cloud';
|
||||
export const providerName = 'Ollama Cloud';
|
||||
const aliases = ['ollama-cloud', 'ollamacloud'];
|
||||
|
||||
const readCookieFile = () => {
|
||||
try {
|
||||
if (!existsSync(COOKIE_PATH)) return null;
|
||||
const content = readFileSync(COOKIE_PATH, 'utf-8');
|
||||
const trimmed = content.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseOllamaSettingsHtml = (html) => {
|
||||
export const parseOllamaSettingsHtml = (html) => {
|
||||
const windows = {};
|
||||
const sessionMatch = html.match(/Session\s+usage[^0-9]*([0-9.]+)%/i);
|
||||
if (sessionMatch) {
|
||||
@@ -54,14 +39,29 @@ const parseOllamaSettingsHtml = (html) => {
|
||||
};
|
||||
|
||||
export const isConfigured = () => {
|
||||
const cookie = readCookieFile();
|
||||
return Boolean(cookie);
|
||||
return Boolean(readManagedCredential(providerId));
|
||||
};
|
||||
|
||||
export const fetchOllamaCloudUsage = async (credential, fetchImpl = fetch) => {
|
||||
const response = await fetchImpl('https://ollama.com/settings', {
|
||||
method: 'GET',
|
||||
headers: { Cookie: credential.cookie, 'User-Agent': 'OpenChamber quota provider' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403 || (response.status >= 300 && response.status < 400)) {
|
||||
throw new Error('Ollama Cloud authentication failed');
|
||||
}
|
||||
if (!response.ok) throw new Error(`Ollama Cloud returned HTTP ${response.status}`);
|
||||
const windows = parseOllamaSettingsHtml(await response.text());
|
||||
if (Object.keys(windows).length === 0) throw new Error('Ollama Cloud usage data could not be parsed');
|
||||
return windows;
|
||||
};
|
||||
|
||||
export const fetchQuota = async () => {
|
||||
const cookie = readCookieFile();
|
||||
const credential = readManagedCredential(providerId);
|
||||
|
||||
if (!cookie) {
|
||||
if (!credential) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
@@ -72,26 +72,7 @@ export const fetchQuota = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://ollama.com/settings', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName,
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: `API error: ${response.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const windows = parseOllamaSettingsHtml(html);
|
||||
const windows = await fetchOllamaCloudUsage(credential);
|
||||
|
||||
return buildResult({
|
||||
providerId,
|
||||
@@ -109,4 +90,4 @@ export const fetchQuota = async () => {
|
||||
error: error instanceof Error ? error.message : 'Request failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { fetchOllamaCloudUsage } from './ollama-cloud.js';
|
||||
|
||||
describe('Ollama Cloud quota provider', () => {
|
||||
it('rejects redirects without forwarding credentials', async () => {
|
||||
await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response('', { status: 302 }))).rejects.toThrow('authentication failed');
|
||||
});
|
||||
|
||||
it('rejects successful pages without usage data', async () => {
|
||||
await expect(fetchOllamaCloudUsage({ cookie: 'session=secret' }, async () => new Response('<html></html>'))).rejects.toThrow('could not be parsed');
|
||||
});
|
||||
});
|
||||
@@ -44,9 +44,10 @@ export const fetchOpenCodeGoUsage = async (credential, fetchImpl = fetch) => {
|
||||
Cookie: `auth=${credential.authCookie}`,
|
||||
'User-Agent': 'OpenChamber quota provider',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (response.status === 401 || response.status === 403 || (response.redirected && /\/auth(?:\/|$|\?)/.test(new URL(response.url).pathname))) {
|
||||
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}`);
|
||||
|
||||
@@ -1,65 +1,95 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
deleteOpenCodeGoCredential,
|
||||
getOpenCodeGoCredentialStatus,
|
||||
normalizeOpenCodeGoCredential,
|
||||
readOpenCodeGoCredential,
|
||||
writeOpenCodeGoCredential,
|
||||
} from './opencode-go-credentials.js';
|
||||
import { deleteManagedCredential, getManagedCredentialStatus, normalizers, readManagedCredential, writeManagedCredential } from './credentials/providers.js';
|
||||
import { fetchOpenCodeGoUsage } from './providers/opencode-go.js';
|
||||
import { fetchOllamaCloudUsage } from './providers/ollama-cloud.js';
|
||||
import { importCursorCredential, validateCursorCredential } from './providers/cursor.js';
|
||||
|
||||
const validators = {
|
||||
'opencode-go': fetchOpenCodeGoUsage,
|
||||
'ollama-cloud': fetchOllamaCloudUsage,
|
||||
cursor: validateCursorCredential,
|
||||
};
|
||||
|
||||
const getProvider = (req, res) => {
|
||||
const providerId = req.params.providerId;
|
||||
if (!normalizers[providerId]) {
|
||||
res.status(404).json({ code: 'UNSUPPORTED_PROVIDER', error: 'Unsupported credential provider' });
|
||||
return null;
|
||||
}
|
||||
return providerId;
|
||||
};
|
||||
|
||||
const credentialError = (res, error) => res.status(400).json({
|
||||
code: 'INVALID_CREDENTIAL',
|
||||
error: error instanceof Error ? error.message : 'Credential validation failed',
|
||||
});
|
||||
|
||||
export function registerQuotaRoutes(app, { getQuotaProviders }) {
|
||||
app.get('/api/quota/providers', async (_req, res) => {
|
||||
try {
|
||||
const { listConfiguredQuotaProviders } = await getQuotaProviders();
|
||||
const providers = listConfiguredQuotaProviders();
|
||||
res.json({ providers });
|
||||
res.json({ providers: listConfiguredQuotaProviders() });
|
||||
} catch (error) {
|
||||
console.error('Failed to list quota providers:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to list quota providers' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/quota/credentials/opencode-go', (_req, res) => {
|
||||
res.json(getOpenCodeGoCredentialStatus());
|
||||
app.get('/api/quota/credentials/:providerId', (req, res) => {
|
||||
const providerId = getProvider(req, res);
|
||||
if (providerId) res.json(getManagedCredentialStatus(providerId));
|
||||
});
|
||||
|
||||
app.put('/api/quota/credentials/opencode-go', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
app.put('/api/quota/credentials/:providerId', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
const providerId = getProvider(req, res);
|
||||
if (!providerId) return;
|
||||
const credential = normalizers[providerId](req.body);
|
||||
if (!credential) return credentialError(res, new Error('Invalid credential'));
|
||||
try {
|
||||
const credential = normalizeOpenCodeGoCredential(req.body);
|
||||
if (!credential) return res.status(400).json({ error: 'Workspace ID and auth cookie are required' });
|
||||
await fetchOpenCodeGoUsage(credential);
|
||||
res.json(writeOpenCodeGoCredential(credential));
|
||||
await validators[providerId](credential);
|
||||
res.json(writeManagedCredential(providerId, credential));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error instanceof Error ? error.message : 'Credential validation failed' });
|
||||
credentialError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/quota/credentials/opencode-go/validate', async (_req, res) => {
|
||||
app.post('/api/quota/credentials/:providerId/validate', async (req, res) => {
|
||||
const providerId = getProvider(req, res);
|
||||
if (!providerId) return;
|
||||
const credential = readManagedCredential(providerId);
|
||||
if (!credential) return res.status(404).json({ code: 'NOT_CONFIGURED', error: 'Not configured' });
|
||||
try {
|
||||
const credential = readOpenCodeGoCredential();
|
||||
if (!credential) return res.status(404).json({ error: 'Not configured' });
|
||||
await fetchOpenCodeGoUsage(credential);
|
||||
await validators[providerId](credential);
|
||||
res.json({ valid: true });
|
||||
} catch (error) {
|
||||
res.status(400).json({ valid: false, error: error instanceof Error ? error.message : 'Credential validation failed' });
|
||||
credentialError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/quota/credentials/opencode-go', (_req, res) => {
|
||||
deleteOpenCodeGoCredential();
|
||||
app.post('/api/quota/credentials/:providerId/import', async (req, res) => {
|
||||
const providerId = getProvider(req, res);
|
||||
if (!providerId) return;
|
||||
if (providerId !== 'cursor') return res.status(404).json({ code: 'IMPORT_UNAVAILABLE', error: 'Import unavailable' });
|
||||
try {
|
||||
res.json(await importCursorCredential());
|
||||
} catch (error) {
|
||||
credentialError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/quota/credentials/:providerId', (req, res) => {
|
||||
const providerId = getProvider(req, res);
|
||||
if (!providerId) return;
|
||||
deleteManagedCredential(providerId);
|
||||
res.json({ configured: false });
|
||||
});
|
||||
|
||||
app.get('/api/quota/:providerId', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
if (!providerId) {
|
||||
return res.status(400).json({ error: 'Provider ID is required' });
|
||||
}
|
||||
if (!providerId) return res.status(400).json({ error: 'Provider ID is required' });
|
||||
const { fetchQuotaForProvider } = await getQuotaProviders();
|
||||
const result = await fetchQuotaForProvider(providerId);
|
||||
res.json(result);
|
||||
res.json(await fetchQuotaForProvider(providerId));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch quota:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to fetch quota' });
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('OpenCode Go credential routes', () => {
|
||||
body: JSON.stringify({ workspaceId: 'wrk_test', authCookie: 'auth=secret' }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ configured: true, workspaceId: 'wrk_test', authCookieMasked: '••••••••' });
|
||||
expect(await response.json()).toEqual({ configured: true, workspaceId: 'wrk_test', secretMasked: '••••••••' });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
server.close();
|
||||
|
||||
Reference in New Issue
Block a user