/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ /** * Extension settings storage. * * Stores non-sensitive settings in .env files and sensitive settings * in the OS keychain via SecureStore. All keyring access is delegated * to SecureStore, eliminating direct @napi-rs/keyring imports. * * @plan PLAN-20260211-SECURESTORE.P09 * @requirement R7.5, R7.7 */ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'node:crypto'; import type { ExtensionSetting } from './extensionSettings.js'; import { SecureStore, isRuntimeReplacedError, type SecureStoreOptions, } from '@vybestack/llxprt-code-storage'; import { debugLogger } from '@vybestack/llxprt-code-telemetry'; import { getWorkspaceIdentity } from '../../utils/gitUtils.js'; /** * SecureStore error codes that represent terminal write failures — the * secret was NOT saved and the user must be told. These must not be silently * swallowed by the catch block in persistSensitiveSetting. */ const TERMINAL_WRITE_ERROR_CODES: ReadonlySet = new Set([ 'CONFLICT', 'TIMEOUT', ]); /** * Structurally detects a terminal SecureStore write failure (CONFLICT or * TIMEOUT) using duck-typing on the `code` property, consistent with * isRuntimeReplacedError. Does NOT use instanceof (two copies of the class * may exist under bundling / duplicated dependency resolution). */ function isTerminalWriteError(error: unknown): boolean { if (typeof error !== 'object' || error === null || !('code' in error)) { return false; } return ( typeof error.code === 'string' && TERMINAL_WRITE_ERROR_CODES.has(error.code) ); } /** * Returns the path to the .env file for an extension. */ export function getSettingsEnvFilePath(extensionDir: string): string { return path.join(extensionDir, '.env'); } /** * Returns the keychain service name for an extension. * Sanitizes the extension name and limits length to 255 characters. * For workspace scope, includes the workspace directory in the service name. * * @param extensionName - Extension name * @param extensionDir - Extension directory (used to detect scope) * @returns Keychain service name */ export function getKeychainServiceName( extensionName: string, extensionDir?: string, ): string { // Remove special characters, keeping only alphanumeric, dash, and underscore const sanitized = extensionName.replace(/[^a-zA-Z0-9-_]/g, ''); // Check if this is a workspace-scoped path const isWorkspaceScope = extensionDir ?.replace(/\\/g, '/') .includes('.llxprt/extensions'); if (isWorkspaceScope === true) { // Include workspace identifier for workspace scope // Use getWorkspaceIdentity() to get the git root, not process.cwd() const workspaceIdentity = getWorkspaceIdentity(); const workspaceHash = crypto .createHash('md5') .update(workspaceIdentity) .digest('hex') .substring(0, 8); const serviceName = `LLxprt Code Extension ${sanitized} Workspace ${workspaceHash}`; return serviceName.substring(0, 255); } // Format: "LLxprt Code Extension {name}" const serviceName = `LLxprt Code Extension ${sanitized}`; // Limit to 255 characters (common keychain limit) return serviceName.substring(0, 255); } /** * Parses a .env file content into a key-value record. */ /** * Parses a single .env line into a [key, value] pair, or returns null for * blank lines, comments, and lines without an `=` separator. */ function parseEnvLine(line: string): [string, string] | null { const trimmed = line.trim(); // Skip empty lines and comments if (!trimmed || trimmed.startsWith('#')) { return null; } const equalIndex = trimmed.indexOf('='); if (equalIndex === -1) { return null; } const key = trimmed.substring(0, equalIndex).trim(); let value = trimmed.substring(equalIndex + 1).trim(); // Remove surrounding quotes if present if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.substring(1, value.length - 1); } return [key, value]; } function parseEnvFile(content: string): Record { const result: Record = {}; for (const line of content.split('\n')) { const parsed = parseEnvLine(line); if (parsed) { result[parsed[0]] = parsed[1]; } } return result; } /** * Formats a key-value record into .env file format. */ function formatEnvFile(values: Record): string { const lines: string[] = []; for (const [key, value] of Object.entries(values)) { // Quote values that contain spaces or special characters const needsQuotes = /[\s"'\\]/.test(value); const formattedValue = needsQuotes ? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"` : value; lines.push(`${key}=${formattedValue}`); } return lines.join('\n') + '\n'; } /** * Storage implementation for extension settings. * Stores non-sensitive settings in .env file and sensitive settings * via SecureStore (keychain + encrypted file fallback). */ export class ExtensionSettingsStorage { private readonly extensionName: string; private readonly extensionDir: string; private readonly store: SecureStore; /** * @param storeOptions Optional SecureStore configuration. Production callers * omit it and get the default keychain-backed store. Supplying it lets a * caller point the store at an isolated fallback dir, lock dir, and keyring * adapter, so the real SecureStore can be exercised without touching the * user's OS keychain. */ constructor( extensionName: string, extensionDir: string, storeOptions?: SecureStoreOptions, ) { this.extensionName = extensionName; this.extensionDir = extensionDir; this.store = new SecureStore( getKeychainServiceName(extensionName, extensionDir), storeOptions, ); } /** * Saves settings to appropriate storage (env file for non-sensitive, SecureStore for sensitive). */ async saveSettings( settings: ExtensionSetting[], values: Record, ): Promise { // Ensure directory exists await fs.promises.mkdir(this.extensionDir, { recursive: true }); // Separate sensitive and non-sensitive settings const nonSensitiveValues: Record = {}; const sensitiveSettings = settings.filter((s) => s.sensitive); const nonSensitiveSettings = settings.filter((s) => !s.sensitive); // Collect non-sensitive values for .env file for (const setting of nonSensitiveSettings) { const value = values[setting.envVar]; if (value !== undefined) { nonSensitiveValues[setting.envVar] = value; } } // Write non-sensitive settings to .env file (or delete stale file) const envPath = getSettingsEnvFilePath(this.extensionDir); if (Object.keys(nonSensitiveValues).length > 0) { const content = formatEnvFile(nonSensitiveValues); await fs.promises.writeFile(envPath, content, 'utf-8'); } else if (fs.existsSync(envPath)) { await fs.promises.unlink(envPath); } // Write sensitive settings to SecureStore (delete removed ones) for (const setting of sensitiveSettings) { await this.persistSensitiveSetting( setting.envVar, values[setting.envVar], ); } } private async persistSensitiveSetting( envVar: string, value: string | undefined, ): Promise { try { if (value !== undefined) { await this.store.set(envVar, value); } else { await this.store.delete(envVar); } } catch (error) { if (isRuntimeReplacedError(error)) { throw error; } // M1: CONFLICT and TIMEOUT are terminal write failures — the secret // was NOT saved. Rethrow so the caller (settingsIntegration) reports // the failure rather than claiming success. if (isTerminalWriteError(error)) { throw error; } debugLogger.error( `Failed to persist sensitive setting ${envVar} in keychain:`, error, ); } } /** * Loads settings from appropriate storage. * Returns a record with undefined for missing settings. * Falls back to legacy cwd-based path for workspace-scoped settings. */ async loadSettings( settings: ExtensionSetting[], ): Promise> { const result: Record = {}; // Load non-sensitive settings from .env file const envPath = getSettingsEnvFilePath(this.extensionDir); let envValues: Record = {}; try { if (fs.existsSync(envPath)) { const content = await fs.promises.readFile(envPath, 'utf-8'); envValues = parseEnvFile(content); } else { // Backward compatibility: try legacy cwd-based path // This supports migration from cwd-based to git-root-based workspace paths const legacyPath = path.join( process.cwd(), '.llxprt', 'extensions', this.extensionName, '.env', ); if (fs.existsSync(legacyPath)) { const content = await fs.promises.readFile(legacyPath, 'utf-8'); envValues = parseEnvFile(content); } } } catch (error) { // Handle missing file gracefully debugLogger.error('Failed to read .env file:', error); } // Load sensitive settings from SecureStore const sensitiveSettings = settings.filter((s) => s.sensitive); const nonSensitiveSettings = settings.filter((s) => !s.sensitive); // Populate non-sensitive values for (const setting of nonSensitiveSettings) { result[setting.envVar] = envValues[setting.envVar]; } // Populate sensitive values from SecureStore for (const setting of sensitiveSettings) { try { const value = await this.store.get(setting.envVar); result[setting.envVar] = value ?? undefined; } catch (error) { if (isRuntimeReplacedError(error)) { throw error; } debugLogger.error( `Failed to load sensitive setting ${setting.envVar} from keychain:`, error, ); result[setting.envVar] = undefined; } } return result; } /** * Deletes all settings (both env file and keychain entries). */ async deleteSettings(): Promise { // Delete .env file const envPath = getSettingsEnvFilePath(this.extensionDir); try { if (fs.existsSync(envPath)) { await fs.promises.unlink(envPath); } } catch (error) { debugLogger.error('Failed to delete .env file:', error); } // Delete all SecureStore entries for this service try { const keys = await this.store.list(); await this.deleteKeychainEntries(keys); } catch (error) { if (isRuntimeReplacedError(error)) { throw error; } debugLogger.error('Failed to delete keychain entries:', error); } } /** * Deletes each keychain entry, rethrowing RUNTIME_REPLACED errors. * Extracted to keep deleteSettings within the nesting limit. */ private async deleteKeychainEntries(keys: readonly string[]): Promise { for (const key of keys) { try { await this.store.delete(key); } catch (error) { if (isRuntimeReplacedError(error)) { throw error; } debugLogger.error(`Failed to delete keychain entry ${key}:`, error); } } } /** * Checks if any settings exist (env file or keychain entries). */ async hasSettings(): Promise { // Check for .env file const envPath = getSettingsEnvFilePath(this.extensionDir); if (fs.existsSync(envPath)) { return true; } // Check for SecureStore entries try { const keys = await this.store.list(); return keys.length > 0; } catch (error) { if (isRuntimeReplacedError(error)) { throw error; } debugLogger.error('Failed to check keychain entries:', error); } return false; } }