mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'], {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)), {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user