diff --git a/backend/auth/credential-crypto.ts b/backend/auth/credential-crypto.ts new file mode 100644 index 00000000..1f206a19 --- /dev/null +++ b/backend/auth/credential-crypto.ts @@ -0,0 +1,114 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { debug } from '$shared/utils/logger'; + +const ALGORITHM = 'AES-GCM'; +const KEY_LENGTH = 256; +const IV_LENGTH = 12; +const KEY_FILE = join(homedir(), '.clopen', 'credential-key'); +const CIPHER_VERSION = 'v1'; + +let cachedKey: CryptoKey | null = null; + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + return bytes; +} + +function base64Encode(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} + +function base64Decode(str: string): Uint8Array { + const binary = atob(str); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function loadOrCreateKey(): Promise { + const envKey = process.env.CLOPEN_CREDENTIAL_KEY; + if (envKey) { + const raw = hexToBytes(envKey); + const rawArr = new ArrayBuffer(raw.byteLength); + new Uint8Array(rawArr).set(raw); + return await crypto.subtle.importKey('raw', rawArr, ALGORITHM, false, ['encrypt', 'decrypt']); + } + + let raw: Uint8Array; + try { + const stored = await readFile(KEY_FILE, 'utf-8'); + raw = hexToBytes(stored.trim()); + } catch { + raw = crypto.getRandomValues(new Uint8Array(32)); + await mkdir(join(homedir(), '.clopen'), { recursive: true }); + await writeFile(KEY_FILE, bytesToHex(raw), 'utf-8'); + debug.log('auth', `Generated credential encryption key at ${KEY_FILE}`); + } + + const rawArr = new ArrayBuffer(raw.byteLength); + new Uint8Array(rawArr).set(raw); + return await crypto.subtle.importKey('raw', rawArr, ALGORITHM, false, ['encrypt', 'decrypt']); +} + +async function getKey(): Promise { + if (!cachedKey) { + cachedKey = await loadOrCreateKey(); + } + return cachedKey; +} + +export function isEncrypted(value: string): boolean { + return value.startsWith(`${CIPHER_VERSION}:`); +} + +export async function encryptCredential(plaintext: string): Promise { + const key = await getKey(); + const ivRaw = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const ivArr = new ArrayBuffer(12); + new Uint8Array(ivArr).set(ivRaw); + const encoded = new TextEncoder().encode(plaintext); + const encrypted = await crypto.subtle.encrypt({ name: ALGORITHM, iv: ivArr }, key, encoded); + const encryptedBytes = new Uint8Array(encrypted); + const authTag = encryptedBytes.slice(-16); + const ciphertext = encryptedBytes.slice(0, -16); + const payload = `${CIPHER_VERSION}:${base64Encode(ivRaw)}:${base64Encode(authTag)}:${base64Encode(ciphertext)}`; + return payload; +} + +export async function decryptCredential(payload: string): Promise { + if (!isEncrypted(payload)) return payload; + + const parts = payload.split(':'); + if (parts.length !== 4) return payload; + + const ivRaw = base64Decode(parts[1]); + const authTag = base64Decode(parts[2]); + const ciphertext = base64Decode(parts[3]); + + const combined = new Uint8Array(ciphertext.length + authTag.length); + combined.set(ciphertext, 0); + combined.set(authTag, ciphertext.length); + + const key = await getKey(); + const ivArr = new ArrayBuffer(12); + new Uint8Array(ivArr).set(ivRaw); + const combinedBuf = new ArrayBuffer(combined.byteLength); + new Uint8Array(combinedBuf).set(combined); + const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv: ivArr }, key, combinedBuf); + return new TextDecoder().decode(decrypted); +} + +export function resetCredentialKeyCache(): void { + cachedKey = null; +} diff --git a/backend/database/migrations/040_encrypt_engine_credentials.ts b/backend/database/migrations/040_encrypt_engine_credentials.ts new file mode 100644 index 00000000..0a3d2724 --- /dev/null +++ b/backend/database/migrations/040_encrypt_engine_credentials.ts @@ -0,0 +1,22 @@ +import type { DatabaseConnection } from '$shared/types/database/connection'; +import { encryptCredential, isEncrypted } from '../../auth/credential-crypto'; + +export const id = '040'; +export const description = 'Encrypt existing engine account credentials at rest'; + +export async function up(db: DatabaseConnection): Promise { + const rows = db.prepare( + `SELECT id, credential FROM engine_accounts` + ).all() as { id: number; credential: string }[]; + + const update = db.prepare(`UPDATE engine_accounts SET credential = ? WHERE id = ?`); + + for (const row of rows) { + if (isEncrypted(row.credential)) continue; + const encrypted = await encryptCredential(row.credential); + update.run(encrypted, row.id); + } +} + +export async function down(_db: DatabaseConnection): Promise { +} diff --git a/backend/database/migrations/index.ts b/backend/database/migrations/index.ts index eac3cf06..e72912fe 100644 --- a/backend/database/migrations/index.ts +++ b/backend/database/migrations/index.ts @@ -38,6 +38,7 @@ import * as migration036 from './036_create_auth_audit_log'; import * as migration037 from './037_repair_auth_audit_log_schema'; import * as migration038 from './038_add_workspace_state_to_user_projects'; import * as migration039 from './039_create_file_audit_log'; +import * as migration040 from './040_encrypt_engine_credentials'; // Export all migrations in order export const migrations = [ @@ -274,6 +275,12 @@ export const migrations = [ description: migration039.description, up: migration039.up, down: migration039.down + }, + { + id: '040', + description: migration040.description, + up: migration040.up, + down: migration040.down } ]; diff --git a/backend/database/queries/engine-queries.ts b/backend/database/queries/engine-queries.ts index ee6e98bd..16c5564a 100644 --- a/backend/database/queries/engine-queries.ts +++ b/backend/database/queries/engine-queries.ts @@ -15,6 +15,7 @@ import type { EngineType } from '$shared/types/unified'; import { getDatabase } from '../index'; +import { encryptCredential, decryptCredential, isEncrypted } from '../../auth/credential-crypto'; // ============================================================================ // Types @@ -46,6 +47,16 @@ export interface EngineProviderWithAccounts extends EngineProvider { accounts: EngineAccount[]; } +async function decryptAccount(account: EngineAccount | null): Promise { + if (!account) return null; + if (!isEncrypted(account.credential)) return account; + return { ...account, credential: await decryptCredential(account.credential) }; +} + +async function decryptAccounts(accounts: EngineAccount[]): Promise { + return Promise.all(accounts.map(a => decryptAccount(a).then(a => a!))); +} + // ============================================================================ // Queries // ============================================================================ @@ -135,32 +146,35 @@ export const engineQueries = { // Accounts // ------------------------------------------------------------------ - getAccount(id: number): EngineAccount | null { + async getAccount(id: number): Promise { const db = getDatabase(); - return db.prepare(`SELECT * FROM engine_accounts WHERE id = ?`).get(id) as EngineAccount | null; + const account = db.prepare(`SELECT * FROM engine_accounts WHERE id = ?`).get(id) as EngineAccount | null; + return decryptAccount(account); }, - getAccountsByProvider(providerId: number): EngineAccount[] { + async getAccountsByProvider(providerId: number): Promise { const db = getDatabase(); - return db.prepare( + const accounts = db.prepare( `SELECT * FROM engine_accounts WHERE provider_id = ? ORDER BY created_at ASC` ).all(providerId) as EngineAccount[]; + return decryptAccounts(accounts); }, - getActiveAccount(providerId: number): EngineAccount | null { + async getActiveAccount(providerId: number): Promise { const db = getDatabase(); - return db.prepare( + const account = db.prepare( `SELECT * FROM engine_accounts WHERE provider_id = ? AND is_active = 1` ).get(providerId) as EngineAccount | null; + return decryptAccount(account); }, /** * Get the active account for the first enabled provider of the given engine. * For claude-code this is effectively the active Anthropic account. */ - getActiveAccountForEngine(engineType: EngineType): EngineAccount | null { + async getActiveAccountForEngine(engineType: EngineType): Promise { const db = getDatabase(); - return db.prepare(` + const account = db.prepare(` SELECT a.* FROM engine_accounts a JOIN engine_providers p ON p.id = a.provider_id @@ -168,10 +182,12 @@ export const engineQueries = { ORDER BY p.created_at ASC, a.created_at ASC LIMIT 1 `).get(engineType) as EngineAccount | null; + return decryptAccount(account); }, - createAccount(providerId: number, name: string, credential: string): EngineAccount { + async createAccount(providerId: number, name: string, credential: string): Promise { const db = getDatabase(); + const encrypted = await encryptCredential(credential); // If it's the first account for this provider → mark active. const count = (db.prepare( @@ -182,10 +198,11 @@ export const engineQueries = { const result = db.prepare(` INSERT INTO engine_accounts (provider_id, name, credential, is_active) VALUES (?, ?, ?, ?) - `).run(providerId, name, credential, isActive) as { lastInsertRowid: number | bigint }; + `).run(providerId, name, encrypted, isActive) as { lastInsertRowid: number | bigint }; const id = Number(result.lastInsertRowid); - return db.prepare(`SELECT * FROM engine_accounts WHERE id = ?`).get(id) as EngineAccount; + const account = db.prepare(`SELECT * FROM engine_accounts WHERE id = ?`).get(id) as EngineAccount; + return { ...account, credential }; }, switchAccount(accountId: number): void { @@ -227,20 +244,24 @@ export const engineQueries = { * into `credential` after each stream so refreshed tokens survive across * account switches. */ - updateAccountCredential(accountId: number, credential: string): void { + async updateAccountCredential(accountId: number, credential: string): Promise { const db = getDatabase(); - db.prepare(`UPDATE engine_accounts SET credential = ? WHERE id = ?`).run(credential, accountId); + const encrypted = await encryptCredential(credential); + db.prepare(`UPDATE engine_accounts SET credential = ? WHERE id = ?`).run(encrypted, accountId); }, // ------------------------------------------------------------------ // Composite // ------------------------------------------------------------------ - getProvidersWithAccounts(engineType?: EngineType): EngineProviderWithAccounts[] { + async getProvidersWithAccounts(engineType?: EngineType): Promise { const providers = this.getProviders(engineType); - return providers.map(p => ({ + const accountsByProvider = await Promise.all( + providers.map(p => this.getAccountsByProvider(p.id)) + ); + return providers.map((p, i) => ({ ...p, - accounts: this.getAccountsByProvider(p.id), + accounts: accountsByProvider[i], })); }, }; diff --git a/backend/database/utils/migration-runner.ts b/backend/database/utils/migration-runner.ts index 334354ba..0bfeb95d 100644 --- a/backend/database/utils/migration-runner.ts +++ b/backend/database/utils/migration-runner.ts @@ -4,8 +4,8 @@ import { debug } from '$shared/utils/logger'; interface Migration { id: string; description: string; - up: (db: DatabaseConnection) => void; - down: (db: DatabaseConnection) => void; + up: (db: DatabaseConnection) => void | Promise; + down: (db: DatabaseConnection) => void | Promise; } export class MigrationRunner { @@ -52,7 +52,7 @@ export class MigrationRunner { try { // Execute migration - migration.up(this.db); + await migration.up(this.db); // Record migration as executed this.db.prepare(` @@ -86,7 +86,7 @@ export class MigrationRunner { try { // Execute rollback - migration.down(this.db); + await migration.down(this.db); // Remove migration record this.db.prepare(` diff --git a/backend/engine/adapters/claude/environment.ts b/backend/engine/adapters/claude/environment.ts index 9ed6ff7b..262406ab 100644 --- a/backend/engine/adapters/claude/environment.ts +++ b/backend/engine/adapters/claude/environment.ts @@ -59,26 +59,23 @@ export async function setupEnvironmentOnce(): Promise { * When accountId is provided, overrides the OAuth token with that * specific account's token instead of the globally active account. */ -export function getEngineEnv(accountId?: number): Record { - // Start from clean env (no Bun/npm/Vite pollution) - const env = getCleanSpawnEnv(); - // Apply our overrides - Object.assign(env, _envOverrides); - - // Override with specific account token if requested - if (accountId !== undefined) { - try { - const account = engineQueries.getAccount(accountId); - if (account) { - env['CLAUDE_CODE_OAUTH_TOKEN'] = account.credential; - debug.log('engine', `Claude Code: Per-session account override → "${account.name}"`); - } - } catch { - // Ignore — fall back to default token from overrides - } - } - - return env; +export async function getEngineEnv(accountId?: number): Promise> { + const env = getCleanSpawnEnv(); + Object.assign(env, _envOverrides); + + if (accountId !== undefined) { + try { + const account = await engineQueries.getAccount(accountId); + if (account) { + env['CLAUDE_CODE_OAUTH_TOKEN'] = account.credential; + debug.log('engine', `Claude Code: Per-session account override → "${account.name}"`); + } + } catch { + // Ignore DB errors during per-session override. + } + } + + return env; } async function _doSetup(): Promise { @@ -94,7 +91,7 @@ async function _doSetup(): Promise { // Inject OAuth token from active account (if any) try { - const activeAccount = engineQueries.getActiveAccountForEngine('claude-code'); + const activeAccount = await engineQueries.getActiveAccountForEngine('claude-code'); if (activeAccount) { overrides['CLAUDE_CODE_OAUTH_TOKEN'] = activeAccount.credential; debug.log('engine', `✅ Claude Code: Using account "${activeAccount.name}"`); diff --git a/backend/engine/adapters/claude/stream.ts b/backend/engine/adapters/claude/stream.ts index c9b3e7cd..7baf9eb6 100644 --- a/backend/engine/adapters/claude/stream.ts +++ b/backend/engine/adapters/claude/stream.ts @@ -115,7 +115,7 @@ export class ClaudeCodeEngine implements AIEngine { permissionMode: 'bypassPermissions' as PermissionMode, allowDangerouslySkipPermissions: true, cwd: resolvedProjectPath, - env: getEngineEnv(accountId), + env: await getEngineEnv(accountId), systemPrompt: { type: "preset", preset: "claude_code" }, settingSources: ["user", "project", "local"], forkSession: true, @@ -304,7 +304,7 @@ export class ClaudeCodeEngine implements AIEngine { permissionMode: 'bypassPermissions' as PermissionMode, allowDangerouslySkipPermissions: true, cwd: resolvedPath, - env: getEngineEnv(accountId), + env: await getEngineEnv(accountId), systemPrompt: 'You are a structured data generator. Return JSON matching the provided schema.', tools: [], outputFormat: { diff --git a/backend/engine/adapters/codex/credential.ts b/backend/engine/adapters/codex/credential.ts index ed8d2e3e..c64e21e9 100644 --- a/backend/engine/adapters/codex/credential.ts +++ b/backend/engine/adapters/codex/credential.ts @@ -158,8 +158,8 @@ export function applyAccountAuth(account: EngineAccount): CodexCredential | null * - The auth.json file doesn't exist. * - The on-disk content is identical to what we already have stored. */ -export function snapshotAuthJsonToActiveAccount(): void { - const account = engineQueries.getActiveAccountForEngine('codex'); +export async function snapshotAuthJsonToActiveAccount(): Promise { + const account = await engineQueries.getActiveAccountForEngine('codex'); if (!account) return; const parsed = parseCodexCredential(account.credential); @@ -171,7 +171,7 @@ export function snapshotAuthJsonToActiveAccount(): void { const updated = serializeCodexCredential({ kind: 'chatgpt', authJson: current }); try { - engineQueries.updateAccountCredential(account.id, updated); + await engineQueries.updateAccountCredential(account.id, updated); debug.log('engine', `Codex: persisted refreshed auth.json snapshot to account ${account.id}`); } catch (error) { debug.warn('engine', 'Codex: failed to persist auth.json snapshot:', error); diff --git a/backend/engine/adapters/codex/stream.ts b/backend/engine/adapters/codex/stream.ts index b57e9c30..a3b44b25 100644 --- a/backend/engine/adapters/codex/stream.ts +++ b/backend/engine/adapters/codex/stream.ts @@ -73,9 +73,9 @@ export class CodexEngine implements AIEngine { return; } - const account = accountId != null + const account = await (accountId != null ? engineQueries.getAccount(accountId) - : engineQueries.getActiveAccountForEngine('codex'); + : engineQueries.getActiveAccountForEngine('codex')); if (!account) { throw new Error('Codex is not configured. Add an OpenAI API key or sign in with ChatGPT in Settings → Engines → Codex.'); } @@ -148,9 +148,9 @@ export class CodexEngine implements AIEngine { // Refresh auth.json for the active account every turn (cheap idempotent // op — only writes when the file content drifts from the stored blob). - const activeAccount = accountId != null + const activeAccount = await (accountId != null ? engineQueries.getAccount(accountId) - : engineQueries.getActiveAccountForEngine('codex'); + : engineQueries.getActiveAccountForEngine('codex')); if (activeAccount) { applyAccountAuth(activeAccount); } @@ -245,7 +245,7 @@ export class CodexEngine implements AIEngine { // Snapshot the (possibly token-refreshed) auth.json back to DB so // refreshes survive across account switches (README §9.13 step 4). try { - snapshotAuthJsonToActiveAccount(); + await snapshotAuthJsonToActiveAccount(); } catch (snapshotErr) { debug.warn('engine', 'Codex: post-stream auth.json snapshot failed (non-fatal):', snapshotErr); } diff --git a/backend/engine/adapters/copilot/stream.ts b/backend/engine/adapters/copilot/stream.ts index 2c3b4557..254878a4 100644 --- a/backend/engine/adapters/copilot/stream.ts +++ b/backend/engine/adapters/copilot/stream.ts @@ -113,9 +113,9 @@ export class CopilotEngine implements AIEngine { return; } - const account = accountId != null + const account = await (accountId != null ? engineQueries.getAccount(accountId) - : engineQueries.getActiveAccountForEngine('copilot'); + : engineQueries.getActiveAccountForEngine('copilot')); if (!account) { throw new Error('Copilot is not configured. Add a Personal Access Token in Settings → Engines → Copilot.'); } diff --git a/backend/engine/adapters/opencode/config.ts b/backend/engine/adapters/opencode/config.ts index 7ca3641e..9fa65e98 100644 --- a/backend/engine/adapters/opencode/config.ts +++ b/backend/engine/adapters/opencode/config.ts @@ -60,13 +60,13 @@ export function parseCredentialMap(credential: string): Record | * plain, the value is bound to the catalog's first env name and the rest * fall back to the provider's shared `options` JSON. */ -export function generateOpenCodeProviderConfig(): OpenCodeProviderConfigResult { +export async function generateOpenCodeProviderConfig(): Promise { const providers = engineQueries.getEnabledProviders('opencode'); const enabledProviders: string[] = ['opencode']; const envVars: Record = {}; for (const provider of providers) { - const activeAccount = engineQueries.getActiveAccount(provider.id); + const activeAccount = await engineQueries.getActiveAccount(provider.id); if (!activeAccount) continue; enabledProviders.push(provider.slug); diff --git a/backend/engine/adapters/opencode/server.ts b/backend/engine/adapters/opencode/server.ts index adc69752..10052b1c 100644 --- a/backend/engine/adapters/opencode/server.ts +++ b/backend/engine/adapters/opencode/server.ts @@ -147,7 +147,7 @@ async function init(): Promise { } // Build provider config from DB (enabled providers + env vars) - const providerConfig = generateOpenCodeProviderConfig(); + const providerConfig = await generateOpenCodeProviderConfig(); if (providerConfig.enabledProviders.length > 0) { debug.log('engine', `Open Code server: enabling ${providerConfig.enabledProviders.length} provider(s): ${providerConfig.enabledProviders.join(', ')}`); } diff --git a/backend/engine/adapters/qwen/environment.ts b/backend/engine/adapters/qwen/environment.ts index f623ef3f..1dfd57ef 100644 --- a/backend/engine/adapters/qwen/environment.ts +++ b/backend/engine/adapters/qwen/environment.ts @@ -39,14 +39,14 @@ export interface QwenEnvResolution { * caller should surface a friendly error rather than spawning the CLI with * bad/empty credentials. */ -export function getEngineEnv(accountId?: number): QwenEnvResolution | null { +export async function getEngineEnv(accountId?: number): Promise { const env = getCleanSpawnEnv(); let account: EngineAccount | null; if (accountId !== undefined) { - account = engineQueries.getAccount(accountId); + account = await engineQueries.getAccount(accountId); } else { - account = engineQueries.getActiveAccountForEngine('qwen'); + account = await engineQueries.getActiveAccountForEngine('qwen'); } if (!account) { diff --git a/backend/engine/adapters/qwen/stream.ts b/backend/engine/adapters/qwen/stream.ts index 1b5fd519..4e021f29 100644 --- a/backend/engine/adapters/qwen/stream.ts +++ b/backend/engine/adapters/qwen/stream.ts @@ -106,7 +106,7 @@ export class QwenEngine implements AIEngine { * failure). */ async getAvailableModels(): Promise { - const env = getEngineEnv(); + const env = await getEngineEnv(); if (!env) { throw new Error('Qwen Code is not configured. Add an API key in Settings → Engines → Qwen Code.'); } @@ -129,7 +129,7 @@ export class QwenEngine implements AIEngine { debug.log('chat', 'Qwen Code - Stream Query', { modelId, resume }); - const resolution = getEngineEnv(accountId); + const resolution = await getEngineEnv(accountId); if (!resolution) { throw new Error('Qwen Code is not configured. Add an API key in Settings → Engines → Qwen Code.'); } @@ -364,7 +364,7 @@ export class QwenEngine implements AIEngine { accountId, } = options; - const resolution = getEngineEnv(accountId); + const resolution = await getEngineEnv(accountId); if (!resolution) { throw new Error('Qwen Code is not configured. Add an API key in Settings → Engines → Qwen Code.'); } diff --git a/backend/ws/engine/claude/accounts.ts b/backend/ws/engine/claude/accounts.ts index 6bdd50aa..90449fb6 100644 --- a/backend/ws/engine/claude/accounts.ts +++ b/backend/ws/engine/claude/accounts.ts @@ -131,7 +131,7 @@ export const accountsHandler = createRouter() }, async () => { const provider = engineQueries.getProviderBySlug('claude-code', 'anthropic'); if (!provider) return { accounts: [] }; - const accounts = engineQueries.getAccountsByProvider(provider.id); + const accounts = await engineQueries.getAccountsByProvider(provider.id); return { accounts: accounts.map(a => ({ id: a.id, @@ -155,7 +155,7 @@ export const accountsHandler = createRouter() data: t.Object({ id: t.Number() }), response: t.Object({ success: t.Boolean() }) }, async ({ data }) => { - const active = engineQueries.getActiveAccountForEngine('claude-code'); + const active = await engineQueries.getActiveAccountForEngine('claude-code'); engineQueries.deleteAccount(data.id); if (active?.id === data.id) resetEnvironment(); return { success: true }; @@ -232,7 +232,7 @@ export const accountsHandler = createRouter() userSetups.set(userId, setupId); // ── Single onData listener — handles ALL phases ── - pty.onData((data: string) => { + pty.onData(async (data: string) => { if (entry.disposed || entry.phase === 'done') return; entry.buffer += data; @@ -271,7 +271,7 @@ export const accountsHandler = createRouter() cleanupSetup(setupId); return; } - const account = engineQueries.createAccount(provider.id, entry.accountName, token); + const account = await engineQueries.createAccount(provider.id, entry.accountName, token); resetEnvironment(); ws.emit.user(userId, 'engine:claude-account-setup-complete', { @@ -300,7 +300,7 @@ export const accountsHandler = createRouter() }); // ── Single onExit listener — handles ALL phases ── - pty.onExit(() => { + pty.onExit(async () => { if (entry.disposed || entry.phase === 'done') return; debug.log('engine', `[${setupId}] PTY exited during phase: ${entry.phase}`); @@ -325,7 +325,7 @@ export const accountsHandler = createRouter() cleanupSetup(setupId); return; } - const account = engineQueries.createAccount(provider.id, entry.accountName, token); + const account = await engineQueries.createAccount(provider.id, entry.accountName, token); resetEnvironment(); ws.emit.user(userId, 'engine:claude-account-setup-complete', { setupId, diff --git a/backend/ws/engine/claude/status.ts b/backend/ws/engine/claude/status.ts index f2062367..1e317472 100644 --- a/backend/ws/engine/claude/status.ts +++ b/backend/ws/engine/claude/status.ts @@ -32,8 +32,8 @@ export const claudeCodeStatusHandler = createRouter() const { installed, version } = await getStatus('claude'); const provider = engineQueries.getProviderBySlug('claude-code', 'anthropic'); - const accounts = provider ? engineQueries.getAccountsByProvider(provider.id) : []; - const activeAccount = engineQueries.getActiveAccountForEngine('claude-code'); + const accounts = provider ? await engineQueries.getAccountsByProvider(provider.id) : []; + const activeAccount = await engineQueries.getActiveAccountForEngine('claude-code'); return { installed, diff --git a/backend/ws/engine/codex/accounts.ts b/backend/ws/engine/codex/accounts.ts index 687ad6d4..ce7fc058 100644 --- a/backend/ws/engine/codex/accounts.ts +++ b/backend/ws/engine/codex/accounts.ts @@ -156,7 +156,7 @@ function isLoginSuccess(buffer: string): boolean { return /Successfully logged in/i.test(buffer); } -function persistChatGptLoginResult(accountName: string): { ok: true; accountId: number } | { ok: false; error: string } { +async function persistChatGptLoginResult(accountName: string): Promise<{ ok: true; accountId: number } | { ok: false; error: string }> { const provider = engineQueries.getProviderBySlug('codex', 'openai'); if (!provider) return { ok: false, error: 'OpenAI Codex provider not found in DB' }; @@ -166,7 +166,7 @@ function persistChatGptLoginResult(accountName: string): { ok: true; accountId: } const credential = serializeCodexCredential({ kind: 'chatgpt', authJson }); - const account = engineQueries.createAccount(provider.id, accountName, credential); + const account = await engineQueries.createAccount(provider.id, accountName, credential); return { ok: true, accountId: account.id }; } @@ -190,7 +190,7 @@ export const codexAccountsHandler = createRouter() }, async () => { const provider = engineQueries.getProviderBySlug('codex', 'openai'); if (!provider) return { accounts: [] }; - const accounts = engineQueries.getAccountsByProvider(provider.id); + const accounts = await engineQueries.getAccountsByProvider(provider.id); return { accounts: accounts.map(a => ({ id: a.id, @@ -224,7 +224,7 @@ export const codexAccountsHandler = createRouter() } const credential = serializeCodexCredential({ kind: 'api_key', apiKey: data.apiKey.trim() }); - const account = engineQueries.createAccount(provider.id, data.name.trim(), credential); + const account = await engineQueries.createAccount(provider.id, data.name.trim(), credential); if (account.is_active === 1) { await disposeCodexEngines(); @@ -245,7 +245,7 @@ export const codexAccountsHandler = createRouter() data: t.Object({ id: t.Number() }), response: t.Object({ success: t.Boolean() }) }, async ({ data }) => { - const account = engineQueries.getAccount(data.id); + const account = await engineQueries.getAccount(data.id); if (!account) throw new Error('Account not found'); engineQueries.switchAccount(data.id); @@ -263,13 +263,13 @@ export const codexAccountsHandler = createRouter() data: t.Object({ id: t.Number() }), response: t.Object({ success: t.Boolean() }) }, async ({ data }) => { - const active = engineQueries.getActiveAccountForEngine('codex'); + const active = await engineQueries.getActiveAccountForEngine('codex'); engineQueries.deleteAccount(data.id); if (active?.id === data.id) { // The new active account (auto-promoted by deleteAccount) needs its // auth applied so the next stream uses the correct credential. - const newActive = engineQueries.getActiveAccountForEngine('codex'); + const newActive = await engineQueries.getActiveAccountForEngine('codex'); if (newActive) applyAccountAuth(newActive); await disposeCodexEngines(); } @@ -366,7 +366,7 @@ export const codexAccountsHandler = createRouter() userSetups.set(userId, setupId); // ── Single onData listener — matches the Claude Code pattern ── - pty.onData((chunk: string) => { + pty.onData(async (chunk: string) => { if (entry.disposed || entry.completed) return; entry.buffer += chunk; @@ -406,7 +406,7 @@ export const codexAccountsHandler = createRouter() if (isLoginSuccess(clean) && !entry.completed) { entry.completed = true; debug.log('engine', `[${setupId}] Codex login success — persisting auth.json`); - const result = persistChatGptLoginResult(entry.accountName); + const result = await persistChatGptLoginResult(entry.accountName); if (result.ok) { disposeCodexEngines().finally(() => { ws.emit.user(userId, 'engine:codex-account-setup-complete', { @@ -425,7 +425,7 @@ export const codexAccountsHandler = createRouter() } }); - pty.onExit(({ exitCode }) => { + pty.onExit(async ({ exitCode }) => { if (entry.disposed || entry.cancelled) return; if (entry.completed) return; @@ -435,7 +435,7 @@ export const codexAccountsHandler = createRouter() // in the final chunk right before exit. if (isLoginSuccess(clean)) { entry.completed = true; - const result = persistChatGptLoginResult(entry.accountName); + const result = await persistChatGptLoginResult(entry.accountName); if (result.ok) { disposeCodexEngines().finally(() => { ws.emit.user(userId, 'engine:codex-account-setup-complete', { diff --git a/backend/ws/engine/codex/status.ts b/backend/ws/engine/codex/status.ts index c0300269..2d95cac3 100644 --- a/backend/ws/engine/codex/status.ts +++ b/backend/ws/engine/codex/status.ts @@ -61,8 +61,8 @@ export const codexStatusHandler = createRouter() debug.log('engine', 'Checking Codex status...'); const provider = engineQueries.getProviderBySlug('codex', 'openai'); - const accounts = provider ? engineQueries.getAccountsByProvider(provider.id) : []; - const activeAccount = engineQueries.getActiveAccountForEngine('codex'); + const accounts = provider ? await engineQueries.getAccountsByProvider(provider.id) : []; + const activeAccount = await engineQueries.getActiveAccountForEngine('codex'); const cliVersion = await readCliVersion(); diff --git a/backend/ws/engine/copilot/accounts.ts b/backend/ws/engine/copilot/accounts.ts index 0c8ade42..9fb51c7e 100644 --- a/backend/ws/engine/copilot/accounts.ts +++ b/backend/ws/engine/copilot/accounts.ts @@ -37,7 +37,7 @@ export const copilotAccountsHandler = createRouter() }, async () => { const provider = engineQueries.getProviderBySlug('copilot', 'github'); if (!provider) return { accounts: [] }; - const accounts = engineQueries.getAccountsByProvider(provider.id); + const accounts = await engineQueries.getAccountsByProvider(provider.id); return { accounts: accounts.map(a => ({ id: a.id, @@ -67,7 +67,7 @@ export const copilotAccountsHandler = createRouter() throw new Error('GitHub Copilot provider not found in database'); } - const account = engineQueries.createAccount(provider.id, data.name.trim(), data.token.trim()); + const account = await engineQueries.createAccount(provider.id, data.name.trim(), data.token.trim()); // If this is the first account it became active — drop any stale engine instances. if (account.is_active === 1) { @@ -97,7 +97,7 @@ export const copilotAccountsHandler = createRouter() data: t.Object({ id: t.Number() }), response: t.Object({ success: t.Boolean() }) }, async ({ data }) => { - const active = engineQueries.getActiveAccountForEngine('copilot'); + const active = await engineQueries.getActiveAccountForEngine('copilot'); engineQueries.deleteAccount(data.id); if (active?.id === data.id) await disposeCopilotEngines(); return { success: true }; diff --git a/backend/ws/engine/copilot/status.ts b/backend/ws/engine/copilot/status.ts index e0210144..e57f108e 100644 --- a/backend/ws/engine/copilot/status.ts +++ b/backend/ws/engine/copilot/status.ts @@ -41,8 +41,8 @@ export const copilotStatusHandler = createRouter() debug.log('engine', 'Checking Copilot status...'); const provider = engineQueries.getProviderBySlug('copilot', 'github'); - const accounts = provider ? engineQueries.getAccountsByProvider(provider.id) : []; - const activeAccount = engineQueries.getActiveAccountForEngine('copilot'); + const accounts = provider ? await engineQueries.getAccountsByProvider(provider.id) : []; + const activeAccount = await engineQueries.getActiveAccountForEngine('copilot'); return { installed: true, diff --git a/backend/ws/engine/opencode/providers.ts b/backend/ws/engine/opencode/providers.ts index c9e1505f..cafb66d7 100644 --- a/backend/ws/engine/opencode/providers.ts +++ b/backend/ws/engine/opencode/providers.ts @@ -48,7 +48,7 @@ export const openCodeProviderHandler = createRouter() providers: t.Array(ProviderSchema) }) }, async () => { - const providers = engineQueries.getProvidersWithAccounts('opencode'); + const providers = await engineQueries.getProvidersWithAccounts('opencode'); return { providers: providers.map(p => ({ id: p.id, @@ -100,7 +100,7 @@ export const openCodeProviderHandler = createRouter() }); // Create first account (auto-active) - const account = engineQueries.createAccount(provider.id, data.accountName, data.credential); + const account = await engineQueries.createAccount(provider.id, data.accountName, data.credential); debug.log('engine', `OpenCode provider added: ${data.slug} with account "${data.accountName}"`); @@ -165,7 +165,7 @@ export const openCodeProviderHandler = createRouter() account: AccountSchema }) }, async ({ data }) => { - const account = engineQueries.createAccount(data.providerDbId, data.name, data.credential); + const account = await engineQueries.createAccount(data.providerDbId, data.name, data.credential); debug.log('engine', `OpenCode account added: "${data.name}" for provider ${data.providerDbId}`); return { account: { diff --git a/backend/ws/engine/qwen/accounts.ts b/backend/ws/engine/qwen/accounts.ts index b6f4168b..786885ac 100644 --- a/backend/ws/engine/qwen/accounts.ts +++ b/backend/ws/engine/qwen/accounts.ts @@ -51,7 +51,7 @@ export const qwenAccountsHandler = createRouter() }, async () => { const provider = engineQueries.getProviderBySlug('qwen', 'qwen'); if (!provider) return { accounts: [] }; - const accounts = engineQueries.getAccountsByProvider(provider.id); + const accounts = await engineQueries.getAccountsByProvider(provider.id); return { accounts: accounts.map(a => { const credential = parseQwenCredential(a.credential); @@ -92,7 +92,7 @@ export const qwenAccountsHandler = createRouter() preset: data.preset, }); - const account = engineQueries.createAccount(provider.id, data.name.trim(), credential); + const account = await engineQueries.createAccount(provider.id, data.name.trim(), credential); if (account.is_active === 1) { await disposeQwenEngines(); @@ -122,7 +122,7 @@ export const qwenAccountsHandler = createRouter() data: t.Object({ id: t.Number() }), response: t.Object({ success: t.Boolean() }) }, async ({ data }) => { - const active = engineQueries.getActiveAccountForEngine('qwen'); + const active = await engineQueries.getActiveAccountForEngine('qwen'); engineQueries.deleteAccount(data.id); if (active?.id === data.id) await disposeQwenEngines(); return { success: true }; diff --git a/backend/ws/engine/qwen/status.ts b/backend/ws/engine/qwen/status.ts index 0f43e9e8..2f317e17 100644 --- a/backend/ws/engine/qwen/status.ts +++ b/backend/ws/engine/qwen/status.ts @@ -41,8 +41,8 @@ export const qwenStatusHandler = createRouter() debug.log('engine', 'Checking Qwen Code status...'); const provider = engineQueries.getProviderBySlug('qwen', 'qwen'); - const accounts = provider ? engineQueries.getAccountsByProvider(provider.id) : []; - const activeAccount = engineQueries.getActiveAccountForEngine('qwen'); + const accounts = provider ? await engineQueries.getAccountsByProvider(provider.id) : []; + const activeAccount = await engineQueries.getActiveAccountForEngine('qwen'); return { installed: true,