refactor(config): remove secrets.yaml architecture

- delete secrets-manager.ts entirely

- remove secrets API routes and client methods

- simplify unified-config-types (remove vault/secrets)

- update profile-reader to remove secrets loading

- clean up unused hooks and API client methods
This commit is contained in:
kaitranntt
2025-12-20 23:10:35 -05:00
parent 96310dd1ac
commit 4f4ab43eb3
14 changed files with 49 additions and 368 deletions
+1 -24
View File
@@ -9,7 +9,6 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfig } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { getProfileSecrets } from '../../config/secrets-manager';
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
/**
@@ -36,29 +35,7 @@ export function isApiProfileConfigured(apiName: string): boolean {
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
if (isUnifiedMode()) {
// Check secrets.yaml first
const secrets = getProfileSecrets(apiName);
const secretToken = secrets?.ANTHROPIC_AUTH_TOKEN || '';
if (
secretToken.length > 0 &&
!secretToken.includes('YOUR_') &&
!secretToken.includes('your-')
) {
return true;
}
// Fallback: check settings.json file (profiles created via UI store keys here)
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || '';
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
}
return false;
}
// Legacy: check settings.json file
// Check settings.json file for API key
if (!fs.existsSync(settingsPath)) return false;
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
-4
View File
@@ -11,7 +11,6 @@ import {
saveUnifiedConfig,
isUnifiedMode,
} from '../../config/unified-config-loader';
import { deleteAllProfileSecrets } from '../../config/secrets-manager';
import type { ModelMapping, CreateApiProfileResult, RemoveApiProfileResult } from './profile-types';
/** Create settings.json file for API profile (legacy format) */
@@ -152,9 +151,6 @@ function removeApiProfileUnified(name: string): void {
}
saveUnifiedConfig(config);
// Remove any legacy secrets
deleteAllProfileSecrets(name);
}
/** Remove API profile from legacy config */
+1 -4
View File
@@ -16,7 +16,6 @@ import { findSimilarStrings } from '../utils/helpers';
import { Config, Settings, ProfileMetadata } from '../types';
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
import { getProfileSecrets } from '../config/secrets-manager';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
@@ -110,12 +109,10 @@ class ProfileDetector {
const profile = config.profiles[profileName];
// Load env from settings file
const settingsEnv = loadSettingsFromFile(profile.settings);
// Merge with secrets (for backward compat with any extracted secrets)
const secrets = getProfileSecrets(profileName);
return {
type: 'settings',
name: profileName,
env: { ...settingsEnv, ...secrets },
env: settingsEnv,
};
}
+1 -3
View File
@@ -384,11 +384,9 @@ async function handleRemove(args: string[]): Promise<void> {
// Confirm deletion
console.log('');
console.log(`API '${color(name, 'command')}' will be removed.`);
console.log(` Settings: ~/.ccs/${name}.settings.json`);
if (isUsingUnifiedConfig()) {
console.log(' Config: ~/.ccs/config.yaml');
console.log(' Secrets: ~/.ccs/secrets.yaml');
} else {
console.log(` Settings: ~/.ccs/${name}.settings.json`);
}
console.log('');
-1
View File
@@ -15,7 +15,6 @@ export * from './reserved-names';
// Loaders
export * from './unified-config-loader';
export * from './secrets-manager';
// Migration
export * from './migration-manager';
-2
View File
@@ -224,11 +224,9 @@ export async function rollback(backupPath: string): Promise<boolean> {
try {
// Remove new config files
const configYaml = path.join(ccsDir, 'config.yaml');
const secretsYaml = path.join(ccsDir, 'secrets.yaml');
const cacheDir = path.join(ccsDir, 'cache');
if (fs.existsSync(configYaml)) fs.unlinkSync(configYaml);
if (fs.existsSync(secretsYaml)) fs.unlinkSync(secretsYaml);
// Restore cache files to original locations
if (fs.existsSync(cacheDir)) {
-187
View File
@@ -1,187 +0,0 @@
/**
* Secrets Manager
*
* Handles loading and saving secrets (API keys, tokens) in a separate file
* with restricted permissions (chmod 600).
*/
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
import { getCcsDir } from '../utils/config-manager';
import { SecretsConfig, isSecretsConfig, createEmptySecretsConfig } from './unified-config-types';
// Re-export from shared utility for backward compatibility
export { isSensitiveKey as isSecretKey } from '../utils/sensitive-keys';
const SECRETS_FILE = 'secrets.yaml';
const SECRETS_FILE_MODE = 0o600; // Owner read/write only
/**
* Get path to secrets.yaml
*/
export function getSecretsPath(): string {
return path.join(getCcsDir(), SECRETS_FILE);
}
/**
* Check if secrets.yaml exists
*/
export function hasSecrets(): boolean {
return fs.existsSync(getSecretsPath());
}
/**
* Load secrets from YAML file.
* Returns empty secrets config if file doesn't exist.
*/
export function loadSecrets(): SecretsConfig {
const secretsPath = getSecretsPath();
if (!fs.existsSync(secretsPath)) {
return createEmptySecretsConfig();
}
try {
const content = fs.readFileSync(secretsPath, 'utf8');
const parsed = yaml.load(content);
if (!isSecretsConfig(parsed)) {
console.error(`[!] Invalid secrets format in ${secretsPath}`);
return createEmptySecretsConfig();
}
return parsed;
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
console.error(`[X] Failed to load secrets: ${error}`);
return createEmptySecretsConfig();
}
}
/**
* Save secrets to YAML file with restricted permissions.
* Uses atomic write (temp file + rename) to prevent corruption.
*/
export function saveSecrets(secrets: SecretsConfig): void {
const secretsPath = getSecretsPath();
const dir = path.dirname(secretsPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Convert to YAML
const content = yaml.dump(secrets, {
indent: 2,
lineWidth: -1,
quotingType: '"',
noRefs: true,
});
// Atomic write: write to temp file, then rename
const tempPath = `${secretsPath}.tmp.${process.pid}`;
try {
fs.writeFileSync(tempPath, content, { mode: SECRETS_FILE_MODE });
fs.renameSync(tempPath, secretsPath);
// Ensure correct permissions after rename (some systems may not preserve)
fs.chmodSync(secretsPath, SECRETS_FILE_MODE);
} catch (err) {
// Clean up temp file on error
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw err;
}
}
/**
* Get a secret value for a specific profile.
*/
export function getProfileSecret(profileName: string, key: string): string | undefined {
const secrets = loadSecrets();
return secrets.profiles[profileName]?.[key];
}
/**
* Set a secret value for a specific profile.
*/
export function setProfileSecret(profileName: string, key: string, value: string): void {
const secrets = loadSecrets();
if (!secrets.profiles[profileName]) {
secrets.profiles[profileName] = {};
}
secrets.profiles[profileName][key] = value;
saveSecrets(secrets);
}
/**
* Delete a secret value for a specific profile.
*/
export function deleteProfileSecret(profileName: string, key: string): boolean {
const secrets = loadSecrets();
if (!secrets.profiles[profileName]?.[key]) {
return false;
}
delete secrets.profiles[profileName][key];
// Clean up empty profile object
if (Object.keys(secrets.profiles[profileName]).length === 0) {
delete secrets.profiles[profileName];
}
saveSecrets(secrets);
return true;
}
/**
* Get all secrets for a profile.
*/
export function getProfileSecrets(profileName: string): Record<string, string> {
const secrets = loadSecrets();
return secrets.profiles[profileName] || {};
}
/**
* Set all secrets for a profile (replaces existing).
*/
export function setProfileSecrets(
profileName: string,
profileSecrets: Record<string, string>
): void {
const secrets = loadSecrets();
if (Object.keys(profileSecrets).length === 0) {
delete secrets.profiles[profileName];
} else {
secrets.profiles[profileName] = profileSecrets;
}
saveSecrets(secrets);
}
/**
* Delete all secrets for a profile.
*/
export function deleteAllProfileSecrets(profileName: string): boolean {
const secrets = loadSecrets();
if (!secrets.profiles[profileName]) {
return false;
}
delete secrets.profiles[profileName];
saveSecrets(secrets);
return true;
}
+1 -32
View File
@@ -6,7 +6,7 @@
* - profiles.json (account metadata)
* - *.settings.json (env vars)
*
* Into a single config.yaml + secrets.yaml structure.
* Into a single config.yaml structure.
*/
/**
@@ -321,18 +321,6 @@ export interface UnifiedConfig {
cliproxy_server?: CliproxyServerConfig;
}
/**
* Secrets configuration structure.
* Stored in ~/.ccs/secrets.yaml with chmod 600.
* Contains sensitive values like API keys.
*/
export interface SecretsConfig {
/** Secrets version */
version: number;
/** Profile secrets mapping: profile_name -> { key: value } */
profiles: Record<string, Record<string, string>>;
}
/**
* Default Copilot configuration.
* Strictly opt-in - disabled by default.
@@ -422,16 +410,6 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
};
}
/**
* Create an empty secrets config.
*/
export function createEmptySecretsConfig(): SecretsConfig {
return {
version: 1,
profiles: {},
};
}
/**
* Type guard for UnifiedConfig.
* Relaxed validation: accepts configs with version >= 1 and any subset of sections.
@@ -444,12 +422,3 @@ export function isUnifiedConfig(obj: unknown): obj is UnifiedConfig {
// Sections are optional - will be merged with defaults in loadOrCreateUnifiedConfig
return typeof config.version === 'number' && config.version >= 1;
}
/**
* Type guard for SecretsConfig.
*/
export function isSecretsConfig(obj: unknown): obj is SecretsConfig {
if (typeof obj !== 'object' || obj === null) return false;
const config = obj as Record<string, unknown>;
return typeof config.version === 'number' && typeof config.profiles === 'object';
}
+42 -5
View File
@@ -4,6 +4,7 @@ import * as os from 'os';
import { Config, isConfig, Settings, isSettings } from '../types';
import { expandPath, error } from './helpers';
import { info } from './ui';
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
// TODO: Replace with proper imports after converting these files
// const { ErrorManager } = require('./error-manager');
@@ -82,16 +83,52 @@ export function readConfig(): Config {
}
/**
* Get settings path for profile
* Get settings path for profile.
* In unified mode (config.yaml exists), reads from config.yaml first,
* then falls back to config.json for backward compatibility.
*/
export function getSettingsPath(profile: string): string {
const config = readConfig();
let settingsPath: string | undefined;
let availableProfiles: string[] = [];
// Get settings path
const settingsPath = config.profiles[profile];
// Check unified config first (config.yaml)
if (isUnifiedMode()) {
const unifiedConfig = loadOrCreateUnifiedConfig();
// Check if profile exists in unified config
const profileConfig = unifiedConfig.profiles[profile];
if (profileConfig?.settings) {
settingsPath = profileConfig.settings;
}
// Collect available profiles from unified config
availableProfiles = Object.keys(unifiedConfig.profiles);
// If not found in unified config, try legacy config.json as fallback
if (!settingsPath) {
try {
const legacyConfig = loadConfig();
if (legacyConfig.profiles[profile]) {
settingsPath = legacyConfig.profiles[profile];
// Merge legacy profiles into available list (avoid duplicates)
for (const p of Object.keys(legacyConfig.profiles)) {
if (!availableProfiles.includes(p)) {
availableProfiles.push(p);
}
}
}
} catch {
// Legacy config doesn't exist or is invalid - that's OK in unified mode
}
}
} else {
// Legacy mode - read from config.json only
const config = readConfig();
settingsPath = config.profiles[profile];
availableProfiles = Object.keys(config.profiles);
}
if (!settingsPath) {
const availableProfiles = Object.keys(config.profiles);
const profileList = availableProfiles.map((p) => ` - ${p}`);
error(`Profile '${profile}' not found. Available profiles:\n${profileList.join('\n')}`);
}
-33
View File
@@ -17,7 +17,6 @@ import {
rollback,
getBackupDirectories,
} from '../../config/migration-manager';
import { getProfileSecrets, setProfileSecrets } from '../../config/secrets-manager';
import { isUnifiedConfig } from '../../config/unified-config-types';
const router = Router();
@@ -111,36 +110,4 @@ router.post('/rollback', async (req: Request, res: Response): Promise<void> => {
res.json({ success });
});
/**
* PUT /api/secrets/:profile - Update profile secrets (write-only)
*/
router.put('/secrets/:profile', (req: Request, res: Response): void => {
const { profile } = req.params;
const secrets = req.body;
if (!secrets || typeof secrets !== 'object') {
res.status(400).json({ error: 'Invalid secrets format' });
return;
}
try {
setProfileSecrets(profile, secrets as Record<string, string>);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
/**
* GET /api/secrets/:profile/exists - Check if secrets exist (no values returned)
*/
router.get('/secrets/:profile/exists', (req: Request, res: Response) => {
const { profile } = req.params;
const secrets = getProfileSecrets(profile);
res.json({
exists: Object.keys(secrets).length > 0,
keys: Object.keys(secrets), // Only key names, not values
});
});
export default router;
+1 -2
View File
@@ -31,9 +31,8 @@ apiRoutes.use('/settings', settingsRoutes);
apiRoutes.use('/accounts', profileRoutes);
// ==================== Unified Config ====================
// Config format, migration, secrets
// Config format, migration
apiRoutes.use('/config', configRoutes);
apiRoutes.use('/secrets', configRoutes);
// ==================== Health Checks ====================
apiRoutes.use('/health', healthRoutes);
+2 -31
View File
@@ -11,14 +11,12 @@ import {
} from '../../src/config/reserved-names';
import {
createEmptyUnifiedConfig,
createEmptySecretsConfig,
isUnifiedConfig,
isSecretsConfig,
UNIFIED_CONFIG_VERSION,
} from '../../src/config/unified-config-types';
import { isUnifiedConfigEnabled } from '../../src/config/feature-flags';
// Inline helper to test secret key detection (copied from secrets-manager to avoid import chain)
// Inline helper to test secret key detection (utility kept for potential reuse)
function isSecretKey(key: string): boolean {
const upper = key.toUpperCase();
const secretPatterns = ['TOKEN', 'SECRET', 'API_KEY', 'APIKEY', 'PASSWORD', 'CREDENTIAL', 'AUTH', 'PRIVATE'];
@@ -102,18 +100,6 @@ describe('unified-config-types', () => {
});
});
describe('createEmptySecretsConfig', () => {
it('should create secrets with version 1', () => {
const secrets = createEmptySecretsConfig();
expect(secrets.version).toBe(1);
});
it('should have empty profiles', () => {
const secrets = createEmptySecretsConfig();
expect(Object.keys(secrets.profiles)).toHaveLength(0);
});
});
describe('isUnifiedConfig', () => {
it('should return true for valid config', () => {
const config = createEmptyUnifiedConfig();
@@ -143,24 +129,9 @@ describe('unified-config-types', () => {
expect(isUnifiedConfig({ version: -1 })).toBe(false);
});
});
describe('isSecretsConfig', () => {
it('should return true for valid secrets', () => {
const secrets = createEmptySecretsConfig();
expect(isSecretsConfig(secrets)).toBe(true);
});
it('should return false for null', () => {
expect(isSecretsConfig(null)).toBe(false);
});
it('should return false for missing fields', () => {
expect(isSecretsConfig({ version: 1 })).toBe(false);
});
});
});
describe('secrets-manager', () => {
describe('sensitive-keys', () => {
describe('isSecretKey', () => {
it('should identify token keys as secrets', () => {
expect(isSecretKey('ANTHROPIC_AUTH_TOKEN')).toBe(true);
-27
View File
@@ -98,30 +98,3 @@ export function useRollback() {
},
});
}
/**
* Update profile secrets
*/
export function useUpdateSecrets() {
return useMutation({
mutationFn: ({ profile, secrets }: { profile: string; secrets: Record<string, string> }) =>
api.secrets.update(profile, secrets),
onSuccess: () => {
toast.success('Secrets updated successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
/**
* Check if profile has secrets (doesn't return values)
*/
export function useSecretsExists(profile: string) {
return useQuery({
queryKey: ['secrets-exists', profile],
queryFn: () => api.secrets.exists(profile),
enabled: !!profile,
});
}
-13
View File
@@ -132,11 +132,6 @@ export interface MigrationResult {
warnings: string[];
}
export interface SecretsExists {
exists: boolean;
keys: string[];
}
/** Model preset for quick model switching */
export interface ModelPreset {
name: string;
@@ -369,14 +364,6 @@ export const api = {
body: JSON.stringify({ backupPath }),
}),
},
secrets: {
update: (profile: string, secrets: Record<string, string>) =>
request<{ success: boolean }>(`/secrets/${profile}`, {
method: 'PUT',
body: JSON.stringify(secrets),
}),
exists: (profile: string) => request<SecretsExists>(`/secrets/${profile}/exists`),
},
/** Model presets for quick model switching */
presets: {
list: (profile: string) => request<{ presets: ModelPreset[] }>(`/settings/${profile}/presets`),