Merge pull request #1244 from kaitranntt/dev

feat: promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-14 12:52:25 -04:00
committed by GitHub
88 changed files with 3786 additions and 505 deletions
+19 -1
View File
@@ -1,6 +1,6 @@
# Browser Automation
Last Updated: 2026-04-19
Last Updated: 2026-05-11
CCS provides browser automation through two separate runtime paths:
@@ -127,6 +127,8 @@ CCS still supports environment-variable overrides for backward compatibility.
| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir |
| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory |
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override |
| `CCS_BROWSER_UPLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for local files that browser upload tools may read |
| `CCS_BROWSER_DOWNLOAD_ROOTS` | Optional `path.delimiter`-separated allowlist for caller-provided browser download directories |
If an override is active, Browser status surfaces should report that the current session is being
managed externally by environment variables.
@@ -144,6 +146,22 @@ Config-backed Browser Attach always passes an explicit DevTools port to the runt
effective value is the default `9222`. Metadata-based port discovery is preserved only for the
legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set.
### Browser File Transfer Safety
Claude Browser Attach file-transfer tools intentionally use a deny-by-default filesystem boundary:
- Downloads without an explicit `downloadPath` go to a CCS-created temporary session directory.
- A caller-provided `downloadPath` must be inside that temporary session directory or inside one of
the directories listed in `CCS_BROWSER_DOWNLOAD_ROOTS`.
- Local upload and drag-and-drop files must be inside the temporary session download directory or
inside one of the directories listed in `CCS_BROWSER_UPLOAD_ROOTS`.
- Hidden path segments and common secret locations/files, such as `.ssh`, `.aws`, `.ccs`,
`.claude`, `.env`, and private-key filenames, are rejected even inside an allowed root.
- Each file-transfer call is limited to 10 files, and each local file must be at most 10 MiB.
Set upload/download roots only to purpose-built scratch directories. Do not point these variables at
your home directory, a source checkout with secrets, or a real cloud/tooling config directory.
## Managed Runtime Files
- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach
+2 -1
View File
@@ -273,7 +273,8 @@ 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.
- 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.
- 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`.
- Compatibility guardrails: `src/targets/target-runtime-compatibility.ts` centralizes which profile types can execute on Codex.
+2 -2
View File
@@ -6,9 +6,9 @@ CLI commands for managing CCS dashboard authentication.
## Overview
The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, including when the runtime's default bind is network-accessible or when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`.
The CCS dashboard (`ccs config`) can be protected with username/password authentication. This is useful whenever the dashboard is reachable from another device, such as when you explicitly bind it beyond loopback with `ccs config --host 0.0.0.0`.
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
Authentication is **disabled by default** for backward compatibility, while `ccs config` binds to `localhost` by default. Use the CLI to configure and enable auth before exposing the dashboard to other devices.
CCS does **not** ship a default dashboard username or password. When someone opens the dashboard from a non-loopback/IP address before auth is enabled, the UI now shows a setup state instead of an ambiguous login form. The host owner must run `ccs config auth setup`, or the user should switch back to the localhost URL if they are on the same machine.
@@ -521,6 +521,9 @@ ccsxp
CCS_INTERNAL_ENTRY_TARGET=codex
→ 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`
→ 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
```
If a user launches CCS through a custom shim instead of the built-in package bins, target
+49 -3
View File
@@ -379,6 +379,47 @@ function outputSuccess(filePath, description, model, fileSize) {
/**
* Determine if hook should skip, with debug logging
*/
function normalizePathForComparison(value) {
return process.platform === 'win32' ? value.toLowerCase() : value;
}
function isPathWithinWorkspace(workspaceRoot, candidatePath) {
const relativePath = path.relative(workspaceRoot, candidatePath);
return (
relativePath === '' ||
(!relativePath.startsWith('..') &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath))
);
}
function resolveWorkspaceFilePath(filePath) {
const workspaceRoot = (() => {
try {
return fs.realpathSync(process.cwd());
} catch {
return path.resolve(process.cwd());
}
})();
const absolutePath = path.resolve(process.cwd(), filePath);
const realFilePath = fs.realpathSync(absolutePath);
if (
!isPathWithinWorkspace(
normalizePathForComparison(workspaceRoot),
normalizePathForComparison(realFilePath)
)
) {
debugLog('Skipping: file is outside the current workspace', {
workspaceRoot,
filePath: realFilePath,
});
return null;
}
return realFilePath;
}
function shouldSkipHook() {
if (process.env.CCS_IMAGE_ANALYSIS_SKIP_HOOK === '1') {
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP_HOOK=1');
@@ -484,11 +525,16 @@ async function processHook() {
process.exit(0);
}
const debugContext = getDebugContext(filePath, null);
const workspaceFilePath = resolveWorkspaceFilePath(filePath);
if (!workspaceFilePath) {
process.exit(0);
}
const debugContext = getDebugContext(workspaceFilePath, null);
debugLog('Image analysis runtime prepared', debugContext);
const result = await analyzeFile(filePath);
outputSuccess(filePath, result.description, result.model, result.fileSize);
const result = await analyzeFile(workspaceFilePath);
outputSuccess(workspaceFilePath, result.description, result.model, result.fileSize);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Error:', err.message);
+154 -7
View File
@@ -151,6 +151,8 @@ const DEFAULT_DRAG_STEPS = 5;
const MAX_POINTER_ACTIONS = 25;
const SESSION_START_SETTLE_WINDOW_MS = 250;
const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024;
const MAX_LOCAL_TRANSFER_FILE_BYTES = 10 * 1024 * 1024;
const MAX_LOCAL_TRANSFER_FILES = 10;
const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED';
@@ -177,6 +179,37 @@ const recentDownloads = [];
const interceptSessionsByPageId = new Map();
let browserDownloadSession = null;
let sessionDownloadDir = '';
const SENSITIVE_LOCAL_PATH_SEGMENTS = new Set([
'.ssh',
'.gnupg',
'.aws',
'.azure',
'.kube',
'.docker',
'.npmrc',
'.netrc',
'.pypirc',
'.config',
'.claude',
'.ccs',
]);
const SENSITIVE_LOCAL_FILE_NAMES = new Set([
'.env',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
'known_hosts',
'authorized_keys',
'credentials',
'credentials.json',
'config.json',
'settings.json',
'history',
'.bash_history',
'.zsh_history',
'.fish_history',
]);
const MAX_RECENT_REQUESTS = 100;
const MAX_RECENT_DOWNLOADS = 100;
const FETCH_FAIL_ERROR_REASON = 'Failed';
@@ -1438,10 +1471,105 @@ function getSessionDownloadPath() {
return sessionDownloadDir;
}
function splitConfiguredPathRoots(value) {
return String(value || '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => path.resolve(entry));
}
function getDownloadSafeRoots() {
return [
getSessionDownloadPath(),
...splitConfiguredPathRoots(process.env.CCS_BROWSER_DOWNLOAD_ROOTS),
];
}
function getUploadSafeRoots() {
return [
getSessionDownloadPath(),
...splitConfiguredPathRoots(process.env.CCS_BROWSER_UPLOAD_ROOTS),
];
}
function getNearestExistingAncestor(candidatePath) {
let currentPath = candidatePath;
while (!fs.existsSync(currentPath)) {
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
return currentPath;
}
currentPath = parentPath;
}
return currentPath;
}
function resolveExistingRoot(rootPath) {
fs.mkdirSync(rootPath, { recursive: true });
return fs.realpathSync(rootPath);
}
function resolvePathWithRealAncestor(candidatePath) {
const resolvedPath = path.resolve(candidatePath);
const ancestorPath = getNearestExistingAncestor(resolvedPath);
const realAncestorPath = fs.realpathSync(ancestorPath);
const relativeSuffix = path.relative(ancestorPath, resolvedPath);
return relativeSuffix ? path.resolve(realAncestorPath, relativeSuffix) : realAncestorPath;
}
function isPathInsideRoot(candidatePath, rootPath) {
const relativePath = path.relative(rootPath, candidatePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
function findContainingRoot(candidatePath, rootPaths) {
return rootPaths.find((rootPath) => isPathInsideRoot(candidatePath, rootPath)) || '';
}
function getLocalPathSegments(candidatePath, rootPath) {
const rootSegments = path.resolve(rootPath).split(path.sep).filter(Boolean);
const relativePath = path.relative(rootPath, candidatePath);
const relativeSegments = relativePath.split(path.sep).filter(Boolean);
return [...rootSegments, ...relativeSegments];
}
function assertNoSensitiveLocalPathSegments(candidatePath, rootPath, label) {
const segments = getLocalPathSegments(candidatePath, rootPath);
for (const segment of segments) {
const normalizedSegment = segment.toLowerCase();
if (normalizedSegment.startsWith('.') || SENSITIVE_LOCAL_PATH_SEGMENTS.has(normalizedSegment)) {
throw new Error(`${label} cannot include hidden or sensitive path segment: ${segment}`);
}
}
const fileName = path.basename(candidatePath).toLowerCase();
if (SENSITIVE_LOCAL_FILE_NAMES.has(fileName)) {
throw new Error(
`${label} cannot reference sensitive file name: ${path.basename(candidatePath)}`
);
}
}
function ensureWritableDirectory(downloadPath) {
fs.mkdirSync(downloadPath, { recursive: true });
fs.accessSync(downloadPath, fs.constants.W_OK);
return downloadPath;
const resolvedPath = path.resolve(downloadPath);
const candidatePath = resolvePathWithRealAncestor(resolvedPath);
const safeRoots = getDownloadSafeRoots().map(resolveExistingRoot);
const containingRoot = findContainingRoot(candidatePath, safeRoots);
if (!containingRoot) {
throw new Error(
'downloadPath must be inside the browser session download directory or a CCS_BROWSER_DOWNLOAD_ROOTS entry'
);
}
assertNoSensitiveLocalPathSegments(candidatePath, containingRoot, 'downloadPath');
fs.mkdirSync(resolvedPath, { recursive: true });
const realDownloadPath = fs.realpathSync(resolvedPath);
if (!isPathInsideRoot(realDownloadPath, containingRoot)) {
throw new Error('downloadPath cannot traverse outside the allowed download root');
}
fs.accessSync(realDownloadPath, fs.constants.W_OK);
return realDownloadPath;
}
function pushRecentDownload(entry) {
@@ -2418,16 +2546,35 @@ function buildFileInputHandleExpression(selector, nth, frameSelector, pierceShad
}
function validateLocalFiles(files) {
if (files.length > MAX_LOCAL_TRANSFER_FILES) {
throw new Error(`files exceeds maximum of ${MAX_LOCAL_TRANSFER_FILES}`);
}
const safeRoots = getUploadSafeRoots().map(resolveExistingRoot);
return files.map((filePath) => {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`file does not exist: ${resolvedPath}`);
}
const stat = fs.statSync(resolvedPath);
if (!stat.isFile()) {
throw new Error(`file is not a regular file: ${resolvedPath}`);
const realFilePath = fs.realpathSync(resolvedPath);
const containingRoot = findContainingRoot(realFilePath, safeRoots);
if (!containingRoot) {
throw new Error(
'file must be inside the browser session download directory or a CCS_BROWSER_UPLOAD_ROOTS entry'
);
}
return resolvedPath;
assertNoSensitiveLocalPathSegments(realFilePath, containingRoot, 'file');
const stat = fs.statSync(realFilePath);
if (!stat.isFile()) {
throw new Error(`file is not a regular file: ${realFilePath}`);
}
if (stat.size > MAX_LOCAL_TRANSFER_FILE_BYTES) {
throw new Error(
`file exceeds maximum size of ${MAX_LOCAL_TRANSFER_FILE_BYTES} bytes: ${realFilePath}`
);
}
return realFilePath;
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.78.1",
"version": "7.78.1-dev.8",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+2
View File
@@ -1,10 +1,12 @@
const os = require('os');
const path = require('path');
const { CCSXP_CLIPROXY_SHORTCUT_ENV } = require('../targets/codex-cliproxy-provider-config');
const { stripTargetFlag } = require('../targets/target-resolver');
const { expandPath } = require('../utils/helpers');
const { fail } = require('../utils/ui');
process.env.CCS_INTERNAL_ENTRY_TARGET = 'codex';
process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = '1';
const CCSXP_CLIPROXY_OVERRIDE = 'model_provider="cliproxy"';
const DISALLOWED_CCSXP_CONFIG_KEY_REGEX =
/^(model_provider|local_provider|profile)\s*=|^model_providers\./i;
+12
View File
@@ -9,6 +9,7 @@ import {
isClaudeCliVersionAtLeast,
type ClaudeAuthStatus,
} from '../utils/claude-detector';
import { isClaudeSubcommandInvocation } from '../utils/claude-subcommand-detector';
export interface OfficialChannelDefinition {
id: OfficialChannelId;
@@ -337,6 +338,17 @@ export function resolveOfficialChannelsLaunchPlan(
};
}
// Claude subcommands (agents, doctor, mcp, ...) don't accept official-channels
// plugin args. Skip silently — channels are session-only. Issue #1218.
if (isClaudeSubcommandInvocation(args)) {
return {
applied: false,
wantsPermissionBypass: false,
appliedChannels: [],
skippedMessages,
};
}
if (!isDiscordChannelsSessionSupported(target, profileType)) {
return {
applied: false,
+3 -4
View File
@@ -11,6 +11,7 @@ import {
isOfficialChannelTokenRequired,
} from './official-channels-runtime';
import { getCcsDir } from '../config/config-loader-facade';
import { listAccountInstancePaths } from '../management/instance-directory';
export type OfficialChannelTokenSource = 'saved_env' | 'process_env' | 'missing';
@@ -151,10 +152,8 @@ function listManagedClaudeConfigDirs(): string[] {
return [...dirs];
}
for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
dirs.add(path.join(instancesDir, entry.name));
}
for (const instancePath of listAccountInstancePaths(instancesDir)) {
dirs.add(instancePath);
}
return [...dirs];
@@ -14,6 +14,13 @@ const remoteTarget: ProxyTarget = {
isRemote: true,
};
const localTarget: ProxyTarget = {
host: '127.0.0.1',
port: 8317,
protocol: 'http',
isRemote: false,
};
afterEach(() => {
restoreFetch();
});
@@ -80,6 +87,31 @@ describe('requestPasteCallbackStart', () => {
});
});
describe('OAuth start failure guidance', () => {
it('explains Codex paste-callback recovery in headless local mode', async () => {
const { buildOAuthStartFailureGuidance, formatOAuthStartFailureForCli } = await import(
`../oauth-start-failure-guidance?codex-local-guidance=${Date.now()}`
);
const guidance = buildOAuthStartFailureGuidance('codex', {
target: localTarget,
startPath: '/v0/management/codex-auth-url?is_webui=true',
cause: new Error('fetch failed'),
addAccount: true,
});
const cliOutput = formatOAuthStartFailureForCli(guidance).join('\n');
expect(guidance.message).toContain('OpenAI Codex OAuth could not start');
expect(guidance.endpoint).toBe(
'http://127.0.0.1:8317/v0/management/codex-auth-url?is_webui=true'
);
expect(cliOutput).toContain('ccs cliproxy start');
expect(cliOutput).toContain('ccs codex --auth --add --paste-callback');
expect(cliOutput).toContain('ssh -L 1455:localhost:1455 <USER>@<HOST>');
expect(cliOutput).toContain('ccs codex --auth --add --port-forward');
});
});
describe('Gemini Plus OAuth credential diagnostics', () => {
it('fails fast when Gemini uses Plus without OAuth client env', async () => {
const { getGeminiPlusOAuthCredentialError } = await import(
+26
View File
@@ -58,6 +58,10 @@ import {
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
import { parseGitLabPatAuthResponse } from './gitlab-pat-response';
import {
buildOAuthStartFailureGuidance,
formatOAuthStartFailureForCli,
} from './oauth-start-failure-guidance';
import {
getProxyTarget,
buildProxyUrl,
@@ -647,6 +651,7 @@ async function handlePasteCallbackMode(
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
add?: boolean;
}
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
@@ -661,13 +666,33 @@ async function handlePasteCallbackMode(
// Request auth URL from CLIProxyAPI management endpoints when the selected
// provider/method supports the manual start-url contract.
let startData: PasteCallbackStartData;
let startPath = getPasteCallbackStartPath(provider, {
kiroMethod: options?.kiroMethod,
});
const normalizedGitLabBaseUrl =
provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined;
if (startPath && normalizedGitLabBaseUrl) {
startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`;
}
try {
startData = await requestPasteCallbackStart(provider, target, {
kiroMethod: options?.kiroMethod,
gitlabBaseUrl: options?.gitlabBaseUrl,
});
} catch (error) {
const startError = (error as Error).message;
console.log(fail('Failed to start OAuth flow'));
if (startPath) {
const guidance = buildOAuthStartFailureGuidance(provider, {
target,
startPath,
cause: error,
addAccount: options?.add,
});
for (const line of formatOAuthStartFailureForCli(guidance)) {
console.log(` ${line}`);
}
}
warnPossible403Ban(provider, startError);
return null;
}
@@ -1109,6 +1134,7 @@ export async function triggerOAuth(
{
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
add,
}
);
}
@@ -0,0 +1,110 @@
import type { CLIProxyProvider } from '../types';
import { getOAuthCallbackPort, getProviderDisplayName } from '../provider-capabilities';
import { buildProxyUrl, type ProxyTarget } from '../proxy/proxy-target-resolver';
export interface OAuthStartFailureGuidance {
error: 'cliproxy_oauth_start_failed';
provider: CLIProxyProvider;
message: string;
details: string;
hints: string[];
endpoint: string | null;
retryCommand: string;
portForwardCommand?: string;
}
interface GuidanceOptions {
target: ProxyTarget;
startPath?: string | null;
cause?: unknown;
addAccount?: boolean;
}
function getCauseMessage(cause: unknown): string | null {
if (cause instanceof Error && cause.message.trim()) {
return cause.message.trim();
}
if (typeof cause === 'string' && cause.trim()) {
return cause.trim();
}
return null;
}
function buildAuthCommand(
provider: CLIProxyProvider,
flag: '--paste-callback' | '--port-forward',
addAccount?: boolean
): string {
const parts = ['ccs', provider, '--auth'];
if (addAccount) {
parts.push('--add');
}
parts.push(flag);
return parts.join(' ');
}
export function buildOAuthStartFailureGuidance(
provider: CLIProxyProvider,
options: GuidanceOptions
): OAuthStartFailureGuidance {
const { target, startPath, cause, addAccount } = options;
const displayName = getProviderDisplayName(provider);
const endpoint = startPath ? buildProxyUrl(target, startPath) : null;
const causeMessage = getCauseMessage(cause);
const retryCommand = buildAuthCommand(provider, '--paste-callback', addAccount);
const portForwardRetryCommand = buildAuthCommand(provider, '--port-forward', addAccount);
const callbackPort = getOAuthCallbackPort(provider);
const portForwardCommand = callbackPort
? `ssh -L ${callbackPort}:localhost:${callbackPort} <USER>@<HOST>`
: undefined;
const targetDescription = target.isRemote
? `remote CLIProxy management API at ${target.protocol}://${target.host}:${target.port}`
: `local CLIProxy management API at ${target.protocol}://${target.host}:${target.port}`;
const message = `${displayName} OAuth could not start through the ${targetDescription}.`;
const details = [
endpoint ? `Endpoint: ${endpoint}` : null,
causeMessage ? `Cause: ${causeMessage}` : null,
]
.filter(Boolean)
.join(' ');
const hints = target.isRemote
? [
'Verify the remote CLIProxy server is running and reachable from this machine.',
'Check cliproxy_server.remote.management_key or auth_token in CCS config.',
`After fixing the remote proxy, retry: ${retryCommand}`,
]
: [
'Start local CLIProxy first: ccs cliproxy start',
`Then retry paste-callback mode: ${retryCommand}`,
...(portForwardCommand
? [
`For SSH/VPS auth, open a tunnel from your local machine: ${portForwardCommand}`,
`Then run inside that SSH session: ${portForwardRetryCommand}`,
]
: []),
];
return {
error: 'cliproxy_oauth_start_failed',
provider,
message,
details,
hints,
endpoint,
retryCommand,
portForwardCommand,
};
}
export function formatOAuthStartFailureForCli(guidance: OAuthStartFailureGuidance): string[] {
const lines = [guidance.message];
if (guidance.details) {
lines.push(guidance.details);
}
lines.push('Next steps:');
lines.push(...guidance.hints.map((hint) => ` - ${hint}`));
return lines;
}
+30 -9
View File
@@ -24,6 +24,12 @@ import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
import { setupCleanupHandlers } from './session-bridge';
import { resolveRuntimeQuotaMonitorProviders } from './account-resolution';
import {
isClaudeSubcommandInvocation,
stripClaudeCodeFeatureBlockingEnv,
stripClaudeSubcommandSessionArgs,
stripSubcommandBlockingEnv,
} from '../../utils/claude-subcommand-detector';
export interface ClaudeLaunchContext {
/** Path to the Claude CLI executable */
@@ -90,18 +96,25 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
? cfg.customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
// Claude subcommands (agents, doctor, mcp, ...) don't accept `--settings` —
// its presence flips `agents` into list mode instead of opening the
// interactive agent view. Provider routing still flows via env vars.
// Issue #1218.
const isSubcommand = isClaudeSubcommandInvocation(claudeArgs);
const claudeSessionArgs = isSubcommand
? stripClaudeSubcommandSessionArgs(claudeArgs)
: claudeArgs;
// Assemble final args: image analysis tools → browser tools → web search tools → settings
const imageAnalysisArgs = imageAnalysisMcpReady
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
: claudeArgs;
? appendThirdPartyImageAnalysisToolArgs(claudeSessionArgs)
: claudeSessionArgs;
const browserArgs = browserRuntimeEnv
? appendBrowserToolArgs(imageAnalysisArgs)
: imageAnalysisArgs;
const launchArgs = [
'--settings',
settingsPath,
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const launchArgs = isSubcommand
? appendThirdPartyWebSearchToolArgs(browserArgs)
: ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
// Inject web search trace context into env
const traceEnv = createWebSearchTraceContext({
@@ -112,7 +125,11 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
settingsPath,
claudeConfigDir: inheritedClaudeConfigDir,
});
const tracedEnv = { ...env, ...traceEnv };
const baseTracedEnv = stripClaudeCodeFeatureBlockingEnv({ ...env, ...traceEnv });
// Strip telemetry-disable env vars for subcommands; otherwise Claude's
// `agents`/`mcp`/... TUIs silently fall back to non-interactive list mode.
// Issue #1218.
const tracedEnv = isSubcommand ? stripSubcommandBlockingEnv(baseTracedEnv) : baseTracedEnv;
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
let claude: ChildProcess;
@@ -146,6 +163,9 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
}
}
const hasSessionProxy = Boolean(codexReasoningProxy || toolSanitizationProxy || httpsTunnel);
const backgroundKeepAliveBaseUrl = hasSessionProxy ? tracedEnv.ANTHROPIC_BASE_URL : undefined;
// Wire cleanup handlers (process exit, SIGINT, SIGTERM, proxy teardown)
setupCleanupHandlers(
claude,
@@ -154,7 +174,8 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
codexReasoningProxy,
toolSanitizationProxy,
httpsTunnel,
verbose
verbose,
{ backgroundKeepAliveBaseUrl }
);
return claude;
+119 -78
View File
@@ -8,7 +8,7 @@
* - Startup lock coordination
*/
import { ChildProcess } from 'child_process';
import { ChildProcess, execFileSync } from 'child_process';
import { info, warn } from '../../utils/ui';
import { getInstalledCliproxyVersion } from '../binary-manager';
import { CLIProxyBackend } from '../types';
@@ -33,6 +33,70 @@ export interface ProxySessionResult {
shouldSpawn: boolean;
}
export interface SessionProxyKeepAliveOptions {
backgroundKeepAliveBaseUrl?: string;
keepAlivePollIntervalMs?: number;
hasBackgroundWorkerUsingBaseUrl?: (baseUrl: string) => boolean;
}
export interface ShouldKeepSessionProxiesAliveOptions {
code: number | null;
signal: NodeJS.Signals | null;
backgroundKeepAliveBaseUrl?: string;
hasBackgroundWorkerUsingBaseUrl: (baseUrl: string) => boolean;
}
const CLAUDE_BG_WORKER_ARGS = ['--bg-spare', '--bg-pty-host'] as const;
export function hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(
processList: string,
baseUrl: string
): boolean {
const envNeedle = `ANTHROPIC_BASE_URL=${baseUrl}`;
return processList
.split(/\r?\n/)
.some(
(line) => line.includes(envNeedle) && CLAUDE_BG_WORKER_ARGS.some((arg) => line.includes(arg))
);
}
export function hasClaudeBackgroundWorkerUsingBaseUrl(baseUrl: string): boolean {
if (process.platform === 'win32') {
return false;
}
for (const args of [['auxEww'], ['auxeww'], ['eww', '-axo', 'pid=,command=']]) {
try {
const processList = execFileSync('ps', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
if (hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)) {
return true;
}
} catch {
continue;
}
}
return false;
}
export function shouldKeepSessionProxiesAlive(
options: ShouldKeepSessionProxiesAliveOptions
): boolean {
if (options.code !== 0 || options.signal) {
return false;
}
const baseUrl = options.backgroundKeepAliveBaseUrl;
if (!baseUrl) {
return false;
}
return options.hasBackgroundWorkerUsingBaseUrl(baseUrl);
}
/**
* Check for existing proxy and handle version mismatch, or determine if new spawn needed
*/
@@ -180,7 +244,8 @@ export function setupCleanupHandlers(
codexReasoningProxy: unknown,
toolSanitizationProxy: unknown,
httpsTunnel: unknown,
verbose: boolean
verbose: boolean,
keepAliveOptions: SessionProxyKeepAliveOptions = {}
): void {
const log = (msg: string) => {
if (verbose) {
@@ -188,66 +253,67 @@ export function setupCleanupHandlers(
}
};
const cleanup = () => {
stopQuotaMonitor();
log('Parent signal received, cleaning up');
let resourcesStopped = false;
if (
codexReasoningProxy &&
typeof codexReasoningProxy === 'object' &&
'stop' in codexReasoningProxy
) {
(codexReasoningProxy as { stop: () => void }).stop();
const stopResource = (resource: unknown) => {
if (resource && typeof resource === 'object' && 'stop' in resource) {
(resource as { stop: () => void }).stop();
}
if (
toolSanitizationProxy &&
typeof toolSanitizationProxy === 'object' &&
'stop' in toolSanitizationProxy
) {
(toolSanitizationProxy as { stop: () => void }).stop();
}
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
(httpsTunnel as { stop: () => void }).stop();
}
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
unregisterSession(sessionId, sessionPort);
}
claude.kill('SIGTERM');
};
claude.on('exit', (code, signal) => {
const stopSessionResources = () => {
if (resourcesStopped) return;
resourcesStopped = true;
stopQuotaMonitor();
log(`Claude exited: code=${code}, signal=${signal}`);
stopResource(codexReasoningProxy);
stopResource(toolSanitizationProxy);
stopResource(httpsTunnel);
if (
codexReasoningProxy &&
typeof codexReasoningProxy === 'object' &&
'stop' in codexReasoningProxy
) {
(codexReasoningProxy as { stop: () => void }).stop();
}
if (
toolSanitizationProxy &&
typeof toolSanitizationProxy === 'object' &&
'stop' in toolSanitizationProxy
) {
(toolSanitizationProxy as { stop: () => void }).stop();
}
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
(httpsTunnel as { stop: () => void }).stop();
}
// Unregister this session (proxy keeps running for persistence) - only for local mode
if (sessionId) {
unregisterSession(sessionId, sessionPort);
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
}
};
const backgroundProbe =
keepAliveOptions.hasBackgroundWorkerUsingBaseUrl ?? hasClaudeBackgroundWorkerUsingBaseUrl;
const startKeepAliveWatcher = (baseUrl: string) => {
const timer = setInterval(() => {
if (backgroundProbe(baseUrl)) return;
log('No Claude bg worker is using the session proxy; cleaning up');
stopSessionResources();
process.exit(0);
}, keepAliveOptions.keepAlivePollIntervalMs ?? 30000);
timer.unref();
};
const cleanup = () => {
log('Parent signal received, cleaning up');
stopSessionResources();
claude.kill('SIGTERM');
};
claude.on('exit', (code, signal) => {
log(`Claude exited: code=${code}, signal=${signal}`);
const backgroundKeepAliveBaseUrl = keepAliveOptions.backgroundKeepAliveBaseUrl;
if (
shouldKeepSessionProxiesAlive({
code,
signal,
backgroundKeepAliveBaseUrl,
hasBackgroundWorkerUsingBaseUrl: backgroundProbe,
}) &&
backgroundKeepAliveBaseUrl
) {
stopQuotaMonitor();
log(`Keeping session proxy alive for Claude bg worker: ${backgroundKeepAliveBaseUrl}`);
startKeepAliveWatcher(backgroundKeepAliveBaseUrl);
return;
}
stopSessionResources();
if (signal) {
process.kill(process.pid, signal as NodeJS.Signals);
@@ -257,33 +323,8 @@ export function setupCleanupHandlers(
});
claude.on('error', (error) => {
stopQuotaMonitor();
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
if (
codexReasoningProxy &&
typeof codexReasoningProxy === 'object' &&
'stop' in codexReasoningProxy
) {
(codexReasoningProxy as { stop: () => void }).stop();
}
if (
toolSanitizationProxy &&
typeof toolSanitizationProxy === 'object' &&
'stop' in toolSanitizationProxy
) {
(toolSanitizationProxy as { stop: () => void }).stop();
}
if (httpsTunnel && typeof httpsTunnel === 'object' && 'stop' in httpsTunnel) {
(httpsTunnel as { stop: () => void }).stop();
}
// Unregister session, proxy keeps running (local mode only)
if (sessionId) {
unregisterSession(sessionId, sessionPort);
}
stopSessionResources();
process.exit(1);
});
+4 -2
View File
@@ -1,5 +1,6 @@
import { extractOption, hasAnyFlag, scanCommandArgs } from './arg-extractor';
import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime';
import { DEFAULT_DASHBOARD_HOST } from './config-dashboard-host';
const CONFIG_COMMAND_FLAGS = ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'] as const;
@@ -22,6 +23,7 @@ function formatUnexpectedArgsError(tokens: string[]): string {
export function parseConfigCommandArgs(args: string[]): ConfigCommandParseResult {
const options: ConfigCommandOptions = {
host: DEFAULT_DASHBOARD_HOST,
hostProvided: false,
dev: false,
};
@@ -121,7 +123,7 @@ export function showConfigCommandHelp(): void {
console.log('');
console.log('Options:');
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
console.log(' --host, -H HOST Bind dashboard server host (default: system default)');
console.log(' --host, -H HOST Bind dashboard server host (default: localhost)');
console.log(' --dev Development mode with Vite HMR');
console.log(' --help, -h Show this help message');
console.log('');
@@ -129,7 +131,7 @@ export function showConfigCommandHelp(): void {
console.log(' ccs config Auto-detect available port');
console.log(' ccs config --port 3000 Use specific port');
console.log(' ccs config --host 0.0.0.0 Force all-interface binding for remote devices');
console.log(' ccs config --host 127.0.0.1 Restrict dashboard to this machine');
console.log(' ccs config --host localhost Restrict dashboard to this machine');
console.log(' ccs config --dev Development mode with hot reload');
console.log(' ccs config auth setup Configure dashboard login');
console.log(' ccs config channels Show Official Channels status');
+1 -1
View File
@@ -176,7 +176,7 @@ export async function handleConfigCommand(
port,
dev: options.dev,
};
if (options.hostProvided && options.host) {
if (options.host) {
serverOptions.host = normalizeDashboardHost(options.host);
}
+2
View File
@@ -1,5 +1,7 @@
import * as os from 'os';
export const DEFAULT_DASHBOARD_HOST = 'localhost';
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']);
@@ -252,6 +252,65 @@ describe('resolveProfileAndTarget', () => {
expect(exitSpy).not.toHaveBeenCalled();
});
it('keeps explicit codex cliproxy profile on the Codex target', async () => {
const mockCodexAdapter = {
displayName: 'Codex CLI',
detectBinary: mock(() => ({ path: '/usr/local/bin/codex', version: '1.0.0' })),
supportsProfileType: mock(() => true),
};
mockGetTarget.mockImplementation((_name: string) => mockCodexAdapter);
mockResolveTargetType.mockImplementation((_args: string[]) => 'codex' as const);
mockStripTargetFlag.mockImplementation((args: string[]) => {
const next: string[] = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === '--target') {
index += 1;
continue;
}
next.push(args[index]);
}
return next;
});
mockDetectProfile.mockImplementation((args: string[]) => ({
profile: args[0] || 'default',
remainingArgs: args.slice(1),
}));
mockDetectProfileType.mockImplementation((profile: string) =>
profile === 'codex'
? {
type: 'cliproxy' as const,
name: 'codex',
provider: 'codex',
target: undefined,
}
: {
type: 'account' as const,
name: 'work',
target: undefined,
}
);
const result = await resolveProfileAndTarget({
args: ['codex', '--target', 'codex', 'fix failing tests'],
browserLaunchOverride: undefined,
cliLogger: stubLogger,
});
expect(result.profile).toBe('codex');
expect(result.profileInfo.type).toBe('cliproxy');
expect(result.resolvedTarget).toBe('codex');
expect(result.targetAdapter).toBe(mockCodexAdapter);
expect(result.targetRemainingArgs).toEqual(['fix failing tests']);
expect(mockEvaluateTargetRuntimeCompatibility).toHaveBeenCalledWith(
expect.objectContaining({
target: 'codex',
profileType: 'cliproxy',
cliproxyProvider: 'codex',
})
);
expect(exitSpy).not.toHaveBeenCalled();
});
it('resolves settings profile (glm) with --target droid and loads settings', async () => {
// Settings loading only runs in the non-claude preflight block (resolvedTarget !== 'claude').
const mockDroidAdapter = {
+24 -10
View File
@@ -44,6 +44,10 @@ import {
normalizeDeprecatedGlmtEnv,
} from '../../utils/glmt-deprecation';
import { createOpenAICompatLaunchSettings } from '../../utils/openai-compat-launch-settings';
import {
isClaudeSubcommandInvocation,
stripClaudeSubcommandSessionArgs,
} from '../../utils/claude-subcommand-detector';
import {
resolveDroidProvider,
evaluateTargetRuntimeCompatibility,
@@ -367,11 +371,19 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
delete proxyEnv.ANTHROPIC_API_KEY;
const launchSettings = createOpenAICompatLaunchSettings(expandedSettingsPath, settings);
const launchArgs = [
'--settings',
launchSettings.settingsPath,
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
// Claude subcommands reject `--settings` (it flips `agents` to list mode).
// Routing env vars still flow via proxyEnv. Issue #1218.
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
const subcommandArgs = isSubcommand
? stripClaudeSubcommandSessionArgs(browserArgs)
: browserArgs;
const launchArgs = isSubcommand
? appendThirdPartyWebSearchToolArgs(subcommandArgs)
: [
'--settings',
launchSettings.settingsPath,
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile.proxy',
args: launchArgs,
@@ -384,11 +396,13 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
return;
}
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(browserArgs),
];
// Skip `--settings` for Claude subcommands so the interactive agent view
// works; env vars from the settings file still flow via envVars. Issue #1218.
const isSubcommand = isClaudeSubcommandInvocation(browserArgs);
const subcommandArgs = isSubcommand ? stripClaudeSubcommandSessionArgs(browserArgs) : browserArgs;
const launchArgs = isSubcommand
? appendThirdPartyWebSearchToolArgs(subcommandArgs)
: ['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(browserArgs)];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile',
args: launchArgs,
+17 -1
View File
@@ -79,6 +79,18 @@ export interface ResolvedProfile {
detector: InstanceType<typeof ProfileDetector>;
}
function usesImplicitDefaultProfile(cleanArgs: string[]): boolean {
return cleanArgs.length === 0 || cleanArgs[0]?.startsWith('-') === true;
}
function buildNativeCodexDefaultProfile(): ProfileDetectionResult {
return {
type: 'default',
name: 'default',
message: 'Using native Codex auth; CCS default profile is not applied to Codex target.',
};
}
// ========== Profile and Target Resolver ==========
/**
@@ -112,7 +124,7 @@ export async function resolveProfileAndTarget(
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
let profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
@@ -127,6 +139,10 @@ export async function resolveProfileAndTarget(
throw error;
}
if (resolvedTarget === 'codex' && usesImplicitDefaultProfile(cleanArgs)) {
profileInfo = buildNativeCodexDefaultProfile();
}
// Detect Claude CLI (needed for claude target and all CLIProxy-derived flows)
const claudeCliRaw = detectClaudeCli();
if (resolvedTarget === 'claude' && !claudeCliRaw) {
+2 -3
View File
@@ -7,6 +7,7 @@ import * as path from 'path';
import { ok, fail, warn, info } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../config/config-loader-facade';
import { listAccountInstanceNames } from '../instance-directory';
const ora = createSpinner();
@@ -144,9 +145,7 @@ export class InstancesChecker implements IHealthChecker {
return;
}
const instances = fs.readdirSync(instancesDir).filter((name) => {
return fs.statSync(path.join(instancesDir, name)).isDirectory();
});
const instances = listAccountInstanceNames(instancesDir);
if (instances.length === 0) {
spinner.info();
+2 -3
View File
@@ -8,6 +8,7 @@ import * as os from 'os';
import { ok, fail, warn } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../config/config-loader-facade';
import { listAccountInstanceNames } from '../instance-directory';
const ora = createSpinner();
@@ -180,9 +181,7 @@ export class SettingsSymlinksChecker implements IHealthChecker {
return;
}
const instances = fs
.readdirSync(instancesDir)
.filter((n) => fs.statSync(path.join(instancesDir, n)).isDirectory());
const instances = listAccountInstanceNames(instancesDir);
const broken = instances.filter((inst) => {
const instSettings = path.join(instancesDir, inst, 'settings.json');
+6 -2
View File
@@ -5,7 +5,11 @@
import * as fs from 'fs';
import { spawn } from 'child_process';
import { getClaudeCliInfo } from '../../utils/claude-detector';
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripClaudeCodeEnv,
} from '../../utils/shell-executor';
import { ok, fail } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../config/config-loader-facade';
@@ -47,7 +51,7 @@ export class ClaudeCliChecker implements IHealthChecker {
? spawn([claudeCli, '--version'].map(escapeShellArg).join(' '), {
stdio: 'pipe',
timeout: 5000,
shell: true,
shell: getWindowsEscapedCommandShell(),
env: stripClaudeCodeEnv(process.env),
})
: spawn(claudeCli, ['--version'], {
+28
View File
@@ -0,0 +1,28 @@
import * as fs from 'fs';
import * as path from 'path';
export function isAccountInstanceName(name: string): boolean {
return !name.startsWith('.');
}
export function listAccountInstanceNames(instancesDir: string): string[] {
if (!fs.existsSync(instancesDir)) {
return [];
}
return fs.readdirSync(instancesDir).filter((name) => {
if (!isAccountInstanceName(name)) {
return false;
}
try {
return fs.statSync(path.join(instancesDir, name)).isDirectory();
} catch {
return false;
}
});
}
export function listAccountInstancePaths(instancesDir: string): string[] {
return listAccountInstanceNames(instancesDir).map((name) => path.join(instancesDir, name));
}
+2 -12
View File
@@ -15,6 +15,7 @@ import type { AccountContextPolicy } from '../auth/account-context';
import { getCcsHome } from '../utils/config-manager';
import { createLogger } from '../services/logging';
import { getCcsDir } from '../config/config-loader-facade';
import { listAccountInstanceNames } from './instance-directory';
const logger = createLogger('management:instance-manager');
@@ -189,18 +190,7 @@ class InstanceManager {
* List all instance names
*/
listInstances(): string[] {
if (!fs.existsSync(this.instancesDir)) {
return [];
}
return fs.readdirSync(this.instancesDir).filter((name) => {
if (name.startsWith('.')) {
return false;
}
const instancePath = path.join(this.instancesDir, name);
return fs.statSync(instancePath).isDirectory();
});
return listAccountInstanceNames(this.instancesDir);
}
/**
+6 -20
View File
@@ -19,6 +19,7 @@ import {
normalizePluginMetadataValue,
} from './plugin-path-normalizer';
import { getCcsDir } from '../config/config-loader-facade';
import { listAccountInstanceNames, listAccountInstancePaths } from './instance-directory';
export {
normalizePluginMetadataContent,
normalizePluginMetadataPathString,
@@ -832,16 +833,8 @@ class SharedManager {
path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'),
]);
if (fs.existsSync(this.instancesDir)) {
for (const entry of fs.readdirSync(this.instancesDir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.')) {
continue;
}
sourcePaths.add(
path.join(this.instancesDir, entry.name, 'plugins', 'known_marketplaces.json')
);
}
for (const instancePath of listAccountInstancePaths(this.instancesDir)) {
sourcePaths.add(path.join(instancePath, 'plugins', 'known_marketplaces.json'));
}
if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) {
@@ -1037,14 +1030,10 @@ class SharedManager {
// Update all instances to use new symlinks
if (fs.existsSync(this.instancesDir)) {
try {
const instances = fs.readdirSync(this.instancesDir);
for (const instance of instances) {
for (const instance of listAccountInstanceNames(this.instancesDir)) {
const instancePath = path.join(this.instancesDir, instance);
try {
if (fs.statSync(instancePath).isDirectory()) {
this.linkSharedDirectories(instancePath);
}
this.linkSharedDirectories(instancePath);
} catch (_err) {
console.log(warn(`Failed to update instance ${instance}: ${(_err as Error).message}`));
}
@@ -1081,10 +1070,7 @@ class SharedManager {
return;
}
const instances = fs.readdirSync(this.instancesDir).filter((name) => {
const instancePath = path.join(this.instancesDir, name);
return fs.statSync(instancePath).isDirectory();
});
const instances = listAccountInstanceNames(this.instancesDir);
let migrated = 0;
let skipped = 0;
+26 -3
View File
@@ -1,3 +1,4 @@
import * as fs from 'fs';
import { resolveOpenAICompatProfileConfig } from './profile-router';
import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths';
import { startOpenAICompatProxyServer } from './server/proxy-server';
@@ -9,6 +10,7 @@ interface RuntimeOptions {
profileName: string;
settingsPath: string;
authToken: string;
authTokenFile: string;
insecure: boolean;
}
@@ -18,6 +20,7 @@ function parseArgs(argv: string[]): RuntimeOptions {
let profileName = '';
let settingsPath = '';
let authToken = '';
let authTokenFile = '';
let insecure = false;
for (let i = 0; i < argv.length; i++) {
@@ -42,16 +45,36 @@ function parseArgs(argv: string[]): RuntimeOptions {
authToken = argv[++i] || '';
continue;
}
if (arg === '--auth-token-file' && argv[i + 1]) {
authTokenFile = argv[++i] || '';
continue;
}
if (arg === '--insecure') {
insecure = true;
}
}
return { port, host, profileName, settingsPath, authToken, insecure };
return { port, host, profileName, settingsPath, authToken, authTokenFile, insecure };
}
function readAuthToken(options: RuntimeOptions): string {
if (options.authTokenFile) {
try {
const token = fs.readFileSync(options.authTokenFile, 'utf8').trim();
fs.unlinkSync(options.authTokenFile);
return token;
} catch (error) {
throw new Error(`Failed to read local proxy auth token file: ${(error as Error).message}`);
}
}
return options.authToken;
}
function startRuntime(options: RuntimeOptions): void {
if (!options.authToken.trim()) {
const authToken = readAuthToken(options);
if (!authToken.trim()) {
throw new Error('Missing local proxy auth token');
}
@@ -71,7 +94,7 @@ function startRuntime(options: RuntimeOptions): void {
profile,
host: options.host,
port: options.port,
authToken: options.authToken,
authToken,
insecure: options.insecure,
});
server.once('error', (error) => {
+46 -7
View File
@@ -1,3 +1,4 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import {
@@ -20,7 +21,40 @@ export interface OpenAICompatProxySession {
}
function ensureProxyDir(): void {
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
const proxyDir = getOpenAICompatProxyDir();
fs.mkdirSync(proxyDir, { recursive: true, mode: 0o700 });
fs.chmodSync(proxyDir, 0o700);
}
function encodeProfileFileKey(profileName: string): string {
return encodeURIComponent(profileName);
}
export function writeOpenAICompatProxyAuthTokenFile(
profileName: string,
authToken: string
): string {
ensureProxyDir();
const tokenPath = path.join(
getOpenAICompatProxyDir(),
`.${encodeProfileFileKey(profileName)}.${crypto.randomBytes(8).toString('hex')}.token`
);
const fd = fs.openSync(tokenPath, 'wx', 0o600);
try {
fs.writeFileSync(fd, authToken, 'utf8');
} finally {
fs.closeSync(fd);
}
fs.chmodSync(tokenPath, 0o600);
return tokenPath;
}
export function removeOpenAICompatProxyAuthTokenFile(tokenPath: string): void {
try {
fs.unlinkSync(tokenPath);
} catch {
// Best-effort cleanup.
}
}
function readPid(pidPath: string): number | null {
@@ -43,7 +77,11 @@ export function getLegacyOpenAICompatProxyPid(): number | null {
export function writeOpenAICompatProxyPid(profileName: string, pid: number): void {
ensureProxyDir();
fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8');
fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), {
encoding: 'utf8',
mode: 0o600,
});
fs.chmodSync(getOpenAICompatProxyPidPath(profileName), 0o600);
}
export function removeOpenAICompatProxyPid(profileName: string): void {
@@ -80,11 +118,12 @@ export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession |
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
ensureProxyDir();
fs.writeFileSync(
getOpenAICompatProxySessionPath(session.profileName),
JSON.stringify(session, null, 2) + '\n',
'utf8'
);
const sessionPath = getOpenAICompatProxySessionPath(session.profileName);
fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o600,
});
fs.chmodSync(sessionPath, 0o600);
}
export function removeOpenAICompatProxySession(profileName: string): void {
+13 -6
View File
@@ -19,10 +19,12 @@ import {
readOpenAICompatProxySession,
removeLegacyOpenAICompatProxyPid,
removeLegacyOpenAICompatProxySession,
removeOpenAICompatProxyAuthTokenFile,
removeOpenAICompatProxyPid,
removeOpenAICompatProxySession,
resolveOpenAICompatProxyEntrypointCandidates,
type OpenAICompatProxySession,
writeOpenAICompatProxyAuthTokenFile,
writeOpenAICompatProxyPid,
writeOpenAICompatProxySession,
} from './proxy-daemon-state';
@@ -82,7 +84,8 @@ function generateProxyAuthToken(): string {
async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promise<T> {
const proxyDir = getOpenAICompatProxyDir();
await fs.promises.mkdir(proxyDir, { recursive: true });
await fs.promises.mkdir(proxyDir, { recursive: true, mode: 0o700 });
await fs.promises.chmod(proxyDir, 0o700);
let release: (() => Promise<void>) | undefined;
try {
@@ -543,6 +546,7 @@ export async function startOpenAICompatProxy(
let timeout: NodeJS.Timeout | null = null;
let stderr = '';
const authToken = generateProxyAuthToken();
const authTokenFile = writeOpenAICompatProxyAuthTokenFile(profile.profileName, authToken);
const commitState = () => {
if (proc.pid) {
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
@@ -563,9 +567,12 @@ export async function startOpenAICompatProxy(
if (resolved) return;
resolved = true;
if (timeout) clearTimeout(timeout);
if (!result.success && persistState) {
removeOpenAICompatProxyPid(profile.profileName);
removeOpenAICompatProxySession(profile.profileName);
if (!result.success) {
removeOpenAICompatProxyAuthTokenFile(authTokenFile);
if (persistState) {
removeOpenAICompatProxyPid(profile.profileName);
removeOpenAICompatProxySession(profile.profileName);
}
}
resolve(result);
};
@@ -582,8 +589,8 @@ export async function startOpenAICompatProxy(
profile.profileName,
'--settings-path',
profile.settingsPath,
'--auth-token',
authToken,
'--auth-token-file',
authTokenFile,
...(options.insecure ? ['--insecure'] : []),
'--ccs-openai-proxy-daemon',
],
@@ -50,6 +50,7 @@ export function createAnthropicErrorResponse(
): Response {
const responseHeaders = new Headers(headers);
responseHeaders.set('Content-Type', 'application/json');
responseHeaders.delete('Content-Encoding');
responseHeaders.delete('Content-Length');
return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), {
+25 -1
View File
@@ -24,6 +24,14 @@ import {
readCodexVersion,
} from './codex-detector';
import { createLogger } from '../services/logging';
import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager';
import { resolveLifecyclePort } from '../cliproxy/config/port-manager';
import {
CCSXP_CLIPROXY_SHORTCUT_ENV,
CODEX_CLIPROXY_PROVIDER_ENV_KEY,
ensureCodexCliproxyProviderConfig,
isCcsxpCliproxyShortcut,
} from './codex-cliproxy-provider-config';
const adapterLogger = createLogger('targets:codex');
@@ -176,13 +184,25 @@ function prepareExplicitCodexHome(
export class CodexAdapter implements TargetAdapter {
readonly type: TargetType = 'codex';
readonly displayName = 'Codex CLI';
private ccsxpCliproxyEnvKey = CODEX_CLIPROXY_PROVIDER_ENV_KEY;
detectBinary(): TargetBinaryInfo | null {
return getCodexBinaryInfo({ includeVersion: false, includeFeatures: false });
}
async prepareCredentials(_creds: TargetCredentials): Promise<void> {
// Codex uses transient -c overrides plus env_key injection.
if (!isCcsxpCliproxyShortcut()) {
return;
}
try {
const providerRepair = await ensureCodexCliproxyProviderConfig(resolveLifecyclePort());
this.ccsxpCliproxyEnvKey = providerRepair.envKey;
} catch (error) {
throw new Error(
`ccsxp could not repair the native Codex cliproxy provider: ${(error as Error).message}`
);
}
}
buildArgs(
@@ -258,7 +278,11 @@ export class CodexAdapter implements TargetAdapter {
const env: NodeJS.ProcessEnv = {
...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))),
};
delete env[CCSXP_CLIPROXY_SHORTCUT_ENV];
delete env[CODEX_RUNTIME_ENV_KEY];
if (profileType === 'default' && isCcsxpCliproxyShortcut()) {
env[this.ccsxpCliproxyEnvKey || CODEX_CLIPROXY_PROVIDER_ENV_KEY] = getEffectiveApiKey();
}
if (profileType !== 'default') {
if (!creds.apiKey?.trim()) {
throw new Error('Codex target requires an API key for CCS-backed profile launches.');
@@ -0,0 +1,174 @@
import * as os from 'os';
import * as path from 'path';
import { expandPath } from '../utils/helpers';
import {
probeTomlObjectFile,
stringifyTomlObject,
writeTomlFileAtomic,
} from '../web-server/services/compatible-cli-toml-file-service';
export const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT';
export const CODEX_CLIPROXY_PROVIDER_ID = 'cliproxy';
export const CODEX_CLIPROXY_PROVIDER_ENV_KEY = 'CLIPROXY_API_KEY';
export const CODEX_CLIPROXY_PROVIDER_NAME = 'CLIProxy Codex';
export interface CodexCliproxyProviderRepairResult {
changed: boolean;
configPath: string;
displayPath: string;
envKey: string;
}
function resolveCodexConfigPath(env: NodeJS.ProcessEnv = process.env): {
configPath: string;
displayPath: string;
} {
const baseDir = path.resolve(
env.CODEX_HOME ? expandPath(env.CODEX_HOME) : path.join(os.homedir(), '.codex')
);
const displayBase = env.CODEX_HOME ? '$CODEX_HOME' : '~/.codex';
return {
configPath: path.join(baseDir, 'config.toml'),
displayPath: `${displayBase}/config.toml`,
};
}
export function buildCodexCliproxyProviderBaseUrl(port: number): string {
return `http://127.0.0.1:${port}/api/provider/codex`;
}
export function isCcsxpCliproxyShortcut(env: NodeJS.ProcessEnv = process.env): boolean {
return env[CCSXP_CLIPROXY_SHORTCUT_ENV] === '1';
}
function asObject(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeLocalProviderUrl(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
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(/\/$/, '');
}
} catch {
return null;
}
return null;
}
function resolveProviderEnvKey(provider: Record<string, unknown> | null): string {
const envKey = provider?.env_key;
if (typeof envKey === 'string' && envKey.trim()) {
return envKey.trim();
}
return CODEX_CLIPROXY_PROVIDER_ENV_KEY;
}
function isProviderReady(
provider: Record<string, unknown>,
expectedBaseUrl: string,
envKey: string
): boolean {
return (
provider.name === CODEX_CLIPROXY_PROVIDER_NAME &&
normalizeLocalProviderUrl(provider.base_url) === expectedBaseUrl &&
provider.env_key === envKey &&
provider.wire_api === 'responses' &&
provider.requires_openai_auth === false &&
provider.supports_websockets === false
);
}
function buildProviderConfig(baseUrl: string, envKey: string): Record<string, unknown> {
return {
name: CODEX_CLIPROXY_PROVIDER_NAME,
base_url: baseUrl,
env_key: envKey,
wire_api: 'responses',
requires_openai_auth: false,
supports_websockets: false,
};
}
function buildProviderBlock(baseUrl: string): string {
return stringifyTomlObject({
model_providers: {
[CODEX_CLIPROXY_PROVIDER_ID]: buildProviderConfig(baseUrl, CODEX_CLIPROXY_PROVIDER_ENV_KEY),
},
});
}
function appendProviderBlock(rawText: string, baseUrl: string): string {
const prefix = rawText.trimEnd();
const providerBlock = buildProviderBlock(baseUrl).trimEnd();
return prefix ? `${prefix}\n\n${providerBlock}\n` : `${providerBlock}\n`;
}
export async function ensureCodexCliproxyProviderConfig(
port: number,
env: NodeJS.ProcessEnv = process.env
): Promise<CodexCliproxyProviderRepairResult> {
const { configPath, displayPath } = resolveCodexConfigPath(env);
const fileProbe = await probeTomlObjectFile(configPath, 'Codex user config', displayPath);
if (fileProbe.diagnostics.readError) {
throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`);
}
if (fileProbe.diagnostics.parseError) {
throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`);
}
const config = fileProbe.config ?? {};
const modelProvidersValue = config.model_providers;
const providers = asObject(modelProvidersValue);
const expectedBaseUrl = buildCodexCliproxyProviderBaseUrl(port);
if (modelProvidersValue !== undefined && !providers) {
throw new Error(`Cannot repair ${displayPath}: [model_providers] must be a table.`);
}
if (!providers || !Object.prototype.hasOwnProperty.call(providers, CODEX_CLIPROXY_PROVIDER_ID)) {
await writeTomlFileAtomic({
filePath: configPath,
rawText: appendProviderBlock(fileProbe.rawText, expectedBaseUrl),
expectedMtime: fileProbe.diagnostics.mtimeMs ?? undefined,
fileLabel: 'config.toml',
});
return { changed: true, configPath, displayPath, envKey: CODEX_CLIPROXY_PROVIDER_ENV_KEY };
}
const currentProvider = asObject(providers[CODEX_CLIPROXY_PROVIDER_ID]);
if (!currentProvider) {
throw new Error(
`Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.`
);
}
const envKey = resolveProviderEnvKey(currentProvider);
if (isProviderReady(currentProvider, expectedBaseUrl, envKey)) {
return { changed: false, configPath, displayPath, envKey };
}
providers[CODEX_CLIPROXY_PROVIDER_ID] = {
...currentProvider,
...buildProviderConfig(expectedBaseUrl, envKey),
};
await writeTomlFileAtomic({
filePath: configPath,
rawText: stringifyTomlObject(config),
expectedMtime: fileProbe.diagnostics.mtimeMs ?? undefined,
fileLabel: 'config.toml',
});
return { changed: true, configPath, displayPath, envKey };
}
+1 -1
View File
@@ -46,7 +46,7 @@ export function evaluateTargetRuntimeCompatibility(
if (input.profileType === 'account') {
return unsupported(
'Codex CLI does not support Claude account-based profiles.',
'Use native Codex auth with: ccs --target codex'
'Native Codex: ccs --target codex. CLIProxy Codex pool: ccs codex --target codex or ccsxp. Manage pool/routing with: ccs codex --auth and ccs cliproxy routing.'
);
}
+3
View File
@@ -2,6 +2,7 @@ import {
hasExactFlagValue as hasExactClaudeFlagValue,
splitArgsAtTerminator as splitClaudeArgsAtTerminator,
} from '../claude-tool-args';
import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const BROWSER_STEERING_PROMPT =
@@ -18,5 +19,7 @@ function ensureBrowserSteeringPrompt(args: string[]): string[] {
}
export function appendBrowserToolArgs(args: string[]): string[] {
// Subcommands reject `--append-system-prompt`. Issue #1218.
if (isClaudeSubcommandInvocation(args)) return args;
return ensureBrowserSteeringPrompt(args);
}
+8 -2
View File
@@ -2,7 +2,12 @@ import * as fs from 'fs';
import { execFileSync, execSync } from 'child_process';
import { expandPath } from './helpers';
import type { ClaudeCliInfo } from '../types';
import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from './shell-executor';
import {
escapeShellArg,
getWindowsEscapedCommandShell,
stripAnthropicEnv,
stripClaudeCodeEnv,
} from './shell-executor';
export interface ClaudeAuthStatus {
loggedIn: boolean;
@@ -164,11 +169,12 @@ function runClaudeCliCommand(args: string[], envOverrides?: NodeJS.ProcessEnv):
}
if (needsShell) {
const shell = getWindowsEscapedCommandShell();
return execSync([claudePath, ...args].map(escapeShellArg).join(' '), {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 5000,
shell: process.env.ComSpec || 'cmd.exe',
shell: typeof shell === 'string' ? shell : undefined,
env,
}).trim();
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Claude subcommand detection.
*
* Claude Code's CLI accepts both an interactive session form (`claude [prompt]`,
* possibly with `--print`) and explicit subcommands (`claude agents`,
* `claude doctor`, `claude mcp`, ...). Subcommand parsers reject most top-level
* session flags e.g. `claude agents --append-system-prompt foo` exits with
* `error: unknown option '--append-system-prompt'`.
*
* CCS injects WebSearch / image-analysis / browser steering args
* (`--append-system-prompt`, `--disallowedTools`) for interactive sessions.
* Those flags must be skipped when the user is actually invoking a Claude
* subcommand, otherwise the subcommand fails or falls back to non-interactive
* mode (e.g. `claude agents` printing the list instead of opening the agent
* view see issue #1218).
*
* Detection walks args left-to-right, skipping known value-taking top-level
* flags, and reports whether the first positional token matches a known
* Claude subcommand.
*/
/**
* Known Claude CLI subcommands. Sourced from `claude --help` (v2.1.139).
* Keep in sync with upstream additions are safe (over-skipping injection
* for an unknown command is acceptable; under-skipping is the bug).
*/
const CLAUDE_SUBCOMMANDS = new Set<string>([
'agents',
'auth',
'auto-mode',
'doctor',
'install',
'mcp',
'plugin',
'plugins',
'project',
'setup-token',
'ultrareview',
'update',
'upgrade',
]);
/**
* Top-level Claude flags that consume the next argv token as their value.
* Used so the detector doesn't mistake a flag value (e.g. `--name auth`) for
* a subcommand. Boolean flags are intentionally absent.
*
* Variadic flags (`--add-dir`, `--mcp-config`, etc.) only consume their
* immediate next token here; Commander.js' real variadic parsing isn't worth
* replicating since the goal is just to skip past one obvious value safely.
*/
const VALUE_TAKING_FLAGS = new Set<string>([
'--add-dir',
'--agent',
'--agents',
'--allowedTools',
'--allowed-tools',
'--append-system-prompt',
'--betas',
'--channels',
'--debug-file',
'--disallowedTools',
'--disallowed-tools',
'--effort',
'--fallback-model',
'--file',
'--input-format',
'--json-schema',
'--max-budget-usd',
'--mcp-config',
'--model',
'--name',
'-n',
'--output-format',
'--permission-mode',
'--plugin-dir',
'--plugin-url',
'--remote-control-session-name-prefix',
'--session-id',
'--setting-sources',
'--settings',
'--system-prompt',
'--teammate-mode',
'--tools',
]);
const SUBCOMMAND_SESSION_ONLY_FLAGS = new Set<string>([
'--allow-dangerously-skip-permissions',
'--dangerously-skip-permissions',
]);
const SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS = new Set<string>([
'--permission-mode',
'--teammate-mode',
]);
/**
* Returns true when `args` look like a Claude subcommand invocation.
*
* Heuristic:
* 1. Walk args until the `--` terminator or end.
* 2. Skip known value-taking flags together with their next token.
* 3. Skip unknown `--flag=value` forms and bare `--flag` / `-x` tokens.
* 4. The first remaining positional token is the candidate.
* 5. Match against CLAUDE_SUBCOMMANDS.
*
* Anything after the candidate is irrelevant once a subcommand is in play,
* the rest of the line belongs to that subcommand.
*/
export function isClaudeSubcommandInvocation(args: readonly string[]): boolean {
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === '--') return false;
if (arg.startsWith('-')) {
if (VALUE_TAKING_FLAGS.has(arg)) {
// Skip the next token as the flag's value (when present and not another flag).
const next = args[i + 1];
if (next !== undefined && !next.startsWith('-')) {
i += 1;
}
}
// `--flag=value`, bare boolean flags, and unknown short/long flags fall through.
continue;
}
return CLAUDE_SUBCOMMANDS.has(arg);
}
return false;
}
export function stripClaudeSubcommandSessionArgs(args: readonly string[]): string[] {
if (!isClaudeSubcommandInvocation(args)) {
return [...args];
}
const out: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (SUBCOMMAND_SESSION_ONLY_FLAGS.has(arg)) {
continue;
}
if (arg.startsWith('--permission-mode=') || arg.startsWith('--teammate-mode=')) {
continue;
}
if (SUBCOMMAND_SESSION_ONLY_VALUE_FLAGS.has(arg)) {
const next = args[i + 1];
if (next !== undefined && !next.startsWith('-')) {
i += 1;
}
continue;
}
out.push(arg);
}
return out;
}
/**
* Claude Code treats DISABLE_TELEMETRY as a feature kill switch for agent-view
* and background-session surfaces, not only as telemetry preference.
*/
const CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS = ['DISABLE_TELEMETRY'] as const;
export function stripClaudeCodeFeatureBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const out: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(env)) {
if (
CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS.includes(
key as (typeof CLAUDE_CODE_FEATURE_BLOCKING_ENV_KEYS)[number]
)
) {
continue;
}
out[key] = value;
}
return out;
}
/**
* Return a shallow copy of `env` with subcommand-blocking telemetry vars
* removed. Caller is responsible for only invoking this when args are a
* Claude subcommand invocation.
*/
export function stripSubcommandBlockingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return stripClaudeCodeFeatureBlockingEnv(env);
}
@@ -14,6 +14,7 @@ import {
hasManagedPromptFileArg,
PROMPT_FLAG_INLINE,
} from '../prompt-injection-strategy';
import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector';
const IMAGE_ANALYSIS_STEERING_PROMPT = {
name: 'ccs-prompt-image-analysis-tool',
@@ -46,6 +47,8 @@ function ensureImageAnalysisSteeringPrompt(args: string[]): string[] {
}
export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] {
// Subcommands reject session-only prompt flags. Issue #1218.
if (isClaudeSubcommandInvocation(args)) return args;
return ensureImageAnalysisSteeringPrompt(args);
}
+48 -7
View File
@@ -5,9 +5,16 @@
*/
import { spawn, spawnSync, ChildProcess, type SpawnOptions } from 'child_process';
import * as path from 'path';
import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager';
import { wireChildProcessSignals } from './signal-forwarder';
import {
isClaudeSubcommandInvocation,
stripClaudeCodeFeatureBlockingEnv,
stripClaudeSubcommandSessionArgs,
stripSubcommandBlockingEnv,
} from './claude-subcommand-detector';
import SharedManager from '../management/shared-manager';
import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade';
@@ -49,6 +56,7 @@ const TMUX_SYNC_ENV_KEYS = [
...ANTHROPIC_MODEL_ENV_KEYS,
...ANTHROPIC_ROUTING_ENV_KEYS,
];
const DEFAULT_WINDOWS_CMD_SHELL = 'C:\\Windows\\System32\\cmd.exe';
/**
* Strip inherited Anthropic routing/auth env while preserving model intent.
@@ -210,15 +218,36 @@ export function escapeShellArg(arg: string): string {
/**
* Return the shell that matches escapeShellArg() quoting semantics.
*
* On Windows, prefer ComSpec over a bare `cmd.exe` so escaped wrapper launches
* keep the same shell contract without depending on PATH lookup.
* On Windows, use an absolute, trusted system cmd.exe path instead of a bare
* executable name so wrapper launches cannot be hijacked through the current
* directory or PATH. ComSpec is accepted only when it resolves to that same
* system shell.
*/
export function getWindowsEscapedCommandShell(): SpawnOptions['shell'] {
if (process.platform !== 'win32') {
return true;
}
return process.env.ComSpec || process.env.COMSPEC || 'cmd.exe';
const systemRoot = [process.env.SystemRoot, process.env.SYSTEMROOT, process.env.windir].find(
(candidate): candidate is string => Boolean(candidate && path.win32.isAbsolute(candidate))
);
const trustedSystemCmd = systemRoot
? path.win32.normalize(path.win32.join(systemRoot, 'System32', 'cmd.exe'))
: DEFAULT_WINDOWS_CMD_SHELL;
const trustedSystemCmdLower = trustedSystemCmd.toLowerCase();
for (const candidate of [process.env.ComSpec, process.env.COMSPEC]) {
if (!candidate || !path.win32.isAbsolute(candidate)) {
continue;
}
const normalizedCandidate = path.win32.normalize(candidate);
if (normalizedCandidate.toLowerCase() === trustedSystemCmdLower) {
return normalizedCandidate;
}
}
return trustedSystemCmd;
}
/**
@@ -261,9 +290,21 @@ export function execClaude(
? stripAnthropicRoutingEnv(mergedEnv, envVars ?? undefined)
: mergedEnv;
const effectiveArgs = isClaudeSubcommandInvocation(args)
? stripClaudeSubcommandSessionArgs(args)
: args;
// Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const env = stripClaudeCodeEnv(effectiveMergedEnv);
let env = stripClaudeCodeFeatureBlockingEnv(stripClaudeCodeEnv(effectiveMergedEnv));
// For Claude subcommand invocations (`agents`, `mcp`, `doctor`, ...) strip
// telemetry-disable env vars that cause upstream Claude Code to fall back
// to non-interactive list mode instead of opening the subcommand TUI.
// Issue #1218.
if (isClaudeSubcommandInvocation(effectiveArgs)) {
env = stripSubcommandBlockingEnv(env);
}
if (profileType !== 'account') {
try {
@@ -281,7 +322,7 @@ export function execClaude(
if (isPowerShellScript) {
child = spawn(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...args],
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', claudeCli, ...effectiveArgs],
{
stdio: 'inherit',
windowsHide: true,
@@ -290,7 +331,7 @@ export function execClaude(
);
} else if (needsShell) {
// When shell needed: concatenate into string to avoid DEP0190 warning
const cmdString = [claudeCli, ...args].map(escapeShellArg).join(' ');
const cmdString = [claudeCli, ...effectiveArgs].map(escapeShellArg).join(' ');
child = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
@@ -299,7 +340,7 @@ export function execClaude(
});
} else {
// When no shell needed: use array form (faster, no shell overhead)
child = spawn(claudeCli, args, {
child = spawn(claudeCli, effectiveArgs, {
stdio: 'inherit',
windowsHide: true,
env,
+4
View File
@@ -15,6 +15,7 @@ import {
hasManagedPromptFileArg,
PROMPT_FLAG_INLINE,
} from '../prompt-injection-strategy';
import { isClaudeSubcommandInvocation } from '../claude-subcommand-detector';
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
@@ -135,5 +136,8 @@ function ensureWebSearchSteeringPrompt(args: string[]): string[] {
}
export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] {
// Claude subcommands (agents, doctor, mcp, ...) reject top-level session flags
// like `--append-system-prompt` and `--disallowedTools`. Issue #1218.
if (isClaudeSubcommandInvocation(args)) return args;
return ensureWebSearchSteeringPrompt(ensureDisallowedNativeWebSearchTool(args));
}
+2 -3
View File
@@ -10,6 +10,7 @@ import * as path from 'path';
import type { HealthCheck } from './types';
import { isUnifiedMode, loadUnifiedConfig } from '../../config/config-loader-facade';
import { listAccountInstanceNames } from '../../management/instance-directory';
/**
* Check profiles configuration (API profiles from config.json or config.yaml)
@@ -108,9 +109,7 @@ export function checkInstances(ccsDir: string): HealthCheck {
};
}
const instances = fs.readdirSync(instancesDir).filter((name) => {
return fs.statSync(path.join(instancesDir, name)).isDirectory();
});
const instances = listAccountInstanceNames(instancesDir);
if (instances.length === 0) {
return {
+2 -3
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
import type { HealthCheck } from './types';
import { listAccountInstanceNames } from '../../management/instance-directory';
/**
* Check CCS symlinks health
@@ -100,9 +101,7 @@ export function checkSettingsSymlinks(ccsDir: string, claudeDir: string): Health
};
}
const instances = fs.readdirSync(instancesDir).filter((name) => {
return fs.statSync(`${instancesDir}/${name}`).isDirectory();
});
const instances = listAccountInstanceNames(instancesDir);
let broken = 0;
for (const instance of instances) {
+117 -23
View File
@@ -8,16 +8,23 @@
import express from 'express';
import http from 'http';
import type { AddressInfo } from 'net';
import path from 'path';
import { WebSocketServer } from 'ws';
import { setupWebSocket } from './websocket';
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
import {
authMiddleware,
createSessionMiddleware,
getDashboardWebSocketRejectionStatus,
isDashboardWebSocketUpgradeAllowed,
} from './middleware/auth-middleware';
import { requestLoggingMiddleware } from './middleware/request-logging-middleware';
import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes';
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
import { shutdownUsageAggregator } from './usage/aggregator';
import { createLogger } from '../services/logging';
import { DEFAULT_DASHBOARD_HOST, isLoopbackHost } from '../commands/config-dashboard-host';
export interface ServerOptions {
port: number;
@@ -32,6 +39,10 @@ export interface ServerInstance {
cleanup: () => void;
}
function getListenHost(options: ServerOptions): string {
return options.host || DEFAULT_DASHBOARD_HOST;
}
const logger = createLogger('web-server');
/**
@@ -41,8 +52,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({
server,
path: '/ws',
noServer: true,
maxPayload: 1024 * 1024, // 1MB hard limit to prevent DoS
perMessageDeflate: false, // Prevent zip bomb attacks
});
@@ -66,7 +76,8 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
app.use(requestLoggingMiddleware);
// Session middleware (for dashboard auth)
app.use(createSessionMiddleware());
const sessionMiddleware = createSessionMiddleware();
app.use(sessionMiddleware);
// Auth middleware (protects API routes when enabled)
app.use(authMiddleware);
@@ -115,6 +126,46 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
});
}
server.on('upgrade', (request, socket, head) => {
const pathname = getUpgradePathname(request.url);
if (!pathname) {
rejectWebSocketUpgrade(socket, 400, 'Invalid WebSocket upgrade request');
return;
}
if (pathname !== '/ws') {
if (!options.dev) {
rejectWebSocketUpgrade(socket, 404, 'WebSocket endpoint not found');
}
return;
}
const response = new http.ServerResponse(request);
sessionMiddleware(
request as express.Request,
response as express.Response,
(error?: unknown) => {
if (error) {
rejectWebSocketUpgrade(socket, 500, 'WebSocket session validation failed');
return;
}
if (!isDashboardWebSocketUpgradeAllowed(request)) {
rejectWebSocketUpgrade(
socket,
getDashboardWebSocketRejectionStatus(request),
'WebSocket access denied'
);
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
}
);
});
// WebSocket connection handler + file watcher
const { cleanup: wsCleanup } = setupWebSocket(wss);
@@ -138,11 +189,12 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start listening
return new Promise<ServerInstance>((resolve, reject) => {
const listenHost = getListenHost(options);
const onError = (error: NodeJS.ErrnoException) => {
logger.error('server.listen_failed', 'Dashboard server failed to start', {
code: error.code || 'unknown',
message: error.message,
host: options.host || null,
host: listenHost,
port: options.port,
});
cleanup();
@@ -153,8 +205,18 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const onListening = () => {
server.off('error', onError);
try {
assertSafeDashboardBind(options, server.address());
} catch (error) {
cleanup();
server.close(() => {
reject(error instanceof Error ? error : new Error(String(error)));
});
return;
}
logger.info('server.listening', 'Dashboard server listening', {
host: options.host || '0.0.0.0',
host: listenHost,
port: options.port,
dev: Boolean(options.dev),
});
@@ -164,12 +226,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
};
try {
if (options.host) {
server.listen(options.port, options.host, onListening);
return;
}
server.listen(options.port, onListening);
server.listen(options.port, listenHost, onListening);
} catch (error) {
server.off('error', onError);
cleanup();
@@ -178,26 +235,63 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
});
}
function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string {
if (error.code === 'EADDRINUSE' && options.host) {
return `Unable to bind ${options.host}:${options.port}; the address may be unavailable or the port may already be in use`;
function getUpgradePathname(requestUrl: string | undefined): string | null {
try {
return new URL(requestUrl ?? '/', 'http://localhost').pathname;
} catch {
return null;
}
}
function rejectWebSocketUpgrade(
socket: NodeJS.WritableStream & { destroy: () => void },
statusCode: 400 | 401 | 403 | 404 | 500,
message: string
): void {
socket.write(
`HTTP/1.1 ${statusCode} ${message}\r\n` +
'Connection: close\r\n' +
'Content-Type: text/plain; charset=utf-8\r\n' +
`Content-Length: ${Buffer.byteLength(message)}\r\n` +
'\r\n' +
message
);
socket.destroy();
}
function assertSafeDashboardBind(
options: ServerOptions,
address: string | AddressInfo | null
): void {
const listenHost = getListenHost(options);
if (!isLoopbackHost(listenHost) || typeof address === 'string' || !address) {
return;
}
if (isLoopbackHost(address.address)) {
return;
}
throw new Error(
`Dashboard host ${listenHost} resolved to non-loopback address ${address.address}; pass --host explicitly to allow network exposure.`
);
}
function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string {
const listenHost = getListenHost(options);
if (error.code === 'EADDRINUSE') {
return `Port ${options.port} is already in use`;
return `Unable to bind ${listenHost}:${options.port}; the address may be unavailable or the port may already be in use`;
}
if (error.code === 'EADDRNOTAVAIL' && options.host) {
return `Cannot bind to ${options.host}:${options.port} on this machine`;
if (error.code === 'EADDRNOTAVAIL') {
return `Cannot bind to ${listenHost}:${options.port} on this machine`;
}
if (error.code === 'EACCES') {
return `Permission denied while binding to port ${options.port}`;
}
if (options.host) {
return `Cannot bind to ${options.host}:${options.port}: ${error.message}`;
}
return error.message;
return `Cannot bind to ${listenHost}:${options.port}: ${error.message}`;
}
@@ -3,6 +3,7 @@
* Session-based auth with httpOnly cookies for CCS dashboard.
*/
import type { IncomingMessage } from 'http';
import type { NextFunction, Request, Response } from 'express';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
@@ -151,6 +152,90 @@ export function isLoopbackRemoteAddress(value: string | undefined): boolean {
);
}
function isLoopbackHostname(value: string | undefined): boolean {
if (!value) return false;
const normalized = value
.trim()
.toLowerCase()
.replace(/^\[|\]$/g, '');
return (
normalized === 'localhost' ||
normalized.endsWith('.localhost') ||
isLoopbackRemoteAddress(normalized)
);
}
function getSingleHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function parseHostHeader(value: string | undefined): URL | null {
if (!value) return null;
try {
return new URL(`http://${value}`);
} catch {
return null;
}
}
function isHttpOrigin(origin: URL): boolean {
return origin.protocol === 'http:' || origin.protocol === 'https:';
}
export function isDashboardWebSocketOriginAllowed(req: IncomingMessage): boolean {
const originHeader = getSingleHeader(req.headers.origin);
if (!originHeader) return true;
let origin: URL;
try {
origin = new URL(originHeader);
} catch {
return false;
}
if (!isHttpOrigin(origin)) {
return false;
}
const host = parseHostHeader(getSingleHeader(req.headers.host));
if (!host) {
return false;
}
if (origin.host.toLowerCase() === host.host.toLowerCase()) {
return true;
}
return (
isLoopbackHostname(origin.hostname) &&
isLoopbackHostname(host.hostname) &&
origin.port === host.port
);
}
export function isDashboardWebSocketUpgradeAllowed(req: IncomingMessage): boolean {
if (!isDashboardWebSocketOriginAllowed(req)) {
return false;
}
if (!isDashboardAuthEnabled()) {
return isLoopbackRemoteAddress(req.socket.remoteAddress);
}
return Boolean((req as Request).session?.authenticated);
}
export function getDashboardWebSocketRejectionStatus(req?: IncomingMessage): 401 | 403 {
if (req && !isDashboardWebSocketOriginAllowed(req)) {
return 403;
}
if (!isDashboardAuthEnabled()) return 403;
return 401;
}
export function requireLocalAccessWhenAuthDisabled(
req: Request,
res: Response,
+34 -9
View File
@@ -74,6 +74,7 @@ import {
getPlusOAuthCredentialError,
getPlusAuthUrlCredentialError,
} from '../../cliproxy/auth/oauth-handler';
import { buildOAuthStartFailureGuidance } from '../../cliproxy/auth/oauth-start-failure-guidance';
import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager';
const router = Router();
@@ -1065,6 +1066,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
let startPath: string | null = null;
try {
const authUrlProvider =
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
@@ -1077,20 +1079,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
provider === 'gitlab' && gitlabBaseUrl
? `&base_url=${encodeURIComponent(gitlabBaseUrl)}`
: '';
startPath = `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`;
// Call CLIProxyAPI to start OAuth and get auth URL
// CLIProxyAPI management routes are under /v0/management prefix
const response = await fetch(
buildProxyUrl(
target,
`/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`
),
{ headers: buildManagementHeaders(target) }
);
const response = await fetch(buildProxyUrl(target, startPath), {
headers: buildManagementHeaders(target),
});
if (!response.ok) {
const error = await response.text();
res.status(response.status).json({ error: error || 'Failed to start OAuth' });
const guidance = buildOAuthStartFailureGuidance(provider as CLIProxyProvider, {
target,
startPath,
cause: error || `HTTP ${response.status}`,
});
res.status(response.status).json(guidance);
return;
}
@@ -1144,7 +1148,28 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
method: data.method || null,
});
} catch (error) {
respondInternalError(res, error, 'CLIProxyAPI not reachable.', 503);
if (error instanceof SyntaxError) {
console.error(
`[cliproxy-auth-routes] Invalid OAuth start response for provider=${provider}: ${error.message}`
);
res.status(502).json({
error: 'cliproxy_oauth_start_invalid_response',
provider,
message: 'CLIProxyAPI returned an invalid OAuth start response.',
details: error.message,
});
return;
}
const authUrlProvider =
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
const guidance = buildOAuthStartFailureGuidance(provider as CLIProxyProvider, {
target,
startPath: startPath ?? `/v0/management/${authUrlProvider}-auth-url?is_webui=true`,
cause: error,
});
console.error(`[cliproxy-auth-routes] ${guidance.message} ${guidance.details}`);
res.status(503).json(guidance);
}
});
+9
View File
@@ -1,5 +1,6 @@
import type { Request, Response } from 'express';
import { Router } from 'express';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import {
CodexRawConfigConflictError,
CodexRawConfigValidationError,
@@ -10,6 +11,14 @@ import {
} from '../services/codex-dashboard-service';
const router = Router();
const CODEX_CONFIG_ACCESS_ERROR =
'Codex configuration endpoints require localhost access when dashboard auth is disabled.';
router.use('/config', (req: Request, res: Response, next) => {
if (requireLocalAccessWhenAuthDisabled(req, res, CODEX_CONFIG_ACCESS_ERROR)) {
next();
}
});
router.get('/diagnostics', async (_req: Request, res: Response): Promise<void> => {
try {
+107 -14
View File
@@ -63,13 +63,90 @@ const { logRouteError, respondInternalError } = createRouteErrorHelpers('setting
function resolvePathWithin(basePath: string, targetPath: string): string {
const resolvedBase = path.resolve(basePath);
const resolvedTarget = path.resolve(targetPath);
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)) {
const resolvedTarget = path.resolve(
path.isAbsolute(targetPath) ? targetPath : path.join(resolvedBase, targetPath)
);
if (!isPathWithin(resolvedBase, resolvedTarget)) {
throw new Error('Invalid settings path');
}
return resolvedTarget;
}
function normalizePathForComparison(targetPath: string): string {
const resolvedPath = path.resolve(targetPath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
function isPathWithin(basePath: string, targetPath: string): boolean {
const normalizedBase = normalizePathForComparison(basePath);
const normalizedTarget = normalizePathForComparison(targetPath);
const relative = path.relative(normalizedBase, normalizedTarget);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function safeRealPath(targetPath: string): string | null {
try {
return fs.realpathSync.native(targetPath);
} catch {
return null;
}
}
function getRealCcsDir(): string {
return safeRealPath(getCcsDir()) ?? path.resolve(getCcsDir());
}
function requirePathWithinRealCcsDir(targetPath: string): void {
if (!isPathWithin(getRealCcsDir(), targetPath)) {
throw new Error('Invalid settings path');
}
}
function requireExistingSettingsPathWithinCcs(settingsPath: string): boolean {
const realSettingsPath = safeRealPath(settingsPath);
if (!realSettingsPath) {
return false;
}
requirePathWithinRealCcsDir(realSettingsPath);
return true;
}
function findExistingParentRealPath(targetPath: string): string | null {
let currentPath = path.dirname(targetPath);
while (true) {
const realParentPath = safeRealPath(currentPath);
if (realParentPath) {
return realParentPath;
}
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
return null;
}
currentPath = parentPath;
}
}
function requireWritableSettingsPathWithinCcs(settingsPath: string): void {
const realSettingsPath = safeRealPath(settingsPath);
if (realSettingsPath) {
requirePathWithinRealCcsDir(realSettingsPath);
return;
}
const realParentPath = findExistingParentRealPath(settingsPath);
if (!realParentPath) {
throw new Error('Invalid settings path');
}
requirePathWithinRealCcsDir(realParentPath);
}
function resolveSettingsCandidatePath(ccsDir: string, settingsPath: string): string {
return resolvePathWithin(ccsDir, expandPath(settingsPath));
}
function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
return requireLocalAccessWhenAuthDisabled(
req,
@@ -114,24 +191,25 @@ function resolveSettingsPath(profileOrVariant: string): string {
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
);
}
return path.resolve(resolveProviderSettingsPath(directProvider));
return resolvePathWithin(resolvedCcsDir, resolveProviderSettingsPath(directProvider));
}
// Check if this is a variant
const variants = listVariants();
const variant = variants[profileOrVariant];
if (variant?.settings) {
return path.resolve(expandPath(variant.settings));
return resolveSettingsCandidatePath(resolvedCcsDir, variant.settings);
}
let configuredSettingsPath: string | undefined;
try {
const configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant];
if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) {
return path.resolve(expandPath(configuredSettingsPath));
}
configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant];
} catch {
// Fall back to the conventional ~/.ccs/<profile>.settings.json path below.
}
if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) {
return resolveSettingsCandidatePath(resolvedCcsDir, configuredSettingsPath);
}
// Regular profile settings
return resolvePathWithin(
@@ -328,13 +406,21 @@ async function resolvePreviewImageAnalysisStatus(profileOrVariant: string, setti
}
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
requireWritableSettingsPathWithinCcs(settingsPath);
const tempPath = `${settingsPath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
requireWritableSettingsPathWithinCcs(tempPath);
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', { flag: 'wx' });
fs.renameSync(tempPath, settingsPath);
}
function withSettingsFileLock<T>(settingsPath: string, callback: () => T): T {
const lockTarget = fs.existsSync(settingsPath) ? settingsPath : path.dirname(settingsPath);
requireWritableSettingsPathWithinCcs(settingsPath);
const lockTarget = safeRealPath(settingsPath)
? settingsPath
: findExistingParentRealPath(settingsPath);
if (!lockTarget) {
throw new Error('Invalid settings path');
}
let release: (() => void) | undefined;
try {
@@ -357,6 +443,10 @@ function loadCanonicalProfileSettings(
persist = false,
strictPersist = false
): Settings {
if (!requireExistingSettingsPathWithinCcs(settingsPath)) {
throw new Error('Settings not found');
}
const loaded = loadSettings(settingsPath);
const canonicalized = canonicalizeProfileSettings(profileOrVariant, loaded);
@@ -399,7 +489,7 @@ router.get('/:profile', async (req: Request, res: Response): Promise<void> => {
const { profile } = req.params;
const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
if (!requireExistingSettingsPathWithinCcs(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
@@ -435,7 +525,7 @@ router.get('/:profile/raw', async (req: Request, res: Response): Promise<void> =
const { profile } = req.params;
const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
if (!requireExistingSettingsPathWithinCcs(settingsPath)) {
res.status(404).json({ error: 'Settings not found' });
return;
}
@@ -556,11 +646,13 @@ router.put('/:profile', (req: Request, res: Response): void => {
const existingContent = fs.readFileSync(settingsPath, 'utf8');
if (existingContent !== newContent) {
const backupDir = path.join(ccsDir, 'backups');
requireWritableSettingsPathWithinCcs(path.join(backupDir, '.settings-backup-probe'));
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
requireWritableSettingsPathWithinCcs(backupPath);
fs.copyFileSync(settingsPath, backupPath);
}
} else {
@@ -569,7 +661,8 @@ router.put('/:profile', (req: Request, res: Response): void => {
}
const tempPath = `${settingsPath}.tmp.${process.pid}`;
fs.writeFileSync(tempPath, newContent);
requireWritableSettingsPathWithinCcs(tempPath);
fs.writeFileSync(tempPath, newContent, { flag: 'wx' });
fs.renameSync(tempPath, settingsPath);
newMtime = fs.statSync(settingsPath).mtime.getTime();
});
@@ -604,7 +697,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
const { profile } = req.params;
const settingsPath = resolveSettingsPath(profile);
if (!fs.existsSync(settingsPath)) {
if (!requireExistingSettingsPathWithinCcs(settingsPath)) {
res.json({ presets: [] });
return;
}
+6 -9
View File
@@ -41,6 +41,7 @@ import {
getProviderModelKey,
} from './model-identity';
import { getCcsDir } from '../../config/config-loader-facade';
import { listAccountInstancePaths } from '../../management/instance-directory';
// ============================================================================
// Multi-Instance Support - Aggregate usage from CCS profiles
@@ -77,15 +78,11 @@ function getInstancePaths(): string[] {
}
try {
const entries = fs.readdirSync(instancesDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(instancesDir, entry.name))
.filter((instancePath) => {
// Only include instances that have a projects directory
const projectsPath = path.join(instancePath, 'projects');
return fs.existsSync(projectsPath);
});
return listAccountInstancePaths(instancesDir).filter((instancePath) => {
// Only include instances that have a projects directory
const projectsPath = path.join(instancePath, 'projects');
return fs.existsSync(projectsPath);
});
} catch {
console.error(fail('Failed to read CCS instances directory'));
return [];
@@ -169,6 +169,7 @@ function invokeHook(
input: JSON.stringify(input),
encoding: 'utf8',
env: buildHookEnv(env),
cwd: TEST_DIR,
timeout: 10000, // 10 second timeout per test
});
@@ -187,6 +188,7 @@ function invokeHookAsync(
const child = spawn('node', [HOOK_PATH], {
env: buildHookEnv(env),
stdio: ['pipe', 'pipe', 'pipe'],
cwd: TEST_DIR,
});
let stdout = '';
@@ -531,6 +533,7 @@ describe('Image Analyzer Hook', () => {
input: 'not valid json',
encoding: 'utf8',
timeout: 5000,
cwd: TEST_DIR,
env: {
...process.env,
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
+54 -2
View File
@@ -47,7 +47,10 @@ function enqueueResponses(...responses: MockResponse[]): void {
queuedResponses = responses;
}
function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
function invokeHook(
env: Record<string, string> = {},
options: { filePath?: string; cwd?: string } = {}
): Promise<HookResult> {
return new Promise((resolve, reject) => {
const child = spawn('node', [HOOK_PATH], {
env: {
@@ -68,6 +71,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
...env,
},
stdio: ['pipe', 'pipe', 'pipe'],
cwd: options.cwd ?? TEST_DIR,
});
let stdout = '';
@@ -98,7 +102,7 @@ function invokeHook(env: Record<string, string> = {}): Promise<HookResult> {
child.stdin.end(
JSON.stringify({
tool_name: 'Read',
tool_input: { file_path: TEST_PNG_PATH },
tool_input: { file_path: options.filePath ?? TEST_PNG_PATH },
})
);
});
@@ -198,6 +202,54 @@ describe('image analyzer hook regression coverage', () => {
expect((requests[0].body as { model: string }).model).toBe('gpt-5.1-codex-mini');
});
it('does not analyze files outside the current workspace', async () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-outside-'));
const outsidePath = path.join(outsideDir, 'sensitive-outside-workspace.png');
createTestPng(outsidePath);
try {
const result = await invokeHook({}, { filePath: outsidePath });
expect(result.code).toBe(0);
expect(requests).toHaveLength(0);
} finally {
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
it.if(process.platform === 'win32')(
'analyzes Windows-style relative paths inside the current workspace',
async () => {
const nestedDir = path.join(TEST_DIR, 'windows-style-fixture-dir');
const nestedPath = path.join(nestedDir, 'inside-workspace.png');
fs.mkdirSync(nestedDir, { recursive: true });
createTestPng(nestedPath);
const result = await invokeHook({}, { filePath: path.win32.relative(TEST_DIR, nestedPath) });
expect(result.code).toBe(2);
expect(requests).toHaveLength(1);
}
);
it('does not analyze workspace symlinks that resolve outside the current workspace', async () => {
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-symlink-'));
const outsidePath = path.join(outsideDir, 'sensitive-symlink-target.png');
const linkPath = path.join(TEST_DIR, 'linked-sensitive.png');
createTestPng(outsidePath);
try {
fs.symlinkSync(outsidePath, linkPath);
const result = await invokeHook({}, { filePath: linkPath });
expect(result.code).toBe(0);
expect(requests).toHaveLength(0);
} finally {
fs.rmSync(linkPath, { force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
}
});
it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => {
const result = await invokeHook({
CCS_CURRENT_PROVIDER: 'unknown-provider',
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -71,6 +72,27 @@ async function getPortOutsideOpenAICompatAdaptiveRange(): Promise<number> {
throw new Error('No stale proxy fixture port found outside the adaptive range');
}
function readProcessCommandLine(pid: number): string | null {
if (process.platform === 'linux') {
try {
const commandLine = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
return commandLine.split('\u0000').filter(Boolean).join(' ') || null;
} catch {
// Fall back to ps below for platforms without readable procfs entries.
}
}
try {
return (
execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
}).trim() || null
);
} catch {
return null;
}
}
describe('openai proxy daemon lifecycle', () => {
it('starts, reports status, serves health/models, and stops', async () => {
const port = await getPort();
@@ -101,18 +123,42 @@ describe('openai proxy daemon lifecycle', () => {
const started = await startOpenAICompatProxy(profile, { port });
expect(started.success).toBe(true);
expect(started.authToken).toBeTruthy();
const authToken = started.authToken;
if (!authToken) {
throw new Error('Expected proxy auth token');
}
const status = await getOpenAICompatProxyStatus();
expect(status.running).toBe(true);
expect(status.profileName).toBe('hf');
expect(status.authToken).toBe(started.authToken);
expect(status.authToken).toBe(authToken);
const sessionPath = getOpenAICompatProxySessionPath('hf');
const proxyDir = path.dirname(sessionPath);
if (process.platform !== 'win32') {
expect(fs.statSync(proxyDir).mode & 0o777).toBe(0o700);
expect(fs.statSync(sessionPath).mode & 0o777).toBe(0o600);
}
expect(fs.readdirSync(proxyDir).some((entry) => entry.endsWith('.token'))).toBe(false);
if (started.pid) {
const commandLine = readProcessCommandLine(started.pid);
if (!commandLine) {
console.warn(
`Skipping daemon argv assertion: could not read command line for PID ${started.pid}`
);
} else {
expect(commandLine).toContain('--auth-token-file');
expect(commandLine).not.toContain(authToken);
expect(commandLine).not.toMatch(/(^|\s)--auth-token(?:\s|=|$)/);
}
}
const health = await fetch(`http://127.0.0.1:${port}/health`);
expect(health.status).toBe(200);
const models = (await (
await fetch(`http://127.0.0.1:${port}/v1/models`, {
headers: { 'x-api-key': started.authToken! },
headers: { 'x-api-key': authToken },
})
).json()) as { data?: Array<{ id: string }> };
expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']);
@@ -3,6 +3,7 @@ import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import * as zlib from 'zlib';
import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server';
import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
@@ -144,6 +145,36 @@ describe('openai proxy message edge cases', () => {
});
});
it('does not leak upstream content-encoding onto synthesized error responses', async () => {
const compressed = zlib.gzipSync(
JSON.stringify({ error: { message: 'invalid upstream request' } })
);
await startProxyWithHandler((_req, res) => {
res.writeHead(400, {
'Content-Type': 'application/json',
'Content-Encoding': 'gzip',
'Content-Length': String(compressed.length),
});
res.end(compressed);
});
const response = await requestProxy({
model: 'hf-model',
messages: [{ role: 'user', content: 'hello' }],
});
expect(response.status).toBe(400);
expect(response.headers.get('content-encoding')).toBeNull();
await expect(response.json()).resolves.toMatchObject({
type: 'error',
error: {
type: 'invalid_request_error',
message: 'invalid upstream request',
},
});
});
it('returns api_error when the upstream JSON response has no usable choices', async () => {
await startProxyWithHandler((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'bun:test';
import {
hasClaudeBackgroundWorkerUsingBaseUrlInProcessList,
shouldKeepSessionProxiesAlive,
} from '../../../src/cliproxy/executor/session-bridge';
describe('session bridge background proxy keepalive', () => {
it('keeps per-session proxies alive after a clean Claude exit when a bg worker inherited the base URL', () => {
expect(
shouldKeepSessionProxiesAlive({
code: 0,
signal: null,
backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex',
hasBackgroundWorkerUsingBaseUrl: () => true,
})
).toBe(true);
});
it('cleans up per-session proxies when no bg worker inherited the base URL', () => {
expect(
shouldKeepSessionProxiesAlive({
code: 0,
signal: null,
backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex',
hasBackgroundWorkerUsingBaseUrl: () => false,
})
).toBe(false);
});
it('cleans up per-session proxies for signaled Claude exits', () => {
expect(
shouldKeepSessionProxiesAlive({
code: null,
signal: 'SIGTERM',
backgroundKeepAliveBaseUrl: 'http://127.0.0.1:64314/api/provider/codex',
hasBackgroundWorkerUsingBaseUrl: () => true,
})
).toBe(false);
});
it('detects Claude bg workers that inherited the exact base URL', () => {
const baseUrl = 'http://127.0.0.1:64314/api/provider/codex';
const processList = `
123 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=${baseUrl} ANTHROPIC_AUTH_TOKEN=ccs-internal-managed
456 /opt/claude/claude.exe --bg-spare /tmp/claim.sock ANTHROPIC_BASE_URL=http://127.0.0.1:62075/api/provider/codex
789 /opt/claude/claude.exe --print ANTHROPIC_BASE_URL=${baseUrl}
`;
expect(hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(processList, baseUrl)).toBe(true);
expect(
hasClaudeBackgroundWorkerUsingBaseUrlInProcessList(
processList,
'http://127.0.0.1:65535/api/provider/codex'
)
).toBe(false);
});
});
@@ -3,12 +3,12 @@ import { describe, expect, it } from 'bun:test';
import { parseConfigCommandArgs } from '../../../src/commands/config-command-options';
describe('config command options parser', () => {
it('defaults to system bind behavior without explicit host', () => {
it('defaults to a localhost-only bind without explicit host', () => {
const result = parseConfigCommandArgs([]);
expect(result.help).toBe(false);
expect(result.error).toBeUndefined();
expect(result.options.host).toBeUndefined();
expect(result.options.host).toBe('localhost');
expect(result.options.hostProvided).toBe(false);
});
+9 -12
View File
@@ -10,7 +10,7 @@ let logLines: string[] = [];
let errorLines: string[] = [];
let dashboardAuthEnabled = false;
let startServerError: Error | null = null;
let mockServerBindHost = '::';
let mockServerBindHost = '::1';
let originalConsoleLog: typeof console.log;
let originalConsoleError: typeof console.error;
let originalProcessExit: typeof process.exit;
@@ -79,7 +79,7 @@ beforeEach(() => {
errorLines = [];
dashboardAuthEnabled = false;
startServerError = null;
mockServerBindHost = '::';
mockServerBindHost = '::1';
originalConsoleLog = console.log;
originalConsoleError = console.error;
@@ -140,19 +140,16 @@ describe('config command dashboard startup', () => {
expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus');
});
it('keeps the default startup path free of an explicit host override', async () => {
it('binds the default startup path to localhost only', async () => {
await handleConfigCommand([], createTestDeps());
expect(startServerCalls).toHaveLength(1);
expect(startServerCalls[0]).toEqual({ port: 3000, dev: false });
expect(startServerCalls[0]).toEqual({ port: 3000, dev: false, host: 'localhost' });
const rendered = logLines.join('\n');
expect(rendered).toContain('Dashboard: http://localhost:3000');
expect(rendered).toContain('Bind host: ::');
expect(rendered).toContain(
'Dashboard may be reachable from other devices that can connect to this machine.'
);
expect(rendered).toContain('Protect it before sharing: ccs config auth setup');
expect(rendered).toContain('Dashboard: http://[::1]:3000');
expect(rendered).not.toContain('Dashboard may be reachable from other devices');
expect(rendered).not.toContain('Protect it before sharing: ccs config auth setup');
expect(errorLines).toHaveLength(0);
});
@@ -205,8 +202,8 @@ describe('config command dashboard startup', () => {
expect(startServerCalls).toHaveLength(1);
const rendered = logLines.join('\n');
expect(rendered).toContain('Dashboard: http://localhost:3000');
expect(rendered).toContain('Bind host: ::');
expect(rendered).toContain('Dashboard: http://[::1]:3000');
expect(rendered).not.toContain('Bind host:');
expect(errorLines).toHaveLength(0);
} finally {
networkInterfacesSpy.mockRestore();
@@ -48,55 +48,59 @@ describe('ccs-browser MCP server - advanced interactions', () => {
},
];
const responses = await runMcpRequests(pages, [
{
jsonrpc: '2.0',
id: 901,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] },
},
},
{
jsonrpc: '2.0',
id: 902,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: {
selector: '#frame-dropzone',
files: [invoicePath],
frameSelector: '#upload-frame',
const responses = await runMcpRequests(
pages,
[
{
jsonrpc: '2.0',
id: 901,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: { selector: '#dropzone', files: [invoicePath, receiptPath] },
},
},
},
{
jsonrpc: '2.0',
id: 903,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: {
selector: '#shadow-dropzone',
files: [receiptPath],
pierceShadow: true,
{
jsonrpc: '2.0',
id: 902,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: {
selector: '#frame-dropzone',
files: [invoicePath],
frameSelector: '#upload-frame',
},
},
},
},
{
jsonrpc: '2.0',
id: 904,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: {
selector: '#cancel-dropzone',
files: [invoicePath],
{
jsonrpc: '2.0',
id: 903,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: {
selector: '#shadow-dropzone',
files: [receiptPath],
pierceShadow: true,
},
},
},
},
]);
{
jsonrpc: '2.0',
id: 904,
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: {
selector: '#cancel-dropzone',
files: [invoicePath],
},
},
},
],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
);
expect(getResponseText(responses.find((message) => message.id === 901))).toContain(
'status: files-dropped'
@@ -184,7 +188,8 @@ describe('ccs-browser MCP server - advanced interactions', () => {
arguments: { selector: '#reject-dropzone', files: [okPath] },
},
},
]
],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
);
expect(getResponseText(responses.find((message) => message.id === 904))).toContain(
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'bun:test';
import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness';
import { mkdirSync, realpathSync } from 'node:fs';
import { delimiter } from 'node:path';
import {
runMcpRequests,
getResponseText,
mkdtempSync,
writeFileSync,
tmpdir,
join,
} from './browser-mcp-test-harness';
import type { MockPageState } from './browser-mcp-test-harness';
describe('ccs-browser MCP server - downloads and file inputs', () => {
@@ -60,9 +69,15 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
{ responseTimeoutMs: 12000 }
);
expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser');
expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv');
expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled');
expect(getResponseText(responses.find((message) => message.id === 57))).toContain(
'scope: browser'
);
expect(getResponseText(responses.find((message) => message.id === 58))).toContain(
'suggestedFilename: report.csv'
);
expect(getResponseText(responses.find((message) => message.id === 60))).toContain(
'status: canceled'
);
expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow');
expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1');
});
@@ -93,6 +108,60 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
);
});
it('restricts caller-provided download paths to configured safe roots', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-download-root-'));
const allowedPath = join(tempDir, 'reports');
const outsidePath = join(tmpdir(), 'ccs-browser-outside-downloads');
const sensitiveRoot = join(tempDir, '.aws');
const sensitiveDownloadPath = join(sensitiveRoot, 'reports');
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports', browser: {} }],
[
{
jsonrpc: '2.0',
id: 615,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: { behavior: 'accept', downloadPath: allowedPath },
},
},
{
jsonrpc: '2.0',
id: 616,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: { behavior: 'accept', downloadPath: outsidePath },
},
},
{
jsonrpc: '2.0',
id: 619,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: { behavior: 'accept', downloadPath: sensitiveDownloadPath },
},
},
],
{ childEnv: { CCS_BROWSER_DOWNLOAD_ROOTS: `${sensitiveRoot}${delimiter}${tempDir}` } }
);
expect(getResponseText(responses.find((message) => message.id === 615))).toContain(
`downloadPath: ${realpathSync(allowedPath)}`
);
const rejectedResponse = responses.find((message) => message.id === 616);
expect((rejectedResponse?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(rejectedResponse)).toContain(
'downloadPath must be inside the browser session download directory or a CCS_BROWSER_DOWNLOAD_ROOTS entry'
);
expect(getResponseText(responses.find((message) => message.id === 619))).toContain(
'downloadPath cannot include hidden or sensitive path segment: .aws'
);
});
it('rejects browser_cancel_download for completed downloads', async () => {
const pages: MockPageState[] = [
{
@@ -144,7 +213,9 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
{ responseTimeoutMs: 12000 }
);
expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed');
expect(getResponseText(responses.find((message) => message.id === 611))).toContain(
'status: completed'
);
const response = responses.find((message) => message.id === 612);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain(
@@ -188,7 +259,9 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
{ responseTimeoutMs: 12000 }
);
expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed');
expect(getResponseText(responses.find((message) => message.id === 62))).toContain(
'status: observed'
);
});
it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => {
@@ -279,23 +352,30 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
},
},
},
]
],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
);
expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1');
expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame');
expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true');
expect(getResponseText(responses.find((message) => message.id === 64))).toContain(
'pageIndex: 1'
);
expect(getResponseText(responses.find((message) => message.id === 65))).toContain(
'frameSelector: #upload-frame'
);
expect(getResponseText(responses.find((message) => message.id === 66))).toContain(
'pierceShadow: true'
);
expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([
invoicePath,
receiptPath,
]);
expect(
(pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles
).toEqual([realpathSync(invoicePath), realpathSync(receiptPath)]);
expect(
(pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles
).toEqual([invoicePath]);
).toEqual([realpathSync(invoicePath)]);
expect(
(pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles
).toEqual([receiptPath]);
(pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState)
.assignedFiles
).toEqual([realpathSync(receiptPath)]);
});
it('uses pageId for browser_set_file_input when provided', async () => {
@@ -340,14 +420,85 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] },
},
},
]
],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
);
expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1');
expect(getResponseText(responses.find((message) => message.id === 68))).toContain(
'pageIndex: 1'
);
expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([
assetPath,
realpathSync(assetPath),
]);
expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined();
expect(
(pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles
).toBeUndefined();
});
it('rejects file uploads outside configured roots and sensitive files inside configured roots', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-root-'));
const outsideDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-outside-'));
const outsidePath = join(outsideDir, 'secret.txt');
const sensitiveDir = join(tempDir, '.ssh');
const sensitivePath = join(sensitiveDir, 'id_rsa');
const sensitiveRootPath = join(sensitiveDir, 'public.txt');
writeFileSync(outsidePath, 'outside');
mkdirSync(sensitiveDir, { recursive: true });
writeFileSync(sensitivePath, 'private-key');
writeFileSync(sensitiveRootPath, 'not-a-key');
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Upload Guard',
currentUrl: 'https://example.com/',
fileInputs: {
'#real-file-input': { kind: 'file' },
},
},
],
[
{
jsonrpc: '2.0',
id: 617,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#real-file-input', files: [outsidePath] },
},
},
{
jsonrpc: '2.0',
id: 618,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#real-file-input', files: [sensitivePath] },
},
},
{
jsonrpc: '2.0',
id: 620,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#real-file-input', files: [sensitiveRootPath] },
},
},
],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: `${sensitiveDir}${delimiter}${tempDir}` } }
);
expect(getResponseText(responses.find((message) => message.id === 617))).toContain(
'file must be inside the browser session download directory or a CCS_BROWSER_UPLOAD_ROOTS entry'
);
expect(getResponseText(responses.find((message) => message.id === 618))).toContain(
'file cannot include hidden or sensitive path segment: .ssh'
);
expect(getResponseText(responses.find((message) => message.id === 620))).toContain(
'file cannot include hidden or sensitive path segment: .ssh'
);
});
it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => {
@@ -393,10 +544,16 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] },
arguments: {
pageIndex: 0,
pageId: 'page-1',
selector: '#real-file-input',
files: [okPath],
},
},
},
]
],
{ childEnv: { CCS_BROWSER_UPLOAD_ROOTS: tempDir } }
);
expect(getResponseText(responses.find((message) => message.id === 69))).toContain(
@@ -409,5 +566,4 @@ describe('ccs-browser MCP server - downloads and file inputs', () => {
'pageIndex and pageId cannot be used together'
);
});
});
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
import * as childProcess from 'child_process';
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { HealthCheck } from '../../../../src/management/checks/types';
const originalPlatform = process.platform;
let originalClaudePath: string | undefined;
function createMockChild(): EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
} {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
setTimeout(() => {
child.stdout.emit('data', Buffer.from('1.2.3'));
child.emit('close', 0);
}, 0);
return child;
}
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
if (originalClaudePath === undefined) delete process.env.CCS_CLAUDE_PATH;
else process.env.CCS_CLAUDE_PATH = originalClaudePath;
});
describe('ClaudeCliChecker', () => {
it('uses the pinned Windows shell for cmd wrapper health checks', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-system-check-'));
const fakeClaude = path.join(tmpDir, 'claude.cmd');
fs.writeFileSync(fakeClaude, '');
originalClaudePath = process.env.CCS_CLAUDE_PATH;
process.env.CCS_CLAUDE_PATH = fakeClaude;
Object.defineProperty(process, 'platform', { value: 'win32' });
const spawnSpy = spyOn(childProcess, 'spawn').mockImplementation(
() => createMockChild() as unknown as ReturnType<typeof childProcess.spawn>
);
try {
const { ClaudeCliChecker } = await import('../../../../src/management/checks/system-check');
const results = new HealthCheck();
await new ClaudeCliChecker().run(results);
expect(spawnSpy).toHaveBeenCalledTimes(1);
const [, options] = spawnSpy.mock.calls[0] as [
string,
Record<string, unknown> | undefined,
];
expect(options?.shell).toBe('C:\\Windows\\System32\\cmd.exe');
expect(results.details['Claude CLI']?.status).toBe('OK');
} finally {
spawnSpy.mockRestore();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { SettingsSymlinksChecker } from '../../../src/management/checks/symlink-check';
import { HealthCheck } from '../../../src/management/checks/types';
import {
listAccountInstanceNames,
listAccountInstancePaths,
} from '../../../src/management/instance-directory';
import { InstancesChecker } from '../../../src/management/checks/profile-check';
import { checkInstances } from '../../../src/web-server/health/profile-checks';
import { checkSettingsSymlinks } from '../../../src/web-server/health/symlink-checks';
describe('account instance directory enumeration', () => {
let tempRoot = '';
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
const ccsDir = () => path.join(tempRoot, '.ccs');
const claudeDir = () => path.join(tempRoot, '.claude');
const instancesDir = () => path.join(ccsDir(), 'instances');
function createValidSettingsLayout(): void {
const claudeSettings = path.join(claudeDir(), 'settings.json');
const sharedSettings = path.join(ccsDir(), 'shared', 'settings.json');
const workSettings = path.join(instancesDir(), 'work', 'settings.json');
fs.mkdirSync(path.dirname(claudeSettings), { recursive: true });
fs.mkdirSync(path.dirname(sharedSettings), { recursive: true });
fs.mkdirSync(path.dirname(workSettings), { recursive: true });
fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true });
fs.writeFileSync(claudeSettings, '{}\n', 'utf8');
fs.symlinkSync(claudeSettings, sharedSettings, 'file');
fs.symlinkSync(sharedSettings, workSettings, 'file');
}
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-directory-test-'));
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
spyOn(os, 'homedir').mockReturnValue(tempRoot);
spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
else delete process.env.CCS_DIR;
if (tempRoot && fs.existsSync(tempRoot)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('lists only user account instance directories', () => {
fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true });
fs.mkdirSync(path.join(instancesDir(), 'personal'), { recursive: true });
fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true });
fs.mkdirSync(path.join(instancesDir(), '.cache'), { recursive: true });
fs.writeFileSync(path.join(instancesDir(), 'README.txt'), 'not an instance', 'utf8');
expect(listAccountInstanceNames(instancesDir()).sort()).toEqual(['personal', 'work']);
expect(listAccountInstancePaths(instancesDir()).sort()).toEqual(
[path.join(instancesDir(), 'personal'), path.join(instancesDir(), 'work')].sort()
);
});
it('skips transient entries that cannot be statted', () => {
fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true });
fs.symlinkSync(
path.join(instancesDir(), 'missing-instance'),
path.join(instancesDir(), 'transient'),
'dir'
);
expect(listAccountInstanceNames(instancesDir())).toEqual(['work']);
});
it('keeps ccs doctor settings symlinks healthy when .locks exists', () => {
createValidSettingsLayout();
const results = new HealthCheck();
new SettingsSymlinksChecker().run(results);
expect(results.warnings).toEqual([]);
expect(results.checks.find((check) => check.name === 'Settings Symlinks')?.status).toBe(
'success'
);
expect(results.details['Settings Symlinks']?.info).toBe('1 instance(s) valid');
});
it('keeps ccs doctor instance counts tied to real profiles', () => {
fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true });
fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true });
const results = new HealthCheck();
new InstancesChecker().run(results);
expect(results.checks.find((check) => check.name === 'Instances')?.message).toBe(
'1 account profiles'
);
});
it('keeps dashboard health checks tied to real profiles', () => {
createValidSettingsLayout();
expect(checkSettingsSymlinks(ccsDir(), claudeDir())).toMatchObject({
status: 'ok',
message: '1 instance(s) valid',
});
expect(checkInstances(ccsDir())).toMatchObject({
status: 'ok',
message: '1 account profile',
});
});
});
+27 -1
View File
@@ -3,11 +3,16 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
getOpenAICompatProxyDir,
getLegacyOpenAICompatProxySessionPath,
getOpenAICompatProxyPidPath,
getOpenAICompatProxySessionPath,
} from '../../../src/proxy/proxy-daemon-paths';
import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state';
import {
listOpenAICompatProxyProfileNames,
removeOpenAICompatProxyAuthTokenFile,
writeOpenAICompatProxyAuthTokenFile,
} from '../../../src/proxy/proxy-daemon-state';
let originalCcsHome: string | undefined;
let tempDir: string;
@@ -51,3 +56,24 @@ describe('listOpenAICompatProxyProfileNames', () => {
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
});
});
describe('writeOpenAICompatProxyAuthTokenFile', () => {
it('writes a private token file under the CCS proxy dir and removes it', () => {
const authToken = 'proxy-token-regression-secret';
const tokenPath = writeOpenAICompatProxyAuthTokenFile('ccg', authToken);
const proxyDir = getOpenAICompatProxyDir();
expect(path.dirname(tokenPath)).toBe(proxyDir);
expect(tokenPath.startsWith(`${proxyDir}${path.sep}`)).toBe(true);
expect(fs.readFileSync(tokenPath, 'utf8')).toBe(authToken);
if (process.platform !== 'win32') {
expect(fs.statSync(proxyDir).mode & 0o777).toBe(0o700);
expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600);
}
removeOpenAICompatProxyAuthTokenFile(tokenPath);
expect(fs.existsSync(tokenPath)).toBe(false);
});
});
@@ -94,7 +94,7 @@ describe('codex-adapter exec', () => {
string,
Record<string, unknown> | undefined,
];
expect(options?.shell).toBe('cmd.exe');
expect(options?.shell).toBe('C:\\Windows\\System32\\cmd.exe');
expect(command).toContain(fakeCodex);
expect(command).toContain('mcp_servers.ccs_browser.args=');
expect(command).toContain('@playwright/mcp@0.0.70');
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
buildCodexCliproxyProviderBaseUrl,
ensureCodexCliproxyProviderConfig,
} from '../../../src/targets/codex-cliproxy-provider-config';
describe('codex cliproxy provider config repair', () => {
let tempHome: string;
let codexHome: string;
let configPath: string;
let env: NodeJS.ProcessEnv;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-provider-config-'));
codexHome = path.join(tempHome, '.codex');
configPath = path.join(codexHome, 'config.toml');
env = { CODEX_HOME: codexHome } as NodeJS.ProcessEnv;
});
afterEach(() => {
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('creates the cliproxy model provider when config.toml is missing', async () => {
const result = await ensureCodexCliproxyProviderConfig(8317, env);
expect(result.changed).toBe(true);
expect(result.envKey).toBe('CLIPROXY_API_KEY');
const rawText = fs.readFileSync(configPath, 'utf8');
expect(rawText).toContain('[model_providers.cliproxy]');
expect(rawText).toContain('name = "CLIProxy Codex"');
expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"');
expect(rawText).not.toContain('model_provider = "cliproxy"');
});
it('appends the missing provider while preserving existing raw config text', async () => {
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(configPath, '# user note\nmodel = "gpt-5.4"\n', 'utf8');
const result = await ensureCodexCliproxyProviderConfig(8317, env);
expect(result.changed).toBe(true);
expect(result.envKey).toBe('CLIPROXY_API_KEY');
const rawText = fs.readFileSync(configPath, 'utf8');
expect(rawText.startsWith('# user note\nmodel = "gpt-5.4"\n\n')).toBe(true);
expect(rawText).toContain('[model_providers.cliproxy]');
});
it('repairs an existing incomplete cliproxy provider', async () => {
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(
configPath,
`[model_providers.cliproxy]
base_url = "http://localhost:8317/api/provider/codex"
wire_api = "responses"
`,
'utf8'
);
const result = await ensureCodexCliproxyProviderConfig(9321, env);
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('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 () => {
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(
configPath,
`[model_providers.cliproxy]
name = "Old Name"
base_url = "http://localhost:8317/api/provider/codex"
env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"
wire_api = "chat"
`,
'utf8'
);
const result = await ensureCodexCliproxyProviderConfig(9321, env);
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('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"');
expect(rawText).toContain('wire_api = "responses"');
});
it('rejects invalid non-table model_providers values without appending broken TOML', async () => {
fs.mkdirSync(codexHome, { recursive: true });
const rawText = 'model_providers = "legacy"\n';
fs.writeFileSync(configPath, rawText, 'utf8');
await expect(ensureCodexCliproxyProviderConfig(8317, env)).rejects.toThrow(
'[model_providers] must be a table'
);
expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText);
});
it('leaves a ready localhost provider unchanged', async () => {
fs.mkdirSync(codexHome, { recursive: true });
const rawText = `[model_providers.cliproxy]
name = "CLIProxy Codex"
base_url = "http://localhost:8317/api/provider/codex"
env_key = "CLIPROXY_API_KEY"
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('CLIPROXY_API_KEY');
expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText);
});
});
+31 -23
View File
@@ -81,7 +81,7 @@ describe('codex-detector', () => {
expect(spawnSyncSpy).toHaveBeenCalled();
expect(cmdWrapperProbeCall).toBeDefined();
expect((cmdWrapperProbeCall?.[1] as Record<string, unknown> | undefined)?.shell).toBe(
'cmd.exe'
'C:\\Windows\\System32\\cmd.exe'
);
expect(info?.needsShell).toBe(true);
expect(info?.features).toContain('config-overrides');
@@ -96,7 +96,9 @@ describe('codex-detector', () => {
fs.writeFileSync(fakePsCodex, '');
Object.defineProperty(process, 'platform', { value: 'win32' });
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakeCmdCodex}\n`);
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(
() => `${fakeCmdCodex}\n`
);
expect(detectCodexCli()).toBe(fakeCmdCodex);
@@ -110,7 +112,9 @@ describe('codex-detector', () => {
fs.writeFileSync(fakePsCodex, '');
Object.defineProperty(process, 'platform', { value: 'win32' });
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakePsCodex}\n`);
const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(
() => `${fakePsCodex}\n`
);
expect(detectCodexCli()).toBe(fakeCmdCodex);
@@ -122,19 +126,21 @@ describe('codex-detector', () => {
fs.writeFileSync(fakeCodex, '');
process.env.CCS_CODEX_PATH = fakeCodex;
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
const joinedArgs = Array.isArray(args) ? args.join(' ') : '';
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation(
(command, args) => {
const joinedArgs = Array.isArray(args) ? args.join(' ') : '';
if (joinedArgs.includes('--help')) {
return 'Codex CLI\n';
}
if (joinedArgs.includes('--help')) {
return 'Codex CLI\n';
}
if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) {
return 'codex-cli 0.119.0-alpha.1';
}
if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) {
return 'codex-cli 0.119.0-alpha.1';
}
return 'codex-cli 0.119.0-alpha.1';
});
);
const info = getCodexBinaryInfo();
@@ -148,19 +154,21 @@ describe('codex-detector', () => {
fs.writeFileSync(fakeCodex, '');
process.env.CCS_CODEX_PATH = fakeCodex;
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
const joinedArgs = Array.isArray(args) ? args.join(' ') : '';
const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation(
(command, args) => {
const joinedArgs = Array.isArray(args) ? args.join(' ') : '';
if (joinedArgs.includes('--help')) {
return 'Codex CLI\n -c, --config <CONFIG_OVERRIDE>\n';
if (joinedArgs.includes('--help')) {
return 'Codex CLI\n -c, --config <CONFIG_OVERRIDE>\n';
}
if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) {
throw new Error('unsupported');
}
return 'codex-cli 0.119.0-alpha.1';
}
if (joinedArgs.includes('-c') && joinedArgs.includes('--version')) {
throw new Error('unsupported');
}
return 'codex-cli 0.119.0-alpha.1';
});
);
const info = getCodexBinaryInfo();
@@ -116,18 +116,32 @@ if (out) {
}
const envOut = process.env.CCS_TEST_CODEX_ENV_OUT;
if (envOut) {
const loggedEnv = {
CODEX_HOME: process.env.CODEX_HOME,
CODEX_CI: process.env.CODEX_CI,
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN,
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR,
CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR,
CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL,
};
if (
process.env.CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY === '1' &&
process.env.CLIPROXY_API_KEY !== undefined
) {
loggedEnv.CLIPROXY_API_KEY = process.env.CLIPROXY_API_KEY;
}
const extraEnvKeys = (process.env.CCS_TEST_CODEX_LOG_ENV_KEYS || '')
.split(',')
.map((key) => key.trim())
.filter(Boolean);
for (const key of extraEnvKeys) {
loggedEnv[key] = process.env[key];
}
fs.appendFileSync(
envOut,
JSON.stringify({
CODEX_HOME: process.env.CODEX_HOME,
CODEX_CI: process.env.CODEX_CI,
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN,
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
CCS_BROWSER_USER_DATA_DIR: process.env.CCS_BROWSER_USER_DATA_DIR,
CCS_BROWSER_PROFILE_DIR: process.env.CCS_BROWSER_PROFILE_DIR,
CCS_BROWSER_DEVTOOLS_WS_URL: process.env.CCS_BROWSER_DEVTOOLS_WS_URL,
}) + '\\n'
JSON.stringify(loggedEnv) + '\\n'
);
}
const configFlagIndex = cliArgs.findIndex((arg) => arg === '-c' || arg === '--config');
@@ -396,6 +410,7 @@ process.exit(0);
...process.env,
CI: '1',
NO_COLOR: '1',
HOME: tmpHome,
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
@@ -413,6 +428,7 @@ process.exit(0);
...process.env,
CI: '1',
NO_COLOR: '1',
HOME: tmpHome,
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
@@ -647,6 +663,7 @@ process.exit(0);
...process.env,
CI: '1',
NO_COLOR: '1',
HOME: tmpHome,
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
@@ -668,21 +685,24 @@ process.exit(0);
...process.env,
CI: '1',
NO_COLOR: '1',
HOME: tmpHome,
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1',
CODEX_HOME: inheritedCodexHome,
});
expect(result.status).toBe(0);
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
{
CODEX_HOME: path.join(os.homedir(), '.codex'),
CODEX_HOME: path.join(tmpHome, '.codex'),
CODEX_CI: undefined,
CODEX_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: undefined,
CLIPROXY_API_KEY: 'ccs-internal-managed',
CCS_BROWSER_USER_DATA_DIR: undefined,
CCS_BROWSER_PROFILE_DIR: undefined,
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
@@ -697,10 +717,112 @@ process.exit(0);
...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_CLIPROXY_API_KEY: '1',
});
expect(result.status).toBe(0);
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
['--config', 'model_provider="cliproxy"', 'fix failing tests'],
]);
const codexConfig = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf8');
expect(codexConfig).toContain('[model_providers.cliproxy]');
expect(codexConfig).toContain('env_key = "CLIPROXY_API_KEY"');
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
{
CODEX_HOME: path.join(tmpHome, '.codex'),
CODEX_CI: undefined,
CODEX_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: undefined,
CLIPROXY_API_KEY: 'ccs-internal-managed',
CCS_BROWSER_USER_DATA_DIR: undefined,
CCS_BROWSER_PROFILE_DIR: undefined,
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
},
]);
});
it('loads the configured cliproxy provider env_key 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 = "http://localhost:8317/api/provider/codex"
env_key = "CCS_CUSTOM_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_CUSTOM_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('env_key = "CCS_CUSTOM_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_CUSTOM_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;
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 2',
'default: work',
'accounts:',
' work:',
' created: "2026-01-01"',
' last_used: "2026-01-01"',
].join('\n')
);
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_CLIPROXY_API_KEY: '1',
});
expect(result.status).toBe(0);
@@ -709,7 +831,51 @@ process.exit(0);
]);
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
{
CODEX_HOME: path.join(os.homedir(), '.codex'),
CODEX_HOME: path.join(tmpHome, '.codex'),
CODEX_CI: undefined,
CODEX_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: undefined,
CLIPROXY_API_KEY: 'ccs-internal-managed',
CCS_BROWSER_USER_DATA_DIR: undefined,
CCS_BROWSER_PROFILE_DIR: undefined,
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
},
]);
});
it('keeps implicit ccs --target codex launches native when the CCS default is a Claude account', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 2',
'default: work',
'accounts:',
' work:',
' created: "2026-01-01"',
' last_used: "2026-01-01"',
].join('\n')
);
const result = runCcs(['--target', 'codex'], {
...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_THINKING: '8192',
});
expect(result.status).toBe(0);
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([[]]);
expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([
{
CODEX_HOME: undefined,
CODEX_CI: undefined,
CODEX_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined,
@@ -721,6 +887,35 @@ process.exit(0);
]);
});
it('still rejects an explicit Claude account profile on the Codex target', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 2',
'accounts:',
' work:',
' created: "2026-01-01"',
' last_used: "2026-01-01"',
].join('\n')
);
const result = runCcs(['work', '--target', 'codex', 'fix failing tests'], {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain('Codex CLI does not support Claude account-based profiles.');
expect(result.stderr).toContain('CLIProxy Codex pool: ccs codex --target codex or ccsxp');
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([]);
});
it('rejects conflicting native provider config overrides for ccsxp', () => {
if (process.platform === 'win32') return;
@@ -728,6 +923,7 @@ process.exit(0);
...process.env,
CI: '1',
NO_COLOR: '1',
HOME: tmpHome,
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
@@ -745,6 +941,7 @@ process.exit(0);
...process.env,
CI: '1',
NO_COLOR: '1',
HOME: tmpHome,
CCS_HOME: tmpHome,
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
@@ -767,6 +964,7 @@ process.exit(0);
CCS_CODEX_PATH: fakeCodexPath,
CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath,
CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test',
CCS_TEST_CODEX_LOG_CLIPROXY_API_KEY: '1',
CODEX_HOME: path.join(tmpHome, 'inherited-codex-home'),
CCSXP_CODEX_HOME: explicitCodexHome,
});
@@ -779,6 +977,7 @@ process.exit(0);
CODEX_MANAGED_BY_BUN: undefined,
CODEX_THREAD_ID: undefined,
ANTHROPIC_BASE_URL: undefined,
CLIPROXY_API_KEY: 'ccs-internal-managed',
CCS_BROWSER_USER_DATA_DIR: undefined,
CCS_BROWSER_PROFILE_DIR: undefined,
CCS_BROWSER_DEVTOOLS_WS_URL: undefined,
@@ -87,16 +87,21 @@ describe('evaluateTargetRuntimeCompatibility', () => {
profileType: 'settings',
});
expect(genericSettingsCompatibility.supported).toBe(false);
expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/);
expect(genericSettingsCompatibility.reason).toMatch(
/currently supports native default sessions/
);
});
test('rejects account, copilot, and cursor profiles on Codex target', () => {
expect(
evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'account',
}).supported
).toBe(false);
const accountCompatibility = evaluateTargetRuntimeCompatibility({
target: 'codex',
profileType: 'account',
});
expect(accountCompatibility.supported).toBe(false);
expect(accountCompatibility.suggestion).toContain('ccs --target codex');
expect(accountCompatibility.suggestion).toContain('ccs codex --target codex');
expect(accountCompatibility.suggestion).toContain('ccs cliproxy routing');
expect(
evaluateTargetRuntimeCompatibility({
target: 'codex',
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'bun:test';
import {
isClaudeSubcommandInvocation,
stripClaudeCodeFeatureBlockingEnv,
stripClaudeSubcommandSessionArgs,
} from '../../../src/utils/claude-subcommand-detector';
import { appendThirdPartyWebSearchToolArgs } from '../../../src/utils/websearch/claude-tool-args';
import { appendThirdPartyImageAnalysisToolArgs } from '../../../src/utils/image-analysis/claude-tool-args';
import { appendBrowserToolArgs } from '../../../src/utils/browser/claude-tool-args';
describe('isClaudeSubcommandInvocation', () => {
it('returns false for an empty arg list', () => {
expect(isClaudeSubcommandInvocation([])).toBe(false);
});
it('returns false for a prompt-only invocation', () => {
expect(isClaudeSubcommandInvocation(['fix the failing test'])).toBe(false);
});
it('detects bare subcommand', () => {
for (const cmd of [
'agents',
'auth',
'doctor',
'mcp',
'plugin',
'plugins',
'project',
'setup-token',
'ultrareview',
'update',
'upgrade',
'auto-mode',
'install',
]) {
expect(isClaudeSubcommandInvocation([cmd])).toBe(true);
}
});
it('skips past value-taking flags before the positional', () => {
expect(isClaudeSubcommandInvocation(['--model', 'sonnet', 'agents'])).toBe(true);
expect(isClaudeSubcommandInvocation(['--settings', '/tmp/s.json', 'doctor'])).toBe(true);
expect(
isClaudeSubcommandInvocation([
'--dangerously-skip-permissions',
'--teammate-mode',
'in-process',
'agents',
])
).toBe(true);
});
it('does not treat a flag value matching a subcommand name as a subcommand', () => {
// `--name auth` sets the session display name to "auth" — still an interactive launch.
expect(isClaudeSubcommandInvocation(['--name', 'auth'])).toBe(false);
});
it('handles --flag=value forms', () => {
expect(isClaudeSubcommandInvocation(['--model=sonnet', 'agents'])).toBe(true);
});
it('stops scanning at the -- terminator', () => {
expect(isClaudeSubcommandInvocation(['--', 'agents'])).toBe(false);
});
it('ignores subcommand-named tokens that come AFTER the first positional prompt', () => {
// First positional is the prompt; "agents" later is just a word in the prompt context.
expect(isClaudeSubcommandInvocation(['talk to me about', 'agents'])).toBe(false);
});
});
describe('stripClaudeCodeFeatureBlockingEnv', () => {
it('removes telemetry disable env that blocks Claude Code background features', () => {
const env = stripClaudeCodeFeatureBlockingEnv({
DISABLE_TELEMETRY: '1',
DISABLE_BUG_COMMAND: '1',
DISABLE_ERROR_REPORTING: '1',
ANTHROPIC_MODEL: 'gpt-5.5',
});
expect(env.DISABLE_TELEMETRY).toBeUndefined();
expect(env.DISABLE_BUG_COMMAND).toBe('1');
expect(env.DISABLE_ERROR_REPORTING).toBe('1');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5');
});
});
describe('stripClaudeSubcommandSessionArgs', () => {
it('removes session-only flags before a Claude subcommand', () => {
expect(
stripClaudeSubcommandSessionArgs([
'--dangerously-skip-permissions',
'--teammate-mode',
'in-process',
'agents',
])
).toEqual(['agents']);
});
it('removes session-only flags after a Claude subcommand while preserving subcommand flags', () => {
expect(
stripClaudeSubcommandSessionArgs([
'agents',
'--dangerously-skip-permissions',
'--teammate-mode',
'in-process',
'--setting-sources',
'user',
])
).toEqual(['agents', '--setting-sources', 'user']);
});
it('leaves non-subcommand interactive launches unchanged', () => {
expect(
stripClaudeSubcommandSessionArgs([
'--dangerously-skip-permissions',
'--teammate-mode',
'in-process',
'fix the bug',
])
).toEqual(['--dangerously-skip-permissions', '--teammate-mode', 'in-process', 'fix the bug']);
});
});
describe('subcommand passthrough — injectors short-circuit', () => {
it('appendThirdPartyWebSearchToolArgs returns args unchanged for subcommand invocations', () => {
expect(appendThirdPartyWebSearchToolArgs(['agents'])).toEqual(['agents']);
expect(appendThirdPartyWebSearchToolArgs(['doctor'])).toEqual(['doctor']);
expect(appendThirdPartyWebSearchToolArgs(['mcp', 'list'])).toEqual(['mcp', 'list']);
});
it('appendThirdPartyImageAnalysisToolArgs returns args unchanged for subcommand invocations', () => {
expect(appendThirdPartyImageAnalysisToolArgs(['agents'])).toEqual(['agents']);
});
it('appendBrowserToolArgs returns args unchanged for subcommand invocations', () => {
expect(appendBrowserToolArgs(['agents'])).toEqual(['agents']);
});
it('injectors still inject for non-subcommand interactive launches', () => {
const out = appendThirdPartyWebSearchToolArgs(['fix the bug']);
expect(out).toContain('--append-system-prompt');
expect(out).toContain('--disallowedTools');
});
});
@@ -405,7 +405,7 @@ describe('CLAUDECODE environment stripping', () => {
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
expect(spawnCalls[0].options?.shell).toBe('cmd.exe');
expect(spawnCalls[0].options?.shell).toBe('C:\\Windows\\System32\\cmd.exe');
});
it('execClaude sets DISABLE_AUTOUPDATER=1 when preferences.auto_update is false', () => {
@@ -827,9 +827,9 @@ describe('CLAUDECODE environment stripping', () => {
expect(persistedLaunchSettings.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(persistedLaunchSettings.env?.ANTHROPIC_API_KEY).toBeUndefined();
expect(persistedLaunchSettings.env?.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(
persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command
).toBe('echo headless-bridge-hook');
expect(persistedLaunchSettings.hooks?.PreToolUse?.[0]?.hooks?.[0]?.command).toBe(
'echo headless-bridge-hook'
);
expect(fs.existsSync(launchSettingsPath)).toBe(false);
});
+73 -11
View File
@@ -82,41 +82,103 @@ describe('escapeShellArg', () => {
expect(escapeShellArg('hello!')).toBe('"hello^^!"');
});
it('prefers ComSpec when resolving the escaped command shell', async () => {
it('accepts ComSpec only when it resolves to the trusted system cmd.exe', async () => {
const originalComSpec = process.env.ComSpec;
const originalCOMSPEC = process.env.COMSPEC;
const originalSystemRoot = process.env.SystemRoot;
try {
process.env.SystemRoot = 'C:\\Windows';
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
delete process.env.COMSPEC;
const { getWindowsEscapedCommandShell } = await import(
'../../../src/utils/shell-executor'
);
const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor');
expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe');
} finally {
if (originalComSpec === undefined) delete process.env.ComSpec;
else process.env.ComSpec = originalComSpec;
if (originalCOMSPEC === undefined) delete process.env.COMSPEC;
else process.env.COMSPEC = originalCOMSPEC;
if (originalSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = originalSystemRoot;
}
});
it('falls back to cmd.exe when ComSpec is unavailable', async () => {
it('rejects relative or non-system ComSpec values', async () => {
const originalComSpec = process.env.ComSpec;
const originalCOMSPEC = process.env.COMSPEC;
const originalSystemRoot = process.env.SystemRoot;
try {
delete process.env.ComSpec;
delete process.env.COMSPEC;
const { getWindowsEscapedCommandShell } = await import(
'../../../src/utils/shell-executor'
);
expect(getWindowsEscapedCommandShell()).toBe('cmd.exe');
process.env.SystemRoot = 'C:\\Windows';
process.env.ComSpec = 'cmd.exe';
process.env.COMSPEC = 'C:\\Temp\\cmd.exe';
const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor');
expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe');
} finally {
if (originalComSpec === undefined) delete process.env.ComSpec;
else process.env.ComSpec = originalComSpec;
if (originalCOMSPEC === undefined) delete process.env.COMSPEC;
else process.env.COMSPEC = originalCOMSPEC;
if (originalSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = originalSystemRoot;
}
});
it('rejects relative Windows root environment values', async () => {
const originalComSpec = process.env.ComSpec;
const originalCOMSPEC = process.env.COMSPEC;
const originalSystemRoot = process.env.SystemRoot;
const originalSYSTEMROOT = process.env.SYSTEMROOT;
const originalWindir = process.env.windir;
try {
process.env.SystemRoot = 'Windows';
delete process.env.SYSTEMROOT;
delete process.env.windir;
process.env.ComSpec = 'Windows\\System32\\cmd.exe';
process.env.COMSPEC = 'C:\\Temp\\cmd.exe';
const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor');
expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe');
} finally {
if (originalComSpec === undefined) delete process.env.ComSpec;
else process.env.ComSpec = originalComSpec;
if (originalCOMSPEC === undefined) delete process.env.COMSPEC;
else process.env.COMSPEC = originalCOMSPEC;
if (originalSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = originalSystemRoot;
if (originalSYSTEMROOT === undefined) delete process.env.SYSTEMROOT;
else process.env.SYSTEMROOT = originalSYSTEMROOT;
if (originalWindir === undefined) delete process.env.windir;
else process.env.windir = originalWindir;
}
});
it('falls back to the absolute default system cmd.exe when Windows root env is unavailable', async () => {
const originalComSpec = process.env.ComSpec;
const originalCOMSPEC = process.env.COMSPEC;
const originalSystemRoot = process.env.SystemRoot;
const originalSYSTEMROOT = process.env.SYSTEMROOT;
const originalWindir = process.env.windir;
try {
delete process.env.ComSpec;
delete process.env.COMSPEC;
delete process.env.SystemRoot;
delete process.env.SYSTEMROOT;
delete process.env.windir;
const { getWindowsEscapedCommandShell } = await import('../../../src/utils/shell-executor');
expect(getWindowsEscapedCommandShell()).toBe('C:\\Windows\\System32\\cmd.exe');
} finally {
if (originalComSpec === undefined) delete process.env.ComSpec;
else process.env.ComSpec = originalComSpec;
if (originalCOMSPEC === undefined) delete process.env.COMSPEC;
else process.env.COMSPEC = originalCOMSPEC;
if (originalSystemRoot === undefined) delete process.env.SystemRoot;
else process.env.SystemRoot = originalSystemRoot;
if (originalSYSTEMROOT === undefined) delete process.env.SYSTEMROOT;
else process.env.SYSTEMROOT = originalSYSTEMROOT;
if (originalWindir === undefined) delete process.env.windir;
else process.env.windir = originalWindir;
}
});
});
@@ -84,6 +84,21 @@ describe('api-routes remote write guard', () => {
expect(response.status).toBe(200);
});
it('blocks remote Codex raw config reads when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/codex/config/raw`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Codex configuration endpoints require localhost access when dashboard auth is disabled.',
});
});
it('allows remote Codex diagnostics when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/codex/diagnostics`);
expect(response.status).toBe(200);
});
it('blocks remote profile creation when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/profiles`, {
method: 'POST',
@@ -9,16 +9,29 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getDashboardAuthConfig } from '../../../src/config/unified-config-loader';
import {
isDashboardWebSocketOriginAllowed,
getDashboardWebSocketRejectionStatus,
isDashboardWebSocketUpgradeAllowed,
} from '../../../src/web-server/middleware/auth-middleware';
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
describe('Dashboard Auth', () => {
let tempDir = '';
let originalDashboardAuthEnabled: string | undefined;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-'));
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
});
afterEach(() => {
if (originalDashboardAuthEnabled === undefined) {
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
} else {
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
}
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
@@ -149,6 +162,96 @@ describe('Dashboard Auth', () => {
});
});
describe('websocket upgrade access', () => {
function makeUpgradeRequest(
remoteAddress: string,
authenticated = false,
headers: Record<string, string> = {}
) {
return {
headers,
socket: { remoteAddress },
session: authenticated ? { authenticated: true } : { authenticated: false },
} as never;
}
it('allows loopback websocket upgrades when dashboard auth is disabled', () => {
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('127.0.0.1'))).toBe(true);
expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('::1'))).toBe(true);
});
it('blocks remote websocket upgrades when dashboard auth is disabled', () => {
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
const request = makeUpgradeRequest('10.10.0.24');
expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false);
expect(getDashboardWebSocketRejectionStatus()).toBe(403);
});
it('blocks cross-site websocket origins when dashboard auth is disabled', () => {
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
const request = makeUpgradeRequest('127.0.0.1', false, {
host: '127.0.0.1:3001',
origin: 'https://evil.example.test',
});
expect(isDashboardWebSocketOriginAllowed(request)).toBe(false);
expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false);
expect(getDashboardWebSocketRejectionStatus(request)).toBe(403);
});
it('requires an authenticated session for websocket upgrades when auth is enabled', () => {
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('127.0.0.1'))).toBe(false);
expect(
isDashboardWebSocketUpgradeAllowed(
makeUpgradeRequest('10.10.0.24', true, {
host: 'dashboard.internal:3001',
origin: 'https://dashboard.internal:3001',
})
)
).toBe(true);
expect(getDashboardWebSocketRejectionStatus()).toBe(401);
});
it('allows same-origin websocket upgrades when auth is enabled', () => {
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
const request = makeUpgradeRequest('10.10.0.24', true, {
host: 'dashboard.example.test:3001',
origin: 'https://dashboard.example.test:3001',
});
expect(isDashboardWebSocketOriginAllowed(request)).toBe(true);
expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(true);
});
it('allows loopback host aliases on the same dashboard port', () => {
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
const request = makeUpgradeRequest('127.0.0.1', true, {
host: '127.0.0.1:3001',
origin: 'http://localhost:3001',
});
expect(isDashboardWebSocketOriginAllowed(request)).toBe(true);
expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(true);
});
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, {
host: '127.0.0.1:3001',
origin: 'https://evil.example.test',
});
expect(isDashboardWebSocketOriginAllowed(request)).toBe(false);
expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false);
expect(getDashboardWebSocketRejectionStatus(request)).toBe(403);
});
});
describe('rate limiting config', () => {
it('rate limit window is 15 minutes', () => {
const windowMs = 15 * 60 * 1000;
@@ -207,6 +207,52 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
expect(requests[0]?.url).toContain('/v0/management/cursor-auth-url?is_webui=true');
});
it('returns actionable Codex guidance when manual OAuth start cannot reach CLIProxy', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
status: 503,
response: 'connect ECONNREFUSED 127.0.0.1:8317',
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(503);
expect(startResponse.body).toMatchObject({
error: 'cliproxy_oauth_start_failed',
provider: 'codex',
message: expect.stringContaining('OpenAI Codex OAuth could not start'),
endpoint: 'http://127.0.0.1:8317/v0/management/codex-auth-url?is_webui=true',
retryCommand: 'ccs codex --auth --paste-callback',
portForwardCommand: 'ssh -L 1455:localhost:1455 <USER>@<HOST>',
});
expect(
(startResponse.body as { hints?: string[] }).hints?.some((hint) =>
hint.includes('ccs codex --auth --port-forward')
)
).toBe(true);
});
it('does not label malformed OAuth start responses as proxy recovery guidance', async () => {
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: 'not-json',
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
expect(startResponse.status).toBe(502);
expect(startResponse.body).toMatchObject({
error: 'cliproxy_oauth_start_invalid_response',
provider: 'codex',
message: 'CLIProxyAPI returned an invalid OAuth start response.',
});
expect((startResponse.body as { hints?: string[] }).hints).toBeUndefined();
});
it('returns wait after callback submission when the local token is not yet available', async () => {
mockFetch([
{
@@ -47,6 +47,24 @@ function writeProfileSettings(
return settingsPath;
}
function createSymlinkIfAvailable(
targetPath: string,
linkPath: string,
type?: fs.symlink.Type
): boolean {
try {
fs.symlinkSync(targetPath, linkPath, type);
return true;
} catch (error) {
const code = (error as { code?: string }).code;
if (process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES')) {
console.warn('Skipping symlink escape regression: symlink creation unavailable on Windows.');
return false;
}
throw error;
}
}
describe('settings-routes image-analysis status', () => {
let server: Server;
let baseUrl = '';
@@ -199,7 +217,7 @@ describe('settings-routes image-analysis status', () => {
});
it('uses the configured custom settings path for status and persistence diagnostics', async () => {
const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json');
const customSettingsPath = path.join(tempHome, '.ccs', 'profiles', 'foo.bar.settings.json');
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
profiles: {
'foo.bar': customSettingsPath,
@@ -231,6 +249,196 @@ describe('settings-routes image-analysis status', () => {
expect(body.imageAnalysisStatus.hookInstalled).toBe(true);
});
it('resolves configured profile and variant relative settings paths under the CCS directory', async () => {
const profileSettingsPath = path.join(tempHome, '.ccs', 'profiles', 'relative.settings.json');
const variantSettingsPath = path.join(
tempHome,
'.ccs',
'variants',
'relative-var.settings.json'
);
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
profiles: {
relative: 'profiles/relative.settings.json',
},
cliproxy: {
'relative-var': {
provider: 'gemini',
settings: 'variants/relative-var.settings.json',
},
},
});
writeProfileSettings(
tempHome,
'relative',
{
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_API_KEY: 'relative-profile-key',
},
profileSettingsPath
);
writeProfileSettings(
tempHome,
'relative-var',
{
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
ANTHROPIC_API_KEY: 'relative-variant-key',
},
variantSettingsPath
);
const profileResponse = await fetch(`${baseUrl}/api/settings/relative/raw`);
expect(profileResponse.status).toBe(200);
const profileBody = (await profileResponse.json()) as { path: string };
expect(profileBody.path).toBe(profileSettingsPath);
const variantResponse = await fetch(`${baseUrl}/api/settings/relative-var/raw`);
expect(variantResponse.status).toBe(200);
const variantBody = (await variantResponse.json()) as { path: string };
expect(variantBody.path).toBe(variantSettingsPath);
});
it('rejects configured profile settings paths outside the CCS directory', async () => {
const outsideSettingsPath = path.join(tempHome, 'outside', 'escaped.settings.json');
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
profiles: {
escaped: outsideSettingsPath,
},
});
writeJson(outsideSettingsPath, {
env: {
ANTHROPIC_AUTH_TOKEN: 'outside-secret',
},
});
const readResponse = await fetch(`${baseUrl}/api/settings/escaped/raw`);
expect(readResponse.status).toBe(500);
const writeResponse = await fetch(`${baseUrl}/api/settings/escaped`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
settings: {
env: {
ANTHROPIC_AUTH_TOKEN: 'overwritten-secret',
},
},
}),
});
expect(writeResponse.status).toBe(500);
const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as {
env: { ANTHROPIC_AUTH_TOKEN: string };
};
expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('outside-secret');
});
it('rejects raw reads through settings-file symlink escapes', async () => {
const outsideSettingsPath = path.join(tempHome, 'outside-raw.settings.json');
const linkedSettingsPath = path.join(tempHome, '.ccs', 'linked.settings.json');
writeJson(outsideSettingsPath, {
env: {
ANTHROPIC_AUTH_TOKEN: 'raw-outside-secret',
},
});
fs.mkdirSync(path.dirname(linkedSettingsPath), { recursive: true });
if (!createSymlinkIfAvailable(outsideSettingsPath, linkedSettingsPath)) {
return;
}
const response = await fetch(`${baseUrl}/api/settings/linked/raw`);
expect(response.status).toBe(500);
const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as {
env: { ANTHROPIC_AUTH_TOKEN: string };
};
expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('raw-outside-secret');
});
it('rejects variant settings paths outside the CCS directory', async () => {
const outsideSettingsPath = path.join(tempHome, 'outside-variant', 'escaped.settings.json');
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
profiles: {},
cliproxy: {
evilvar: {
provider: 'gemini',
settings: outsideSettingsPath,
},
},
});
writeJson(outsideSettingsPath, {
env: {
ANTHROPIC_AUTH_TOKEN: 'variant-outside-secret',
},
});
const response = await fetch(`${baseUrl}/api/settings/evilvar/raw`);
expect(response.status).toBe(500);
const writeResponse = await fetch(`${baseUrl}/api/settings/evilvar`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
settings: {
env: {
ANTHROPIC_AUTH_TOKEN: 'variant-overwritten-secret',
},
},
}),
});
expect(writeResponse.status).toBe(500);
const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as {
env: { ANTHROPIC_AUTH_TOKEN: string };
};
expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('variant-outside-secret');
});
it('rejects variant PUT settings paths that escape through symlinked parents', async () => {
const outsideDir = path.join(tempHome, 'outside-variant-link');
const outsideSettingsPath = path.join(outsideDir, 'escaped.settings.json');
const linkDir = path.join(tempHome, '.ccs', 'variant-link');
const linkedSettingsPath = path.join(linkDir, 'escaped.settings.json');
fs.mkdirSync(outsideDir, { recursive: true });
writeJson(outsideSettingsPath, {
env: {
ANTHROPIC_AUTH_TOKEN: 'variant-link-original',
},
});
fs.mkdirSync(path.dirname(linkDir), { recursive: true });
if (!createSymlinkIfAvailable(outsideDir, linkDir, 'dir')) {
return;
}
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
profiles: {},
cliproxy: {
'linked-var': {
provider: 'gemini',
settings: linkedSettingsPath,
},
},
});
const response = await fetch(`${baseUrl}/api/settings/linked-var`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
settings: {
env: {
ANTHROPIC_AUTH_TOKEN: 'variant-link-overwritten',
},
},
}),
});
expect(response.status).toBe(500);
const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as {
env: { ANTHROPIC_AUTH_TOKEN: string };
};
expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('variant-link-original');
});
it('previews image-analysis status from unsaved editor settings', async () => {
writeProfileSettings(tempHome, 'glm', {
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
@@ -5,6 +5,33 @@ import { startServer } from '../../../src/web-server';
const instances: Array<Awaited<ReturnType<typeof startServer>>> = [];
class MockUpgradeSocket {
data = '';
destroyed = false;
write(chunk: string | Buffer): boolean {
this.data += chunk.toString();
return true;
}
destroy(): void {
this.destroyed = true;
}
}
function dispatchUpgrade(instance: Awaited<ReturnType<typeof startServer>>, url: string) {
const listener = instance.server.listeners('upgrade')[0] as (
request: unknown,
socket: MockUpgradeSocket,
head: Buffer
) => void;
const socket = new MockUpgradeSocket();
listener({ url, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, socket, Buffer.alloc(0));
return socket;
}
afterEach(async () => {
while (instances.length > 0) {
const instance = instances.pop();
@@ -20,12 +47,13 @@ afterEach(async () => {
});
describe('startServer host binding', () => {
it('binds with system-default host when no host is provided', async () => {
it('binds to localhost by default when no host is provided', async () => {
const instance = await startServer({ port: 0 });
instances.push(instance);
const address = instance.server.address() as AddressInfo;
expect(address.port).toBeGreaterThan(0);
expect(['127.0.0.1', '::1']).toContain(address.address);
});
it('binds to an explicit loopback host', async () => {
@@ -66,4 +94,24 @@ describe('startServer host binding', () => {
expect(serverConfig?.middlewareMode).toBe(true);
expect(serverConfig?.hmr?.server).toBe(instance.server);
});
it('rejects unsupported production websocket upgrade paths', async () => {
const instance = await startServer({ port: 0 });
instances.push(instance);
const socket = dispatchUpgrade(instance, '/vite-hmr');
expect(socket.data.startsWith('HTTP/1.1 404')).toBe(true);
expect(socket.destroyed).toBe(true);
});
it('rejects malformed websocket upgrade targets', async () => {
const instance = await startServer({ port: 0 });
instances.push(instance);
const socket = dispatchUpgrade(instance, 'http://[bad');
expect(socket.data.startsWith('HTTP/1.1 400')).toBe(true);
expect(socket.destroyed).toBe(true);
});
});
@@ -378,13 +378,13 @@ export function AddAccountDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-md"
className="grid max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-md grid-rows-[auto_minmax(0,1fr)] overflow-hidden p-0 sm:max-w-md"
onInteractOutside={(e) => {
// Prevent accidental close by clicking outside during auth
if (showAuthUI) e.preventDefault();
}}
>
<DialogHeader>
<DialogHeader className="min-w-0 px-6 pt-6 pr-12">
<DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle>
<DialogDescription>
{isKiro
@@ -395,7 +395,7 @@ export function AddAccountDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="min-h-0 space-y-4 overflow-y-auto px-6 py-4">
{requiresAgyResponsibilityFlow && !showAuthUI && (
<AntigravityResponsibilityChecklist
value={agyRiskChecklist}
@@ -733,7 +733,11 @@ export function AddAccountDialog({
)}
{/* Persist error visibility outside auth-only UI states */}
{errorMessage && <p className="text-xs text-center text-destructive">{errorMessage}</p>}
{errorMessage && (
<p className="text-xs text-center text-destructive whitespace-pre-line">
{errorMessage}
</p>
)}
{/* Kiro import loading */}
{kiroImportMutation.isPending && (
@@ -744,12 +748,17 @@ export function AddAccountDialog({
)}
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleCancel}>
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:items-center sm:justify-end">
<Button variant="ghost" onClick={handleCancel} className="w-full sm:w-auto">
{t('addAccountDialog.cancel')}
</Button>
{isKiro && !showAuthUI && (
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
<Button
variant="outline"
onClick={handleKiroImport}
disabled={isPending}
className="w-full sm:w-auto"
>
{kiroImportMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -766,6 +775,7 @@ export function AddAccountDialog({
{!showAuthUI && (
<Button
onClick={handleAuthenticate}
className="w-full sm:w-auto"
disabled={
isPending ||
isPowerUserModePending ||
@@ -142,8 +142,7 @@ export function AccountsSection({
/>
)}
<User className="w-4 h-4" />
{/* TODO i18n: missing key for "Accounts" */}
Accounts
{t('providerEditor.accounts')}
{accounts.length > 0 && (
<Badge variant="secondary" className="text-xs">
{accounts.length}
@@ -152,7 +151,7 @@ export function AccountsSection({
</h3>
<Button variant="default" size="sm" className="h-7 text-xs gap-1" onClick={onAddAccount}>
<Plus className="w-3 h-3" />
Add
{t('providerEditor.addAccount')}
</Button>
</div>
@@ -23,6 +23,7 @@ interface RawConfigEditorPanelProps {
onRefresh: () => Promise<void> | void;
onDiscard?: () => void;
language?: 'json' | 'yaml' | 'toml';
exactText?: boolean;
loadingLabel?: string;
parseWarningLabel?: string;
ownershipNotice?: ReactNode;
@@ -44,6 +45,7 @@ export function RawConfigEditorPanel({
onRefresh,
onDiscard,
language = 'json',
exactText = false,
loadingLabel = 'Loading settings.json...',
parseWarningLabel = 'Parse warning',
ownershipNotice,
@@ -128,6 +130,7 @@ export function RawConfigEditorPanel({
onChange={onChange}
language={language}
readonly={readOnly}
exactText={exactText}
minHeight="100%"
heightMode="fill-parent"
/>
+7 -5
View File
@@ -177,7 +177,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-lg"
className="grid max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-lg grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden p-0 sm:max-w-lg"
onPointerDownOutside={(e) => {
if (step !== 'success' && step !== 'provider') {
e.preventDefault();
@@ -189,8 +189,8 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
}
}}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DialogHeader className="min-w-0 px-6 pt-6 pr-12">
<DialogTitle className="flex min-w-0 items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
{i18n.t('setupWizard.title')}
</DialogTitle>
@@ -203,7 +203,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="min-h-0 space-y-4 overflow-y-auto px-6 py-4">
{step === 'provider' && (
<ProviderStep providers={PROVIDERS} onSelect={handleProviderSelect} />
)}
@@ -254,7 +254,9 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
{step === 'success' && <SuccessStep variantName={variantName} onClose={onClose} />}
</div>
<ProgressIndicator currentProgress={currentProgress} allSteps={ALL_STEPS} />
<div className="px-6 pb-6">
<ProgressIndicator currentProgress={currentProgress} allSteps={ALL_STEPS} />
</div>
</DialogContent>
</Dialog>
);
@@ -73,12 +73,12 @@ export function VariantStep({
return (
<div className="space-y-4">
{selectedAccount && (
<div className="flex items-start gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" />
<div className="space-y-1">
<div className="flex min-w-0 items-start gap-2 rounded-md bg-muted/50 p-2 text-sm">
<User className="w-4 h-4 shrink-0" />
<div className="min-w-0 space-y-1">
<span>
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
<span className={cn('break-all', privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccountIdentity?.email}
</span>
</span>
@@ -167,16 +167,18 @@ export function VariantStep({
value={catalogModels.some((m) => m.id === modelName) ? modelName : ''}
onValueChange={handleModelSelect}
>
<SelectTrigger>
<SelectTrigger className="min-w-0">
<SelectValue placeholder={t('setupVariant.selectModel')} />
</SelectTrigger>
<SelectContent>
<SelectContent className="max-w-[calc(100vw-2rem)]">
{catalogModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
<div className="flex items-center gap-2">
<span>{m.name}</span>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate">{m.name}</span>
{m.description && (
<span className="text-xs text-muted-foreground">- {m.description}</span>
<span className="truncate text-xs text-muted-foreground">
- {m.description}
</span>
)}
</div>
</SelectItem>
@@ -196,12 +198,12 @@ export function VariantStep({
</div>
</div>
<div className="flex items-center justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
<div className="flex flex-col gap-2 pt-2 sm:flex-row sm:items-center sm:justify-between">
<Button variant="ghost" onClick={onBack} className="justify-start">
<ArrowLeft className="w-4 h-4 mr-2" />
{t('setupVariant.back')}
</Button>
<div className="flex items-center gap-2">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center">
<Button variant="ghost" onClick={onSkip}>
{t('setupVariant.skip')}
</Button>
+103 -24
View File
@@ -4,7 +4,7 @@
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
*/
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useRef, type UIEvent } from 'react';
import Editor from 'react-simple-code-editor';
import { Highlight, themes } from 'prism-react-renderer';
import Prism from 'prismjs';
@@ -24,6 +24,7 @@ interface CodeEditorProps {
onChange: (value: string) => void;
language?: 'json' | 'yaml' | 'toml';
readonly?: boolean;
exactText?: boolean;
className?: string;
minHeight?: string;
heightMode?: 'content' | 'fill-parent';
@@ -95,6 +96,7 @@ export function CodeEditor({
onChange,
language = 'json',
readonly = false,
exactText = false,
className,
minHeight = '300px',
heightMode = 'content',
@@ -170,6 +172,11 @@ export function CodeEditor({
}
const tokenProps = getTokenProps({ token });
// Prism TOML emits a `table` class, which collides with Tailwind's layout utility.
tokenProps.style = {
...tokenProps.style,
display: 'inline',
};
if (isSensitive && isMasked) {
tokenProps.className = cn(
@@ -189,6 +196,14 @@ export function CodeEditor({
),
[isDark, language, validation.line, isMasked]
);
const highlightLayerRef = useRef<HTMLDivElement>(null);
const syncHighlightScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
const layer = highlightLayerRef.current;
if (!layer) return;
const { scrollTop } = event.currentTarget;
layer.style.transform = `translateY(${-scrollTop}px)`;
}, []);
return (
<div
@@ -208,33 +223,97 @@ export function CodeEditor({
data-slot="code-editor-surface"
>
<div
className={cn(isFillParent && 'scrollbar-editor min-h-0 flex-1 overflow-auto')}
className={cn(
isFillParent && 'scrollbar-editor min-h-0 flex-1',
isFillParent && (exactText ? 'overflow-hidden' : 'overflow-auto')
)}
data-slot={isFillParent ? 'code-editor-viewport' : undefined}
>
<Editor
value={value}
onValueChange={readonly ? () => {} : onChange}
highlight={highlightCode}
key={isDark ? 'dark-editor' : 'light-editor'}
padding={12}
disabled={readonly}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
textareaClassName={cn(
'focus:outline-none font-mono text-sm',
readonly && 'cursor-not-allowed'
)}
preClassName="font-mono text-sm"
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '0.875rem',
minHeight,
}}
/>
{exactText ? (
// Exact mode keeps the native textarea as the editable/copyable source of truth while
// rendering Prism colors behind it with the same soft-wrap layout contract.
<div
className={cn(
'relative w-full overflow-hidden',
isFillParent ? 'h-full min-h-full' : 'min-h-[300px]'
)}
style={{
minHeight,
}}
data-slot="code-editor-exact-highlight-wrapper"
>
<div
ref={highlightLayerRef}
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-x-0 top-0 w-full p-3 font-mono text-sm leading-relaxed',
'whitespace-pre-wrap overflow-visible break-words'
)}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '0.875rem',
minHeight,
tabSize: 2,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
data-slot="code-editor-highlight-layer"
>
{highlightCode(value)}
</div>
<textarea
value={value}
onChange={(event) => {
if (!readonly) onChange(event.target.value);
}}
readOnly={readonly}
wrap="soft"
spellCheck={false}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onScroll={syncHighlightScroll}
className={cn(
'absolute inset-0 z-10 block h-full w-full resize-none border-0 bg-transparent p-3 font-mono text-sm leading-relaxed',
'whitespace-pre-wrap overflow-y-auto overflow-x-hidden break-words text-transparent caret-foreground selection:bg-transparent focus:outline-none',
readonly && 'cursor-not-allowed'
)}
style={{
minHeight,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '0.875rem',
tabSize: 2,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
WebkitTextFillColor: 'transparent',
}}
data-slot="code-editor-plain-textarea"
/>
</div>
) : (
<Editor
value={value}
onValueChange={readonly ? () => {} : onChange}
highlight={highlightCode}
key={isDark ? 'dark-editor' : 'light-editor'}
padding={12}
disabled={readonly}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
textareaClassName={cn(
'focus:outline-none font-mono text-sm',
readonly && 'cursor-not-allowed'
)}
preClassName="font-mono text-sm"
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '0.875rem',
minHeight,
}}
/>
)}
</div>
{/* Secrets Toggle Overlay */}
<div className="absolute top-2 right-2 z-10 opacity-50 hover:opacity-100 transition-opacity">
<div className="absolute top-2 right-2 z-20 opacity-50 hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
+17 -11
View File
@@ -69,6 +69,22 @@ async function parseResponseBody(response: Response): Promise<Record<string, unk
}
}
function buildAuthErrorMessage(data: Record<string, unknown>, fallback: string): string {
const message =
typeof data.message === 'string'
? data.message
: typeof data.error === 'string'
? data.error
: fallback;
const hints = Array.isArray(data.hints)
? data.hints.filter(
(hint): hint is string => typeof hint === 'string' && hint.trim().length > 0
)
: [];
return hints.length > 0 ? [message, ...hints].join('\n') : message;
}
/** Initial state for auth flow - extracted for DRY */
const INITIAL_STATE: AuthFlowState = {
provider: null,
@@ -371,17 +387,7 @@ export function useCliproxyAuthFlow() {
const success = data.success === true;
if (!response.ok || !success) {
// For Plus OAuth credential errors the server sends a human-readable
// explanation in `data.message`; prefer it over the machine error code.
const isPlusCredentialError =
data.error === 'plus_oauth_credentials_missing' ||
data.error === 'plus_oauth_url_missing_client_id';
const errorMsg =
isPlusCredentialError && typeof data.message === 'string'
? data.message
: typeof data.error === 'string'
? data.error
: t('toasts.providerStartOAuthFailed');
const errorMsg = buildAuthErrorMessage(data, t('toasts.providerStartOAuthFailed'));
throw new Error(errorMsg);
}
+4 -1
View File
@@ -58,9 +58,12 @@ export interface CodexFeatureCatalogEntry {
export const CLIPROXY_NATIVE_CODEX_RECIPE = `model_provider = "cliproxy"
[model_providers.cliproxy]
name = "CLIProxy Codex"
base_url = "http://127.0.0.1:8317/api/provider/codex"
env_key = "CLIPROXY_API_KEY"
wire_api = "responses"`;
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false`;
export const KNOWN_CODEX_FEATURE_NAMES = [
'multi_agent',
+47 -24
View File
@@ -140,9 +140,9 @@ const resources = {
},
setupVariant: {
using: 'Using:',
variantNameRequired: 'Variant Name *',
variantNameRequired: 'Runtime Variant Name *',
variantNamePlaceholder: 'e.g., my-gemini, g3, flash',
invokeHintPrefix: 'Use this name to invoke:',
invokeHintPrefix: 'Run with this variant:',
model: 'Model',
modelPlaceholder: 'e.g., gemini-2.5-pro, claude-opus-4-6-thinking',
choosePresetInstead: 'Choose from presets instead',
@@ -155,7 +155,7 @@ const resources = {
skip: 'Skip',
creating: 'Creating...',
createVariant: 'Create Variant',
skipHint: 'Skip if you just wanted to add an account without creating a variant',
skipHint: 'Skip if account setup is enough and you do not need a separate runtime variant',
},
customPresetDialog: {
title: 'Custom Preset',
@@ -1277,6 +1277,10 @@ const resources = {
emptyControlPanelPrefix: 'For live usage stats and real-time monitoring, visit the',
controlPanel: 'Control Panel',
quickSetup: 'Quick Setup',
addAccount: 'Add Account',
advancedVariant: 'Advanced Variant',
setupActionsHint:
'Add accounts here. Create variants only when you need a separate runtime/profile.',
accountManagement: 'CCS-level account management',
providers: 'Providers',
variants: 'Variants',
@@ -1783,11 +1787,11 @@ const resources = {
// Domain 2: Accounts / Auth
// ========================================
setupWizard: {
title: 'Quick Setup Wizard',
stepProviderDesc: 'Select a provider to get started',
title: 'Advanced Variant Setup',
stepProviderDesc: 'Select a provider for the runtime variant',
stepAuthDesc: 'Authenticate with your provider',
stepAccountDesc: 'Select which account to use',
stepVariantDesc: 'Create your custom variant',
stepVariantDesc: 'Optional: create a custom runtime variant',
stepSuccessDesc: 'Setup complete!',
authStep: {
authenticateWith: 'Authenticate with {{provider}} to add an account',
@@ -1948,6 +1952,8 @@ const resources = {
status: 'Status',
loadingSettings: 'Loading settings...',
loadingEditor: 'Loading editor...',
accounts: 'Accounts',
addAccount: 'Add Account',
noAccountsConnected: 'No accounts connected',
addAccountToStart: 'Add an account to get started',
gcpProjectIdReadonly: 'GCP Project ID (read-only)',
@@ -2796,9 +2802,9 @@ const resources = {
},
setupVariant: {
using: '当前使用:',
variantNameRequired: '变体名称 *',
variantNameRequired: '运行时变体名称 *',
variantNamePlaceholder: '例如:my-gemini、g3、flash',
invokeHintPrefix: '可通过该名称调用',
invokeHintPrefix: '使用此变体运行',
model: '模型',
modelPlaceholder: '例如:gemini-2.5-pro、claude-opus-4-6-thinking',
choosePresetInstead: '改为从预设中选择',
@@ -2811,7 +2817,7 @@ const resources = {
skip: '跳过',
creating: '创建中...',
createVariant: '创建变体',
skipHint: '如果你只想添加账号而不创建变体,可以直接跳过',
skipHint: '如果账号设置已足够且不需要单独运行时变体,可以跳过',
},
customPresetDialog: {
title: '自定义预设',
@@ -3855,6 +3861,9 @@ const resources = {
emptyControlPanelPrefix: '如需查看实时用量和监控,请前往',
controlPanel: '控制面板',
quickSetup: '快速设置',
addAccount: '添加账号',
advancedVariant: '高级变体',
setupActionsHint: '在这里添加账号。只有需要单独运行时/配置档时才创建变体。',
accountManagement: 'CCS 级账号管理',
providers: '提供商',
variants: '变体',
@@ -4327,11 +4336,11 @@ const resources = {
failedSave: '保存失败',
},
setupWizard: {
title: '快速设置向导',
stepProviderDesc: '选择一个提供商开始',
title: '高级变体设置',
stepProviderDesc: '为运行时变体选择提供商',
stepAuthDesc: '完成提供商认证',
stepAccountDesc: '选择要使用的账号',
stepVariantDesc: '创建自定义变体',
stepVariantDesc: '可选:创建自定义运行时变体',
stepSuccessDesc: '设置完成!',
authStep: {
authenticateWith: '通过 {{provider}} 认证以添加账号',
@@ -4488,6 +4497,8 @@ const resources = {
status: '状态',
loadingSettings: '加载设置中...',
loadingEditor: '加载编辑器中...',
accounts: '账号',
addAccount: '添加账号',
noAccountsConnected: '未连接账号',
addAccountToStart: '添加账号以开始',
gcpProjectIdReadonly: 'GCP 项目 ID(只读)',
@@ -5314,9 +5325,9 @@ const resources = {
},
setupVariant: {
using: 'Sử dụng:',
variantNameRequired: 'Tên biến thể *',
variantNameRequired: 'Tên biến thể runtime *',
variantNamePlaceholder: 'ví dụ: my-gemini, g3, flash',
invokeHintPrefix: 'Sử dụng tên này để gọi:',
invokeHintPrefix: 'Chạy bằng biến thể này:',
model: 'Mô hình',
modelPlaceholder: 'ví dụ: gemini-2.5-pro, claude-opus-4-5',
choosePresetInstead: 'Hoặc chọn từ preset có sẵn',
@@ -5329,7 +5340,7 @@ const resources = {
skip: 'Bỏ qua',
creating: 'Đang tạo...',
createVariant: 'Tạo biến thể',
skipHint: 'Bỏ qua nếu bạn chỉ muốn thêm tài khoản mà không tạo biến thể',
skipHint: 'Bỏ qua nếu thiết lập tài khoản là đủ và không cần biến thể runtime riêng',
},
customPresetDialog: {
title: 'Preset tùy chỉnh',
@@ -6460,6 +6471,9 @@ const resources = {
'Để biết số liệu thống kê sử dụng trực tiếp và theo dõi thời gian thực, hãy truy cập',
controlPanel: 'Bảng điều khiển',
quickSetup: 'Thiết lập nhanh',
addAccount: 'Thêm tài khoản',
advancedVariant: 'Biến thể nâng cao',
setupActionsHint: 'Thêm tài khoản tại đây. Chỉ tạo biến thể khi cần runtime/profile riêng.',
accountManagement: 'Quản lý tài khoản cấp CCS',
providers: 'Nhà cung cấp',
variants: 'Biến thể',
@@ -6940,11 +6954,11 @@ const resources = {
failedSave: 'Không lưu được',
},
setupWizard: {
title: 'Trình thiết lập nhanh',
stepProviderDesc: 'Chọn nhà cung cấp để bắt đầu',
title: 'Thiết lập biến thể nâng cao',
stepProviderDesc: 'Chọn nhà cung cấp cho biến thể runtime',
stepAuthDesc: 'Xác thực với nhà cung cấp',
stepAccountDesc: 'Chọn tài khoản để sử dụng',
stepVariantDesc: 'Tạo biến thể tùy chỉnh',
stepVariantDesc: 'Tùy chọn: tạo biến thể runtime tùy chỉnh',
stepSuccessDesc: 'Thiết lập hoàn tất!',
authStep: {
authenticateWith: 'Xác thực với {{provider}} để thêm tài khoản',
@@ -7101,6 +7115,8 @@ const resources = {
status: 'Trạng thái',
loadingSettings: 'Đang tải cài đặt...',
loadingEditor: 'Đang tải trình soạn thảo...',
accounts: 'Tài khoản',
addAccount: 'Thêm tài khoản',
noAccountsConnected: 'Chưa kết nối tài khoản nào',
addAccountToStart: 'Thêm tài khoản để bắt đầu',
gcpProjectIdReadonly: 'GCP Project ID (chỉ đọc)',
@@ -7941,9 +7957,9 @@ const resources = {
},
setupVariant: {
using: '使用中:',
variantNameRequired: 'バリアント名 *',
variantNameRequired: 'ランタイムバリアント名 *',
variantNamePlaceholder: '例: my-gemini, g3, flash',
invokeHintPrefix: 'この名前で呼び出せます:',
invokeHintPrefix: 'このバリアントで実行:',
model: 'モデル',
modelPlaceholder: '例: gemini-2.5-pro, claude-opus-4-6-thinking',
choosePresetInstead: '代わりにプリセットから選択',
@@ -7956,7 +7972,8 @@ const resources = {
skip: 'スキップ',
creating: '作成中...',
createVariant: 'バリアントを作成',
skipHint: 'アカウント追加だけが目的なら、バリアント作成はスキップできます',
skipHint:
'アカウント設定だけで十分で、別ランタイムバリアントが不要な場合はスキップできます',
},
customPresetDialog: {
title: 'カスタムプリセット',
@@ -9092,6 +9109,10 @@ const resources = {
emptyControlPanelPrefix: 'ライブ利用統計とリアルタイム監視は',
controlPanel: 'コントロールパネルで確認できます',
quickSetup: 'クイックセットアップ',
addAccount: 'アカウントを追加',
advancedVariant: '高度なバリアント',
setupActionsHint:
'ここでアカウントを追加します。別ランタイム/プロファイルが必要な場合のみバリアントを作成します。',
accountManagement: 'CCSレベルのアカウント管理',
providers: 'プロバイダー',
variants: 'バリアント',
@@ -10008,6 +10029,8 @@ const resources = {
status: 'ステータス',
loadingSettings: '設定を読み込み中...',
loadingEditor: 'エディターを読み込み中...',
accounts: 'アカウント',
addAccount: 'アカウントを追加',
noAccountsConnected: '接続されたアカウントなし',
addAccountToStart: 'アカウントを追加して開始',
gcpProjectIdReadonly: 'GCP プロジェクト ID(読み取り専用)',
@@ -10276,11 +10299,11 @@ const resources = {
},
},
setupWizard: {
title: 'クイックセットアップウィザード',
stepProviderDesc: 'プロバイダーを選択して開始',
title: '高度なバリアント設定',
stepProviderDesc: 'ランタイムバリアント用のプロバイダーを選択',
stepAuthDesc: 'プロバイダーで認証',
stepAccountDesc: '使用するアカウントを選択',
stepVariantDesc: 'カスタムバリアントを作成',
stepVariantDesc: '任意: カスタムランタイムバリアントを作成',
stepSuccessDesc: 'セットアップ完了!',
authStep: {
authenticateWith: '{{provider}} で認証してアカウントを追加',
+85 -16
View File
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react';
import { Check, X, RefreshCw, Plus, Zap, GitBranch, Trash2 } from 'lucide-react';
import { QuickSetupWizard } from '@/components/quick-setup-wizard';
import { AddAccountDialog } from '@/components/account/add-account-dialog';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
@@ -183,7 +183,13 @@ function VariantSidebarItem({
}
// Empty state for right panel
function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
function EmptyProviderState({
onAddAccount,
onCreateVariant,
}: {
onAddAccount: () => void;
onCreateVariant: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex-1 flex items-center justify-center bg-muted/20">
@@ -200,10 +206,16 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
</a>
.
</p>
<Button onClick={onSetup} className="gap-2">
<Sparkles className="w-4 h-4" />
{t('cliproxyPage.quickSetup')}
</Button>
<div className="flex flex-col justify-center gap-2 sm:flex-row">
<Button onClick={onAddAccount} className="gap-2">
<Plus className="w-4 h-4" />
{t('cliproxyPage.addAccount')}
</Button>
<Button onClick={onCreateVariant} variant="outline" className="gap-2">
<GitBranch className="w-4 h-4" />
{t('cliproxyPage.advancedVariant')}
</Button>
</div>
</div>
</div>
);
@@ -305,6 +317,38 @@ export function CliproxyPage() {
const parentAuthForVariant = selectedVariantData
? providers.find((p) => p.provider === selectedVariantData.provider)
: undefined;
const selectedAccountTarget =
selectedVariantData && parentAuthForVariant
? {
provider: selectedVariantData.provider,
displayName: parentAuthForVariant.displayName,
accountCount: parentAuthForVariant.accounts?.length || 0,
}
: selectedStatus
? {
provider: selectedStatus.provider,
displayName: selectedStatus.displayName,
accountCount: selectedStatus.accounts?.length || 0,
}
: null;
const fallbackAccountTarget = selectedVariantData
? {
provider: selectedVariantData.provider,
displayName: getProviderDisplayName(selectedVariantData.provider),
accountCount: 0,
}
: selectedProvider && isValidProvider(selectedProvider)
? {
provider: selectedProvider,
displayName: getProviderDisplayName(selectedProvider),
accountCount: 0,
}
: {
provider: 'gemini',
displayName: getProviderDisplayName('gemini'),
accountCount: 0,
};
const accountSetupTarget = selectedAccountTarget ?? fallbackAccountTarget;
const warningProvider = (selectedVariantData?.provider || selectedStatus?.provider || '')
.toLowerCase()
.trim();
@@ -352,6 +396,14 @@ export function CliproxyPage() {
setSelectedProvider(null);
};
const handleAddAccountForSelectedProvider = () => {
setAddAccountProvider({
provider: accountSetupTarget.provider,
displayName: accountSetupTarget.displayName,
isFirstAccount: accountSetupTarget.accountCount === 0,
});
};
return (
<div className="flex h-full min-h-0 overflow-hidden">
{/* Left Sidebar */}
@@ -377,15 +429,29 @@ export function CliproxyPage() {
{t('cliproxyPage.accountManagement')}
</p>
<Button
variant="default"
size="sm"
className="w-full gap-2"
onClick={() => setWizardOpen(true)}
>
<Sparkles className="w-4 h-4" />
{t('cliproxyPage.quickSetup')}
</Button>
<div className="space-y-2">
<Button
variant="default"
size="sm"
className="w-full gap-2"
onClick={handleAddAccountForSelectedProvider}
>
<Plus className="w-4 h-4" />
{t('cliproxyPage.addAccount')}
</Button>
<Button
variant="outline"
size="sm"
className="w-full gap-2"
onClick={() => setWizardOpen(true)}
>
<GitBranch className="w-4 h-4" />
{t('cliproxyPage.advancedVariant')}
</Button>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{t('cliproxyPage.setupActionsHint')}
</p>
</div>
</div>
{/* Providers List */}
@@ -577,7 +643,10 @@ export function CliproxyPage() {
/>
</>
) : (
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
<EmptyProviderState
onAddAccount={handleAddAccountForSelectedProvider}
onCreateVariant={() => setWizardOpen(true)}
/>
)}
</div>
+1
View File
@@ -253,6 +253,7 @@ export function CodexPage() {
onRefresh={refreshAll}
onDiscard={() => setRawDraftText(null)}
language="toml"
exactText
/* TODO i18n: missing key for "Loading config.toml..." */
loadingLabel="Loading config.toml..."
/* TODO i18n: missing key for "TOML warning" */
@@ -15,6 +15,7 @@ const hookState = vi.hoisted(() => ({
>;
}
| undefined,
startAuthData: {} as Record<string, unknown>,
}));
const applyDefaultPresetMock = vi.hoisted(() => vi.fn());
@@ -43,7 +44,7 @@ vi.mock('@/hooks/use-cliproxy', () => ({
useStartAuth: () => ({
isPending: false,
mutate: (_args: unknown, options?: { onSuccess?: (data: Record<string, unknown>) => void }) => {
options?.onSuccess?.({});
options?.onSuccess?.(hookState.startAuthData);
},
}),
useCancelAuth: () => ({
@@ -72,7 +73,22 @@ vi.mock('@/components/setup/wizard/steps/account-step', () => ({
}));
vi.mock('@/components/setup/wizard/steps/variant-step', () => ({
VariantStep: () => <div>variant-step</div>,
VariantStep: ({
variantName,
onVariantNameChange,
}: {
variantName: string;
onVariantNameChange: (value: string) => void;
}) => (
<label>
Runtime Variant Name
<input
aria-label="Runtime Variant Name"
value={variantName}
onChange={(event) => onVariantNameChange(event.target.value)}
/>
</label>
),
}));
vi.mock('@/components/setup/wizard/steps/success-step', () => ({
@@ -84,6 +100,7 @@ import { QuickSetupWizard } from '@/components/setup/wizard';
describe('QuickSetupWizard preset catalog reuse', () => {
beforeEach(() => {
hookState.catalogData = undefined;
hookState.startAuthData = {};
applyDefaultPresetMock.mockReset();
applyDefaultPresetMock.mockResolvedValue({ success: true, presetName: 'Gemini Pro' });
});
@@ -126,4 +143,10 @@ describe('QuickSetupWizard preset catalog reuse', () => {
)
);
});
it('labels the wizard as advanced variant setup', () => {
render(<QuickSetupWizard open onClose={vi.fn()} />);
expect(screen.getByRole('dialog')).toHaveTextContent('Advanced Variant Setup');
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@tests/setup/test-utils';
import { fireEvent, render, screen } from '@tests/setup/test-utils';
import { CodeEditor } from '@/components/shared/code-editor';
@@ -67,4 +67,56 @@ describe('CodeEditor', () => {
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
});
it('renders raw TOML with wrapping syntax color while copied text stays raw', () => {
const rawToml =
'model = "gpt-5.4"\n' +
'[agents.brainstormer]\n' +
'config_file = "agents/brainstormer.toml"\n' +
'[mcp_servers.playwright]\n' +
'command = "node"\n' +
'args = ["--experimental-loader", "./very/long/path/that/should/not/soft/wrap/into/fake/toml/lines.js"]\n';
const { container } = render(
<CodeEditor
value={rawToml}
onChange={vi.fn()}
language="toml"
minHeight="100%"
heightMode="fill-parent"
exactText
/>
);
const textarea = container.querySelector('[data-slot="code-editor-plain-textarea"]');
const highlightLayer = container.querySelector('[data-slot="code-editor-highlight-layer"]');
const highlightedToken = highlightLayer?.querySelector('span');
const tableToken = Array.from(highlightLayer?.querySelectorAll('span') ?? []).find(
(token) => token.textContent === 'agents.brainstormer'
);
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
expect(textarea).toHaveValue(rawToml);
expect(textarea).toHaveAttribute('wrap', 'soft');
expect(textarea).toHaveClass('text-transparent');
expect(textarea).toHaveClass('whitespace-pre-wrap');
expect(textarea).toHaveClass('overflow-x-hidden');
expect(highlightLayer).toBeInTheDocument();
expect(highlightLayer).toHaveClass('whitespace-pre-wrap');
expect(highlightLayer).toHaveStyle({
overflowWrap: 'anywhere',
wordBreak: 'break-word',
});
expect(highlightedToken).toBeInTheDocument();
expect(tableToken).toHaveClass('table');
expect(tableToken).toHaveStyle({ display: 'inline' });
expect(container.querySelector('pre')).not.toBeInTheDocument();
if (textarea instanceof HTMLTextAreaElement && highlightLayer instanceof HTMLElement) {
textarea.scrollLeft = 96;
textarea.scrollTop = 24;
fireEvent.scroll(textarea);
expect(highlightLayer.style.transform).toBe('translateY(-24px)');
}
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
});
});
@@ -86,6 +86,49 @@ describe('useCliproxyAuthFlow', () => {
);
});
it('surfaces manual OAuth start guidance from the dashboard API', async () => {
const guidance = [
'Start local CLIProxy first: ccs cliproxy start',
'For SSH/VPS auth, open a tunnel from your local machine: ssh -L 1455:localhost:1455 <USER>@<HOST>',
'Then run inside that SSH session: ccs codex --auth --port-forward',
];
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/codex/start-url')) {
return Promise.resolve(
createJsonResponse(
{
error: 'cliproxy_oauth_start_failed',
message:
'OpenAI Codex OAuth could not start because CCS could not reach the local CLIProxy management API.',
hints: guidance,
},
503
)
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('codex', { startEndpoint: 'start-url' });
});
expect(result.current.isAuthenticating).toBe(false);
expect(result.current.error).toContain('OpenAI Codex OAuth could not start');
expect(result.current.error).toContain('ccs cliproxy start');
expect(result.current.error).toContain('ssh -L 1455:localhost:1455 <USER>@<HOST>');
expect(result.current.error).not.toContain('cliproxy_oauth_start_failed');
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('ccs cliproxy start'));
});
it('ignores stale poll completions from a superseded auth attempt', async () => {
const firstPoll = createDeferred<Response>();
let startCount = 0;
+66 -4
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
const hookState = vi.hoisted(() => ({
variantsData: { variants: [] as Array<{ name: string; provider: string; target?: string }> },
authData: {
authStatus: [
{
@@ -36,7 +37,7 @@ const hookState = vi.hoisted(() => ({
vi.mock('@/hooks/use-cliproxy', () => ({
useCliproxy: () => ({
data: { variants: [] },
data: hookState.variantsData,
isFetching: false,
}),
useCliproxyAuth: () => ({
@@ -60,7 +61,8 @@ vi.mock('@/hooks/use-cliproxy', () => ({
}));
vi.mock('@/components/quick-setup-wizard', () => ({
QuickSetupWizard: () => null,
QuickSetupWizard: ({ open }: { open: boolean }) =>
open ? <div data-testid="advanced-variant-wizard" /> : null,
}));
vi.mock('@/components/monitoring/proxy-status-widget', () => ({
@@ -82,9 +84,21 @@ vi.mock('@/components/cliproxy/provider-editor', () => ({
}));
vi.mock('@/components/account/add-account-dialog', () => ({
AddAccountDialog: ({ open, catalog }: { open: boolean; catalog?: { defaultModel?: string } }) =>
AddAccountDialog: ({
open,
provider,
catalog,
}: {
open: boolean;
provider?: string;
catalog?: { defaultModel?: string };
}) =>
open ? (
<div data-testid="add-account-dialog" data-catalog={catalog?.defaultModel ?? 'missing'} />
<div
data-testid="add-account-dialog"
data-provider={provider}
data-catalog={catalog?.defaultModel ?? 'missing'}
/>
) : null,
}));
@@ -92,6 +106,7 @@ import { CliproxyPage } from '@/pages/cliproxy';
describe('CliproxyPage add-account catalog gating', () => {
beforeEach(() => {
hookState.variantsData = { variants: [] };
hookState.authData = {
authStatus: [
{
@@ -154,4 +169,51 @@ describe('CliproxyPage add-account catalog gating', () => {
'gemini-3.9-pro-preview'
);
});
it('uses the sidebar setup CTA for account connection instead of variant creation', async () => {
render(<CliproxyPage />);
await userEvent.click(screen.getByRole('button', { name: /add account/i }));
expect(screen.getByTestId('add-account-dialog')).toHaveAttribute('data-provider', 'gemini');
expect(screen.queryByTestId('advanced-variant-wizard')).not.toBeInTheDocument();
});
it('adds accounts against the base provider when a runtime variant is selected', async () => {
hookState.variantsData = {
variants: [{ name: 'gemini-parallel', provider: 'gemini', target: 'claude' }],
};
render(<CliproxyPage />);
await userEvent.click(screen.getByRole('button', { name: /gemini-parallel/i }));
await userEvent.click(screen.getByRole('button', { name: /add account/i }));
expect(screen.getByTestId('add-account-dialog')).toHaveAttribute('data-provider', 'gemini');
expect(screen.queryByTestId('advanced-variant-wizard')).not.toBeInTheDocument();
});
it('surfaces account setup from the empty state before provider data is available', async () => {
hookState.authData = {
authStatus: [],
source: 'local',
};
render(<CliproxyPage />);
const addAccountButtons = screen.getAllByRole('button', { name: /add account/i });
await userEvent.click(addAccountButtons[addAccountButtons.length - 1]);
expect(screen.getByTestId('add-account-dialog')).toHaveAttribute('data-provider', 'gemini');
expect(screen.queryByTestId('advanced-variant-wizard')).not.toBeInTheDocument();
});
it('keeps advanced variant creation available as a secondary action', async () => {
render(<CliproxyPage />);
await userEvent.click(screen.getByRole('button', { name: /advanced variant/i }));
expect(screen.getByTestId('advanced-variant-wizard')).toBeInTheDocument();
expect(screen.queryByTestId('add-account-dialog')).not.toBeInTheDocument();
});
});