mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 12:16:38 +00:00
Merge pull request #596 from kaitranntt/feat/refactor-hardcode-dx-one-pr
refactor(core): centralize hardcoded paths and command/provider sources
This commit is contained in:
30 files changed
+1364
-681
No files matched your search
@@ -144,6 +144,14 @@ describe('applyExtendedContextConfig', () => {
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]');
|
||||
});
|
||||
|
||||
it('handles model IDs that already include both thinking and [1m] suffixes', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro(high)[1m]',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]');
|
||||
});
|
||||
|
||||
it('handles empty env vars gracefully', () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
@@ -164,8 +172,12 @@ describe('applyExtendedContextConfig', () => {
|
||||
it('strips [1m] suffix when --no-1m is explicit even if model has it', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview[1m]',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro[1m]',
|
||||
};
|
||||
applyExtendedContextConfig(env, 'gemini', false);
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gemini-2.5-pro');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
getProvidersByOAuthFlow,
|
||||
isCLIProxyProvider,
|
||||
mapExternalProviderName,
|
||||
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||
isQuotaSupportedProvider,
|
||||
QUOTA_PROVIDER_OPTION_VALUES,
|
||||
QUOTA_PROVIDER_HELP_TEXT,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
import {
|
||||
OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS,
|
||||
@@ -63,9 +67,31 @@ describe('provider-capabilities', () => {
|
||||
expect(mapExternalProviderName('github-copilot')).toBe('ghcp');
|
||||
expect(mapExternalProviderName('copilot')).toBe('ghcp');
|
||||
expect(mapExternalProviderName('anthropic')).toBe('claude');
|
||||
expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp');
|
||||
expect(mapExternalProviderName('')).toBeNull();
|
||||
expect(mapExternalProviderName('unknown-provider')).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes quota-supported providers and guards correctly', () => {
|
||||
expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'gemini', 'ghcp']);
|
||||
expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([
|
||||
'agy',
|
||||
'codex',
|
||||
'gemini',
|
||||
'ghcp',
|
||||
'antigravity',
|
||||
'gemini-cli',
|
||||
'github-copilot',
|
||||
'copilot',
|
||||
'all',
|
||||
]);
|
||||
expect(QUOTA_PROVIDER_HELP_TEXT).toBe(
|
||||
'agy, codex, gemini, ghcp, antigravity, gemini-cli, github-copilot, copilot, all'
|
||||
);
|
||||
expect(isQuotaSupportedProvider('ghcp')).toBe(true);
|
||||
expect(isQuotaSupportedProvider('kiro')).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes callback port and display name capabilities', () => {
|
||||
expect(getOAuthCallbackPort('qwen')).toBeNull();
|
||||
expect(getOAuthCallbackPort('kiro')).toBeNull();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { parseApiCommandArgs } from '../../../src/commands/api-command';
|
||||
|
||||
describe('api-command arg parser', () => {
|
||||
test('keeps positional API name when boolean flags precede it', () => {
|
||||
const parsed = parseApiCommandArgs(['--yes', 'my-api']);
|
||||
|
||||
expect(parsed.yes).toBe(true);
|
||||
expect(parsed.name).toBe('my-api');
|
||||
});
|
||||
|
||||
test('uses last value when repeated value flags are provided', () => {
|
||||
const parsed = parseApiCommandArgs([
|
||||
'profile-a',
|
||||
'--model',
|
||||
'claude-3-5-sonnet',
|
||||
'--model=claude-3-7-sonnet',
|
||||
]);
|
||||
|
||||
expect(parsed.name).toBe('profile-a');
|
||||
expect(parsed.model).toBe('claude-3-7-sonnet');
|
||||
expect(parsed.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('collects missing-value errors for required option values', () => {
|
||||
const parsed = parseApiCommandArgs(['profile-a', '--base-url', '--api-key']);
|
||||
|
||||
expect(parsed.errors).toEqual(['Missing value for --base-url', 'Missing value for --api-key']);
|
||||
});
|
||||
|
||||
test('supports option terminator for positional args that look like flags', () => {
|
||||
const parsed = parseApiCommandArgs(['--yes', '--', '-my-api']);
|
||||
|
||||
expect(parsed.yes).toBe(true);
|
||||
expect(parsed.name).toBe('-my-api');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { extractOption, hasAnyFlag } from '../../../src/commands/arg-extractor';
|
||||
|
||||
describe('arg-extractor', () => {
|
||||
describe('extractOption', () => {
|
||||
it('extracts --flag value and removes both tokens from remaining args', () => {
|
||||
const result = extractOption(['--profile', 'gemini', '--yes'], ['--profile']);
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
value: 'gemini',
|
||||
missingValue: false,
|
||||
remainingArgs: ['--yes'],
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts --flag=value and removes inline token from remaining args', () => {
|
||||
const result = extractOption(['--yes', '--profile=codex', 'prompt'], ['--profile']);
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
value: 'codex',
|
||||
missingValue: false,
|
||||
remainingArgs: ['--yes', 'prompt'],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks missing value when flag is last token', () => {
|
||||
const result = extractOption(['prompt', '--profile'], ['--profile']);
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
missingValue: true,
|
||||
remainingArgs: ['prompt'],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks missing value for empty inline value', () => {
|
||||
const result = extractOption(['--profile=', '--yes'], ['--profile']);
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
missingValue: true,
|
||||
remainingArgs: ['--yes'],
|
||||
});
|
||||
});
|
||||
|
||||
it('marks missing value when next token is another flag and keeps that flag', () => {
|
||||
const result = extractOption(['--profile', '--yes', 'prompt'], ['--profile']);
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
missingValue: true,
|
||||
remainingArgs: ['--yes', 'prompt'],
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts dash-prefixed value when allowDashValue is enabled', () => {
|
||||
const result = extractOption(['--model', '-preview', '--yes'], ['--model'], {
|
||||
allowDashValue: true,
|
||||
knownFlags: ['--model', '--yes'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
value: '-preview',
|
||||
missingValue: false,
|
||||
remainingArgs: ['--yes'],
|
||||
});
|
||||
});
|
||||
|
||||
it('still treats known flags as missing when allowDashValue is enabled', () => {
|
||||
const result = extractOption(['--model', '--yes', 'prompt'], ['--model'], {
|
||||
allowDashValue: true,
|
||||
knownFlags: ['--model', '--yes'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
found: true,
|
||||
missingValue: true,
|
||||
remainingArgs: ['--yes', 'prompt'],
|
||||
});
|
||||
});
|
||||
|
||||
it('supports repeated extraction loops with deterministic last-value wins behavior', () => {
|
||||
let remaining = ['--model', 'gpt-4.1-mini', '--model', 'gpt-4.1'];
|
||||
let selected: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const extracted = extractOption(remaining, ['--model']);
|
||||
if (!extracted.found) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!extracted.missingValue && extracted.value) {
|
||||
selected = extracted.value;
|
||||
}
|
||||
remaining = extracted.remainingArgs;
|
||||
}
|
||||
|
||||
expect(selected).toBe('gpt-4.1');
|
||||
expect(remaining).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns non-match state without altering args content', () => {
|
||||
const args = ['--yes', 'prompt'];
|
||||
const result = extractOption(args, ['--profile', '-p']);
|
||||
|
||||
expect(result).toEqual({
|
||||
found: false,
|
||||
missingValue: false,
|
||||
remainingArgs: ['--yes', 'prompt'],
|
||||
});
|
||||
expect(args).toEqual(['--yes', 'prompt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAnyFlag', () => {
|
||||
it('returns true when any exact flag is present', () => {
|
||||
expect(hasAnyFlag(['prompt', '--yes'], ['--yes', '-y'])).toBe(true);
|
||||
expect(hasAnyFlag(['prompt', '-y'], ['--yes', '-y'])).toBe(true);
|
||||
});
|
||||
|
||||
it('supports inline truthy values for boolean flags', () => {
|
||||
expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(true);
|
||||
expect(hasAnyFlag(['prompt', '--yes=1'], ['--yes', '-y'])).toBe(true);
|
||||
expect(hasAnyFlag(['prompt', '--yes=on'], ['--yes', '-y'])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-truthy or unrelated inline tokens', () => {
|
||||
expect(hasAnyFlag(['prompt', '--yes=false'], ['--yes', '-y'])).toBe(false);
|
||||
expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -117,7 +117,17 @@ image_analysis:
|
||||
|
||||
describe('provider validation', () => {
|
||||
it('should accept valid providers', () => {
|
||||
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
|
||||
const validProviders = [
|
||||
'agy',
|
||||
'gemini',
|
||||
'codex',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'claude',
|
||||
'qwen',
|
||||
'iflow',
|
||||
'kimi',
|
||||
];
|
||||
|
||||
for (const provider of validProviders) {
|
||||
expect(validProviders.includes(provider)).toBe(true);
|
||||
@@ -125,7 +135,17 @@ image_analysis:
|
||||
});
|
||||
|
||||
it('should reject invalid providers', () => {
|
||||
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
|
||||
const validProviders = [
|
||||
'agy',
|
||||
'gemini',
|
||||
'codex',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'claude',
|
||||
'qwen',
|
||||
'iflow',
|
||||
'kimi',
|
||||
];
|
||||
const invalidProviders = ['unknown', 'custom', 'my-provider', 'test'];
|
||||
|
||||
for (const provider of invalidProviders) {
|
||||
@@ -147,12 +167,15 @@ image_analysis:
|
||||
kiro: 'kiro-claude-haiku-4-5',
|
||||
ghcp: 'claude-haiku-4.5',
|
||||
claude: 'claude-haiku-4-5-20251001',
|
||||
qwen: 'vision-model',
|
||||
iflow: 'qwen3-vl-plus',
|
||||
kimi: 'vision-model',
|
||||
},
|
||||
};
|
||||
|
||||
expect(defaultConfig.enabled).toBe(true);
|
||||
expect(defaultConfig.timeout).toBe(60);
|
||||
expect(Object.keys(defaultConfig.provider_models).length).toBe(6);
|
||||
expect(Object.keys(defaultConfig.provider_models).length).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -167,6 +167,28 @@ describe('parseUsageEntry', () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.target).toBeUndefined();
|
||||
});
|
||||
|
||||
test('coerces token fields to non-negative numbers', () => {
|
||||
const withInvalidUsage = JSON.stringify({
|
||||
...JSON.parse(VALID_ASSISTANT_ENTRY),
|
||||
message: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
usage: {
|
||||
input_tokens: '1500',
|
||||
output_tokens: -10,
|
||||
cache_creation_input_tokens: 'bad',
|
||||
cache_read_input_tokens: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseUsageEntry(withInvalidUsage, '/test');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.inputTokens).toBe(1500);
|
||||
expect(result!.outputTokens).toBe(0);
|
||||
expect(result!.cacheCreationTokens).toBe(0);
|
||||
expect(result!.cacheReadTokens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@@ -217,6 +239,14 @@ describe('parseJsonlFile', () => {
|
||||
expect(entries.length).toBe(0);
|
||||
});
|
||||
|
||||
test('returns empty array when stream cannot be opened', async () => {
|
||||
const directoryPath = path.join(tempDir, 'not-a-file');
|
||||
fs.mkdirSync(directoryPath);
|
||||
|
||||
const entries = await parseJsonlFile(directoryPath, '/test');
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('handles file with blank lines', async () => {
|
||||
const filePath = path.join(tempDir, 'blanks.jsonl');
|
||||
const content = ['', VALID_ASSISTANT_ENTRY, '', ' ', ASSISTANT_ENTRY_NO_CACHE, ''].join('\n');
|
||||
@@ -280,6 +310,17 @@ describe('parseProjectDirectory', () => {
|
||||
readdirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('sanitizes derived projectPath from dashed directory names', async () => {
|
||||
const projectDir = path.join(tempDir, '-..-etc-passwd');
|
||||
fs.mkdirSync(projectDir);
|
||||
fs.writeFileSync(path.join(projectDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY);
|
||||
|
||||
const entries = await parseProjectDirectory(projectDir);
|
||||
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].projectPath).toBe('/etc/passwd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findProjectDirectories', () => {
|
||||
@@ -395,6 +436,33 @@ describe('scanProjectsDirectory', () => {
|
||||
expect(entries[0].sessionId).toBe('new');
|
||||
});
|
||||
|
||||
test('skips entries with invalid timestamps when minDate filtering is enabled', async () => {
|
||||
const project = path.join(tempDir, '-test-invalid-timestamp');
|
||||
fs.mkdirSync(project);
|
||||
|
||||
const invalidTimestampEntry = JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'invalid-time',
|
||||
timestamp: 'not-a-date',
|
||||
message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 100, output_tokens: 50 } },
|
||||
});
|
||||
const validEntry = JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'valid-time',
|
||||
timestamp: '2025-12-09T00:00:00.000Z',
|
||||
message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 200, output_tokens: 100 } },
|
||||
});
|
||||
fs.writeFileSync(path.join(project, 'session.jsonl'), [invalidTimestampEntry, validEntry].join('\n'));
|
||||
|
||||
const entries = await scanProjectsDirectory({
|
||||
projectsDir: tempDir,
|
||||
minDate: new Date('2025-01-01'),
|
||||
});
|
||||
|
||||
expect(entries.length).toBe(1);
|
||||
expect(entries[0].sessionId).toBe('valid-time');
|
||||
});
|
||||
|
||||
test('returns empty array for empty directory', async () => {
|
||||
const entries = await scanProjectsDirectory({ projectsDir: tempDir });
|
||||
expect(entries.length).toBe(0);
|
||||
@@ -416,6 +484,41 @@ describe('scanProjectsDirectory', () => {
|
||||
|
||||
expect(entries.length).toBe(5);
|
||||
});
|
||||
|
||||
test('falls back to default concurrency when invalid concurrency is provided', async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const project = path.join(tempDir, `-invalid-concurrency-${i}`);
|
||||
fs.mkdirSync(project);
|
||||
fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY);
|
||||
}
|
||||
|
||||
const zeroEntries = await scanProjectsDirectory({
|
||||
projectsDir: tempDir,
|
||||
concurrency: 0,
|
||||
});
|
||||
expect(zeroEntries.length).toBe(3);
|
||||
|
||||
const negativeEntries = await scanProjectsDirectory({
|
||||
projectsDir: tempDir,
|
||||
concurrency: -5,
|
||||
});
|
||||
expect(negativeEntries.length).toBe(3);
|
||||
});
|
||||
|
||||
test('caps very high concurrency values to a safe maximum', async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const project = path.join(tempDir, `-capped-concurrency-${i}`);
|
||||
fs.mkdirSync(project);
|
||||
fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY);
|
||||
}
|
||||
|
||||
const entries = await scanProjectsDirectory({
|
||||
projectsDir: tempDir,
|
||||
concurrency: 9999,
|
||||
});
|
||||
|
||||
expect(entries.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -235,26 +235,18 @@ describe('Persist Routes', function () {
|
||||
class RestoreMutex {
|
||||
constructor() {
|
||||
this.locked = false;
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
async acquire() {
|
||||
if (this.locked) {
|
||||
return new Promise((resolve) => {
|
||||
this.queue.push(() => resolve(false));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this.locked = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
release() {
|
||||
const next = this.queue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,21 +258,16 @@ describe('Persist Routes', function () {
|
||||
assert.strictEqual(mutex.locked, true);
|
||||
});
|
||||
|
||||
it('should queue and reject concurrent requests', async function () {
|
||||
it('should reject concurrent requests while locked', async function () {
|
||||
const mutex = new RestoreMutex();
|
||||
|
||||
// First acquire succeeds
|
||||
const first = await mutex.acquire();
|
||||
assert.strictEqual(first, true);
|
||||
|
||||
// Second acquire queues and gets false when released
|
||||
const secondPromise = mutex.acquire();
|
||||
|
||||
// Release the mutex
|
||||
mutex.release();
|
||||
|
||||
const second = await secondPromise;
|
||||
assert.strictEqual(second, false); // Queued request returns false
|
||||
// Second acquire fails immediately
|
||||
const second = await mutex.acquire();
|
||||
assert.strictEqual(second, false);
|
||||
});
|
||||
|
||||
it('should unlock after release with no queue', async function () {
|
||||
@@ -293,30 +280,12 @@ describe('Persist Routes', function () {
|
||||
assert.strictEqual(mutex.locked, false);
|
||||
});
|
||||
|
||||
it('should process multiple queued requests in order', async function () {
|
||||
it('should allow new acquire after release', async function () {
|
||||
const mutex = new RestoreMutex();
|
||||
const results = [];
|
||||
|
||||
// First acquire
|
||||
const first = await mutex.acquire();
|
||||
results.push({ id: 1, acquired: first });
|
||||
|
||||
// Queue multiple requests
|
||||
const p2 = mutex.acquire().then((r) => results.push({ id: 2, acquired: r }));
|
||||
const p3 = mutex.acquire().then((r) => results.push({ id: 3, acquired: r }));
|
||||
|
||||
// Release all
|
||||
mutex.release(); // Signals #2
|
||||
mutex.release(); // Signals #3
|
||||
|
||||
await Promise.all([p2, p3]);
|
||||
|
||||
assert.strictEqual(results[0].id, 1);
|
||||
assert.strictEqual(results[0].acquired, true);
|
||||
assert.strictEqual(results[1].id, 2);
|
||||
assert.strictEqual(results[1].acquired, false);
|
||||
assert.strictEqual(results[2].id, 3);
|
||||
assert.strictEqual(results[2].acquired, false);
|
||||
assert.strictEqual(await mutex.acquire(), true);
|
||||
assert.strictEqual(await mutex.acquire(), false);
|
||||
mutex.release();
|
||||
assert.strictEqual(await mutex.acquire(), true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { validateFilePath } from '../../../src/web-server/routes/route-helpers';
|
||||
|
||||
describe('validateFilePath', () => {
|
||||
let tempDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-helpers-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
process.env.CCS_HOME = tempDir;
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-custom');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('allows files within ~/.ccs tree', () => {
|
||||
const filePath = path.join(tempDir, '.ccs', 'config.yaml');
|
||||
const result = validateFilePath(filePath);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.readonly).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects sibling paths that only share ~/.ccs prefix', () => {
|
||||
const bypassPath = path.join(tempDir, '.ccs-evil', 'config.yaml');
|
||||
const result = validateFilePath(bypassPath);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.readonly).toBe(false);
|
||||
});
|
||||
|
||||
test('allows readonly access to resolved Claude settings path', () => {
|
||||
const filePath = path.join(tempDir, '.claude-custom', 'settings.json');
|
||||
const result = validateFilePath(filePath);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.readonly).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects symlinked paths inside ~/.ccs tree', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
const outsideDir = path.join(tempDir, 'outside');
|
||||
const linkedDir = path.join(ccsDir, 'linked');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
fs.symlinkSync(outsideDir, linkedDir, 'dir');
|
||||
|
||||
const result = validateFilePath(path.join(linkedDir, 'config.yaml'));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.readonly).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects symlinked Claude settings path', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const claudeDir = path.join(tempDir, '.claude-custom');
|
||||
const targetFile = path.join(tempDir, 'target-settings.json');
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
fs.writeFileSync(targetFile, '{}');
|
||||
fs.symlinkSync(targetFile, settingsPath, 'file');
|
||||
|
||||
const result = validateFilePath(settingsPath);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.readonly).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user