mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-06 18:18:23 +00:00
chore: merge origin/dev into codex auth profile branch
This commit is contained in:
@@ -273,7 +273,7 @@ src/
|
||||
### Native Codex Runtime Target
|
||||
|
||||
- Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`.
|
||||
- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process.
|
||||
- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, preserves a valid custom `base_url`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process.
|
||||
- Implicit Codex launches such as `ccs --target codex` and `ccsxp` use native Codex default mode even when the CCS default profile is a Claude account. Explicit unsupported profiles such as `ccs work --target codex` still fail fast with native-vs-pool guidance.
|
||||
- `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime.
|
||||
- Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`.
|
||||
|
||||
@@ -522,6 +522,7 @@ ccsxp
|
||||
→ injects native `model_provider="cliproxy"` override
|
||||
→ pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set
|
||||
→ repairs `[model_providers.cliproxy]` in the active Codex `config.toml`
|
||||
→ preserves valid custom `base_url` values for remote or non-default CLIProxy endpoints
|
||||
→ injects the effective CCS CLIProxy auth token into the provider's configured `env_key`
|
||||
→ ignores the configured CCS default account/profile and stays in native Codex default mode
|
||||
```
|
||||
|
||||
@@ -874,7 +874,10 @@ function runGeminiCommand(args, timeoutMs) {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: isWindows,
|
||||
// Never route query-derived prompts through a shell. Node concatenates
|
||||
// arguments for shell-backed Windows spawns, which lets shell metacharacters
|
||||
// in WebSearch queries escape the intended CLI invocation.
|
||||
shell: false,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -929,7 +932,10 @@ function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
timeout: timeoutSec * 1000,
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: isWindows,
|
||||
// Never route query-derived prompts through a shell. Node concatenates
|
||||
// arguments for shell-backed Windows spawns, which lets shell metacharacters
|
||||
// in WebSearch queries escape the intended CLI invocation.
|
||||
shell: false,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -965,7 +971,10 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
timeout: timeoutSec * 1000,
|
||||
maxBuffer: 1024 * 1024 * 2,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
shell: isWindows,
|
||||
// Never route query-derived prompts through a shell. Node concatenates
|
||||
// arguments for shell-backed Windows spawns, which lets shell metacharacters
|
||||
// in WebSearch queries escape the intended CLI invocation.
|
||||
shell: false,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.79.1-dev.3",
|
||||
"version": "7.79.1-dev.5",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -152,6 +152,92 @@ describe('ai-provider service stable ids', () => {
|
||||
).toBe('sk-openrouter');
|
||||
});
|
||||
|
||||
it('redacts custom headers and URL userinfo in provider list views', async () => {
|
||||
const { listAiProviders } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'claude-api-key': [
|
||||
{
|
||||
id: 'claude-secret-route',
|
||||
'api-key': 'sk-ant-provider-secret-1234',
|
||||
'base-url': 'https://baseuser:basepass@anthropic.example/v1',
|
||||
'proxy-url': 'http://proxyuser:proxypass@example.internal:8080',
|
||||
headers: {
|
||||
Authorization: 'Bearer HEADER-SECRET-123456',
|
||||
'X-API-Key': 'x-api-key-secret-abcdef',
|
||||
},
|
||||
},
|
||||
],
|
||||
'openai-compatibility': [
|
||||
{
|
||||
id: 'openrouter-secret-route',
|
||||
name: 'openrouter',
|
||||
'base-url': 'https://routeruser:routerpass@openrouter.example/api/v1',
|
||||
headers: {
|
||||
Authorization: 'Bearer OPENAI-COMPAT-HEADER-SECRET',
|
||||
},
|
||||
'api-key-entries': [{ 'api-key': 'sk-openrouter-secret-9999' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const listed = await listAiProviders();
|
||||
const claudeEntry = listed.families.find((entry) => entry.id === 'claude-api-key')?.entries[0];
|
||||
const openAiEntry = listed.families.find((entry) => entry.id === 'openai-compatibility')
|
||||
?.entries[0];
|
||||
|
||||
expect(claudeEntry?.baseUrl).toBe('https://***:***@anthropic.example/v1');
|
||||
expect(claudeEntry?.proxyUrl).toBe('http://***:***@example.internal:8080/');
|
||||
expect(claudeEntry?.headers).toEqual([
|
||||
{ key: 'Authorization', value: '...3456' },
|
||||
{ key: 'X-API-Key', value: '...cdef' },
|
||||
]);
|
||||
expect(openAiEntry?.baseUrl).toBe('https://***:***@openrouter.example/api/v1');
|
||||
expect(openAiEntry?.headers).toEqual([{ key: 'Authorization', value: '...CRET' }]);
|
||||
});
|
||||
|
||||
it('preserves stored header and URL secrets when saving unchanged redacted values', async () => {
|
||||
const { listAiProviders, updateAiProviderEntry } = await loadAiProviderService();
|
||||
|
||||
writeCliproxyConfig(tempHome, {
|
||||
'claude-api-key': [
|
||||
{
|
||||
id: 'claude-secret-route',
|
||||
'api-key': 'sk-ant-provider-secret-1234',
|
||||
'base-url': 'https://baseuser:basepass@anthropic.example/v1',
|
||||
'proxy-url': 'http://proxyuser:proxypass@example.internal:8080',
|
||||
headers: {
|
||||
Authorization: 'Bearer HEADER-SECRET-123456',
|
||||
'X-Project': 'public-routing-context',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const listed = await listAiProviders();
|
||||
const entry = listed.families.find((family) => family.id === 'claude-api-key')?.entries[0];
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
|
||||
await updateAiProviderEntry('claude-api-key', 'claude-secret-route', {
|
||||
apiKey: 'sk-ant-provider-secret-1234',
|
||||
baseUrl: entry?.baseUrl,
|
||||
proxyUrl: entry?.proxyUrl,
|
||||
headers: entry?.headers,
|
||||
preserveSecrets: true,
|
||||
});
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['claude-api-key'] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(persisted[0]?.['base-url']).toBe('https://baseuser:basepass@anthropic.example/v1');
|
||||
expect(persisted[0]?.['proxy-url']).toBe('http://proxyuser:proxypass@example.internal:8080');
|
||||
expect(persisted[0]?.headers).toEqual({
|
||||
Authorization: 'Bearer HEADER-SECRET-123456',
|
||||
'X-Project': 'public-routing-context',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes plain openai-compatible model rules without aliases', async () => {
|
||||
const { listAiProviders } = await loadAiProviderService();
|
||||
|
||||
|
||||
@@ -17,14 +17,39 @@ function maskSecret(value: string | undefined): string | undefined {
|
||||
return value.length > 8 ? `...${value.slice(-4)}` : '***';
|
||||
}
|
||||
|
||||
function sanitizeUrlForView(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.username) parsed.username = '***';
|
||||
if (parsed.password) parsed.password = '***';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreMaskedViewValue(
|
||||
value: string | undefined,
|
||||
existing: string | undefined,
|
||||
sanitizeForView: (value: string | undefined) => string | undefined = maskSecret
|
||||
): string | undefined {
|
||||
const next = value?.trim() || undefined;
|
||||
if (!next || !existing) return next;
|
||||
return next === sanitizeForView(existing) ? existing : next;
|
||||
}
|
||||
|
||||
function normalizeHeaders(
|
||||
headers: Array<{ key: string; value: string }> | undefined
|
||||
headers: Array<{ key: string; value: string }> | undefined,
|
||||
existing?: Record<string, string>
|
||||
): Record<string, string> | undefined {
|
||||
if (!headers) return undefined;
|
||||
const normalized = headers.reduce<Record<string, string>>((acc, header) => {
|
||||
const key = header.key.trim();
|
||||
if (!key) return acc;
|
||||
acc[key] = header.value;
|
||||
acc[key] = restoreMaskedViewValue(header.value, existing?.[key]) || '';
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
@@ -33,7 +58,10 @@ function normalizeHeaders(
|
||||
function toHeaderPairs(
|
||||
headers: Record<string, string> | undefined
|
||||
): Array<{ key: string; value: string }> {
|
||||
return Object.entries(headers || {}).map(([key, value]) => ({ key, value }));
|
||||
return Object.entries(headers || {}).map(([key, value]) => ({
|
||||
key,
|
||||
value: maskSecret(value) || '***',
|
||||
}));
|
||||
}
|
||||
|
||||
function readModelRulePart(model: unknown, key: keyof AiProviderModelAlias) {
|
||||
@@ -62,9 +90,9 @@ function buildApiKeyEntryView(
|
||||
return {
|
||||
id: entry.id || `${family}:${index}`,
|
||||
index,
|
||||
label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`,
|
||||
baseUrl: entry['base-url']?.trim() || undefined,
|
||||
proxyUrl: entry['proxy-url']?.trim() || undefined,
|
||||
label: entry.prefix?.trim() || sanitizeUrlForView(entry['base-url']) || `Entry ${index + 1}`,
|
||||
baseUrl: sanitizeUrlForView(entry['base-url']),
|
||||
proxyUrl: sanitizeUrlForView(entry['proxy-url']),
|
||||
prefix: entry.prefix?.trim() || undefined,
|
||||
headers: toHeaderPairs(entry.headers),
|
||||
excludedModels: [...(entry['excluded-models'] || [])],
|
||||
@@ -80,7 +108,7 @@ function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): Ai
|
||||
index,
|
||||
name: entry.name,
|
||||
label: entry.name,
|
||||
baseUrl: entry['base-url']?.trim() || undefined,
|
||||
baseUrl: sanitizeUrlForView(entry['base-url']),
|
||||
headers: toHeaderPairs(entry.headers),
|
||||
excludedModels: [],
|
||||
models: normalizeModelAliases(entry.models),
|
||||
@@ -142,10 +170,14 @@ function toApiKeyEntry(
|
||||
return {
|
||||
id: existing?.id,
|
||||
'api-key': nextSecret,
|
||||
'base-url': input.baseUrl?.trim() || undefined,
|
||||
'proxy-url': input.proxyUrl?.trim() || undefined,
|
||||
'base-url': restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView),
|
||||
'proxy-url': restoreMaskedViewValue(
|
||||
input.proxyUrl,
|
||||
existing?.['proxy-url'],
|
||||
sanitizeUrlForView
|
||||
),
|
||||
prefix: input.prefix?.trim() || undefined,
|
||||
headers: normalizeHeaders(input.headers),
|
||||
headers: normalizeHeaders(input.headers, existing?.headers),
|
||||
'excluded-models': (input.excludedModels || [])
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
@@ -167,8 +199,11 @@ function toOpenAiCompatEntry(
|
||||
return {
|
||||
id: existing?.id,
|
||||
name: input.name?.trim() || existing?.name || 'connector',
|
||||
'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '',
|
||||
headers: normalizeHeaders(input.headers),
|
||||
'base-url':
|
||||
restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView) ||
|
||||
existing?.['base-url'] ||
|
||||
'',
|
||||
headers: normalizeHeaders(input.headers, existing?.headers),
|
||||
'api-key-entries': nextApiKeys.map((apiKey) => ({ 'api-key': apiKey })),
|
||||
models: normalizeModelAliases(input.models),
|
||||
};
|
||||
|
||||
@@ -50,22 +50,16 @@ function asObject(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeLocalProviderUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
function isValidCodexCliproxyBaseUrl(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (
|
||||
(url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&
|
||||
url.pathname === '/api/provider/codex'
|
||||
) {
|
||||
url.hostname = '127.0.0.1';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveProviderEnvKey(provider: Record<string, unknown> | null): string {
|
||||
@@ -76,14 +70,10 @@ function resolveProviderEnvKey(provider: Record<string, unknown> | null): string
|
||||
return CODEX_CLIPROXY_PROVIDER_ENV_KEY;
|
||||
}
|
||||
|
||||
function isProviderReady(
|
||||
provider: Record<string, unknown>,
|
||||
expectedBaseUrl: string,
|
||||
envKey: string
|
||||
): boolean {
|
||||
function isProviderReady(provider: Record<string, unknown>, envKey: string): boolean {
|
||||
return (
|
||||
provider.name === CODEX_CLIPROXY_PROVIDER_NAME &&
|
||||
normalizeLocalProviderUrl(provider.base_url) === expectedBaseUrl &&
|
||||
isValidCodexCliproxyBaseUrl(provider.base_url) &&
|
||||
provider.env_key === envKey &&
|
||||
provider.wire_api === 'responses' &&
|
||||
provider.requires_openai_auth === false &&
|
||||
@@ -102,6 +92,17 @@ function buildProviderConfig(baseUrl: string, envKey: string): Record<string, un
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderBaseUrl(
|
||||
provider: Record<string, unknown>,
|
||||
fallbackBaseUrl: string
|
||||
): string {
|
||||
const baseUrl = provider.base_url;
|
||||
if (isValidCodexCliproxyBaseUrl(baseUrl)) {
|
||||
return baseUrl.trim();
|
||||
}
|
||||
return fallbackBaseUrl;
|
||||
}
|
||||
|
||||
function appendProviderBlock(rawText: string, baseUrl: string): string {
|
||||
const prefix = rawText.trimEnd();
|
||||
const providerBlock = stringifyTomlObject({
|
||||
@@ -179,12 +180,12 @@ export async function ensureCodexCliproxyProviderConfig(
|
||||
}
|
||||
|
||||
const envKey = resolveProviderEnvKey(currentProvider);
|
||||
const providerReady = isProviderReady(currentProvider, expectedBaseUrl, envKey);
|
||||
const providerReady = isProviderReady(currentProvider, envKey);
|
||||
|
||||
if (!providerReady) {
|
||||
providers[CODEX_CLIPROXY_PROVIDER_ID] = {
|
||||
...currentProvider,
|
||||
...buildProviderConfig(expectedBaseUrl, envKey),
|
||||
...buildProviderConfig(resolveProviderBaseUrl(currentProvider, expectedBaseUrl), envKey),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import session from 'express-session';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
import crypto from 'crypto';
|
||||
import * as net from 'net';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
@@ -161,7 +162,7 @@ function isLoopbackHostname(value: string | undefined): boolean {
|
||||
return (
|
||||
normalized === 'localhost' ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
isLoopbackRemoteAddress(normalized)
|
||||
(net.isIP(normalized) !== 0 && isLoopbackRemoteAddress(normalized))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,12 @@ import {
|
||||
type ClaudeExtensionActionTarget,
|
||||
verifyClaudeExtensionBinding,
|
||||
} from '../services/claude-extension-settings-service';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
const VALID_HOSTS = new Set(CLAUDE_EXTENSION_HOSTS.map((host) => host.id));
|
||||
const SETUP_LOCAL_ACCESS_ERROR =
|
||||
'Claude extension setup requires localhost access when dashboard auth is disabled.';
|
||||
const VALID_TARGETS = new Set<ClaudeExtensionActionTarget>(['shared', 'ide', 'all']);
|
||||
|
||||
function getHostFromRequest(req: Request): ClaudeExtensionHost {
|
||||
@@ -79,6 +82,10 @@ router.get('/profiles', (_req: Request, res: Response): void => {
|
||||
});
|
||||
|
||||
router.get('/setup', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireLocalAccessWhenAuthDisabled(req, res, SETUP_LOCAL_ACCESS_ERROR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawProfile = typeof req.query.profile === 'string' ? req.query.profile.trim() : '';
|
||||
if (!rawProfile) {
|
||||
res.status(400).json({ error: 'Missing required query parameter: profile' });
|
||||
|
||||
@@ -131,6 +131,15 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail
|
||||
}
|
||||
}
|
||||
|
||||
describe('websearch-transformer legacy CLI safety', () => {
|
||||
it('does not enable shell execution for query-derived legacy CLI prompts', () => {
|
||||
const source = readFileSync(hookPath, 'utf8');
|
||||
|
||||
expect(source).not.toContain('shell: isWindows');
|
||||
expect(source.match(/shell: false/g) || []).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('websearch-transformer hook helpers', () => {
|
||||
it('parses Retry-After seconds and HTTP dates', () => {
|
||||
expect(hook.parseRetryAfterSeconds('2')).toBe(2);
|
||||
|
||||
@@ -78,19 +78,19 @@ wire_api = "responses"
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.envKey).toBe('CLIPROXY_API_KEY');
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`);
|
||||
expect(rawText).toContain('base_url = "http://localhost:8317/api/provider/codex"');
|
||||
expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"');
|
||||
expect(rawText).toContain('requires_openai_auth = false');
|
||||
expect(rawText).toContain('supports_websockets = false');
|
||||
});
|
||||
|
||||
it('preserves a custom cliproxy provider env key while repairing other fields', async () => {
|
||||
it('preserves custom cliproxy provider values while repairing other fields', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`[model_providers.cliproxy]
|
||||
name = "Old Name"
|
||||
base_url = "http://localhost:8317/api/provider/codex"
|
||||
base_url = "https://cliproxy.example.com/api/provider/codex/responses"
|
||||
env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"
|
||||
wire_api = "chat"
|
||||
`,
|
||||
@@ -102,11 +102,35 @@ wire_api = "chat"
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.envKey).toBe('CCS_CUSTOM_CLIPROXY_TOKEN');
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`);
|
||||
expect(rawText).toContain(
|
||||
'base_url = "https://cliproxy.example.com/api/provider/codex/responses"'
|
||||
);
|
||||
expect(rawText).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"');
|
||||
expect(rawText).toContain('wire_api = "responses"');
|
||||
});
|
||||
|
||||
it('repairs invalid cliproxy provider base URLs back to the managed local default', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "not-a-url"
|
||||
env_key = "CLIPROXY_API_KEY"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await ensureCodexCliproxyProviderConfig(9321, env);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
const rawText = fs.readFileSync(configPath, 'utf8');
|
||||
expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`);
|
||||
});
|
||||
|
||||
it('rejects invalid non-table model_providers values without appending broken TOML', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const rawText = 'model_providers = "legacy"\n';
|
||||
@@ -137,6 +161,25 @@ supports_websockets = false
|
||||
expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText);
|
||||
});
|
||||
|
||||
it('leaves a ready remote provider unchanged', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
const rawText = `[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "https://cliproxy.example.com/api/provider/codex"
|
||||
env_key = "CCS_REMOTE_CLIPROXY_TOKEN"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false
|
||||
`;
|
||||
fs.writeFileSync(configPath, rawText, 'utf8');
|
||||
|
||||
const result = await ensureCodexCliproxyProviderConfig(8317, env);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.envKey).toBe('CCS_REMOTE_CLIPROXY_TOKEN');
|
||||
expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText);
|
||||
});
|
||||
|
||||
it('normalizes a ready native Codex tuning alias before requests reach cliproxy', async () => {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -828,6 +828,60 @@ supports_websockets = false
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves a custom cliproxy provider base_url for ccsxp launches', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const codexHome = path.join(tmpHome, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'config.toml'),
|
||||
`[model_providers.cliproxy]
|
||||
name = "CLIProxy Codex"
|
||||
base_url = "https://cliproxy.example.com/api/provider/codex/responses"
|
||||
env_key = "CCS_REMOTE_CLIPROXY_TOKEN"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
supports_websockets = false
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = runCcsxpAlias(['fix failing tests'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
HOME: tmpHome,
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
|
||||
CCS_TEST_CODEX_LOG_ENV_KEYS: 'CCS_REMOTE_CLIPROXY_TOKEN',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
|
||||
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
|
||||
]);
|
||||
const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
expect(codexConfig).toContain(
|
||||
'base_url = "https://cliproxy.example.com/api/provider/codex/responses"'
|
||||
);
|
||||
expect(codexConfig).toContain('env_key = "CCS_REMOTE_CLIPROXY_TOKEN"');
|
||||
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
|
||||
{
|
||||
CODEX_HOME: codexHome,
|
||||
CODEX_CI: undefined,
|
||||
CODEX_MANAGED_BY_BUN: undefined,
|
||||
CODEX_THREAD_ID: undefined,
|
||||
ANTHROPIC_BASE_URL: undefined,
|
||||
CCS_REMOTE_CLIPROXY_TOKEN: 'ccs-internal-managed',
|
||||
CCS_BROWSER_USER_DATA_DIR: undefined,
|
||||
CCS_BROWSER_PROFILE_DIR: undefined,
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps ccsxp native when the CCS default profile is a Claude account', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
|
||||
@@ -239,6 +239,18 @@ describe('Dashboard Auth', () => {
|
||||
expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks 127-prefixed DNS names from loopback websocket origin aliases', () => {
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||
const request = makeUpgradeRequest('127.0.0.1', false, {
|
||||
host: 'localhost:3001',
|
||||
origin: 'http://127.evil.example.test:3001',
|
||||
});
|
||||
|
||||
expect(isDashboardWebSocketOriginAllowed(request)).toBe(false);
|
||||
expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false);
|
||||
expect(getDashboardWebSocketRejectionStatus(request)).toBe(403);
|
||||
});
|
||||
|
||||
it('blocks cross-site websocket origins even with an authenticated session', () => {
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
|
||||
const request = makeUpgradeRequest('127.0.0.1', true, {
|
||||
|
||||
@@ -34,9 +34,8 @@ describe('web-server claude-extension-routes', () => {
|
||||
({ default: SharedManager } = await import('../../../src/management/shared-manager'));
|
||||
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
|
||||
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
|
||||
({ default: claudeExtensionRoutes } = await import(
|
||||
'../../../src/web-server/routes/claude-extension-routes'
|
||||
));
|
||||
({ default: claudeExtensionRoutes } =
|
||||
await import('../../../src/web-server/routes/claude-extension-routes'));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -68,7 +67,8 @@ describe('web-server claude-extension-routes', () => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
if (originalClaudeConfigDir !== undefined)
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
});
|
||||
|
||||
@@ -178,6 +178,47 @@ describe('web-server claude-extension-routes', () => {
|
||||
expect(payload.sharedSettings.json).toContain('"env"');
|
||||
});
|
||||
|
||||
it('blocks non-local setup requests when dashboard auth is disabled', async () => {
|
||||
const app = express();
|
||||
app.use((_req, _res, next) => {
|
||||
Object.defineProperty(_req.socket, 'remoteAddress', {
|
||||
configurable: true,
|
||||
value: '10.0.0.25',
|
||||
});
|
||||
next();
|
||||
});
|
||||
app.use('/api/claude-extension', claudeExtensionRoutes);
|
||||
|
||||
const remoteServer = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = app.listen(0, '127.0.0.1');
|
||||
const handleError = (error: Error) => reject(error);
|
||||
instance.once('error', handleError);
|
||||
instance.once('listening', () => {
|
||||
instance.off('error', handleError);
|
||||
resolve(instance);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const address = remoteServer.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve remote test server port');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${address.port}/api/claude-extension/setup?profile=glm&host=vscode`
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toBe(
|
||||
'Claude extension setup requires localhost access when dashboard auth is disabled.'
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => remoteServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes the effective profile CLAUDE_CONFIG_DIR for extension setup', async () => {
|
||||
const explicitConfigDir = path.join(tempHome, '.claude-profiles', 'glm');
|
||||
const glmSettingsPath = path.join(tempHome, '.ccs', 'glm.settings.json');
|
||||
|
||||
Reference in New Issue
Block a user