Merge pull request #933 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-08 22:46:02 -04:00
commit c6694fcda2
155 files changed
+9950 -756

No files matched your search

+3 -1
View File
@@ -82,7 +82,9 @@ config. Deep dive:
![CLIProxy API](assets/screenshots/cliproxyapi.webp)
Manage OAuth-backed providers, quota visibility, and routing from one place.
Manage OAuth-backed providers, quota visibility, and proxy-wide routing from one place. CCS now
surfaces round-robin vs fill-first natively in both CLI and dashboard flows instead of hiding that
choice inside raw upstream controls.
Deep dive:
[CLIProxy API](https://docs.ccs.kaitran.ca/features/proxy/cliproxy-api).
+9 -1
View File
@@ -1,6 +1,6 @@
# CCS Code Standards
Last Updated: 2026-02-04
Last Updated: 2026-04-07
Code standards, modularization patterns, and conventions for the CCS codebase.
@@ -383,6 +383,14 @@ export type {
## Terminal Output Standards
### CCS Logging Standards
- Use the shared logger from `src/services/logging/` for CCS-owned runtime diagnostics, request tracing, and structured events.
- Keep `utils/ui` and deliberate `console.log`/`console.error` output for user-facing CLI UX only.
- Redact secrets before persistence; never write raw tokens, cookies, API keys, or password hashes into CCS-owned logs.
- Persist CCS-owned logs only under `getCcsDir()/logs`; do not invent per-feature log roots.
- When adding dashboard polling or diagnostics routes, prevent them from recursively logging the log viewer itself.
### ASCII Only
```typescript
+9 -1
View File
@@ -1,6 +1,6 @@
# CCS Codebase Summary
Last Updated: 2026-03-28
Last Updated: 2026-04-07
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, account-context validation hardening, Official Claude Channels runtime support, and native Codex runtime target support.
@@ -269,6 +269,14 @@ src/
- Auto-enable is gated on Bun availability, verified Claude Code v2.1.80+, verified `claude.ai` auth, native Claude `default/account` sessions, and per-channel setup readiness.
- The dashboard channels section surfaces Bun/version/auth/state-scope status from `/api/channels`, preserves token drafts when save-follow-up refresh fails, and keeps unsupported selected iMessage visible only so it can be turned off.
### Structured Logging Domain
- CCS-owned runtime logging now lives in `src/services/logging/`.
- The shared domain owns path resolution, redaction, rotation/pruning, buffered recent-entry reads, and the logger factory used by CLI/server/runtime code.
- Dashboard exposure lives in `src/web-server/routes/logs-routes.ts`, `src/web-server/services/logs-dashboard-service.ts`, and `src/web-server/middleware/request-logging-middleware.ts`.
- The native dashboard viewer lives at `ui/src/pages/logs.tsx` with supporting components under `ui/src/components/logs/` and hooks in `ui/src/hooks/use-logs.ts`.
- Legacy CLIProxy error files still exist under `~/.ccs/cliproxy/logs` and are surfaced as a labeled legacy source rather than the primary CCS logging model.
### Target Adapter Module
The targets module provides an extensible interface for dispatching profiles to different CLI implementations.
+3 -1
View File
@@ -1,6 +1,6 @@
# Dashboard Authentication CLI
Last Updated: 2026-03-23
Last Updated: 2026-04-06
CLI commands for managing CCS dashboard authentication.
@@ -10,6 +10,8 @@ The CCS dashboard (`ccs config`) can be protected with username/password authent
Authentication is **disabled by default** for backward compatibility. Use the CLI to configure and enable it.
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.
When auth stays disabled, CCS now applies a localhost-only fallback on sensitive management endpoints. Remote devices can still open the dashboard UI when you intentionally bind it beyond loopback, but write-capable routes such as AI Provider management and CLIProxy auth/status helpers reject non-loopback requests until you enable dashboard auth.
## Account Context Modes (Related Feature)
+13 -2
View File
@@ -8,7 +8,7 @@ Native Claude accounts keep Anthropic's own vision flow.
Third-party profiles now use a CCS-managed local MCP tool named `ImageAnalysis` when the runtime is available. CCS also appends a short steering hint so Claude prefers that tool over `Read` for local image and PDF files.
If the managed runtime, auth, or proxy path is unavailable, CCS falls back to native `Read` instead of failing the whole launch. The old `Read` hook remains only as a compatibility fallback when it can be installed safely.
Healthy Claude-target launches suppress the legacy CCS `Read` hook so MCP stays authoritative. If the managed runtime cannot be provisioned, CCS keeps the old `Read` hook available only as a compatibility fallback when that path is still viable. If runtime/auth/proxy readiness is degraded beyond that, CCS falls back to native `Read` instead of failing the whole launch.
## Routing Model
@@ -29,7 +29,8 @@ Important:
|--------------|--------------|
| Claude `default` / `account` | Native Claude vision / native `Read` |
| Third-party settings / CLIProxy / Copilot | CCS local `ImageAnalysis` MCP tool when ready |
| Third-party when runtime unavailable | Native `Read` fallback |
| Third-party when MCP provisioning fails but provider-backed analysis is still viable | Legacy CCS `Read` hook fallback |
| Third-party when runtime/auth/proxy is unavailable | Native `Read` fallback |
## Configuration
@@ -79,18 +80,28 @@ Key runtime env vars:
| Variable | Purpose |
|----------|---------|
| `CCS_IMAGE_ANALYSIS_SKIP` | Disable image analysis for the current launch |
| `CCS_IMAGE_ANALYSIS_SKIP_HOOK` | Suppress only the legacy CCS `Read` hook while keeping MCP ImageAnalysis available |
| `CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL` | Explicit CCS runtime base URL |
| `CCS_IMAGE_ANALYSIS_RUNTIME_PATH` | Provider route such as `/api/provider/agy` |
| `CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY` | Explicit CCS runtime auth key |
| `CCS_IMAGE_ANALYSIS_MODEL` | Force a single image-analysis model |
| `CCS_DEBUG` | Verbose runtime logging |
## Self-Heal
CCS now auto-heals stale managed image-analysis state in three places:
- Healthy Claude launches remove stale CCS-managed image `Read` hooks from the active profile settings before launch.
- `Settings -> Image` save/provisioning repairs managed MCP runtime files, syncs managed MCP entries into isolated Claude config dirs, and cleans stale CCS-managed image hooks from `~/.ccs/*.settings.json`.
- `ccs doctor --fix` repairs invalid image-analysis config, removes stale CCS-managed image hooks, and resyncs managed `ccs-image-analysis` MCP entries into isolated configs.
## Troubleshooting
### Claude still uses `Read`
- Confirm `ccs config image-analysis` shows `enabled: true`
- Check the active profile resolves to a configured backend
- Run `ccs doctor --fix` to repair stale managed hooks or missing managed MCP sync
- Run with `CCS_DEBUG=1` to see runtime preparation details
### ImageAnalysis is not exposed
+10 -3
View File
@@ -1,6 +1,6 @@
# CCS Product Development Requirements (PDR)
Last Updated: 2026-04-02
Last Updated: 2026-04-08
## Product Overview
@@ -39,6 +39,7 @@ CCS provides:
7. **Automatic Image Analysis**: First-class local ImageAnalysis tool with direct provider routing for third-party profiles
8. **Usage Analytics**: Token tracking, cost analysis, model breakdown
9. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage
10. **Routing Strategy Guidance**: First-class `round-robin` vs `fill-first` controls in CLI and dashboard, with explicit opt-in changes and no account-based guessing
---
@@ -104,8 +105,10 @@ CCS provides:
- Expose a CCS-managed local `ImageAnalysis` MCP tool for third-party profiles that need provider-backed vision
- Resolve the provider route before launch and send requests directly to `/api/provider/<backend>/v1/messages`
- Use editable prompt templates for `default`, `screenshot`, and `document` analysis modes
- Keep the old `Read` hook as compatibility fallback only, not the primary user experience
- Fall back to native `Read` without failing the whole launch when the managed runtime is unavailable
- Suppress the old CCS-managed `Read` hook during healthy MCP launches so it cannot compete with the primary path
- Keep the old `Read` hook as compatibility fallback only when MCP provisioning fails but provider-backed analysis is still viable
- Auto-heal stale CCS-managed image hooks and missing isolated MCP sync through launch-time cleanup, dashboard provisioning, and `ccs doctor --fix`
- Fall back to native `Read` without failing the whole launch when managed runtime, auth, or proxy readiness is unavailable
### FR-008: Remote CLIProxy Support
- Connect to remote CLIProxyAPI instances
@@ -118,6 +121,10 @@ CCS provides:
### FR-009: Quota Management (v7.14)
- Pause/resume individual accounts via `ccs cliproxy pause/resume <account>`
- Check quota status via `ccs cliproxy status [account]`
- Inspect the current proxy-wide routing strategy via `ccs cliproxy routing`
- Explicitly switch `round-robin` vs `fill-first` from CLI or dashboard
- Keep `round-robin` as the default until the user explicitly changes it
- Never infer routing strategy from account count, tier mix, or paused/default account state
- Auto-failover when account exhausted
- Tier detection: free/paid/unknown
- Pre-flight quota checks before session start
+8 -2
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-04-04
Last Updated: 2026-04-08
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,7 +41,13 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank.
- **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically.
- **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition.
- **2026-04-07**: **#926** CCS now has a first-class structured logging layer under `src/services/logging/`, a bounded top-level `logging` config section in `~/.ccs/config.yaml`, automatic rotation/retention for CCS-owned logs under `~/.ccs/logs/`, native `/api/logs` dashboard endpoints, request tracing for the dashboard backend, and a dedicated `System -> Logs` dashboard route for browsing recent entries and editing retention settings. Legacy CLIProxy error files remain available as a labeled legacy source instead of acting as the primary logging model.
- **2026-04-06**: The dashboard login surface now distinguishes a real sign-in from a host-setup requirement. Remote/IP visitors no longer see a misleading blank credential form when dashboard auth is disabled or incomplete; they now get explicit guidance that CCS has no default credentials, should be enabled on the host with `ccs config auth setup`, or should be reopened via localhost when used on the same machine. The password field now includes a show/hide toggle, and the page exposes an explicit light/dark theme switch before sign-in.
- **2026-04-04**: The GitHub README was reduced from a wall-of-text reference dump into a shorter conversion surface that keeps the hero, proof screenshots, and fast-start commands while delegating deeper installation, provider, feature, and CLI-reference content to `docs.ccs.kaitran.ca`. The docs site now includes a dedicated `Product Tour` page for the screenshot-led walkthrough.
- **2026-04-05**: **#912 #913 #914** Kiro auth is now aligned with the current CLIProxyAPIPlus contract. CCS auto-selects the Builder ID path for the default `ccs kiro --auth` flow instead of stalling on the upstream Builder ID vs IDC chooser, callback-based Kiro auth methods can use `--paste-callback` by replaying the pasted redirect URL back into the local callback server, and the CLI now supports IDC auth via `--kiro-auth-method idc` plus `--kiro-idc-start-url`, `--kiro-idc-region`, and `--kiro-idc-flow`.
- **2026-04-03**: CCS CLI help and completion UX was refreshed. Root help is now shorter and task-oriented, `ccs help <topic|command>` routes to topic-aware help, and shell completions now delegate to the hidden `ccs __complete` backend.
- **2026-04-02**: Third-party image and PDF analysis now follows the same first-class local-tool model as WebSearch. CCS provisions `ccs-image-analysis` as a managed MCP tool, routes requests directly to provider-scoped CCS endpoints such as `/api/provider/agy/v1/messages`, keeps editable prompt templates under `~/.ccs/prompts/image-analysis/`, and demotes the old `Read` hook to a best-effort compatibility fallback. Launches now stay non-fatal and fall back to native `Read` when the managed runtime cannot be prepared.
- **2026-04-01**: The `Compatible -> Codex CLI` dashboard now exposes manual long-context controls for `model_context_window` and `model_auto_compact_token_limit`. CCS reads and patches those upstream Codex config keys directly, adds official guidance that GPT-5.4 long context is experimental and opt-in, and keeps the behavior manual-only so the dashboard never auto-fills or auto-saves long-context values for the user.
@@ -57,7 +63,7 @@ All major modularization work is complete. The codebase evolved from monolithic
- **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint.
- **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo.
- **#744**: API profile creation now keeps featured providers in a horizontal rail with scroll fallback, moves Anthropic Direct API to the end, reuses the shared Claude logo, and separates the custom-endpoint entry point from advanced template discovery.
- **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and auto-repairs stale paid-only Codex defaults when the active account is on the free plan.
- **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and uses runtime fallback handling for unsupported paid-only models without rewriting the saved dashboard settings.
- **#737**: Dashboard model pickers in Cursor, Copilot, and CLIProxy now use a searchable combobox with autofocus and explicit no-results states for large model catalogs.
- **#736**: `ccs config` now supports explicit dashboard bind hosts via `--host`, and surfaces remote-access warnings plus reachable URLs when the effective bind is non-loopback.
+10 -1
View File
@@ -1,6 +1,6 @@
# CCS System Architecture
Last Updated: 2026-03-28
Last Updated: 2026-04-07
High-level architecture overview for the CCS (Claude Code Switch) system.
@@ -18,6 +18,7 @@ The system consists of two main components:
Dashboard localization (i18n) architecture and contributor workflow are documented in [Dashboard i18n Guide](../i18n-dashboard.md).
CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types.
CCS v7.67 adds a native structured logging lane for CCS-owned runtime events, backed by `src/services/logging/`, bounded JSONL files under `~/.ccs/logs/`, and a dedicated dashboard `/logs` route.
```
+===========================================================================+
@@ -216,6 +217,14 @@ For detailed provider flows (CLIProxyAPI, legacy GLMT compatibility, quota manag
## Configuration Architecture
### CCS Logging Architecture
- Shared logging contract lives in `src/services/logging/` and is used for CCS-owned runtime diagnostics, request tracing, and bounded recent-entry reads.
- Config lives at top-level `logging.*` in `~/.ccs/config.yaml`; `cliproxy.logging.*` still controls upstream CLIProxy runtime files only.
- CCS-owned runtime logs write to `~/.ccs/logs/current.jsonl` and rotate into `~/.ccs/logs/archive/` based on policy.
- Dashboard exposure uses native `/api/logs/config`, `/api/logs/sources`, and `/api/logs/entries` endpoints plus the `System -> Logs` React page.
- Request logging explicitly skips `/api/logs` reads so the log viewer does not recursively log itself.
### Config File Hierarchy
```
+5
View File
@@ -380,6 +380,11 @@ function outputSuccess(filePath, description, model, fileSize) {
* Determine if hook should skip, with debug logging
*/
function shouldSkipHook() {
if (process.env.CCS_IMAGE_ANALYSIS_SKIP_HOOK === '1') {
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP_HOOK=1');
return true;
}
// Explicit skip signal
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') {
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1');
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.66.1",
"version": "7.66.1-dev.6",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+72 -40
View File
@@ -37,7 +37,10 @@ import {
appendThirdPartyImageAnalysisToolArgs,
} from './utils/image-analysis';
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
} from './utils/hooks/image-analyzer-profile-hook-injector';
import {
applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv,
@@ -65,6 +68,7 @@ import { tryHandleRootCommand } from './commands/root-command-router';
import { execClaude } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
// Import target adapter system
import {
@@ -318,6 +322,7 @@ async function main(): Promise<void> {
registerTarget(new ClaudeAdapter());
registerTarget(new DroidAdapter());
registerTarget(new CodexAdapter());
const cliLogger = createLogger('cli');
const args = process.argv.slice(2);
const isCompletionCommand = args[0] === '__complete';
@@ -395,6 +400,12 @@ async function main(): Promise<void> {
return;
}
cliLogger.info('command.start', 'CLI invocation started', {
command: args[0] || 'default',
argCount: args.length,
flags: args.filter((arg) => arg.startsWith('-')).slice(0, 20),
});
if (shouldPassthroughNativeCodexFlagCommand(args)) {
execNativeCodexFlagCommand(args);
return;
@@ -445,6 +456,9 @@ async function main(): Promise<void> {
recovery.showRecoveryHints();
}
} catch (err) {
cliLogger.warn('recovery.failed', 'Auto-recovery failed during CLI startup', {
message: (err as Error).message,
});
// Recovery is best-effort - don't block basic CLI functionality
console.warn('[!] Recovery failed:', (err as Error).message);
}
@@ -711,22 +725,30 @@ async function main(): Promise<void> {
if (profileInfo.type === 'cliproxy') {
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
const imageAnalysisMcpReady =
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
}
const imageAnalysisFallbackHookReady =
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
cliproxyProvider: provider,
isComposite: profileInfo.isComposite,
settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const expandedCliproxySettingsPath = profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
: undefined;
if (resolvedTarget === 'claude') {
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath);
} else {
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
cliproxyProvider: provider,
isComposite: profileInfo.isComposite,
settingsPath: expandedCliproxySettingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
}
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
const variantPort = profileInfo.port; // variant-specific port for isolation
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
@@ -757,6 +779,9 @@ async function main(): Promise<void> {
'--port-forward',
'--nickname',
'--kiro-auth-method',
'--kiro-idc-start-url',
'--kiro-idc-region',
'--kiro-idc-flow',
'--backend',
'--proxy-host',
'--proxy-port',
@@ -880,15 +905,19 @@ async function main(): Promise<void> {
} else if (profileInfo.type === 'copilot') {
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
const imageAnalysisFallbackHookReady =
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
// Inject Image Analyzer hook into profile settings before launch
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
if (resolvedTarget === 'claude') {
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(profileInfo.name);
} else {
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
}
const { executeCopilotProfile } = await import('./copilot');
const copilotConfig = profileInfo.copilotConfig;
@@ -978,8 +1007,6 @@ async function main(): Promise<void> {
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
const imageAnalysisFallbackHookReady =
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
const expandedSettingsPath =
resolvedSettingsPath ??
(profileInfo.settingsPath
@@ -987,14 +1014,22 @@ async function main(): Promise<void> {
: getSettingsPath(profileInfo.name));
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
let imageAnalysisFallbackHookReady: boolean | undefined;
if (resolvedTarget === 'claude') {
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath);
} else {
imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profileInfo.name,
profileType: profileInfo.type,
settingsPath: expandedSettingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
}
if (resolvedTarget !== 'claude') {
const compatibility = evaluateTargetRuntimeCompatibility({
target: resolvedTarget,
@@ -1113,14 +1148,11 @@ async function main(): Promise<void> {
apiKey: runtimeConnection.apiKey,
allowSelfSigned: runtimeConnection.allowSelfSigned,
});
if (!imageAnalysisMcpReady) {
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
}
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_IMAGE_ANALYSIS_SKIP_HOOK:
resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0',
};
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
if (
+83 -10
View File
@@ -22,14 +22,19 @@ import {
* - aws-authcode: AWS Builder ID via Authorization Code flow (CLI flag only)
* - google: Social OAuth via Google
* - github: Social OAuth via GitHub (management API only)
* - idc: IAM Identity Center (IDC) via CLI flags with start URL + region
*/
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const;
export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github', 'idc'] as const;
export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number];
/** CLI binary supports these Kiro methods directly via flags. */
export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google'] as const;
export const KIRO_CLI_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'idc'] as const;
export type KiroCLIAuthMethod = (typeof KIRO_CLI_AUTH_METHODS)[number];
export const KIRO_IDC_FLOWS = ['authcode', 'device'] as const;
export type KiroIDCFlow = (typeof KIRO_IDC_FLOWS)[number];
export const DEFAULT_KIRO_IDC_FLOW: KiroIDCFlow = 'authcode';
/** Default Kiro method for CCS UX and AWS Organization support. */
export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws';
@@ -41,18 +46,40 @@ export function isKiroCLIAuthMethod(value: string): value is KiroCLIAuthMethod {
return KIRO_CLI_AUTH_METHODS.includes(value as KiroCLIAuthMethod);
}
export function isKiroIDCFlow(value: string): value is KiroIDCFlow {
return KIRO_IDC_FLOWS.includes(value as KiroIDCFlow);
}
export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod {
if (!value) return DEFAULT_KIRO_AUTH_METHOD;
const normalized = value.trim().toLowerCase();
return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD;
}
export function isKiroDeviceCodeMethod(method: KiroAuthMethod): boolean {
return method === 'aws';
export function normalizeKiroIDCFlow(value?: string): KiroIDCFlow {
if (!value) return DEFAULT_KIRO_IDC_FLOW;
const normalized = value.trim().toLowerCase();
return isKiroIDCFlow(normalized) ? normalized : DEFAULT_KIRO_IDC_FLOW;
}
export function getKiroCallbackPort(method: KiroAuthMethod): number | null {
return isKiroDeviceCodeMethod(method) ? null : 9876;
export function isKiroDeviceCodeMethod(
method: KiroAuthMethod,
options?: { idcFlow?: KiroIDCFlow }
): boolean {
if (method === 'aws') {
return true;
}
if (method === 'idc') {
return normalizeKiroIDCFlow(options?.idcFlow) === 'device';
}
return false;
}
export function getKiroCallbackPort(
method: KiroAuthMethod,
options?: { idcFlow?: KiroIDCFlow }
): number | null {
return isKiroDeviceCodeMethod(method, options) ? null : 9876;
}
export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string {
@@ -63,19 +90,49 @@ export function getKiroCLIAuthFlag(method: KiroCLIAuthMethod): string {
return '--kiro-aws-authcode';
case 'google':
return '--kiro-google-login';
case 'idc':
return '--kiro-idc-login';
}
}
export function getKiroCLIAuthArgs(
method: KiroCLIAuthMethod,
options?: {
idcStartUrl?: string;
idcRegion?: string;
idcFlow?: KiroIDCFlow;
}
): string[] {
if (method !== 'idc') {
return [getKiroCLIAuthFlag(method)];
}
const startUrl = options?.idcStartUrl?.trim();
if (!startUrl) {
throw new Error('Kiro IDC login requires --kiro-idc-start-url');
}
const args = [getKiroCLIAuthFlag('idc'), '--kiro-idc-start-url', startUrl];
const region = options?.idcRegion?.trim();
if (region) {
args.push('--kiro-idc-region', region);
}
args.push('--kiro-idc-flow', normalizeKiroIDCFlow(options?.idcFlow));
return args;
}
/**
* Kiro method for CLIProxyAPI management endpoint:
* GET /v0/management/kiro-auth-url?method=<value>
*/
export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' {
export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' | 'github' | null {
switch (method) {
case 'google':
return 'google';
case 'github':
return 'github';
case 'idc':
return null;
case 'aws-authcode':
return 'aws';
case 'aws':
@@ -258,10 +315,20 @@ export function getManagementAuthUrlPath(provider: CLIProxyProvider): string {
return `/v0/management/${authUrlProvider}-auth-url?is_webui=true`;
}
export function getPasteCallbackStartPath(provider: CLIProxyProvider): string {
// Kiro CLI auth methods still use the legacy start route.
export function getPasteCallbackStartPath(
provider: CLIProxyProvider,
options?: { kiroMethod?: KiroAuthMethod }
): string | null {
if (provider === 'kiro') {
return `/oauth/${provider}/start`;
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
if (kiroMethod === 'aws-authcode' || kiroMethod === 'idc') {
return null;
}
const managementMethod = toKiroManagementMethod(kiroMethod);
if (!managementMethod) {
return null;
}
return `${getManagementAuthUrlPath(provider)}&method=${encodeURIComponent(managementMethod)}`;
}
return getManagementAuthUrlPath(provider);
}
@@ -294,6 +361,12 @@ export interface OAuthOptions {
acceptAgyRisk?: boolean;
/** Kiro auth method override (CLI + Dashboard parity). */
kiroMethod?: KiroAuthMethod;
/** Kiro IDC start URL (required when kiroMethod=idc). */
kiroIDCStartUrl?: string;
/** Kiro IDC region override. */
kiroIDCRegion?: string;
/** Kiro IDC flow override (authcode or device). */
kiroIDCFlow?: KiroIDCFlow;
/** If true, triggered from Web UI (enables project selection prompt) */
fromUI?: boolean;
/** If true, use --no-incognito flag (Kiro only - use normal browser instead of incognito) */
+269 -72
View File
@@ -32,8 +32,9 @@ import {
import {
OAuthOptions,
DEFAULT_KIRO_AUTH_METHOD,
DEFAULT_KIRO_IDC_FLOW,
getKiroCallbackPort,
getKiroCLIAuthFlag,
getKiroCLIAuthArgs,
isKiroCLIAuthMethod,
isKiroDeviceCodeMethod,
getOAuthConfig,
@@ -42,9 +43,17 @@ import {
getPasteCallbackStartPath,
getManagementOAuthCallbackPath,
normalizeKiroAuthMethod,
normalizeKiroIDCFlow,
} from './auth-types';
import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector';
import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager';
import {
ProviderTokenSnapshot,
findNewTokenSnapshotForAuthAttempt,
getProviderTokenDir,
isAuthenticated,
listProviderTokenSnapshots,
registerAccountFromToken,
} from './token-manager';
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
import {
@@ -69,18 +78,23 @@ interface PasteCallbackStartData {
}
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
export async function requestPasteCallbackStart(
provider: CLIProxyProvider,
target: ProxyTarget
target: ProxyTarget,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
): Promise<PasteCallbackStartData> {
const startPath = getPasteCallbackStartPath(provider);
const startPath = getPasteCallbackStartPath(provider, {
kiroMethod: options?.kiroMethod,
});
if (!startPath) {
throw new Error(
`Paste-callback start is not available for ${provider} with the selected method`
);
}
const response = await fetch(buildProxyUrl(target, startPath), {
...(provider === 'kiro' ? { method: 'POST' } : {}),
headers:
provider === 'kiro'
? buildManagementHeaders(target, { 'Content-Type': 'application/json' })
: buildManagementHeaders(target),
headers: buildManagementHeaders(target),
});
if (!response.ok) {
@@ -116,6 +130,91 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseAuthUrlState(url: string | null | undefined): string | null {
if (!url) {
return null;
}
try {
return new URL(url).searchParams.get('state');
} catch {
return null;
}
}
export function findNewTokenSnapshotForManualAuth(
provider: CLIProxyProvider,
tokenDir: string,
knownTokenFiles: ProviderTokenSnapshot[],
expectedAccountId?: string
): ProviderTokenSnapshot | null {
return findNewTokenSnapshotForAuthAttempt(provider, tokenDir, knownTokenFiles, expectedAccountId);
}
async function waitForManualCallbackToken(
provider: CLIProxyProvider,
target: ProxyTarget,
tokenDir: string,
oauthState: string | null,
knownTokenFiles: ProviderTokenSnapshot[],
expectedAccountId: string | undefined,
timeoutMs: number,
pollIntervalMs: number = PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS
): Promise<{ tokenSnapshot: ProviderTokenSnapshot | null; error?: string }> {
const deadline = Date.now() + timeoutMs;
let upstreamCompletedAt: number | null = null;
while (Date.now() < deadline) {
const tokenSnapshot = findNewTokenSnapshotForManualAuth(
provider,
tokenDir,
knownTokenFiles,
expectedAccountId
);
if (tokenSnapshot) {
return { tokenSnapshot };
}
if (oauthState) {
const response = await fetch(
buildProxyUrl(
target,
`/v0/management/get-auth-status?state=${encodeURIComponent(oauthState)}`
),
{ headers: buildManagementHeaders(target) }
);
if (response.ok) {
const data = (await response.json()) as { status?: string; error?: string };
if (data.status === 'error') {
return {
tokenSnapshot: null,
error: data.error || 'Authentication failed while waiting for local token persistence',
};
}
if (data.status === 'ok' && upstreamCompletedAt === null) {
upstreamCompletedAt = Date.now();
}
}
}
if (
upstreamCompletedAt !== null &&
Date.now() - upstreamCompletedAt >= POLLED_AUTH_LOCAL_TOKEN_GRACE_MS
) {
break;
}
if (Date.now() + pollIntervalMs >= deadline) {
break;
}
await sleep(pollIntervalMs);
}
return { tokenSnapshot: null };
}
export async function resolvePasteCallbackAuthUrl(
target: ProxyTarget,
startData: PasteCallbackStartData,
@@ -297,6 +396,57 @@ async function prepareBinary(
}
}
function buildOAuthArgs(
provider: CLIProxyProvider,
configPath: string,
headless: boolean,
noIncognito: boolean,
options: {
kiroMethod?: OAuthOptions['kiroMethod'];
kiroIDCStartUrl?: string;
kiroIDCRegion?: string;
kiroIDCFlow?: OAuthOptions['kiroIDCFlow'];
} = {}
): string[] {
const args = ['--config', configPath];
if (provider === 'kiro') {
const method = normalizeKiroAuthMethod(options.kiroMethod);
if (!isKiroCLIAuthMethod(method)) {
throw new Error(`Kiro auth method '${method}' is not supported by CLI flow.`);
}
args.push(
...getKiroCLIAuthArgs(method, {
idcStartUrl: options.kiroIDCStartUrl,
idcRegion: options.kiroIDCRegion,
idcFlow: options.kiroIDCFlow,
})
);
} else {
args.push(getOAuthConfig(provider).authFlag);
}
if (headless) {
args.push('--no-browser');
}
if (provider === 'kiro' && noIncognito) {
args.push('--no-incognito');
}
return args;
}
export function usesKiroLocalCallbackReplay(
method: OAuthOptions['kiroMethod'],
idcFlow: OAuthOptions['kiroIDCFlow']
): boolean {
const normalizedMethod = normalizeKiroAuthMethod(method);
if (normalizedMethod === 'aws-authcode') {
return true;
}
return normalizedMethod === 'idc' && normalizeKiroIDCFlow(idcFlow) === 'authcode';
}
/**
* Handle paste-callback mode: show auth URL, prompt for callback paste
* Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote)
@@ -307,7 +457,8 @@ async function handlePasteCallbackMode(
verbose: boolean,
tokenDir: string,
nickname?: string,
expectedAccountId?: string
expectedAccountId?: string,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
@@ -318,12 +469,13 @@ async function handlePasteCallbackMode(
console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`));
try {
// Request auth URL from CLIProxyAPI.
// Kiro keeps its legacy start route because CLI auth methods do not share the generic
// management auth-url contract used by providers like Claude.
// Request auth URL from CLIProxyAPI management endpoints when the selected
// provider/method supports the manual start-url contract.
let startData: PasteCallbackStartData;
try {
startData = await requestPasteCallbackStart(provider, target);
startData = await requestPasteCallbackStart(provider, target, {
kiroMethod: options?.kiroMethod,
});
} catch (error) {
const startError = (error as Error).message;
console.log(fail('Failed to start OAuth flow'));
@@ -338,6 +490,9 @@ async function handlePasteCallbackMode(
return null;
}
const oauthState = startData.state || parseAuthUrlState(authUrl);
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
// Display auth URL in box
console.log('');
console.log(' ╔══════════════════════════════════════════════════════════════╗');
@@ -430,15 +585,49 @@ async function handlePasteCallbackMode(
return null;
}
console.log(ok('Authentication successful!'));
console.log(info('Callback submitted. Waiting for token exchange...'));
const { tokenSnapshot, error: tokenWaitError } = await waitForManualCallbackToken(
provider,
target,
tokenDir,
oauthState,
knownTokenFiles,
expectedAccountId,
OAUTH_STATE_TIMEOUT_MS
);
if (tokenWaitError) {
console.log(fail(tokenWaitError));
warnPossible403Ban(provider, tokenWaitError);
return null;
}
if (!tokenSnapshot) {
console.log(
fail(
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.'
)
);
return null;
}
const account = registerAccountFromToken(
provider,
tokenDir,
nickname,
verbose,
expectedAccountId
tokenSnapshot.file
);
if (!account) {
console.log(
fail('Authenticated token could not be matched to the requested account. Retry the flow.')
);
return null;
}
console.log(ok('Authentication successful!'));
// Account safety: check for cross-provider conflicts
if (account?.email) {
const conflicts = checkNewAccountConflict(provider, account.email);
@@ -475,6 +664,8 @@ export async function triggerOAuth(
const { nickname } = options;
const resolvedKiroMethod =
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
const resolvedKiroIDCFlow =
provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW;
if (provider === 'agy') {
if (fromUI && !acceptAgyRisk) {
@@ -505,19 +696,6 @@ export async function triggerOAuth(
return null;
}
// Handle paste-callback mode
if (options.pasteCallback) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
}
// Handle --import flag: skip OAuth and import from Kiro IDE directly
if (options.import && provider === 'kiro') {
const tokenDir = getProviderTokenDir(provider);
@@ -535,52 +713,44 @@ export async function triggerOAuth(
}
const callbackPort =
provider === 'kiro' ? getKiroCallbackPort(resolvedKiroMethod) : OAUTH_PORTS[provider];
provider === 'kiro'
? getKiroCallbackPort(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow })
: OAUTH_PORTS[provider];
const isCLI = !fromUI;
const headless = options.headless ?? isHeadlessEnvironment();
const isDeviceCodeFlow =
provider === 'kiro' ? isKiroDeviceCodeMethod(resolvedKiroMethod) : callbackPort === null;
provider === 'kiro'
? isKiroDeviceCodeMethod(resolvedKiroMethod, { idcFlow: resolvedKiroIDCFlow })
: callbackPort === null;
let selectedPasteCallback = options.pasteCallback === true;
let authFlag = oauthConfig.authFlag;
if (provider === 'kiro') {
if (!isKiroCLIAuthMethod(resolvedKiroMethod)) {
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
console.log(' Use Dashboard management OAuth for this method.');
return null;
}
authFlag = getKiroCLIAuthFlag(resolvedKiroMethod);
if (provider === 'kiro' && !isKiroCLIAuthMethod(resolvedKiroMethod)) {
console.log(fail(`Kiro auth method '${resolvedKiroMethod}' is not supported by CLI flow.`));
console.log(' Use Dashboard management OAuth for this method.');
return null;
}
// Interactive mode selection for headless environments
// Skip if explicit mode flag provided or device code flow (no callback needed)
if (headless && !options.pasteCallback && !options.portForward && !isDeviceCodeFlow) {
if (headless && !selectedPasteCallback && !options.portForward && !isDeviceCodeFlow) {
// Non-interactive environment (piped input) - default to paste mode
if (!process.stdin.isTTY) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
selectedPasteCallback = true;
} else {
const mode = await promptOAuthModeChoice(callbackPort);
if (mode === 'paste') {
selectedPasteCallback = true;
}
}
const mode = await promptOAuthModeChoice(callbackPort);
if (mode === 'paste') {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id
);
}
// mode === 'forward' continues to existing port-forwarding flow below
}
const useSelectedKiroLocalPasteCallback =
selectedPasteCallback &&
provider === 'kiro' &&
usesKiroLocalCallbackReplay(resolvedKiroMethod, resolvedKiroIDCFlow);
const useSelectedKiroDirectCliFlow =
provider === 'kiro' && (isDeviceCodeFlow || useSelectedKiroLocalPasteCallback);
if (existingAccounts.length > 0 && !add) {
console.log('');
console.log(
@@ -595,6 +765,19 @@ export async function triggerOAuth(
}
}
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
{ kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined }
);
}
// Pre-flight checks (skip for device code flows which don't need callback ports)
if (!isDeviceCodeFlow && !(await runPreflightChecks(provider, oauthConfig))) {
return null;
@@ -617,14 +800,18 @@ export async function triggerOAuth(
}
}
// Build args
const args = ['--config', configPath, authFlag];
if (headless) {
args.push('--no-browser');
}
// Kiro-specific: --no-incognito to use normal browser (saves login credentials)
if (provider === 'kiro' && noIncognito) {
args.push('--no-incognito');
const processHeadless = selectedPasteCallback && provider === 'kiro' ? true : headless;
let args: string[];
try {
args = buildOAuthArgs(provider, configPath, processHeadless, noIncognito, {
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
kiroIDCStartUrl: options.kiroIDCStartUrl,
kiroIDCRegion: options.kiroIDCRegion,
kiroIDCFlow: provider === 'kiro' ? resolvedKiroIDCFlow : undefined,
});
} catch (error) {
console.log(fail((error as Error).message));
return null;
}
// Show step based on flow type
@@ -636,7 +823,14 @@ export async function triggerOAuth(
showStep(2, 4, 'progress', `Starting callback server on port ${callbackPort}...`);
// Show headless instructions (only for authorization code flows)
if (headless) {
if (useSelectedKiroLocalPasteCallback) {
console.log('');
console.log(info('Paste-callback mode enabled for Kiro CLI auth.'));
console.log(
' CCS will print the authorization URL and wait for you to paste the final callback URL.'
);
console.log('');
} else if (headless) {
console.log('');
console.log(warn('PORT FORWARDING REQUIRED'));
console.log(` OAuth callback uses localhost:${callbackPort} which must be reachable.`);
@@ -656,11 +850,14 @@ export async function triggerOAuth(
tokenDir,
oauthConfig,
callbackPort,
headless,
headless: processHeadless,
verbose,
isCLI,
nickname,
expectedAccountId: existingNameMatch?.id,
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
manualCallback: useSelectedKiroLocalPasteCallback,
});
// Show hint for Kiro users about --no-incognito option (first-time auth only)
+382 -55
View File
@@ -22,9 +22,14 @@ import {
type GCloudProject,
type ProjectSelectionPrompt,
} from '../project-selection-handler';
import { ProviderOAuthConfig } from './auth-types';
import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types';
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
import { isAuthenticated, registerAccountFromToken } from './token-manager';
import {
type ProviderTokenSnapshot,
findNewTokenSnapshot,
listProviderTokenSnapshots,
registerAccountFromToken,
} from './token-manager';
import {
deviceCodeEvents,
DEVICE_CODE_TIMEOUT_MS,
@@ -51,6 +56,9 @@ export interface OAuthProcessOptions {
isCLI: boolean;
nickname?: string;
expectedAccountId?: string;
authFlowType?: 'device_code' | 'authorization_code';
kiroMethod?: KiroAuthMethod;
manualCallback?: boolean;
}
/** Internal state for OAuth process */
@@ -66,6 +74,9 @@ interface ProcessState {
deviceCodeDisplayed: boolean;
/** The user code to enter at verification URL */
userCode: string | null;
kiroMethodSelectionHandled: boolean;
manualCallbackPrompted: boolean;
cancelManualCallbackPrompt: (() => void) | null;
}
/**
@@ -106,6 +117,231 @@ async function handleProjectSelection(
}
}
function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'authorization_code' {
return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
}
export function isLoopbackHost(hostname: string): boolean {
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase();
return (
normalized === '127.0.0.1' ||
normalized === 'localhost' ||
normalized === '::1' ||
normalized === '0:0:0:0:0:0:0:1'
);
}
export function getExpectedLocalCallback(authUrl: string): {
origin: string;
pathname: string;
state: string | null;
} | null {
try {
const parsedAuthUrl = new URL(authUrl);
const redirectUriRaw = parsedAuthUrl.searchParams.get('redirect_uri');
if (!redirectUriRaw) {
return null;
}
const redirectUri = new URL(redirectUriRaw);
if (!isLoopbackHost(redirectUri.hostname)) {
return null;
}
return {
origin: redirectUri.origin,
pathname: redirectUri.pathname,
state: parsedAuthUrl.searchParams.get('state'),
};
} catch {
return null;
}
}
export function validateManualCallbackUrl(callbackUrl: string, authUrl: string): string | null {
let parsedCallback: URL;
try {
parsedCallback = new URL(callbackUrl);
} catch {
return 'Invalid callback URL format';
}
if (!parsedCallback.searchParams.get('code')) {
return 'Invalid callback URL: missing code parameter';
}
const expectedCallback = getExpectedLocalCallback(authUrl);
if (!expectedCallback) {
return 'Unable to determine the expected local callback target';
}
if (!isLoopbackHost(parsedCallback.hostname)) {
return 'Callback URL must target the local OAuth callback server';
}
if (
parsedCallback.origin !== expectedCallback.origin ||
parsedCallback.pathname !== expectedCallback.pathname
) {
return 'Callback URL does not match the expected local OAuth callback target';
}
if (expectedCallback.state) {
const callbackState = parsedCallback.searchParams.get('state');
if (callbackState !== expectedCallback.state) {
return 'Callback URL state does not match the active OAuth session';
}
}
return null;
}
export function getKiroBuilderIdSelectionInput(output: string): string | null {
const promptMatch = /Select login method/i.exec(output);
if (!promptMatch || promptMatch.index === undefined) {
return null;
}
const promptWindow = output.slice(promptMatch.index, promptMatch.index + 600);
const optionMatch = /(?:^|\n)\s*(\d+)\s*[\).:-]?\s*.*\bBuilder ID\b/im.exec(promptWindow);
if (!optionMatch) {
return null;
}
return `${optionMatch[1]}\n`;
}
export function extractLikelyOAuthAuthorizationUrl(output: string): string | null {
const urls = Array.from(output.matchAll(/https?:\/\/[^\s]+/g), (match) => match[0]);
let selectedUrl: string | null = null;
let selectedScore = 0;
for (const url of urls) {
try {
const parsed = new URL(url);
let score = 0;
if (parsed.searchParams.has('redirect_uri')) score += 4;
if (parsed.searchParams.has('state')) score += 2;
if (parsed.searchParams.has('code_challenge')) score += 1;
if (parsed.pathname.includes('/authorize')) score += 1;
if (isLoopbackHost(parsed.hostname)) score -= 3;
if (score >= selectedScore && score > 0) {
selectedUrl = url;
selectedScore = score;
}
} catch {
continue;
}
}
return selectedUrl;
}
async function promptManualCallbackUrl(
displayName: string,
state: ProcessState,
timeoutMs: number
): Promise<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string | null>((resolve) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const finish = (value: string | null) => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
state.cancelManualCallbackPrompt = null;
resolve(value);
};
state.cancelManualCallbackPrompt = () => {
if (!settled) {
rl.close();
finish(null);
}
};
rl.on('close', () => {
finish(null);
});
console.log('');
console.log(info(`${displayName} is waiting for the OAuth callback.`));
console.log('Paste the full callback URL after you finish the login in your browser.');
rl.question('> ', (answer) => {
rl.close();
finish(answer.trim() || null);
});
timeout = setTimeout(() => {
if (!settled) {
console.log('');
console.log(fail('Timed out waiting for callback URL'));
rl.close();
}
}, timeoutMs);
});
}
async function replayManualCallback(
oauthConfig: ProviderOAuthConfig,
authProcess: ChildProcess,
authUrl: string,
verbose: boolean,
state: ProcessState,
timeoutMs: number
): Promise<boolean> {
if (!authUrl.includes('http://') && !authUrl.includes('https://')) {
return false;
}
const callbackUrl = await promptManualCallbackUrl(oauthConfig.displayName, state, timeoutMs);
if (!callbackUrl) {
console.log(info('Cancelled'));
killWithEscalation(authProcess);
return true;
}
const validationError = validateManualCallbackUrl(callbackUrl, authUrl);
if (validationError) {
console.log(fail(validationError));
killWithEscalation(authProcess);
return true;
}
console.log(info('Replaying callback to the local auth server...'));
try {
const response = await fetch(callbackUrl);
if (!response.ok && response.status >= 400) {
console.log(fail(`OAuth callback failed with status ${response.status}`));
killWithEscalation(authProcess);
return true;
}
console.log(ok('Callback submitted. Waiting for token exchange...'));
} catch (error) {
if (verbose) {
console.log(fail(`Failed to replay callback: ${(error as Error).message}`));
} else {
console.log(fail('Failed to replay callback to the local auth server'));
}
killWithEscalation(authProcess);
}
return true;
}
/**
* Handle stdout data from OAuth process
*/
@@ -119,10 +355,23 @@ async function handleStdout(
log(`stdout: ${output.trim()}`);
state.accumulatedOutput += output;
// H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check
const flowType = OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
const flowType = resolveAuthFlowType(options);
const isDeviceCodeFlow = flowType === 'device_code';
if (
options.provider === 'kiro' &&
options.kiroMethod === 'aws' &&
!state.kiroMethodSelectionHandled &&
state.accumulatedOutput.includes('Select login method')
) {
const builderIdSelection = getKiroBuilderIdSelectionInput(state.accumulatedOutput);
if (builderIdSelection) {
state.kiroMethodSelectionHandled = true;
authProcess.stdin?.write(builderIdSelection);
log(`Auto-selected Kiro Builder ID flow (${builderIdSelection.trim()})`);
}
}
// Parse project list when available
if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) {
state.parsedProjects = parseProjectList(state.accumulatedOutput);
@@ -191,13 +440,25 @@ async function handleStdout(
// Display OAuth URL for all modes (enables VS Code terminal URL detection popup)
if (!isDeviceCodeFlow && !state.urlDisplayed) {
const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
const authUrl = extractLikelyOAuthAuthorizationUrl(state.accumulatedOutput);
if (authUrl) {
console.log('');
console.log(info(`${options.oauthConfig.displayName} OAuth URL:`));
console.log(` ${urlMatch[0]}`);
console.log(` ${authUrl}`);
console.log('');
state.urlDisplayed = true;
if (options.manualCallback && !state.manualCallbackPrompted) {
state.manualCallbackPrompted = true;
await replayManualCallback(
options.oauthConfig,
authProcess,
authUrl,
options.verbose,
state,
10 * 60 * 1000
);
}
}
}
}
@@ -208,11 +469,11 @@ function displayUrlFromStderr(
state: ProcessState,
oauthConfig: ProviderOAuthConfig
): void {
const urlMatch = output.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
const authUrl = extractLikelyOAuthAuthorizationUrl(output);
if (authUrl) {
console.log('');
console.log(info(`${oauthConfig.displayName} OAuth URL:`));
console.log(` ${urlMatch[0]}`);
console.log(` ${authUrl}`);
console.log('');
state.urlDisplayed = true;
}
@@ -220,20 +481,15 @@ function displayUrlFromStderr(
const ANSI_ESCAPE_REGEX = /\x1b\[[0-9;]*m/g;
export function extractLikelyAuthFailureFromStderr(
export function extractLikelyAuthFailureFromLogs(
provider: CLIProxyProvider,
stderrData: string
logData: string
): string | null {
// Keep this scoped to ghcp to avoid over-classifying other providers.
if (provider !== 'ghcp') {
if (!logData.trim()) {
return null;
}
if (!stderrData.trim()) {
return null;
}
const normalizedLines = stderrData
const normalizedLines = logData
.split('\n')
.map((line) => line.replace(ANSI_ESCAPE_REGEX, '').trim())
.filter(Boolean)
@@ -251,11 +507,23 @@ export function extractLikelyAuthFailureFromStderr(
return line;
});
const providerPatterns: Partial<Record<CLIProxyProvider, RegExp[]>> = {
ghcp: [
/github copilot authentication failed:\s*(.+)/i,
/failed to verify copilot access[^:]*:\s*(.+)/i,
],
kiro: [
/kiro idc authentication failed:\s*(.+)/i,
/kiro authentication failed:\s*(.+)/i,
/login failed:\s*(.+)/i,
/failed to register client:\s*(.+)/i,
],
};
const prioritizedPatterns = [
/github copilot authentication failed:\s*(.+)/i,
/authentication failed:\s*(.+)/i,
/failed to verify copilot access[^:]*:\s*(.+)/i,
/failed to save auth:\s*(.+)/i,
...(providerPatterns[provider] || []),
/^authentication failed:\s*(.+)/i,
/^failed to save auth:\s*(.+)/i,
];
for (let i = normalizedLines.length - 1; i >= 0; i--) {
@@ -271,6 +539,34 @@ export function extractLikelyAuthFailureFromStderr(
return null;
}
export function extractLikelyAuthFailureFromStderr(
provider: CLIProxyProvider,
stderrData: string
): string | null {
return extractLikelyAuthFailureFromLogs(provider, stderrData);
}
export function analyzeSuccessfulAuthExit(options: {
provider: CLIProxyProvider;
knownTokenFiles: ProviderTokenSnapshot[];
currentTokenFiles: ProviderTokenSnapshot[];
expectedAccountId?: string;
stdoutData: string;
stderrData: string;
}): { tokenSnapshot: ProviderTokenSnapshot | null; failureReason: string | null } {
const tokenSnapshot = findNewTokenSnapshot(
options.currentTokenFiles,
options.knownTokenFiles,
options.expectedAccountId
);
const failureReason = extractLikelyAuthFailureFromLogs(
options.provider,
[options.stdoutData, options.stderrData].filter(Boolean).join('\n')
);
return { tokenSnapshot, failureReason };
}
/** Handle token not found after successful process exit */
async function handleTokenNotFound(
provider: CLIProxyProvider,
@@ -281,9 +577,22 @@ async function handleTokenNotFound(
verbose: boolean,
failureReason?: string
): Promise<AccountInfo | null> {
console.log('');
if (failureReason) {
// Sanitize internal URLs/paths from failure reason to avoid leaking infrastructure details
const sanitizedReason = failureReason
.replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s]*/gi, '[internal-url]')
.replace(/\/(?:root|home|opt|tmp|var)\/[^\s]*/g, '[path]');
console.log(fail('Authentication failed before a usable token was saved'));
console.log(` ${sanitizedReason}`);
console.log('');
console.log(`Try: ccs ${provider} --auth --verbose`);
return null;
}
// Kiro-specific: Try auto-import from Kiro IDE
if (provider === 'kiro') {
console.log('');
console.log(warn('Callback redirected to Kiro IDE. Attempting to import token...'));
const result = await tryKiroImport(tokenDir, verbose);
@@ -302,24 +611,6 @@ async function handleTokenNotFound(
return null;
}
// Default behavior for other providers
console.log('');
if (failureReason) {
// Sanitize internal URLs/paths from failure reason to avoid leaking infrastructure details
const sanitizedReason = failureReason
.replace(/https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)[^\s]*/gi, '[internal-url]')
.replace(/\/(?:root|home|opt|tmp|var)\/[^\s]*/g, '[path]');
console.log(fail('Authentication completed but token was not persisted'));
console.log(` ${sanitizedReason}`);
console.log('');
console.log('This usually means provider-side authorization was accepted,');
console.log('but CLIProxy failed a post-auth verification or token save step.');
console.log('');
console.log(`Try: ccs ${provider} --auth --verbose`);
return null;
}
console.log(fail('Token not found after authentication'));
console.log('');
console.log('The browser showed success but callback was not received.');
@@ -386,14 +677,18 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
};
return new Promise<AccountInfo | null>((resolve) => {
// H4: Use explicit flow type from OAUTH_FLOW_TYPES instead of null port check
const flowType = OAUTH_FLOW_TYPES[provider] || 'authorization_code';
const flowType = resolveAuthFlowType(options);
const isDeviceCodeFlow = flowType === 'device_code';
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
// H6: TTY detection - only inherit stdin if TTY available (prevents issues in CI/piped scripts)
// Device Code flows may need interactive stdin for email/prompts
// Authorization Code flows need piped stdin for project selection
const stdinMode = isDeviceCodeFlow && process.stdin.isTTY ? 'inherit' : 'pipe';
// Device-code flows can usually inherit stdin, but Kiro's default AWS flow now
// prints an intermediate Builder ID vs IDC selector that CCS auto-answers.
const stdinMode =
isDeviceCodeFlow &&
process.stdin.isTTY &&
!(provider === 'kiro' && options.kiroMethod === 'aws')
? 'inherit'
: 'pipe';
const authProcess = spawn(binaryPath, args, {
stdio: [stdinMode, 'pipe', 'pipe'],
@@ -424,6 +719,9 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
sessionId: generateSessionId(),
deviceCodeDisplayed: false,
userCode: null,
kiroMethodSelectionHandled: false,
manualCallbackPrompted: false,
cancelManualCallbackPrompt: null,
};
// Register session for cancellation support
@@ -459,13 +757,28 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
await handleStdout(data.toString(), state, options, authProcess, log);
});
authProcess.stderr?.on('data', (data: Buffer) => {
authProcess.stderr?.on('data', async (data: Buffer) => {
const output = data.toString();
state.stderrData += output;
log(`stderr: ${output.trim()}`);
if (headless && !state.urlDisplayed) {
displayUrlFromStderr(output, state, oauthConfig);
}
if (options.manualCallback && !state.manualCallbackPrompted) {
const authUrl =
extractLikelyOAuthAuthorizationUrl(output) ?? output.match(/https?:\/\/[^\s]+/)?.[0];
if (authUrl) {
state.manualCallbackPrompted = true;
await replayManualCallback(
options.oauthConfig,
authProcess,
authUrl,
options.verbose,
state,
10 * 60 * 1000
);
}
}
});
// Show waiting message after delay
@@ -500,10 +813,15 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
// Timeout handling
// Device code flows need longer timeout to match CLIProxy binary's polling window (60 attempts × 5s = 300s)
const timeoutMs = headless || isDeviceCodeFlow ? 300000 : 120000;
const timeoutMs = options.manualCallback
? 10 * 60 * 1000
: headless || isDeviceCodeFlow
? 300000
: 120000;
const timeout = setTimeout(() => {
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers before killing process
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
@@ -523,6 +841,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
clearTimeout(timeout);
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
@@ -532,7 +851,16 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
if (isAuthenticated(provider)) {
const exitAnalysis = analyzeSuccessfulAuthExit({
provider,
knownTokenFiles,
currentTokenFiles: listProviderTokenSnapshots(provider, tokenDir),
expectedAccountId,
stdoutData: state.accumulatedOutput,
stderrData: state.stderrData,
});
if (exitAnalysis.tokenSnapshot) {
console.log('');
console.log(ok(`Authentication successful (${elapsed}s)`));
@@ -545,13 +873,11 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
registerAccountFromToken(provider, tokenDir, nickname, verbose, expectedAccountId)
);
} else {
const failureReason = extractLikelyAuthFailureFromStderr(provider, state.stderrData);
// Emit device code failure event for UI
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
deviceCodeEvents.emit('deviceCode:failed', {
sessionId: state.sessionId,
error: failureReason || 'Token not found after authentication',
error: exitAnalysis.failureReason || 'Token not found after authentication',
});
}
@@ -563,7 +889,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
nickname,
expectedAccountId,
verbose,
failureReason || undefined
exitAnalysis.failureReason || undefined
);
resolve(account);
}
@@ -585,6 +911,7 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
clearTimeout(timeout);
// H7: Clear stdin keepalive interval
if (stdinKeepalive) clearInterval(stdinKeepalive);
state.cancelManualCallbackPrompt?.();
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
+161 -73
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { createHash } from 'crypto';
import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { getProviderAuthDir } from '../config-generator';
@@ -43,6 +44,165 @@ export function isTokenFileForProvider(filePath: string, provider: CLIProxyProvi
}
}
export type ProviderTokenSnapshot = {
file: string;
mtimeMs: number;
accountId?: string;
fingerprint?: string;
};
type TokenCandidate = {
file: string;
filePath: string;
email?: string;
projectId?: string;
accountId: string;
mtimeMs: number;
alreadyRegistered: boolean;
fingerprint: string;
};
type RawTokenCandidate = Omit<TokenCandidate, 'accountId' | 'fingerprint'> & { content: string };
function buildTokenFingerprint(content: string): string {
return createHash('sha256').update(content).digest('hex');
}
function listTokenCandidates(provider: CLIProxyProvider, tokenDir: string): TokenCandidate[] {
if (!fs.existsSync(tokenDir)) {
return [];
}
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
const existingAccounts = getProviderAccounts(provider);
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) {
return [];
}
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const stats = fs.statSync(filePath);
return [
{
file,
filePath,
content,
email,
projectId,
mtimeMs: stats.mtimeMs,
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
},
];
});
const duplicateEmailCounts = new Map<string, number>();
const duplicateEmailTokenSets = new Map<string, Set<string>>();
for (const account of existingAccounts) {
if (!account.email) continue;
const key = account.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(account.tokenFile);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const candidate of rawCandidates) {
if (!candidate.email) continue;
const key = candidate.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(candidate.file);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const [key, tokenSet] of duplicateEmailTokenSets) {
duplicateEmailCounts.set(key, tokenSet.size);
}
return rawCandidates
.map((rawCandidate) => {
const duplicateEmailCount = rawCandidate.email
? (duplicateEmailCounts.get(rawCandidate.email.toLowerCase()) ?? 1)
: 1;
const accountId = rawCandidate.email
? buildEmailBackedAccountId(
provider,
rawCandidate.file,
rawCandidate.email,
duplicateEmailCount
)
: extractAccountIdFromTokenFile(rawCandidate.file, rawCandidate.email);
return {
...rawCandidate,
accountId,
fingerprint: buildTokenFingerprint(rawCandidate.content),
};
})
.sort((left, right) => right.mtimeMs - left.mtimeMs);
}
export function listProviderTokenSnapshots(
provider: CLIProxyProvider,
tokenDir: string = getProviderTokenDir(provider)
): ProviderTokenSnapshot[] {
return listTokenCandidates(provider, tokenDir).map((candidate) => ({
file: candidate.file,
mtimeMs: candidate.mtimeMs,
accountId: candidate.accountId,
fingerprint: candidate.fingerprint,
}));
}
export function findNewTokenSnapshot(
currentTokenFiles: ProviderTokenSnapshot[],
knownTokenFiles: ProviderTokenSnapshot[],
expectedAccountId?: string
): ProviderTokenSnapshot | null {
const knownSnapshotsByFile = new Map(
knownTokenFiles.map((snapshot) => [snapshot.file, snapshot])
);
return (
currentTokenFiles.find((snapshot) => {
const knownSnapshot = knownSnapshotsByFile.get(snapshot.file);
if (!expectedAccountId) {
return !knownSnapshot;
}
const matchesExpectedAccount =
snapshot.file === expectedAccountId || snapshot.accountId === expectedAccountId;
if (!matchesExpectedAccount) {
return false;
}
if (!knownSnapshot) {
return true;
}
return (
snapshot.fingerprint !== knownSnapshot.fingerprint ||
snapshot.mtimeMs !== knownSnapshot.mtimeMs
);
}) || null
);
}
export function findNewTokenSnapshotForAuthAttempt(
provider: CLIProxyProvider,
tokenDir: string,
knownTokenFiles: ProviderTokenSnapshot[],
expectedAccountId?: string
): ProviderTokenSnapshot | null {
return findNewTokenSnapshot(
listProviderTokenSnapshots(provider, tokenDir),
knownTokenFiles,
expectedAccountId
);
}
/**
* Check if provider has valid authentication
* CLIProxyAPI stores OAuth tokens as JSON files in the auth directory.
@@ -207,83 +367,11 @@ export function registerAccountFromToken(
verbose = false,
expectedAccountId?: string
): import('../account-manager').AccountInfo | null {
type TokenCandidate = {
file: string;
filePath: string;
email?: string;
projectId?: string;
accountId: string;
mtimeMs: number;
alreadyRegistered: boolean;
};
type RawTokenCandidate = Omit<TokenCandidate, 'accountId'>;
const { registerAccount } = require('../account-manager');
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
try {
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
const candidates = listTokenCandidates(provider, tokenDir);
const existingAccounts = getProviderAccounts(provider);
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) return [];
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const stats = fs.statSync(filePath);
return [
{
file,
filePath,
email,
projectId,
mtimeMs: stats.mtimeMs,
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
},
];
});
const duplicateEmailCounts = new Map<string, number>();
const duplicateEmailTokenSets = new Map<string, Set<string>>();
for (const account of existingAccounts) {
if (!account.email) continue;
const key = account.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(account.tokenFile);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const candidate of rawCandidates) {
if (!candidate.email) continue;
const key = candidate.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(candidate.file);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const [key, tokenSet] of duplicateEmailTokenSets) {
duplicateEmailCounts.set(key, tokenSet.size);
}
const candidates: TokenCandidate[] = rawCandidates
.map((rawCandidate) => {
const duplicateEmailCount = rawCandidate.email
? (duplicateEmailCounts.get(rawCandidate.email.toLowerCase()) ?? 1)
: 1;
const accountId = rawCandidate.email
? buildEmailBackedAccountId(
provider,
rawCandidate.file,
rawCandidate.email,
duplicateEmailCount
)
: extractAccountIdFromTokenFile(rawCandidate.file, rawCandidate.email);
return {
...rawCandidate,
accountId,
};
})
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (expectedAccountId) {
selectedCandidate =
+150 -25
View File
@@ -4,11 +4,17 @@ import { getCcsDir } from '../utils/config-manager';
import type { CLIProxyProvider } from './types';
import type { ModelEntry, ProviderCatalog, ThinkingSupport } from './model-catalog';
import { MODEL_CATALOG } from './model-catalog';
import type { RemoteModelInfo, RemoteThinkingSupport } from './management-api-types';
import type {
GetModelDefinitionsResponse,
RemoteModelInfo,
RemoteThinkingSupport,
} from './management-api-types';
import { getDeniedModelIdReasonForProvider } from './model-id-normalizer';
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
const CACHE_FILE_NAME = 'model-catalog-cache.json';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
const LIVE_FETCH_TIMEOUT_MS = 3000;
/** Cache structure stored on disk */
interface CatalogCacheData {
@@ -25,6 +31,8 @@ const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
qwen: 'qwen',
iflow: 'iflow',
kimi: 'kimi',
kiro: 'kiro',
'github-copilot': 'ghcp',
};
/** CCS provider → channel name mapping (reverse) */
@@ -33,7 +41,17 @@ export const PROVIDER_TO_CHANNEL: Record<string, string> = Object.fromEntries(
);
/** Providers to sync from CLIProxyAPI */
export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = ['agy', 'gemini', 'codex', 'claude', 'kimi'];
export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = [
...new Set(Object.values(CHANNEL_TO_PROVIDER)),
] as CLIProxyProvider[];
export type CatalogSource = 'live' | 'cache' | 'static';
export interface ResolvedCatalogSnapshot {
catalogs: Partial<Record<CLIProxyProvider, ProviderCatalog>>;
source: CatalogSource;
cacheAge: string | null;
}
function getCacheFilePath(): string {
return path.join(getCcsDir(), CACHE_FILE_NAME);
@@ -94,6 +112,77 @@ export function getCacheAge(): string | null {
}
}
async function fetchProviderCatalog(
provider: CLIProxyProvider
): Promise<[CLIProxyProvider, RemoteModelInfo[] | null]> {
const channel = PROVIDER_TO_CHANNEL[provider];
if (!channel) {
return [provider, null];
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), LIVE_FETCH_TIMEOUT_MS);
try {
const target = getProxyTarget();
const response = await fetch(
buildProxyUrl(target, `/v0/management/model-definitions/${channel}`),
{
signal: controller.signal,
headers: buildManagementHeaders(target),
}
);
if (!response.ok) {
return [provider, null];
}
const data = (await response.json()) as GetModelDefinitionsResponse;
return [provider, Array.isArray(data.models) ? data.models : null];
} catch {
return [provider, null];
} finally {
clearTimeout(timeoutId);
}
}
async function isProxyCatalogReachable(): Promise<boolean> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1000);
try {
const target = getProxyTarget();
const response = await fetch(buildProxyUrl(target, '/'), {
signal: controller.signal,
});
return response.ok;
} catch {
return false;
} finally {
clearTimeout(timeoutId);
}
}
export async function refreshCatalogFromProxy(): Promise<Record<string, RemoteModelInfo[]> | null> {
if (!(await isProxyCatalogReachable())) {
return null;
}
const settled = await Promise.all(
SYNCABLE_PROVIDERS.map((provider) => fetchProviderCatalog(provider))
);
const providers = Object.fromEntries(
settled.filter(([, models]) => Array.isArray(models) && models.length > 0)
) as Record<string, RemoteModelInfo[]>;
if (Object.keys(providers).length === 0) {
return null;
}
setCachedCatalog(providers);
return providers;
}
/** Map remote thinking support to CCS ThinkingSupport */
function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefined {
if (!remote) return undefined;
@@ -137,8 +226,8 @@ function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry {
* Merge remote models with static catalog for a provider.
* Remote fields override static where present.
* Static-only fields preserved: broken, deprecated, deprecationReason, issueUrl, tier.
* Models in static but not in remote → kept.
* Models in remote but not in static → added.
* Models removed upstream stay hidden; UI falls back to static only when live data is unavailable.
*/
export function mergeCatalog(
provider: CLIProxyProvider,
@@ -190,18 +279,6 @@ export function mergeCatalog(
}
}
// Add static-only models not in remote
if (staticCatalog) {
for (const model of staticCatalog.models) {
if (getDeniedModelIdReasonForProvider(model.id, provider)) {
continue;
}
if (!mergedIds.has(model.id.toLowerCase())) {
mergedModels.push(model);
}
}
}
return {
provider,
displayName,
@@ -210,6 +287,42 @@ export function mergeCatalog(
};
}
function getResolvedCatalogFromProviders(
provider: CLIProxyProvider,
providers?: Record<string, RemoteModelInfo[]>
): ProviderCatalog | undefined {
if (providers?.[provider]) {
return mergeCatalog(provider, providers[provider]);
}
return MODEL_CATALOG[provider];
}
function getAllResolvedCatalogsFromProviders(
providers?: Record<string, RemoteModelInfo[]>
): Partial<Record<CLIProxyProvider, ProviderCatalog>> {
const result: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {};
const providerIds = new Set<CLIProxyProvider>();
for (const provider of Object.keys(MODEL_CATALOG) as CLIProxyProvider[]) {
providerIds.add(provider);
}
if (providers) {
for (const provider of Object.keys(providers) as CLIProxyProvider[]) {
providerIds.add(provider);
}
}
for (const provider of providerIds) {
const catalog = getResolvedCatalogFromProviders(provider, providers);
if (catalog) {
result[provider] = catalog;
}
}
return result;
}
/**
* Get resolved catalog for a provider.
* Uses cached remote data if available, falls back to static.
@@ -226,20 +339,32 @@ export function getResolvedCatalog(provider: CLIProxyProvider): ProviderCatalog
* Get all resolved catalogs (for Dashboard).
*/
export function getAllResolvedCatalogs(): Partial<Record<CLIProxyProvider, ProviderCatalog>> {
const result: Partial<Record<CLIProxyProvider, ProviderCatalog>> = {};
const cached = getCachedCatalog();
return getAllResolvedCatalogsFromProviders(cached?.providers);
}
// Get all known providers from both static and cache
const providers = new Set<CLIProxyProvider>();
for (const p of Object.keys(MODEL_CATALOG) as CLIProxyProvider[]) providers.add(p);
if (cached) {
for (const p of Object.keys(cached.providers) as CLIProxyProvider[]) providers.add(p);
export async function getResolvedCatalogSnapshot(): Promise<ResolvedCatalogSnapshot> {
const liveProviders = await refreshCatalogFromProxy();
if (liveProviders) {
return {
catalogs: getAllResolvedCatalogsFromProviders(liveProviders),
source: 'live',
cacheAge: getCacheAge(),
};
}
for (const provider of providers) {
const catalog = getResolvedCatalog(provider);
if (catalog) result[provider] = catalog;
const cached = getCachedCatalog();
if (cached?.providers) {
return {
catalogs: getAllResolvedCatalogsFromProviders(cached.providers),
source: 'cache',
cacheAge: getCacheAge(),
};
}
return result;
return {
catalogs: getAllResolvedCatalogsFromProviders(),
source: 'static',
cacheAge: null,
};
}
+3 -8
View File
@@ -3,7 +3,6 @@ import { getProviderCatalog } from './model-catalog';
import { fetchCodexQuota } from './quota-fetcher-codex';
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
import type { CodexQuotaResult } from './quota-types';
import { updateSettingsModel } from './services/variant-settings';
import { info, warn } from '../utils/ui';
export type CodexPlanType = CodexQuotaResult['planType'];
@@ -140,13 +139,12 @@ export function resolveRuntimeCodexFallbackModel(options: {
export async function reconcileCodexModelForActivePlan(
options: {
settingsPath: string;
currentModel: string | undefined;
verbose: boolean;
},
deps: CodexPlanCompatibilityDeps = {}
): Promise<void> {
const { settingsPath, currentModel, verbose } = options;
const { currentModel, verbose } = options;
if (!currentModel) return;
const fallbackModel = getFreePlanFallbackCodexModel(currentModel);
@@ -175,13 +173,10 @@ export async function reconcileCodexModelForActivePlan(
}
if (quota.planType === 'free') {
updateSettingsModel(settingsPath, fallbackModel, 'codex', {
rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel,
});
console.error(
formatInfo(
`Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` +
`to "${fallbackModel}".`
`Codex free plan detected. Keeping saved model "${normalizeCodexModelId(currentModel)}" in settings; ` +
`runtime requests will fall back to "${fallbackModel}" when needed.`
)
);
return;
+12 -1
View File
@@ -38,8 +38,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v14: Added Gemini 3.1 Flash Antigravity aliases for upcoming rollout compatibility
* v15: Prune stale generated Antigravity Gemini preview aliases during regeneration
* v16: Narrow stale Gemini alias cleanup to broad multi-version guessed ranges
* v17: Persist routing.strategy from CCS unified config
*/
export const CLIPROXY_CONFIG_VERSION = 16;
export const CLIPROXY_CONFIG_VERSION = 17;
interface OAuthModelAliasEntry {
name: string;
@@ -115,6 +116,11 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } {
};
}
function getRoutingStrategy(): 'round-robin' | 'fill-first' {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin';
}
function sanitizeYamlScalar(rawValue: string): string {
const trimmed = rawValue.trim();
if (
@@ -534,6 +540,7 @@ function generateUnifiedConfigContent(
// Get logging settings from user config (disabled by default)
const { loggingToFile, requestLog } = getLoggingSettings();
const routingStrategy = getRoutingStrategy();
// Get effective auth tokens (respects user customization)
const effectiveApiKey = getEffectiveApiKey();
@@ -606,6 +613,10 @@ quota-exceeded:
switch-project: true
switch-preview-model: true
# Credential selection strategy when multiple matching accounts are available
routing:
strategy: ${routingStrategy}
# =============================================================================
# Authentication
# =============================================================================
+147 -16
View File
@@ -65,7 +65,14 @@ import {
} from '../../utils/image-analysis';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
import {
isKiroAuthMethod,
isKiroIDCFlow,
KiroAuthMethod,
KiroIDCFlow,
normalizeKiroAuthMethod,
normalizeKiroIDCFlow,
} from '../auth/auth-types';
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
// Import modular components
@@ -110,6 +117,34 @@ const DEFAULT_CONFIG: ExecutorConfig = {
pollInterval: 100,
};
export function readOptionValue(
args: string[],
flag: string
): { present: boolean; value?: string; missingValue: boolean } {
const inlinePrefix = `${flag}=`;
const inlineArg = args.find((arg) => arg.startsWith(inlinePrefix));
if (inlineArg !== undefined) {
const value = inlineArg.slice(inlinePrefix.length).trim();
return {
present: true,
value: value.length > 0 ? value : undefined,
missingValue: value.length === 0,
};
}
const index = args.indexOf(flag);
if (index === -1) {
return { present: false, missingValue: false };
}
const next = args[index + 1];
if (!next || next.startsWith('-')) {
return { present: true, missingValue: true };
}
return { present: true, value: next.trim(), missingValue: false };
}
/**
* Execute Claude CLI with CLIProxy (main entry point)
*
@@ -339,31 +374,112 @@ export async function execClaudeWithCLIProxy(
// Parse --kiro-auth-method flag
let kiroAuthMethod: KiroAuthMethod | undefined;
const kiroMethodIdx = argsWithoutProxy.indexOf('--kiro-auth-method');
if (kiroMethodIdx !== -1) {
const rawMethod = argsWithoutProxy[kiroMethodIdx + 1];
if (!rawMethod || rawMethod.startsWith('-')) {
const kiroMethodValue = readOptionValue(argsWithoutProxy, '--kiro-auth-method');
if (kiroMethodValue.present) {
const rawMethod = kiroMethodValue.value;
if (kiroMethodValue.missingValue || !rawMethod) {
console.error(fail('--kiro-auth-method requires a value'));
console.error(' Supported values: aws, aws-authcode, google, github');
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.exitCode = 1;
return;
}
const normalized = rawMethod.trim().toLowerCase();
if (!isKiroAuthMethod(normalized)) {
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
console.error(' Supported values: aws, aws-authcode, google, github');
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.exitCode = 1;
return;
}
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
}
let kiroIDCStartUrl: string | undefined;
const kiroIDCStartUrlValue = readOptionValue(argsWithoutProxy, '--kiro-idc-start-url');
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
} else if (kiroIDCStartUrlValue.present) {
console.error(fail('--kiro-idc-start-url requires a value'));
process.exitCode = 1;
return;
}
let kiroIDCRegion: string | undefined;
const kiroIDCRegionValue = readOptionValue(argsWithoutProxy, '--kiro-idc-region');
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
kiroIDCRegion = kiroIDCRegionValue.value;
} else if (kiroIDCRegionValue.present) {
console.error(fail('--kiro-idc-region requires a value'));
process.exitCode = 1;
return;
}
let kiroIDCFlow: KiroIDCFlow | undefined;
const kiroIDCFlowValue = readOptionValue(argsWithoutProxy, '--kiro-idc-flow');
if (kiroIDCFlowValue.present) {
const rawFlow = kiroIDCFlowValue.value;
if (kiroIDCFlowValue.missingValue || !rawFlow) {
console.error(fail('--kiro-idc-flow requires a value'));
console.error(' Supported values: authcode, device');
process.exitCode = 1;
return;
}
const normalized = rawFlow.trim().toLowerCase();
if (!isKiroIDCFlow(normalized)) {
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
console.error(' Supported values: authcode, device');
process.exitCode = 1;
return;
}
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
}
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1;
return;
}
if (
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) &&
provider !== 'kiro' &&
!compositeProviders.includes('kiro')
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
)
);
process.exitCode = 1;
return;
}
if (!kiroAuthMethod && (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)) {
kiroAuthMethod = 'idc';
}
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
console.error(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
);
process.exitCode = 1;
return;
}
if (
kiroAuthMethod &&
kiroAuthMethod !== 'idc' &&
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
)
);
process.exitCode = 1;
return;
}
// Parse --thinking / --effort flags (aliases; first occurrence wins)
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
if (thinkingParse.error) {
@@ -533,6 +649,9 @@ export async function execClaudeWithCLIProxy(
verbose,
import: true,
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(setNickname ? { nickname: setNickname } : {}),
});
if (!authSuccess) {
@@ -597,6 +716,9 @@ export async function execClaudeWithCLIProxy(
add: addAccount,
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -639,6 +761,9 @@ export async function execClaudeWithCLIProxy(
add: addAccount,
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -758,7 +883,6 @@ export async function execClaudeWithCLIProxy(
if (provider === 'codex' && !cfg.isComposite && !skipLocalAuth) {
await reconcileCodexModelForActivePlan({
settingsPath: cfg.customSettingsPath || getProviderSettingsPath(provider),
currentModel: getCurrentModel(provider, cfg.customSettingsPath),
verbose,
});
@@ -854,15 +978,12 @@ export async function execClaudeWithCLIProxy(
});
const imageAnalysisProvisioningFailed =
!imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1';
const imageAnalysisEnv = imageAnalysisProvisioningFailed
? {
...imageAnalysisResolution.env,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
}
: imageAnalysisResolution.env;
const imageAnalysisEnv = {
...imageAnalysisResolution.env,
CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0',
};
const imageAnalysisWarning = imageAnalysisProvisioningFailed
? 'ImageAnalysis MCP provisioning failed. This session will use native Read.'
? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.'
: imageAnalysisResolution.warning;
// 9. Setup tool sanitization proxy
@@ -1052,6 +1173,9 @@ export async function execClaudeWithCLIProxy(
'--use',
'--nickname',
'--kiro-auth-method',
'--kiro-idc-start-url',
'--kiro-idc-region',
'--kiro-idc-flow',
'--thinking',
'--effort',
'--1m',
@@ -1066,6 +1190,10 @@ export async function execClaudeWithCLIProxy(
];
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
if (ccsFlags.includes(arg)) return false;
if (arg.startsWith('--kiro-auth-method=')) return false;
if (arg.startsWith('--kiro-idc-start-url=')) return false;
if (arg.startsWith('--kiro-idc-region=')) return false;
if (arg.startsWith('--kiro-idc-flow=')) return false;
if (arg.startsWith('--thinking=')) return false;
if (arg.startsWith('--effort=')) return false;
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
@@ -1073,6 +1201,9 @@ export async function execClaudeWithCLIProxy(
argsWithoutProxy[idx - 1] === '--use' ||
argsWithoutProxy[idx - 1] === '--nickname' ||
argsWithoutProxy[idx - 1] === '--kiro-auth-method' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-start-url' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-region' ||
argsWithoutProxy[idx - 1] === '--kiro-idc-flow' ||
argsWithoutProxy[idx - 1] === '--thinking' ||
argsWithoutProxy[idx - 1] === '--effort'
)
+1
View File
@@ -16,6 +16,7 @@ export type {
ChecksumResult,
DownloadResult,
CLIProxyProvider,
CliproxyRoutingStrategy,
CLIProxyConfig,
ExecutorConfig,
ProviderConfig,
+17
View File
@@ -16,6 +16,7 @@ import type {
GetModelDefinitionsResponse,
} from './management-api-types';
import { CLIPROXY_DEFAULT_PORT } from './config/port-manager';
import type { CliproxyRoutingStrategy } from './types';
/** Default timeout for management operations (longer than health check) */
const DEFAULT_TIMEOUT_MS = 5000;
@@ -227,6 +228,22 @@ export class ManagementApiClient {
return response.data?.models ?? [];
}
/**
* Get the global credential routing strategy from CLIProxy.
*/
async getRoutingStrategy(): Promise<CliproxyRoutingStrategy> {
const response = await this.request<{ strategy?: string }>('GET', '/routing/strategy');
return response.data?.strategy === 'fill-first' ? 'fill-first' : 'round-robin';
}
/**
* Update the global credential routing strategy on CLIProxy.
*/
async putRoutingStrategy(strategy: CliproxyRoutingStrategy): Promise<CliproxyRoutingStrategy> {
await this.request('PUT', '/routing/strategy', { value: strategy });
return strategy;
}
/**
* Get a management section from CLIProxyAPI.
* Example sections: claude-api-key, gemini-api-key, codex-api-key.
+6 -2
View File
@@ -19,6 +19,7 @@ import { getExistingProxy, registerSession, getRunningProxyVersion } from './ses
import { isCliproxyRunning } from './stats-fetcher';
import { getPortProcess, isCLIProxyProcess, PortProcess } from '../utils/port-utils';
import { CLIPROXY_DEFAULT_PORT } from './config-generator';
import { createLogger } from '../services/logging';
/** Detection method used to find the proxy */
export type DetectionMethod = 'http' | 'session-lock' | 'port-process' | 'http-retry';
@@ -48,6 +49,7 @@ type LogFn = (msg: string) => void;
/** No-op logger for when verbose is disabled */
const noopLog: LogFn = () => {};
const logger = createLogger('cliproxy:proxy-detector');
/**
* Detect running CLIProxy using multiple methods with fallbacks.
@@ -65,7 +67,7 @@ export async function detectRunningProxy(
port: number = CLIPROXY_DEFAULT_PORT,
verbose: boolean = false
): Promise<ProxyStatus> {
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
const log: LogFn = verbose ? (msg) => logger.debug('detect.verbose', msg, { port }) : noopLog;
// Validate port - fallback to default if invalid
const validPort =
@@ -235,7 +237,9 @@ export function reclaimOrphanedProxy(
pid: number,
verbose: boolean = false
): string | null {
const log: LogFn = verbose ? (msg) => console.error(`[proxy-detector] ${msg}`) : noopLog;
const log: LogFn = verbose
? (msg) => logger.debug('reclaim.verbose', msg, { port, pid })
: noopLog;
try {
log(`Reclaiming orphaned proxy: port=${port}, pid=${pid}`);
+117
View File
@@ -0,0 +1,117 @@
import * as https from 'https';
import {
buildManagementHeaders,
buildProxyUrl,
getProxyTarget,
type ProxyTarget,
} from './proxy-target-resolver';
const ROUTING_TIMEOUT_MS = 5000;
export async function fetchCliproxyRoutingResponse(
target: ProxyTarget,
method: 'GET' | 'PUT',
body?: Record<string, string>
): Promise<Response> {
const url = buildProxyUrl(target, '/routing/strategy');
const headers = buildManagementHeaders(
target,
body ? { 'Content-Type': 'application/json' } : {}
);
if (target.protocol !== 'https' || !target.allowSelfSigned) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ROUTING_TIMEOUT_MS);
try {
return await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
}
return new Promise<Response>((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false });
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
callback();
};
const timeoutId = setTimeout(() => {
const error = new Error('Request timeout');
req.destroy(error);
settle(() => reject(error));
}, ROUTING_TIMEOUT_MS);
const req = https.request(
url,
{
method,
headers,
agent,
timeout: ROUTING_TIMEOUT_MS,
},
(res) => {
let payload = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
payload += chunk;
});
res.on('end', () => {
settle(() =>
resolve(
new Response(payload, {
status: res.statusCode || 500,
statusText: res.statusMessage ?? '',
headers:
typeof res.headers['content-type'] === 'string'
? { 'Content-Type': res.headers['content-type'] }
: undefined,
})
)
);
});
}
);
req.on('error', (error) => {
settle(() => reject(error));
});
req.on('timeout', () => {
const error = new Error('Request timeout');
req.destroy(error);
settle(() => reject(error));
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
export function getCliproxyRoutingTarget(): ProxyTarget {
return getProxyTarget();
}
export function getRoutingErrorMessage(response: Response, fallback: string): Promise<string> {
return response
.json()
.then((data) => {
if (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string') {
return data.error;
}
return fallback;
})
.catch(() => fallback);
}
+155
View File
@@ -0,0 +1,155 @@
import { mutateUnifiedConfig, loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
import { regenerateConfig } from './config/generator';
import {
fetchCliproxyRoutingResponse,
getCliproxyRoutingTarget,
getRoutingErrorMessage,
} from './routing-strategy-http';
import type { CliproxyRoutingStrategy } from './types';
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
export interface CliproxyRoutingState {
strategy: CliproxyRoutingStrategy;
source: 'live' | 'config';
target: 'local' | 'remote';
reachable: boolean;
message?: string;
}
export interface CliproxyRoutingApplyResult extends CliproxyRoutingState {
applied: 'live' | 'live-and-config' | 'config-only';
}
export function normalizeCliproxyRoutingStrategy(value: unknown): CliproxyRoutingStrategy | null {
if (typeof value !== 'string') {
return null;
}
switch (value.trim().toLowerCase()) {
case 'round-robin':
case 'roundrobin':
case 'rr':
return 'round-robin';
case 'fill-first':
case 'fillfirst':
case 'ff':
return 'fill-first';
default:
return null;
}
}
export function getConfiguredCliproxyRoutingStrategy(): CliproxyRoutingStrategy {
return (
normalizeCliproxyRoutingStrategy(loadOrCreateUnifiedConfig().cliproxy?.routing?.strategy) ??
DEFAULT_CLIPROXY_ROUTING_STRATEGY
);
}
export async function fetchLiveCliproxyRoutingStrategy(): Promise<CliproxyRoutingStrategy> {
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'GET');
if (!response.ok) {
throw new Error(
await getRoutingErrorMessage(response, `Failed to read routing strategy (${response.status})`)
);
}
const data = (await response.json()) as { strategy?: string };
const strategy = normalizeCliproxyRoutingStrategy(data?.strategy);
if (!strategy) {
throw new Error('CLIProxy returned an invalid routing strategy');
}
return strategy;
}
export async function readCliproxyRoutingState(): Promise<CliproxyRoutingState> {
const target = getCliproxyRoutingTarget();
if (target.isRemote) {
return {
strategy: await fetchLiveCliproxyRoutingStrategy(),
source: 'live',
target: 'remote',
reachable: true,
};
}
try {
return {
strategy: await fetchLiveCliproxyRoutingStrategy(),
source: 'live',
target: 'local',
reachable: true,
};
} catch {
return {
strategy: getConfiguredCliproxyRoutingStrategy(),
source: 'config',
target: 'local',
reachable: false,
message: 'Local CLIProxy is not reachable. Showing the saved startup default.',
};
}
}
export async function applyCliproxyRoutingStrategy(
strategy: CliproxyRoutingStrategy
): Promise<CliproxyRoutingApplyResult> {
const target = getCliproxyRoutingTarget();
if (target.isRemote) {
await updateLiveCliproxyRoutingStrategy(strategy);
return {
strategy,
source: 'live',
target: 'remote',
reachable: true,
applied: 'live',
message: 'Updated remote CLIProxy routing strategy.',
};
}
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = { strategy };
}
});
regenerateConfig(target.port);
try {
await updateLiveCliproxyRoutingStrategy(strategy);
return {
strategy,
source: 'live',
target: 'local',
reachable: true,
applied: 'live-and-config',
message: 'Updated the running proxy and saved the local startup default.',
};
} catch {
return {
strategy,
source: 'config',
target: 'local',
reachable: false,
applied: 'config-only',
message: 'Saved the local startup default. It will apply the next time CLIProxy starts.',
};
}
}
async function updateLiveCliproxyRoutingStrategy(strategy: CliproxyRoutingStrategy): Promise<void> {
const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'PUT', {
value: strategy,
});
if (!response.ok) {
throw new Error(
await getRoutingErrorMessage(
response,
`Failed to update routing strategy (${response.status})`
)
);
}
}
+44 -33
View File
@@ -16,7 +16,10 @@ import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { prepareImageAnalysisFallbackHook } from '../../utils/hooks';
import { getEffectiveApiKey } from '../auth-token-manager';
import { warn } from '../../utils/ui';
@@ -157,21 +160,23 @@ export function createSettingsFile(
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(`${provider}-${name}`, settingsPath);
} else {
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: `${provider}-${name}`,
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks({
profileName: `${provider}-${name}`,
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
return settingsPath;
}
@@ -199,21 +204,23 @@ export function createSettingsFileUnified(
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(`${provider}-${name}`, settingsPath);
} else {
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: `${provider}-${name}`,
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks({
profileName: `${provider}-${name}`,
profileType: 'cliproxy',
cliproxyProvider: provider,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
return settingsPath;
}
@@ -305,20 +312,24 @@ export function createCompositeSettingsFile(
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(`composite-${name}`, settingsPath);
} else {
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: `composite-${name}`,
profileType: 'cliproxy',
cliproxyProvider: tiers[defaultTier].provider,
isComposite: true,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
} catch (error) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw error;
}
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: `composite-${name}`,
profileType: 'cliproxy',
cliproxyProvider: tiers[defaultTier].provider,
isComposite: true,
settingsPath,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
return settingsPath;
+5 -1
View File
@@ -25,6 +25,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir } from './config-generator';
import { createLogger } from '../services/logging';
/** Lock file structure */
interface LockData {
@@ -51,6 +52,7 @@ type LogFn = (msg: string) => void;
/** No-op logger for when verbose is disabled */
const noopLog: LogFn = () => {};
const logger = createLogger('cliproxy:startup-lock');
/**
* Get path to startup lock file
@@ -184,7 +186,9 @@ export async function acquireStartupLock(options?: {
}): Promise<LockResult> {
const retries = options?.retries ?? 20;
const retryInterval = options?.retryInterval ?? 250;
const log: LogFn = options?.verbose ? (msg) => console.error(`[startup-lock] ${msg}`) : noopLog;
const log: LogFn = options?.verbose
? (msg) => logger.debug('lock.verbose', msg, { retries, retryInterval })
: noopLog;
log(`Attempting to acquire startup lock (max ${retries} retries, ${retryInterval}ms interval)`);
+7
View File
@@ -26,6 +26,7 @@ import {
} from './model-id-normalizer';
import { getModelMaxLevel } from './model-catalog';
import { getCcsDir } from '../utils/config-manager';
import { createLogger } from '../services/logging';
export interface ToolSanitizationProxyConfig {
/** Upstream CLIProxy URL */
@@ -153,6 +154,7 @@ export class ToolSanitizationProxy {
private readonly config: Required<ToolSanitizationProxyConfig>;
private readonly logFilePath: string;
private readonly debugMode: boolean;
private readonly logger = createLogger('cliproxy:tool-sanitization-proxy');
constructor(config: ToolSanitizationProxyConfig) {
this.config = {
@@ -207,6 +209,11 @@ export class ToolSanitizationProxy {
if (this.debugMode) {
console.error(`${prefix} ${message}`);
}
this.logger[level](level, message, {
debugMode: this.debugMode,
logFilePath: this.logFilePath,
});
}
private log(message: string): void {
+8
View File
@@ -141,6 +141,11 @@ export type CLIProxyProvider =
*/
export type CLIProxyBackend = 'original' | 'plus';
/**
* Credential routing strategy for matching CLIProxy accounts.
*/
export type CliproxyRoutingStrategy = 'round-robin' | 'fill-first';
/**
* Providers that require CLIProxyAPIPlus backend
*/
@@ -154,6 +159,9 @@ export interface CLIProxyConfig {
'api-keys': string[];
'auth-dir': string;
debug: boolean;
routing?: {
strategy?: CliproxyRoutingStrategy;
};
'gemini-api-key'?: Array<{
'api-key': string;
'base-url'?: string;
+66 -38
View File
@@ -1,7 +1,7 @@
/**
* Cleanup Command Handler
*
* Removes old CLIProxy logs to free up disk space.
* Removes old CCS and CLIProxy logs to free up disk space.
* Supports both main logs and error request logs with age-based filtering.
* Logs can accumulate to several GB without user awareness.
*/
@@ -9,6 +9,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCliproxyDir } from '../cliproxy/config-generator';
import { getLogArchiveDir, getNativeLogsDir } from '../services/logging';
import { info, ok, warn } from '../utils/ui';
/** Default age in days for error log cleanup */
@@ -19,6 +20,14 @@ function getLogsDir(): string {
return path.join(getCliproxyDir(), 'logs');
}
function getCcsLogsDir(): string {
return getNativeLogsDir();
}
function getCcsLogArchiveDir(): string {
return getLogArchiveDir();
}
/** Format bytes to human-readable size */
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
@@ -27,23 +36,20 @@ function formatBytes(bytes: number): string {
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
}
/** Calculate total size of a directory */
/** Calculate total size of regular top-level files in a directory */
function getDirSize(dirPath: string): number {
if (!fs.existsSync(dirPath)) return 0;
let totalSize = 0;
const files = fs.readdirSync(dirPath);
const entries = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
for (const entry of entries) {
const filePath = path.join(dirPath, entry);
try {
const stats = fs.lstatSync(filePath); // Use lstat to detect symlinks
const stats = fs.lstatSync(filePath);
if (stats.isFile() && !stats.isSymbolicLink()) {
totalSize += stats.size;
} else if (stats.isDirectory() && !stats.isSymbolicLink()) {
totalSize += getDirSize(filePath);
}
// Skip symlinks for safety
} catch {
// File may have been deleted between readdir and stat - skip
}
@@ -174,18 +180,18 @@ function printHelp(): void {
console.log('');
console.log('Usage: ccs cleanup [options]');
console.log('');
console.log('Remove old CLIProxy logs to free up disk space.');
console.log('Remove old CCS and CLIProxy logs to free up disk space.');
console.log('');
console.log('Options:');
console.log(' --errors Clean error request logs (error-*.log files)');
console.log(' --errors Clean legacy CLIProxy error request logs (error-*.log files)');
console.log(' --days=N Delete error logs older than N days (default: 7)');
console.log(' --dry-run Show what would be deleted without deleting');
console.log(' --force Skip confirmation prompt');
console.log(' --help, -h Show this help message');
console.log('');
console.log('Examples:');
console.log(' ccs cleanup Interactive main log cleanup');
console.log(' ccs cleanup --errors Clean error logs older than 7 days');
console.log(' ccs cleanup Interactive CCS + CLIProxy log cleanup');
console.log(' ccs cleanup --errors Clean legacy CLIProxy error logs older than 7 days');
console.log(' ccs cleanup --errors --days=3 Clean error logs older than 3 days');
console.log(' ccs cleanup --errors --dry-run Preview error log cleanup');
console.log(' ccs cleanup --dry-run Preview main log cleanup');
@@ -207,6 +213,8 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
const force = args.includes('--force');
const cleanErrors = args.includes('--errors');
const logsDir = getLogsDir();
const ccsLogsDir = getCcsLogsDir();
const ccsArchiveDir = getCcsLogArchiveDir();
// Parse --days=N option
let maxAgeDays = DEFAULT_ERROR_LOG_AGE_DAYS;
@@ -224,7 +232,13 @@ export async function handleCleanupCommand(args: string[]): Promise<void> {
if (cleanErrors) {
await handleErrorLogCleanup(logsDir, maxAgeDays, dryRun, force);
} else {
await handleMainLogCleanup(logsDir, dryRun, force);
await handleMainLogCleanup({
cliproxyLogsDir: logsDir,
ccsLogsDir,
ccsArchiveDir,
dryRun,
force,
});
}
}
@@ -319,40 +333,48 @@ async function handleErrorLogCleanup(
/**
* Handle main log cleanup (main.log and rotated files)
*/
async function handleMainLogCleanup(
logsDir: string,
dryRun: boolean,
force: boolean
): Promise<void> {
// Check if logs directory exists
if (!fs.existsSync(logsDir)) {
console.log(info('No CLIProxy logs found.'));
async function handleMainLogCleanup(options: {
cliproxyLogsDir: string;
ccsLogsDir: string;
ccsArchiveDir: string;
dryRun: boolean;
force: boolean;
}): Promise<void> {
const targets = [
{ label: 'CCS Logs', dir: options.ccsLogsDir },
{ label: 'CCS Log Archives', dir: options.ccsArchiveDir },
{ label: 'CLIProxy Logs', dir: options.cliproxyLogsDir },
].map((target) => ({
...target,
fileCount: countFiles(target.dir),
size: getDirSize(target.dir),
}));
const activeTargets = targets.filter((target) => target.fileCount > 0);
if (activeTargets.length === 0) {
console.log(info('No CCS or CLIProxy logs found.'));
return;
}
// Calculate current size
const currentSize = getDirSize(logsDir);
const fileCount = countFiles(logsDir);
const currentSize = activeTargets.reduce((sum, target) => sum + target.size, 0);
const fileCount = activeTargets.reduce((sum, target) => sum + target.fileCount, 0);
if (fileCount === 0) {
console.log(info('No log files to clean.'));
return;
console.log('');
console.log('Log Cleanup Targets:');
for (const target of activeTargets) {
console.log(` ${target.label}: ${target.fileCount} files (${formatBytes(target.size)})`);
console.log(` ${target.dir}`);
}
console.log('');
console.log(`CLIProxy Logs: ${logsDir}`);
console.log(` Files: ${fileCount}`);
console.log(` Size: ${formatBytes(currentSize)}`);
console.log('');
if (dryRun) {
if (options.dryRun) {
console.log(info('Dry run - no files deleted.'));
console.log(`Would delete ${fileCount} files (${formatBytes(currentSize)})`);
return;
}
// Confirm unless --force
if (!force) {
if (!options.force) {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
@@ -371,13 +393,19 @@ async function handleMainLogCleanup(
}
// Perform cleanup
const { deleted, freedBytes } = cleanDirectory(logsDir);
let deleted = 0;
let freedBytes = 0;
for (const target of activeTargets) {
const result = cleanDirectory(target.dir);
deleted += result.deleted;
freedBytes += result.freedBytes;
}
console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`));
// Suggest disabling logging if it was enabled
if (deleted > 0) {
console.log('');
console.log(warn('Tip: CLIProxy logging is now disabled by default.'));
console.log(' Run `ccs doctor --fix` to update your config.');
console.log(warn('Tip: CCS logging is bounded by retention, but you can lower it further.'));
console.log(' Open `ccs config` and review the Logs settings.');
}
}
+17 -40
View File
@@ -1,14 +1,12 @@
import { initUI, header, subheader, color, dim } from '../../utils/ui';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { createManagementClient } from '../../cliproxy/management-api-client';
import {
getCacheAge,
setCachedCatalog,
clearCatalogCache,
SYNCABLE_PROVIDERS,
PROVIDER_TO_CHANNEL,
getResolvedCatalog,
refreshCatalogFromProxy,
} from '../../cliproxy/catalog-cache';
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
import type { CLIProxyProvider } from '../../cliproxy/types';
import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
@@ -16,46 +14,27 @@ import type { RemoteModelInfo } from '../../cliproxy/management-api-types';
async function fetchRemoteCatalogs(
verbose: boolean
): Promise<Record<string, RemoteModelInfo[]> | null> {
const config = loadOrCreateUnifiedConfig();
const remote = config.cliproxy_server?.remote;
if (!remote?.host) {
if (verbose) console.log(dim(' No remote CLIProxy configured'));
return null;
}
const client = createManagementClient(remote);
// Check health first
const health = await client.health();
if (!health.healthy) {
console.log(color(` [!] CLIProxy unreachable: ${health.error || 'unknown error'}`, 'warning'));
return null;
}
const target = getProxyTarget();
if (verbose) {
console.log(dim(` Connected to ${client.getBaseUrl()}`));
if (health.version) console.log(dim(` CLIProxy version: ${health.version}`));
console.log(
dim(
` Connected to ${target.protocol}://${target.host}:${target.port} (${target.isRemote ? 'remote' : 'local'})`
)
);
}
const result: Record<string, RemoteModelInfo[]> = {};
for (const provider of SYNCABLE_PROVIDERS) {
const channel = PROVIDER_TO_CHANNEL[provider];
if (!channel) continue;
try {
const response = await client.getModelDefinitions(channel);
if (response && response.length > 0) {
result[provider] = response;
if (verbose) console.log(dim(` ${provider}: ${response.length} models`));
const result = await refreshCatalogFromProxy();
if (verbose && result) {
for (const provider of SYNCABLE_PROVIDERS) {
const models = result[provider];
if (models?.length) {
console.log(dim(` ${provider}: ${models.length} models`));
}
} catch {
if (verbose) console.log(dim(` ${provider}: fetch failed (skipped)`));
}
}
return Object.keys(result).length > 0 ? result : null;
return result;
}
/** Show catalog status */
@@ -104,20 +83,18 @@ export async function handleCatalogRefresh(verbose: boolean): Promise<void> {
const result = await fetchRemoteCatalogs(verbose);
if (!result) {
console.log(' Failed to fetch remote catalogs. Static catalog unchanged.');
console.log(' Failed to fetch live catalogs. Static catalog unchanged.');
console.log('');
return;
}
setCachedCatalog(result);
// Show summary
let totalModels = 0;
for (const [provider, models] of Object.entries(result)) {
const merged = getResolvedCatalog(provider as CLIProxyProvider);
const mergedCount = merged?.models.length ?? 0;
console.log(
` ${color(provider.padEnd(12), 'command')} ${models.length} remote -> ${mergedCount} merged`
` ${color(provider.padEnd(12), 'command')} ${models.length} live -> ${mergedCount} merged`
);
totalModels += mergedCount;
}
+3
View File
@@ -57,6 +57,9 @@ export async function showHelp(): Promise<void> {
['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
['routing', 'Show current routing strategy and manual guidance'],
['routing explain', 'Explain round-robin vs fill-first'],
['routing set <mode>', 'Explicitly set round-robin or fill-first'],
],
],
[
+15
View File
@@ -35,6 +35,7 @@ import {
} from './proxy-lifecycle-subcommand';
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
import { showHelp } from './help-subcommand';
import { handleRoutingStatus, handleRoutingExplain, handleRoutingSet } from './routing-subcommand';
import {
handleCatalogStatus,
handleCatalogRefresh,
@@ -174,6 +175,20 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
if (command === 'routing') {
const subcommand = remainingArgs[1];
if (subcommand === 'set') {
await handleRoutingSet(remainingArgs.slice(2));
return;
}
if (subcommand === 'explain') {
await handleRoutingExplain();
return;
}
await handleRoutingStatus();
return;
}
const commandHandlers: Record<string, () => Promise<void>> = {
create: async () => handleCreate(remainingArgs.slice(1), effectiveBackend),
edit: async () => handleEdit(remainingArgs.slice(1), effectiveBackend),
@@ -0,0 +1,80 @@
import { initUI, header, subheader, color, dim, ok, fail, infoBox } from '../../utils/ui';
import {
applyCliproxyRoutingStrategy,
normalizeCliproxyRoutingStrategy,
readCliproxyRoutingState,
} from '../../cliproxy/routing-strategy';
function printStrategyGuide(): void {
console.log(subheader('Routing Modes:'));
console.log(` ${color('round-robin', 'command')} Spread requests across matching accounts.`);
console.log(` ${dim(' Best when you want even usage and predictable distribution.')}`);
console.log('');
console.log(` ${color('fill-first', 'command')} Drain one available account before moving on.`);
console.log(
` ${dim(' Best when you want backup accounts to stay cold until the active one hits a limit.')}`
);
console.log('');
console.log(
dim(
' Default stays round-robin. CCS will not switch strategy from your account mix automatically.'
)
);
console.log('');
}
export async function handleRoutingStatus(): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Routing Strategy'));
console.log('');
const state = await readCliproxyRoutingState();
console.log(` Current: ${color(state.strategy, 'command')}`);
console.log(` Target: ${color(state.target, 'info')}`);
console.log(
` Source: ${color(state.source === 'live' ? 'live CLIProxy' : 'saved startup default', 'info')}`
);
if (state.message) {
console.log('');
console.log(infoBox(state.message, state.reachable ? 'INFO' : 'WARNING'));
}
console.log('');
printStrategyGuide();
}
export async function handleRoutingExplain(): Promise<void> {
await initUI();
console.log('');
console.log(header('CLIProxy Routing Guide'));
console.log('');
printStrategyGuide();
}
export async function handleRoutingSet(args: string[]): Promise<void> {
const requested = normalizeCliproxyRoutingStrategy(args[0]);
if (!requested) {
await initUI();
console.log('');
console.log(fail('Invalid strategy. Use: round-robin or fill-first'));
console.log('');
printStrategyGuide();
process.exitCode = 1;
return;
}
await initUI();
console.log('');
console.log(header('Update CLIProxy Routing'));
console.log('');
const result = await applyCliproxyRoutingStrategy(requested);
console.log(ok(`Routing strategy set to ${requested}`));
console.log(` Applied: ${color(result.applied, 'info')}`);
console.log(` Target: ${color(result.target, 'info')}`);
if (result.message) {
console.log('');
console.log(infoBox(result.message, result.reachable ? 'SUCCESS' : 'INFO'));
}
console.log('');
}
+4 -2
View File
@@ -2,7 +2,7 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
export type HelpTopicName = 'profiles' | 'providers' | 'completion' | 'targets';
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
export interface HelpTopicEntry {
name: HelpTopicName;
@@ -25,6 +25,7 @@ export interface ShortcutEntry {
export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
{ name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' },
{ name: 'completion', summary: 'Shell completion install, refresh, and testing' },
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
] as const;
@@ -136,7 +137,7 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
},
{
name: 'cleanup',
summary: 'Remove old CLIProxy logs',
summary: 'Remove old CCS and CLIProxy logs',
group: 'operations',
aliases: ['--cleanup'],
visibility: 'public',
@@ -236,6 +237,7 @@ export const CLIPROXY_SUBCOMMANDS = [
'edit',
'list',
'remove',
'routing',
'catalog',
'sync',
'quota',
+15 -1
View File
@@ -157,6 +157,12 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
'--help',
'-h',
]);
if (subcommand === 'routing') {
if (lastToken === 'set') {
return completeSubcommands(['round-robin', 'fill-first']);
}
return completeSubcommands(['set', 'explain']);
}
if (['remove', 'edit'].includes(subcommand)) {
return completeSubcommands(getProfileNames('cliproxyVariants'), ['--yes', '-y']);
}
@@ -220,7 +226,15 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
if (command === 'kiro') {
return completeSubcommands(
[],
[...PROVIDER_FLAGS, '--kiro-auth-method', '--import', '--incognito']
[
...PROVIDER_FLAGS,
'--kiro-auth-method',
'--kiro-idc-start-url',
'--kiro-idc-region',
'--kiro-idc-flow',
'--import',
'--incognito',
]
);
}
return completeSubcommands([], PROVIDER_FLAGS);
+24
View File
@@ -22,6 +22,9 @@ import {
resolveDashboardUrls,
} from './config-dashboard-host';
import { parseConfigCommandArgs, showConfigCommandHelp } from './config-command-options';
import { createLogger } from '../services/logging';
const logger = createLogger('command:config');
const CONFIG_SUBCOMMAND_ROUTES: readonly NamedCommandRoute[] = [
{
@@ -123,6 +126,11 @@ export async function handleConfigCommand(
const options = parsed.options;
const verbose = options.dev;
logger.info('dashboard.launch_requested', 'Config dashboard launch requested', {
dev: Boolean(options.dev),
host: options.host || null,
port: options.port || null,
});
console.log(deps.header('CCS Config Dashboard'));
console.log('');
@@ -130,6 +138,13 @@ export async function handleConfigCommand(
// Ensure CLIProxy service is running for dashboard features
console.log(deps.info('Starting CLIProxy service...'));
const cliproxyResult = await deps.ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
logger.info('cliproxy.ensure_result', 'Config command checked CLIProxy availability', {
started: cliproxyResult.started,
alreadyRunning: cliproxyResult.alreadyRunning,
configRegenerated: cliproxyResult.configRegenerated,
port: cliproxyResult.port || null,
error: cliproxyResult.error || null,
});
if (cliproxyResult.started) {
if (cliproxyResult.alreadyRunning) {
@@ -210,14 +225,23 @@ export async function handleConfigCommand(
// Open browser
try {
await deps.openBrowser(urls.browserUrl, { wait: false });
logger.info('dashboard.browser_opened', 'Config dashboard browser launch attempted', {
browserUrl: urls.browserUrl,
});
console.log(deps.info('Browser opened automatically'));
} catch {
logger.warn('dashboard.browser_open_failed', 'Automatic browser launch failed', {
browserUrl: urls.browserUrl,
});
console.log(deps.info(`Open manually: ${urls.browserUrl}`));
}
console.log('');
console.log(deps.info('Press Ctrl+C to stop'));
} catch (error) {
logger.error('dashboard.launch_failed', 'Config dashboard failed to launch', {
message: (error as Error).message,
});
console.error(deps.fail(`Failed to start server: ${(error as Error).message}`));
process.exit(1);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export async function handleUp(args: string[]): Promise<void> {
if (parsed.host) {
console.log(
info(
'Remote access requires dashboard auth. Run inside the container:\n docker exec -it ccs-cliproxy ccs config auth setup'
'Full remote management requires dashboard auth. Without it, remote access stays read-only.\nRun inside the container:\n docker exec -it ccs-cliproxy ccs config auth setup'
)
);
}
+77 -1
View File
@@ -78,6 +78,7 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
},
{ name: 'ccs api create --preset <id>', summary: 'Create an API-backed provider profile' },
{ name: 'ccs config', summary: 'Use the dashboard for provider and model setup' },
{ name: 'ccs help kiro', summary: 'Kiro-specific auth methods and IDC flags' },
],
writeLine
);
@@ -85,6 +86,74 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
writeLine('');
}
async function showKiroHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Kiro Help'));
writeLine('');
writeLine(' Kiro supports Builder ID, IDC, and management-only social OAuth flows.');
writeLine('');
writeCommandTable(
'Authentication Methods',
[
{ name: 'ccs kiro --auth', summary: 'Default AWS Builder ID device-code flow' },
{
name: 'ccs kiro --auth --kiro-auth-method aws-authcode',
summary: 'AWS Builder ID auth-code flow via local callback server',
},
{
name: 'ccs kiro --auth --kiro-auth-method idc',
summary: 'IAM Identity Center flow; requires IDC start URL',
},
{
name: 'ccs config',
summary: 'Dashboard flow for GitHub OAuth and account management',
},
],
writeLine
);
writeCommandTable(
'Kiro Flags',
[
{
name: '--kiro-auth-method <aws|aws-authcode|google|github|idc>',
summary: 'Select the Kiro auth method',
},
{ name: '--kiro-idc-start-url <url>', summary: 'Required IDC start URL when using `idc`' },
{ name: '--kiro-idc-region <region>', summary: 'Optional IDC region override' },
{ name: '--kiro-idc-flow <authcode|device>', summary: 'IDC flow type; defaults to authcode' },
{
name: '--paste-callback',
summary: 'Paste the final callback URL for callback-based CLI auth flows',
},
{ name: '--import', summary: 'Import an existing Kiro IDE token instead of starting OAuth' },
],
writeLine
);
writeCommandTable(
'Examples',
[
{ name: 'ccs kiro --auth', summary: 'Start the default Builder ID device flow' },
{
name: 'ccs kiro --auth --kiro-auth-method aws-authcode --paste-callback',
summary: 'Use auth-code flow and paste the callback URL manually',
},
{
name: 'ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start',
summary: 'Start IDC auth with the default authcode flow',
},
{
name: 'ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start --kiro-idc-flow device',
summary: 'Use IDC device-code flow instead of authcode',
},
],
writeLine
);
writeLine(
` ${dim('GitHub OAuth is dashboard-only: ccs config -> Accounts -> Add Kiro account')}`
);
writeLine('');
}
async function showTargetsHelp(writeLine: HelpWriter): Promise<void> {
await initUI();
writeLine(header('CCS Targets Help'));
@@ -142,7 +211,10 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
{ name: 'ccs cliproxy --help', summary: 'Deep help for variants, quota, and lifecycle' },
{
name: 'ccs cliproxy --help',
summary: 'Deep help for variants, routing, quota, and lifecycle',
},
{ name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' },
{ name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' },
{ name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' },
@@ -176,6 +248,10 @@ export async function handleHelpRoute(
await showProvidersHelp(writeLine);
return;
}
if (topic === 'kiro') {
await showKiroHelp(writeLine);
return;
}
if (topic === 'targets') {
await showTargetsHelp(writeLine);
return;
+44
View File
@@ -24,6 +24,7 @@ import {
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
DEFAULT_LOGGING_CONFIG,
} from './unified-config-types';
import type {
UnifiedConfig,
@@ -34,6 +35,7 @@ import type {
OfficialChannelId,
DashboardAuthConfig,
ImageAnalysisConfig,
LoggingConfig,
CursorConfig,
ContinuityConfig,
} from './unified-config-types';
@@ -373,6 +375,22 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
: undefined, // Invalid values become undefined (defaults to 'plus' at runtime)
// Auto-sync - default to true
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
routing: {
strategy:
partial.cliproxy?.routing?.strategy === 'fill-first' ||
partial.cliproxy?.routing?.strategy === 'round-robin'
? partial.cliproxy.routing.strategy
: defaults.cliproxy.routing?.strategy,
},
},
logging: {
enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: partial.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: partial.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: partial.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size:
partial.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
},
preferences: {
...defaults.preferences,
@@ -650,6 +668,19 @@ function generateYamlWithComments(config: UnifiedConfig): string {
);
lines.push('');
if (config.logging) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Logging: CCS-owned structured runtime logs');
lines.push('# Current file: ~/.ccs/logs/current.jsonl');
lines.push('# Archives rotate automatically and are pruned by retain_days.');
lines.push('# This is separate from cliproxy.logging, which controls CLIProxy runtime files.');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ logging: config.logging }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// CLIProxy Server section (remote proxy configuration) - placed right after cliproxy
if (config.cliproxy_server) {
lines.push('# ----------------------------------------------------------------------------');
@@ -1284,6 +1315,19 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig {
});
}
export function getLoggingConfig(): LoggingConfig {
const config = loadOrCreateUnifiedConfig();
return {
enabled: config.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
level: config.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
rotate_mb: config.logging?.rotate_mb ?? DEFAULT_LOGGING_CONFIG.rotate_mb,
retain_days: config.logging?.retain_days ?? DEFAULT_LOGGING_CONFIG.retain_days,
redact: config.logging?.redact ?? DEFAULT_LOGGING_CONFIG.redact,
live_buffer_size: config.logging?.live_buffer_size ?? DEFAULT_LOGGING_CONFIG.live_buffer_size,
};
}
/**
* Get cursor configuration.
* Returns defaults if not configured.
+44 -1
View File
@@ -10,7 +10,7 @@
*/
import type { TargetType } from '../targets/target-adapter';
import type { CLIProxyProvider } from '../cliproxy/types';
import type { CLIProxyProvider, CliproxyRoutingStrategy } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
/**
@@ -201,6 +201,11 @@ export interface TokenRefreshSettings {
verbose?: boolean;
}
export interface CLIProxyRoutingConfig {
/** Credential selection strategy when multiple accounts match */
strategy?: CliproxyRoutingStrategy;
}
/**
* CLIProxy configuration section.
*/
@@ -225,8 +230,40 @@ export interface CLIProxyConfig {
token_refresh?: TokenRefreshSettings;
/** Auto-sync API profiles to local CLIProxy config on settings change (default: true) */
auto_sync?: boolean;
/** Routing strategy for multi-account CLIProxy selection */
routing?: CLIProxyRoutingConfig;
}
export type LoggingLevel = 'error' | 'warn' | 'info' | 'debug';
/**
* CCS-owned structured logging configuration.
* Separate from cliproxy.logging, which controls CLIProxy runtime files.
*/
export interface LoggingConfig {
/** Enable CCS-owned structured runtime logging */
enabled: boolean;
/** Minimum level written to disk */
level: LoggingLevel;
/** Rotate current log when it reaches this size in MB */
rotate_mb: number;
/** Keep archived segments for this many days */
retain_days: number;
/** Redact sensitive values before persistence */
redact: boolean;
/** In-memory recent event buffer size for dashboard reads */
live_buffer_size: number;
}
export const DEFAULT_LOGGING_CONFIG: LoggingConfig = {
enabled: true,
level: 'info',
rotate_mb: 10,
retain_days: 7,
redact: true,
live_buffer_size: 250,
};
/**
* User preferences.
*/
@@ -805,6 +842,8 @@ export interface UnifiedConfig {
profiles: Record<string, ProfileConfig>;
/** CLIProxy configuration */
cliproxy: CLIProxyConfig;
/** CCS-owned structured logging configuration */
logging?: LoggingConfig;
/** User preferences */
preferences: PreferencesConfig;
/** WebSearch configuration */
@@ -901,7 +940,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
},
safety: { ...DEFAULT_CLIPROXY_SAFETY_CONFIG },
auto_sync: true,
routing: {
strategy: 'round-robin',
},
},
logging: { ...DEFAULT_LOGGING_CONFIG },
preferences: {
theme: 'system',
telemetry: false,
+5 -8
View File
@@ -267,15 +267,12 @@ export async function executeCopilotProfile(
const imageAnalysisProvisioningFailed =
!imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1';
const imageAnalysisWarning = imageAnalysisProvisioningFailed
? 'ImageAnalysis MCP provisioning failed. This session will use native Read.'
? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.'
: imageAnalysisResolution.warning;
const imageAnalysisEnv = imageAnalysisProvisioningFailed
? {
...imageAnalysisResolution.env,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
}
: imageAnalysisResolution.env;
const imageAnalysisEnv = {
...imageAnalysisResolution.env,
CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0',
};
const env = stripClaudeCodeEnv({
...process.env,
...globalEnv,
+22 -18
View File
@@ -31,7 +31,10 @@ import {
resolveImageAnalysisRuntimeConnection,
resolveImageAnalysisRuntimeStatus,
} from '../utils/hooks';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector';
import {
ensureProfileHooks as ensureImageAnalyzerHooks,
removeImageAnalysisProfileHook,
} from '../utils/hooks/image-analyzer-profile-hook-injector';
import { resolveCliproxyBridgeMetadata } from '../api/services';
import { ensureCliproxyService } from '../cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
@@ -127,15 +130,20 @@ export class HeadlessExecutor {
const settings = loadSettings(settingsPath);
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profile,
profileType: 'settings',
settingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
let imageAnalysisFallbackHookReady: boolean | undefined;
if (imageAnalysisMcpReady) {
removeImageAnalysisProfileHook(profile, settingsPath);
} else {
imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
ensureImageAnalyzerHooks({
profileName: profile,
profileType: 'settings',
settingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
}
const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({
profileName: profile,
profileType: 'settings',
@@ -158,14 +166,10 @@ export class HeadlessExecutor {
apiKey: runtimeConnection.apiKey,
allowSelfSigned: runtimeConnection.allowSelfSigned,
});
if (!imageAnalysisMcpReady) {
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_CURRENT_PROVIDER: '',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
}
imageAnalysisEnv = {
...imageAnalysisEnv,
CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0',
};
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
if (
+14
View File
@@ -11,6 +11,9 @@
import { ExitCode, EXIT_CODE_DESCRIPTIONS } from './exit-codes';
import { isCCSError } from './error-types';
import { runCleanup } from './cleanup-registry';
import { createLogger } from '../services/logging';
const logger = createLogger('cli:error-handler');
/**
* Debug mode flag - set via CCS_DEBUG environment variable
@@ -91,6 +94,10 @@ export function handleError(error: unknown): never {
const code = getExitCode(error);
const message = formatErrorMessage(error);
logger.error('command.unhandled_error', 'Unhandled CLI error', {
exitCode: code,
error,
});
// Output error message to stderr
console.error(message);
@@ -112,6 +119,10 @@ export function handleError(error: unknown): never {
*/
export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL_ERROR): never {
runCleanup();
logger.error('command.exit_error', 'CLI exited with error', {
exitCode: code,
message,
});
console.error(`[X] ${message}`);
if (isDebugMode()) {
@@ -131,6 +142,9 @@ export function exitWithError(message: string, code: ExitCode = ExitCode.GENERAL
*/
export function exitWithSuccess(message?: string): never {
runCleanup();
logger.info('command.exit_success', 'CLI exited successfully', {
message: message || null,
});
if (message) {
console.log(`[OK] ${message}`);
}
+9
View File
@@ -12,6 +12,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { DeltaAccumulator } from './delta-accumulator';
import { getCcsDir } from '../utils/config-manager';
import { createLogger } from '../services/logging';
import {
RequestTransformer,
StreamParser,
@@ -36,6 +37,7 @@ export class GlmtTransformer {
private verbose: boolean;
private debugLog: boolean;
debugLogDir: string;
private readonly logger = createLogger('glmt:transformer');
private requestTransformer: RequestTransformer;
private streamParser: StreamParser;
@@ -125,6 +127,9 @@ export class GlmtTransformer {
return anthropicResponse;
} catch (error) {
const err = error as Error;
this.logger.error('response.transform_failed', 'GLMT response transformation failed', {
message: err.message,
});
console.error('[glmt-transformer] Response transformation error:', err);
return {
id: 'msg_error_' + Date.now(),
@@ -175,12 +180,16 @@ export class GlmtTransformer {
const redacted = this.redactSensitiveData(data);
fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8');
} catch (error) {
this.logger.warn('debug-log.write_failed', 'GLMT debug log write failed', {
message: (error as Error).message,
});
console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`);
}
}
private log(message: string): void {
if (this.verbose) {
this.logger.debug('transformer.verbose', message);
console.error(`[glmt-transformer] [${new Date().toTimeString().split(' ')[0]}] ${message}`);
}
}
+26 -1
View File
@@ -7,10 +7,16 @@
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../../config/unified-config-types';
import {
countManagedImageAnalysisHookFiles,
hasImageAnalysisMcpReady,
repairImageAnalysisRuntimeState,
} from '../../utils/image-analysis';
import { ok, warn, dim } from '../../utils/ui';
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config-generator';
import type { HealthCheck } from './types';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
/**
* Run image analysis configuration check
@@ -65,6 +71,16 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
}
console.log(` ${ok('Timeout:')} ${config.timeout}s`);
const staleHookCount = countManagedImageAnalysisHookFiles();
if (staleHookCount > 0) {
results.warnings.push({
name: 'Image Analysis',
message: `${staleHookCount} stale CCS-managed image hook setting file(s) were detected`,
fix: 'Run: ccs doctor --fix',
});
console.log(` ${warn('Hooks:')} ${staleHookCount} stale setting file(s) can be repaired`);
}
// Check 4: CLIProxy availability (only if enabled)
const cliproxyAvailable = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
if (!cliproxyAvailable) {
@@ -102,6 +118,8 @@ export async function fixImageAnalysisConfig(): Promise<boolean> {
const config = loadOrCreateUnifiedConfig();
let fixed = false;
const hadManagedToolReady = hasImageAnalysisMcpReady();
const hadSharedHookReady = hasImageAnalyzerHook();
// Fix missing provider_models
if (
@@ -130,5 +148,12 @@ export async function fixImageAnalysisConfig(): Promise<boolean> {
updateUnifiedConfig({ image_analysis: config.image_analysis });
}
return fixed;
const repairStats = repairImageAnalysisRuntimeState();
return (
fixed ||
repairStats.cleanedSettingsFiles > 0 ||
repairStats.syncedInstances > 0 ||
(!hadManagedToolReady && repairStats.managedToolReady) ||
(!hadSharedHookReady && repairStats.sharedHookReady)
);
}
+78 -2
View File
@@ -47,6 +47,68 @@ class RecoveryManager {
return false;
}
/**
* Remove a dangling symlink so recovery can recreate the directory.
* Mirrors scripts/postinstall.js behavior for skipped lifecycle installs.
*/
private removeIfBrokenSymlink(targetPath: string): boolean {
try {
const stats = fs.lstatSync(targetPath);
if (!stats.isSymbolicLink()) {
return false;
}
try {
fs.statSync(targetPath);
return false;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
return false;
}
fs.unlinkSync(targetPath);
this.recovered.push(`Removed broken symlink: ${targetPath}`);
return true;
}
} catch {
return false;
}
}
private inspectDirectoryPath(
targetPath: string
): { state: 'ready' } | { state: 'missing' } | { state: 'invalid'; reason: string } {
try {
const stats = fs.lstatSync(targetPath);
if (stats.isSymbolicLink()) {
try {
return fs.statSync(targetPath).isDirectory()
? { state: 'ready' }
: { state: 'invalid', reason: 'symlink target is not a directory' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code === 'ENOENT' || code === 'ENOTDIR'
? { state: 'missing' }
: {
state: 'invalid',
reason: `symlink target is not accessible (${code || 'unknown'})`,
};
}
}
return stats.isDirectory()
? { state: 'ready' }
: { state: 'invalid', reason: 'existing path is not a directory' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code === 'ENOENT'
? { state: 'missing' }
: { state: 'invalid', reason: `could not inspect path (${code || 'unknown'})` };
}
}
/**
* Ensure ~/.ccs/config.yaml exists with defaults
* This is the primary config format (YAML unified config)
@@ -126,20 +188,34 @@ class RecoveryManager {
ensureSharedDirectories(): boolean {
let created = false;
this.removeIfBrokenSymlink(this.sharedDir);
const sharedState = this.inspectDirectoryPath(this.sharedDir);
// Create shared directory
if (!fs.existsSync(this.sharedDir)) {
if (sharedState.state === 'missing') {
fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o755 });
this.recovered.push(`Created ${this.sharedDir}`);
created = true;
} else if (sharedState.state === 'invalid') {
this.recovered.push(`Skipped ${this.sharedDir}: ${sharedState.reason}`);
return created;
}
// Create subdirectories
const subdirs = ['commands', 'skills', 'agents', 'plugins'];
for (const subdir of subdirs) {
const subdirPath = path.join(this.sharedDir, subdir);
if (!fs.existsSync(subdirPath)) {
this.removeIfBrokenSymlink(subdirPath);
const subdirState = this.inspectDirectoryPath(subdirPath);
if (subdirState.state === 'missing') {
fs.mkdirSync(subdirPath, { recursive: true, mode: 0o755 });
created = true;
continue;
}
if (subdirState.state === 'invalid') {
this.recovered.push(`Skipped ${subdirPath}: ${subdirState.reason}`);
}
}
+13
View File
@@ -0,0 +1,13 @@
export { createLogger } from './logger';
export { getResolvedLoggingConfig, invalidateLoggingConfigCache } from './log-config';
export { readLogEntries, readLogSourceSummaries, normalizeLogQueryLevel } from './log-reader';
export { pruneExpiredLogArchives } from './log-storage';
export {
ensureLoggingDirectories,
getCurrentLogPath,
getLegacyCliproxyLogsDir,
getLogArchiveDir,
getNativeLogsDir,
isPathInsideDirectory,
} from './log-paths';
export type { LogEntry, LogSourceSummary, LoggingLevel, ReadLogEntriesOptions } from './log-types';
+18
View File
@@ -0,0 +1,18 @@
import type { LogEntry } from './log-types';
let recentEntries: LogEntry[] = [];
export function pushRecentLogEntry(entry: LogEntry, maxEntries: number): void {
recentEntries.push(entry);
if (recentEntries.length > maxEntries) {
recentEntries = recentEntries.slice(recentEntries.length - maxEntries);
}
}
export function getRecentLogEntries(): LogEntry[] {
return [...recentEntries];
}
export function clearRecentLogEntries(): void {
recentEntries = [];
}
+47
View File
@@ -0,0 +1,47 @@
import * as fs from 'fs';
import { DEFAULT_LOGGING_CONFIG } from '../../config/unified-config-types';
import {
getConfigYamlPath,
getLoggingConfig as getUnifiedLoggingConfig,
} from '../../config/unified-config-loader';
import type { LoggingConfig } from './log-types';
const CACHE_RECHECK_MS = 1000;
let cachedConfig: LoggingConfig = { ...DEFAULT_LOGGING_CONFIG };
let cachedMtimeMs: number | null = null;
let lastCheckedAt = 0;
export function invalidateLoggingConfigCache(): void {
cachedConfig = { ...DEFAULT_LOGGING_CONFIG };
cachedMtimeMs = null;
lastCheckedAt = 0;
}
export function getResolvedLoggingConfig(): LoggingConfig {
const now = Date.now();
if (now - lastCheckedAt < CACHE_RECHECK_MS) {
return cachedConfig;
}
try {
const configPath = getConfigYamlPath();
const nextMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : null;
if (nextMtimeMs === cachedMtimeMs) {
lastCheckedAt = now;
return cachedConfig;
}
cachedConfig = {
...DEFAULT_LOGGING_CONFIG,
...getUnifiedLoggingConfig(),
};
cachedMtimeMs = nextMtimeMs;
lastCheckedAt = now;
return cachedConfig;
} catch {
cachedConfig = { ...DEFAULT_LOGGING_CONFIG };
cachedMtimeMs = null;
lastCheckedAt = now;
return cachedConfig;
}
}
+40
View File
@@ -0,0 +1,40 @@
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
const LOGS_DIR = 'logs';
const ARCHIVE_DIR = 'archive';
const CURRENT_LOG_FILE = 'current.jsonl';
export function getNativeLogsDir(): string {
return path.join(getCcsDir(), LOGS_DIR);
}
export function getCurrentLogPath(): string {
return path.join(getNativeLogsDir(), CURRENT_LOG_FILE);
}
export function getLogArchiveDir(): string {
return path.join(getNativeLogsDir(), ARCHIVE_DIR);
}
export function getLegacyCliproxyLogsDir(): string {
return path.join(getCcsDir(), 'cliproxy', 'logs');
}
export function ensureLoggingDirectories(): void {
fs.mkdirSync(getNativeLogsDir(), { recursive: true, mode: 0o700 });
fs.mkdirSync(getLogArchiveDir(), { recursive: true, mode: 0o700 });
}
export function isPathInsideDirectory(candidatePath: string, rootDir: string): boolean {
const resolvedCandidate = path.resolve(candidatePath);
const resolvedRoot = path.resolve(rootDir);
const relative = path.relative(resolvedRoot, resolvedCandidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
export function buildArchiveLogPath(timestamp: Date = new Date()): string {
const compact = timestamp.toISOString().replace(/[:.]/g, '-');
return path.join(getLogArchiveDir(), `ccs-${compact}.jsonl.gz`);
}
+126
View File
@@ -0,0 +1,126 @@
import * as fs from 'fs';
import { getRecentLogEntries } from './log-buffer';
import { getCurrentLogPath } from './log-paths';
import {
isLoggingLevel,
type LogEntry,
type LogSourceSummary,
type ReadLogEntriesOptions,
} from './log-types';
type CurrentLogCache = {
entries: LogEntry[];
mtimeNs: bigint;
path: string;
size: bigint;
} | null;
let currentLogCache: CurrentLogCache = null;
function parseLogLine(line: string): LogEntry | null {
try {
return JSON.parse(line) as LogEntry;
} catch {
return null;
}
}
function readCurrentFileEntries(): LogEntry[] {
const currentLogPath = getCurrentLogPath();
if (!fs.existsSync(currentLogPath)) {
currentLogCache = null;
return [];
}
const stats = fs.statSync(currentLogPath, { bigint: true });
if (
currentLogCache &&
currentLogCache.path === currentLogPath &&
currentLogCache.mtimeNs === stats.mtimeNs &&
currentLogCache.size === stats.size
) {
return [...currentLogCache.entries];
}
const entries = fs
.readFileSync(currentLogPath, 'utf8')
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map(parseLogLine)
.filter((entry): entry is LogEntry => entry !== null);
currentLogCache = {
entries,
mtimeNs: stats.mtimeNs,
path: currentLogPath,
size: stats.size,
};
return [...entries];
}
function matchesLogQuery(entry: LogEntry, options: ReadLogEntriesOptions): boolean {
if (options.source && entry.source !== options.source) {
return false;
}
if (options.level && entry.level !== options.level) {
return false;
}
if (!options.search) {
return true;
}
const search = options.search.toLowerCase();
return (
entry.message.toLowerCase().includes(search) ||
entry.event.toLowerCase().includes(search) ||
entry.source.toLowerCase().includes(search) ||
String(entry.processId).toLowerCase().includes(search) ||
entry.runId.toLowerCase().includes(search) ||
JSON.stringify(entry.context || {})
.toLowerCase()
.includes(search)
);
}
function dedupeEntries(entries: LogEntry[]): LogEntry[] {
const seen = new Map<string, LogEntry>();
for (const entry of entries) {
seen.set(entry.id, entry);
}
return [...seen.values()];
}
export function readLogEntries(options: ReadLogEntriesOptions = {}): LogEntry[] {
const limit = options.limit ?? 200;
const entries = dedupeEntries([...readCurrentFileEntries(), ...getRecentLogEntries()])
.filter((entry) => matchesLogQuery(entry, options))
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
return entries.slice(0, limit);
}
export function readLogSourceSummaries(): LogSourceSummary[] {
const summaryMap = new Map<string, LogSourceSummary>();
for (const entry of readLogEntries({ limit: 500 })) {
const current = summaryMap.get(entry.source) ?? {
source: entry.source,
label: entry.source,
kind: 'native' as const,
count: 0,
lastTimestamp: null,
};
current.count += 1;
current.lastTimestamp = current.lastTimestamp ?? entry.timestamp;
summaryMap.set(entry.source, current);
}
return [...summaryMap.values()].sort((a, b) => a.label.localeCompare(b.label));
}
export function normalizeLogQueryLevel(level: string | undefined) {
return isLoggingLevel(level) ? level : undefined;
}
+62
View File
@@ -0,0 +1,62 @@
const SENSITIVE_KEY_PATTERN =
/^(authorization|cookie|set-cookie|password|password_hash|secret|token|api[_-]?key|management[_-]?key)$/i;
const MAX_STRING_LENGTH = 2000;
const MAX_DEPTH = 5;
function truncateString(value: string): string {
if (value.length <= MAX_STRING_LENGTH) {
return value;
}
return `${value.slice(0, MAX_STRING_LENGTH)}...[truncated]`;
}
function sanitizeValue(value: unknown, depth: number): unknown {
if (value === null || value === undefined) {
return value;
}
if (depth >= MAX_DEPTH) {
return '[max-depth]';
}
if (typeof value === 'string') {
return truncateString(value);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (value instanceof Error) {
return {
name: value.name,
message: truncateString(value.message),
};
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, depth + 1));
}
if (typeof value === 'object') {
const sanitized: Record<string, unknown> = {};
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
sanitized[key] = SENSITIVE_KEY_PATTERN.test(key)
? '[redacted]'
: sanitizeValue(nestedValue, depth + 1);
}
return sanitized;
}
return String(value);
}
export function redactContext(
context: Record<string, unknown> | undefined
): Record<string, unknown> {
if (!context) {
return {};
}
return sanitizeValue(context, 0) as Record<string, unknown>;
}
+94
View File
@@ -0,0 +1,94 @@
import * as fs from 'fs';
import * as path from 'path';
import * as zlib from 'zlib';
import { getResolvedLoggingConfig } from './log-config';
import {
ensureLoggingDirectories,
getCurrentLogPath,
buildArchiveLogPath,
getLogArchiveDir,
} from './log-paths';
import { pushRecentLogEntry } from './log-buffer';
import { shouldWriteLogLevel, type LogEntry } from './log-types';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const PRUNE_INTERVAL_MS = 60 * 1000;
let lastPruneAt = 0;
function getRotateBytes(rotateMb: number): number {
return Math.max(1, rotateMb) * 1024 * 1024;
}
function rotateCurrentLogIfNeeded(): void {
const config = getResolvedLoggingConfig();
const currentLogPath = getCurrentLogPath();
if (!fs.existsSync(currentLogPath)) {
return;
}
const stats = fs.statSync(currentLogPath);
const ageMs = Date.now() - stats.mtimeMs;
const exceedsSize = stats.size >= getRotateBytes(config.rotate_mb);
const exceedsAge = ageMs >= ONE_DAY_MS;
if (!exceedsSize && !exceedsAge) {
return;
}
const currentContent = fs.readFileSync(currentLogPath, 'utf8');
if (!currentContent.trim()) {
fs.truncateSync(currentLogPath, 0);
return;
}
const archivePath = buildArchiveLogPath(new Date(stats.mtimeMs || Date.now()));
fs.writeFileSync(archivePath, zlib.gzipSync(currentContent), { mode: 0o600 });
fs.truncateSync(currentLogPath, 0);
}
export function pruneExpiredLogArchives(): void {
const config = getResolvedLoggingConfig();
const archiveDir = getLogArchiveDir();
if (!fs.existsSync(archiveDir)) {
return;
}
const cutoffMs = Date.now() - config.retain_days * ONE_DAY_MS;
for (const entry of fs.readdirSync(archiveDir)) {
const archivePath = path.join(archiveDir, entry);
try {
const stats = fs.lstatSync(archivePath);
if (!stats.isFile() || stats.isSymbolicLink()) {
continue;
}
if (stats.mtimeMs < cutoffMs) {
fs.unlinkSync(archivePath);
}
} catch {
continue;
}
}
}
export function appendStructuredLogEntry(entry: LogEntry): void {
const config = getResolvedLoggingConfig();
if (!config.enabled || !shouldWriteLogLevel(entry.level, config.level)) {
return;
}
try {
ensureLoggingDirectories();
rotateCurrentLogIfNeeded();
fs.appendFileSync(getCurrentLogPath(), `${JSON.stringify(entry)}\n`, {
encoding: 'utf8',
mode: 0o600,
});
pushRecentLogEntry(entry, config.live_buffer_size);
if (Date.now() - lastPruneAt >= PRUNE_INTERVAL_MS) {
pruneExpiredLogArchives();
lastPruneAt = Date.now();
}
} catch {
// Logging must never break runtime behavior.
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { LoggingConfig, LoggingLevel } from '../../config/unified-config-types';
export type { LoggingConfig, LoggingLevel };
export interface LogEntry {
id: string;
timestamp: string;
level: LoggingLevel;
source: string;
event: string;
message: string;
processId: number;
runId: string;
context?: Record<string, unknown>;
}
export interface LogSourceSummary {
source: string;
label: string;
kind: 'native' | 'legacy';
count: number;
lastTimestamp: string | null;
}
export interface ReadLogEntriesOptions {
source?: string;
level?: LoggingLevel;
search?: string;
limit?: number;
}
export const LOG_LEVELS: readonly LoggingLevel[] = ['error', 'warn', 'info', 'debug'];
const LOG_LEVEL_PRIORITY: Record<LoggingLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
export function shouldWriteLogLevel(level: LoggingLevel, configuredLevel: LoggingLevel): boolean {
return LOG_LEVEL_PRIORITY[level] <= LOG_LEVEL_PRIORITY[configuredLevel];
}
export function isLoggingLevel(value: string | undefined): value is LoggingLevel {
return typeof value === 'string' && LOG_LEVELS.includes(value as LoggingLevel);
}
+67
View File
@@ -0,0 +1,67 @@
import { randomUUID } from 'crypto';
import { getResolvedLoggingConfig } from './log-config';
import { redactContext } from './log-redaction';
import { appendStructuredLogEntry } from './log-storage';
import type { LogEntry, LoggingLevel } from './log-types';
const processRunId = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
function createEntry(
source: string,
level: LoggingLevel,
event: string,
message: string,
context: Record<string, unknown>
): LogEntry {
const config = getResolvedLoggingConfig();
return {
id: randomUUID(),
timestamp: new Date().toISOString(),
level,
source,
event,
message,
processId: process.pid,
runId: processRunId,
context: config.redact ? redactContext(context) : context,
};
}
export interface Logger {
child(context: Record<string, unknown>): Logger;
debug(event: string, message: string, context?: Record<string, unknown>): void;
info(event: string, message: string, context?: Record<string, unknown>): void;
warn(event: string, message: string, context?: Record<string, unknown>): void;
error(event: string, message: string, context?: Record<string, unknown>): void;
}
export function createLogger(source: string, baseContext: Record<string, unknown> = {}): Logger {
const write = (
level: LoggingLevel,
event: string,
message: string,
context?: Record<string, unknown>
) => {
appendStructuredLogEntry(
createEntry(source, level, event, message, { ...baseContext, ...(context || {}) })
);
};
return {
child(context: Record<string, unknown>) {
return createLogger(source, { ...baseContext, ...context });
},
debug(event, message, context) {
write('debug', event, message, context);
},
info(event, message, context) {
write('info', event, message, context);
},
warn(event, message, context) {
write('warn', event, message, context);
},
error(event, message, context) {
write('error', event, message, context);
},
};
}
@@ -0,0 +1,87 @@
/**
* Image Analyzer Hook Utilities
*
* Shared helper functions for CCS-managed image hook detection and cleanup.
*
* @module utils/hooks/image-analyzer-hook-utils
*/
function normalizeCommand(command: string): string {
return command.replace(/\\/g, '/').replace(/\/+/g, '/');
}
function extractManagedHookPath(command: string): string | null {
const normalizedCommand = normalizeCommand(command);
const exactPathMatch = normalizedCommand.match(
/(?:^|["'\s])([^"'\s]*\/\.ccs\/hooks\/image-analyzer-transformer\.cjs)(?:["'\s]|$)/
);
return exactPathMatch?.[1] ?? null;
}
/**
* Check if a hook entry is a CCS-managed image analyzer hook.
* Matches current and legacy path variants by suffix rather than full path.
*/
export function isCcsImageAnalyzerHook(hook: Record<string, unknown>): boolean {
if (hook.matcher !== 'Read') return false;
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
if (!hookArray?.[0]?.command) return false;
const command = hookArray[0].command;
if (typeof command !== 'string') return false;
return extractManagedHookPath(command) !== null;
}
/**
* Remove duplicate CCS-managed image hooks from settings, keeping only the first one.
*/
export function deduplicateCcsImageAnalyzerHooks(settings: Record<string, unknown>): boolean {
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (!hooks?.PreToolUse) return false;
let foundFirst = false;
const originalLength = hooks.PreToolUse.length;
hooks.PreToolUse = hooks.PreToolUse.filter((entry: unknown) => {
const hook = entry as Record<string, unknown>;
if (!isCcsImageAnalyzerHook(hook)) return true;
if (!foundFirst) {
foundFirst = true;
return true;
}
return false;
});
return hooks.PreToolUse.length < originalLength;
}
/**
* Remove all CCS-managed image hooks from settings while preserving unrelated hooks.
*/
export function removeCcsImageAnalyzerHooks(settings: Record<string, unknown>): boolean {
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (!hooks?.PreToolUse) return false;
const originalLength = hooks.PreToolUse.length;
hooks.PreToolUse = hooks.PreToolUse.filter((entry: unknown) => {
const hook = entry as Record<string, unknown>;
return !isCcsImageAnalyzerHook(hook);
});
if (hooks.PreToolUse.length === originalLength) {
return false;
}
if (hooks.PreToolUse.length === 0) {
delete hooks.PreToolUse;
}
if (Object.keys(hooks).length === 0) {
delete settings.hooks;
}
return true;
}
@@ -16,6 +16,11 @@ import {
getImageAnalyzerHookConfig,
getImageAnalyzerHookPath,
} from './image-analyzer-hook-configuration';
import {
deduplicateCcsImageAnalyzerHooks,
isCcsImageAnalyzerHook,
removeCcsImageAnalyzerHooks,
} from './image-analyzer-hook-utils';
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { getCcsDir } from '../config-manager';
import {
@@ -41,17 +46,7 @@ function hasCcsHook(settings: Record<string, unknown>): boolean {
if (!hooks?.PreToolUse) return false;
return hooks.PreToolUse.some((h: unknown) => {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'Read') return false;
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
const command = hookArray?.[0]?.command;
if (typeof command !== 'string') return false;
const normalized = command
.replace(/\\/g, '/') // Windows backslashes
.replace(/\/+/g, '/'); // Collapse multiple slashes
return normalized.includes('.ccs/hooks/image-analyzer-transformer');
return isCcsImageAnalyzerHook(h as Record<string, unknown>);
});
}
@@ -88,6 +83,39 @@ export function hasImageAnalysisProfileHook(
}
}
export function removeImageAnalysisProfileHook(
profileName: string,
settingsPath?: string | null
): boolean {
if (!VALID_PROFILE_NAME.test(profileName)) {
return false;
}
const resolvedSettingsPath = getImageAnalysisProfileSettingsPath(profileName, settingsPath);
if (!fs.existsSync(resolvedSettingsPath)) {
return false;
}
try {
const content = fs.readFileSync(resolvedSettingsPath, 'utf8');
const settings = JSON.parse(content) as Record<string, unknown>;
const removed = removeCcsImageAnalyzerHooks(settings);
if (!removed) {
return false;
}
fs.writeFileSync(resolvedSettingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(
info(`Removed image analyzer hook from ${path.basename(resolvedSettingsPath)}`)
);
}
return true;
} catch {
return false;
}
}
/**
* One-time migration marker management
*/
@@ -174,6 +202,10 @@ export function ensureProfileHooks(input: string | ImageAnalysisResolutionContex
// Check if CCS hook already present
if (hasCcsHook(settings)) {
const hadDuplicates = deduplicateCcsImageAnalyzerHooks(settings);
if (hadDuplicates) {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
}
// Update timeout if needed
return updateHookTimeoutIfNeeded(settings, settingsPath);
}
+1
View File
@@ -38,6 +38,7 @@ export {
uninstallImageAnalyzerHook,
} from './image-analyzer-hook-installer';
export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector';
export { removeImageAnalysisProfileHook } from './image-analyzer-profile-hook-injector';
export function prepareImageAnalysisFallbackHook(): boolean {
return hasInstalledImageAnalyzerHook() || installSharedImageAnalyzerHook();
+7
View File
@@ -25,6 +25,13 @@ export {
appendThirdPartyImageAnalysisToolArgs,
getImageAnalysisSteeringPrompt,
} from './claude-tool-args';
export {
cleanupManagedImageAnalysisHooks,
countManagedImageAnalysisHookFiles,
repairImageAnalysisRuntimeState,
syncManagedImageAnalysisInstances,
type ImageAnalysisRepairStats,
} from './repair';
export const IMAGE_ANALYSIS_PROMPT_TEMPLATES = ['default', 'screenshot', 'document'] as const;
export type ImageAnalysisPromptTemplate = (typeof IMAGE_ANALYSIS_PROMPT_TEMPLATES)[number];
+96
View File
@@ -0,0 +1,96 @@
import * as fs from 'fs';
import * as path from 'path';
import InstanceManager from '../../management/instance-manager';
import { getCcsDir } from '../config-manager';
import { prepareImageAnalysisFallbackHook } from '../hooks';
import { removeCcsImageAnalyzerHooks } from '../hooks/image-analyzer-hook-utils';
import { ensureImageAnalysisMcpOrThrow } from './mcp-installer';
export interface ImageAnalysisRepairStats {
cleanedSettingsFiles: number;
syncedInstances: number;
managedToolReady: boolean;
sharedHookReady: boolean;
}
function visitManagedImageAnalysisSettings(
callback: (settings: Record<string, unknown>, settingsPath: string) => void,
baseDir = getCcsDir()
): void {
if (!fs.existsSync(baseDir)) {
return;
}
for (const entry of fs.readdirSync(baseDir)) {
if (!entry.endsWith('.settings.json')) {
continue;
}
const settingsPath = path.join(baseDir, entry);
try {
const stat = fs.statSync(settingsPath);
if (!stat.isFile()) {
continue;
}
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Record<string, unknown>;
callback(settings, settingsPath);
} catch {
// Best-effort cleanup; preserve malformed files for manual recovery.
}
}
}
export function countManagedImageAnalysisHookFiles(baseDir = getCcsDir()): number {
let count = 0;
visitManagedImageAnalysisSettings((settings) => {
if (
removeCcsImageAnalyzerHooks(JSON.parse(JSON.stringify(settings)) as Record<string, unknown>)
) {
count += 1;
}
}, baseDir);
return count;
}
export function cleanupManagedImageAnalysisHooks(baseDir = getCcsDir()): number {
let cleaned = 0;
visitManagedImageAnalysisSettings((settings, settingsPath) => {
if (!removeCcsImageAnalyzerHooks(settings)) {
return;
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
cleaned += 1;
}, baseDir);
return cleaned;
}
export function syncManagedImageAnalysisInstances(
instanceManager: InstanceManager = new InstanceManager()
): number {
let synced = 0;
for (const instanceName of instanceManager.listInstances()) {
const instancePath = instanceManager.getInstancePath(instanceName);
if (instanceManager.syncMcpServers(instancePath)) {
synced += 1;
}
}
return synced;
}
export function repairImageAnalysisRuntimeState(): ImageAnalysisRepairStats {
const managedToolReady = ensureImageAnalysisMcpOrThrow();
const sharedHookReady = prepareImageAnalysisFallbackHook();
const cleanedSettingsFiles = cleanupManagedImageAnalysisHooks();
const syncedInstances = managedToolReady ? syncManagedImageAnalysisInstances() : 0;
return {
cleanedSettingsFiles,
syncedInstances,
managedToolReady,
sharedHookReady,
};
}
+7
View File
@@ -10,6 +10,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCcsDir } from '../config-manager';
import { createLogger } from '../../services/logging';
const TRACE_FILE_NAME = 'websearch-trace.jsonl';
const NATIVE_WEBSEARCH_TOOL = 'WebSearch';
@@ -17,6 +18,7 @@ const DISALLOWED_TOOLS_FLAG = '--disallowedTools';
const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt';
const THIRD_PARTY_WEBSEARCH_STEERING_PROMPT =
'For web lookup or current-information requests, prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches. If the user explicitly wants shell commands, or WebSearch is unavailable or fails, you may fall back to Bash/network tools.';
const logger = createLogger('websearch');
function parseToolValue(rawValue: string): string[] {
return rawValue
@@ -121,6 +123,11 @@ export function appendWebSearchTrace(
}
try {
logger.info('trace.append', 'WebSearch trace event recorded', {
event,
launchId: env.CCS_WEBSEARCH_TRACE_LAUNCH_ID || null,
payload,
});
const traceFilePath = getTraceFilePath(env);
fs.mkdirSync(path.dirname(traceFilePath), { recursive: true });
fs.appendFileSync(
+16
View File
@@ -12,8 +12,10 @@ import path from 'path';
import { WebSocketServer } from 'ws';
import { setupWebSocket } from './websocket';
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
import { requestLoggingMiddleware } from './middleware/request-logging-middleware';
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
import { shutdownUsageAggregator } from './usage/aggregator';
import { createLogger } from '../services/logging';
export interface ServerOptions {
port: number;
@@ -28,6 +30,8 @@ export interface ServerInstance {
cleanup: () => void;
}
const logger = createLogger('web-server');
/**
* Start Express server with WebSocket support
*/
@@ -57,6 +61,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
next(err);
}
);
app.use(requestLoggingMiddleware);
// Session middleware (for dashboard auth)
app.use(createSessionMiddleware());
@@ -124,6 +129,12 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
// Start listening
return new Promise<ServerInstance>((resolve, reject) => {
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,
port: options.port,
});
cleanup();
reject(new Error(formatListenError(error, options)));
};
@@ -132,6 +143,11 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const onListening = () => {
server.off('error', onError);
logger.info('server.listening', 'Dashboard server listening', {
host: options.host || '0.0.0.0',
port: options.port,
dev: Boolean(options.dev),
});
// Usage cache loads on-demand when Analytics page is visited
// This keeps server startup instant for users who don't need analytics
resolve({ server, wss, cleanup });
@@ -0,0 +1,30 @@
import { randomUUID } from 'crypto';
import type { Request, Response, NextFunction } from 'express';
import { createLogger } from '../../services/logging';
const logger = createLogger('web-server:http');
export function requestLoggingMiddleware(req: Request, res: Response, next: NextFunction): void {
const requestId = randomUUID();
const startTime = Date.now();
res.locals.ccsRequestId = requestId;
res.setHeader('x-ccs-request-id', requestId);
const shouldSkipLogging = req.originalUrl.startsWith('/api/logs');
res.on('finish', () => {
if (shouldSkipLogging) {
return;
}
logger.info('request.completed', 'Dashboard request completed', {
requestId,
method: req.method,
path: req.originalUrl,
statusCode: res.statusCode,
durationMs: Date.now() - startTime,
remoteAddress: req.socket.remoteAddress || null,
userAgent: req.headers['user-agent'] || null,
});
});
next();
}
+49 -7
View File
@@ -7,6 +7,7 @@ import { Router, type Request, type Response } from 'express';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { getDashboardAuthConfig } from '../../config/unified-config-loader';
import type { DashboardAuthConfig } from '../../config/unified-config-types';
import { isLoopbackRemoteAddress, loginRateLimiter } from '../middleware/auth-middleware';
/**
@@ -24,6 +25,52 @@ function timingSafeEqual(a: string, b: string): boolean {
const router = Router();
export type DashboardAccessMode = 'open' | 'login' | 'setup';
export interface DashboardAccessState {
authRequired: boolean;
authEnabled: boolean;
authConfigured: boolean;
isLocalAccess: boolean;
accessMode: DashboardAccessMode;
}
export function resolveDashboardAccessState(
authConfig: DashboardAuthConfig,
remoteAddress: string | undefined
): DashboardAccessState {
const isLocalAccess = isLoopbackRemoteAddress(remoteAddress);
const authConfigured = Boolean(authConfig.username && authConfig.password_hash);
if (!authConfig.enabled) {
return {
authRequired: false,
authEnabled: false,
authConfigured,
isLocalAccess,
accessMode: 'open',
};
}
if (authConfigured) {
return {
authRequired: true,
authEnabled: true,
authConfigured: true,
isLocalAccess,
accessMode: 'login',
};
}
return {
authRequired: true,
authEnabled: true,
authConfigured,
isLocalAccess,
accessMode: 'setup',
};
}
/**
* POST /api/auth/login
* Authenticate user with username/password.
@@ -94,15 +141,10 @@ router.post('/logout', (req: Request, res: Response) => {
*/
router.get('/check', (req: Request, res: Response) => {
const authConfig = getDashboardAuthConfig();
const isLocal = isLoopbackRemoteAddress(req.socket.remoteAddress);
// When auth is not configured and access is remote, the dashboard API
// endpoints return 403. Signal auth-required so the UI can show the
// login/setup page instead of a silently broken dashboard.
const effectiveAuthRequired = authConfig.enabled || !isLocal;
const accessState = resolveDashboardAccessState(authConfig, req.socket.remoteAddress);
res.json({
authRequired: effectiveAuthRequired,
...accessState,
authenticated: req.session?.authenticated ?? false,
username: req.session?.username ?? null,
});
+8 -8
View File
@@ -1,21 +1,21 @@
import { Router, Request, Response } from 'express';
import { getAllResolvedCatalogs, getCacheAge } from '../../cliproxy/catalog-cache';
import { getResolvedCatalogSnapshot } from '../../cliproxy/catalog-cache';
const router = Router();
/**
* GET /api/cliproxy/catalog - Get merged model catalogs
* Returns resolved catalogs (cached + static merged)
* Returns resolved catalogs with live -> cache -> static fallback ordering.
*/
router.get('/', (_req: Request, res: Response): void => {
router.get('/', async (_req: Request, res: Response): Promise<void> => {
try {
const catalogs = getAllResolvedCatalogs();
const cacheAge = getCacheAge();
const snapshot = await getResolvedCatalogSnapshot();
res.json({
catalogs,
catalogs: snapshot.catalogs,
source: snapshot.source,
cache: {
synced: cacheAge !== null,
age: cacheAge,
synced: snapshot.source !== 'static' || snapshot.cacheAge !== null,
age: snapshot.cacheAge,
},
});
} catch (error) {
+78 -64
View File
@@ -1,5 +1,3 @@
import * as fs from 'fs';
import * as path from 'path';
import { Router, Request, Response } from 'express';
import {
getAllAuthStatus,
@@ -37,16 +35,21 @@ import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
import {
type ProviderTokenSnapshot,
findNewTokenSnapshot,
getProviderTokenDir,
isTokenFileForProvider,
listProviderTokenSnapshots,
registerAccountFromToken,
} from '../../cliproxy/auth/token-manager';
import {
CLIPROXY_CALLBACK_PROVIDER_MAP,
CLIPROXY_AUTH_URL_PROVIDER_MAP,
isKiroAuthMethod,
isKiroIDCFlow,
isKiroDeviceCodeMethod,
KiroIDCFlow,
KiroAuthMethod,
normalizeKiroIDCFlow,
normalizeKiroAuthMethod,
toKiroManagementMethod,
} from '../../cliproxy/auth/auth-types';
@@ -63,11 +66,6 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
const router = Router();
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
type ProviderTokenSnapshot = {
file: string;
mtimeMs: number;
email?: string;
};
const pendingManualAuthState = new Map<
string,
@@ -167,62 +165,14 @@ function markManualAuthUpstreamCompleted(state: string, now = Date.now()): numbe
return now;
}
function listProviderTokenSnapshots(provider: CLIProxyProvider): ProviderTokenSnapshot[] {
const tokenDir = getProviderTokenDir(provider);
if (!fs.existsSync(tokenDir)) {
return [];
}
return fs
.readdirSync(tokenDir)
.filter((file) => file.endsWith('.json'))
.map((file): ProviderTokenSnapshot | null => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) {
return null;
}
let email: string | undefined;
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(content) as { email?: string };
email = typeof parsed.email === 'string' ? parsed.email : undefined;
} catch {
email = undefined;
}
const stats = fs.statSync(filePath);
return {
file,
mtimeMs: stats.mtimeMs,
email,
};
})
.filter((snapshot): snapshot is ProviderTokenSnapshot => snapshot !== null)
.sort((left, right) => right.mtimeMs - left.mtimeMs);
}
function findNewTokenSnapshotForPendingAuth(
provider: CLIProxyProvider,
pending: { expectedAccountId?: string; knownTokenFiles: ProviderTokenSnapshot[] }
): ProviderTokenSnapshot | null {
const knownTokenMtimes = new Map(
pending.knownTokenFiles.map((snapshot) => [snapshot.file, snapshot.mtimeMs])
);
return (
listProviderTokenSnapshots(provider).find((snapshot) => {
const knownMtime = knownTokenMtimes.get(snapshot.file);
if (knownMtime === undefined) {
return true;
}
if (!pending.expectedAccountId) {
return false;
}
return snapshot.mtimeMs > knownMtime + 1;
}) || null
return findNewTokenSnapshot(
listProviderTokenSnapshots(provider),
pending.knownTokenFiles,
pending.expectedAccountId
);
}
@@ -256,12 +206,52 @@ function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boole
return { method: normalizeKiroAuthMethod(normalized), invalid: false };
}
function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean } {
if (raw === undefined || raw === null || raw === '') {
return { flow: normalizeKiroIDCFlow(), invalid: false };
}
if (typeof raw !== 'string') {
return { flow: normalizeKiroIDCFlow(), invalid: true };
}
const normalized = raw.trim().toLowerCase();
if (!isKiroIDCFlow(normalized)) {
return { flow: normalizeKiroIDCFlow(), invalid: true };
}
return { flow: normalizeKiroIDCFlow(normalized), invalid: false };
}
export function getKiroStartIDCValidationError(options: {
kiroMethod: KiroAuthMethod;
kiroIDCStartUrl?: string;
invalidKiroIDCFlow?: boolean;
}): { error: string; code: string } | null {
if (options.kiroMethod !== 'idc') {
return null;
}
if (options.invalidKiroIDCFlow) {
return {
error: 'Invalid kiroIDCFlow. Supported: authcode, device',
code: 'INVALID_KIRO_IDC_FLOW',
};
}
if (!options.kiroIDCStartUrl) {
return {
error: 'Kiro IDC login requires kiroIDCStartUrl',
code: 'MISSING_KIRO_IDC_START_URL',
};
}
return null;
}
export function getStartUrlUnsupportedReason(
provider: CLIProxyProvider,
options?: { kiroMethod?: KiroAuthMethod }
): string | null {
if (provider === 'kiro') {
const kiroMethod = options?.kiroMethod ?? normalizeKiroAuthMethod();
if (kiroMethod === 'idc') {
return "Kiro method 'idc' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead.";
}
if (kiroMethod === 'aws-authcode') {
return "Kiro method 'aws-authcode' uses CLI auth flow. Use /api/cliproxy/auth/kiro/start instead.";
}
@@ -597,6 +587,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const noIncognitoBody =
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const kiroIDCStartUrl =
typeof requestBody.kiroIDCStartUrl === 'string'
? requestBody.kiroIDCStartUrl.trim()
: undefined;
const kiroIDCRegion =
typeof requestBody.kiroIDCRegion === 'string' ? requestBody.kiroIDCRegion.trim() : undefined;
const kiroIDCFlowRaw = requestBody.kiroIDCFlow;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const target = getProxyTarget();
if (target.isRemote) {
@@ -606,6 +603,7 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
@@ -615,12 +613,24 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
if (provider === 'kiro' && invalidKiroMethod) {
res.status(400).json({
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
code: 'INVALID_KIRO_METHOD',
});
return;
}
if (provider === 'kiro') {
const kiroIDCValidationError = getKiroStartIDCValidationError({
kiroMethod,
kiroIDCStartUrl,
invalidKiroIDCFlow,
});
if (kiroIDCValidationError) {
res.status(400).json(kiroIDCValidationError);
return;
}
}
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
if (!validation.valid) {
@@ -659,6 +669,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
nickname: nickname || undefined,
acceptAgyRisk: provider === 'agy',
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
kiroIDCRegion: provider === 'kiro' ? kiroIDCRegion : undefined,
kiroIDCFlow: provider === 'kiro' && kiroMethod === 'idc' ? kiroIDCFlow : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
});
@@ -828,7 +841,7 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
if (provider === 'kiro' && invalidKiroMethod) {
res.status(400).json({
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github',
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
code: 'INVALID_KIRO_METHOD',
});
return;
@@ -867,9 +880,10 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
try {
const authUrlProvider =
CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider;
const kiroManagementMethod = provider === 'kiro' ? toKiroManagementMethod(kiroMethod) : null;
const kiroQuery =
provider === 'kiro'
? `&method=${encodeURIComponent(toKiroManagementMethod(kiroMethod))}`
provider === 'kiro' && kiroManagementMethod
? `&method=${encodeURIComponent(kiroManagementMethod)}`
: '';
// Call CLIProxyAPI to start OAuth and get auth URL
@@ -0,0 +1,45 @@
import { Router, Request, Response } from 'express';
import {
applyCliproxyRoutingStrategy,
normalizeCliproxyRoutingStrategy,
readCliproxyRoutingState,
} from '../../cliproxy/routing-strategy';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
const router = Router();
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
req,
res,
'CLIProxy routing endpoints require localhost access when dashboard auth is disabled.'
)
) {
next();
}
});
router.get('/routing/strategy', async (_req: Request, res: Response): Promise<void> => {
try {
res.json(await readCliproxyRoutingState());
} catch (error) {
res.status(502).json({ error: (error as Error).message });
}
});
router.put('/routing/strategy', async (req: Request, res: Response): Promise<void> => {
const strategy = normalizeCliproxyRoutingStrategy(req.body?.value ?? req.body?.strategy);
if (!strategy) {
res.status(400).json({ error: 'Invalid strategy. Use: round-robin or fill-first' });
return;
}
try {
res.json(await applyCliproxyRoutingStrategy(strategy));
} catch (error) {
res.status(502).json({ error: (error as Error).message });
}
});
export default router;
+2 -13
View File
@@ -16,14 +16,12 @@ import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'
import {
normalizeImageAnalysisBackendId,
resolveImageAnalysisRuntimeStatus,
prepareImageAnalysisFallbackHook,
} from '../../utils/hooks';
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { InstanceManager } from '../../management/instance-manager';
import {
ensureImageAnalysisMcpOrThrow,
hasImageAnalysisMcpReady,
repairImageAnalysisRuntimeState,
} from '../../utils/image-analysis';
const router = Router();
@@ -96,13 +94,6 @@ function resolveCurrentTargetMode(
return 'active';
}
function syncManagedImageAnalysisToInstances(): void {
const instanceManager = new InstanceManager();
for (const instanceName of instanceManager.listInstances()) {
instanceManager.syncMcpServers(instanceManager.getInstancePath(instanceName));
}
}
function resolveBackendState(
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
): BackendState {
@@ -469,9 +460,7 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
const nextEnabled = body.enabled ?? currentConfig.enabled;
if (nextEnabled) {
ensureImageAnalysisMcpOrThrow();
prepareImageAnalysisFallbackHook();
syncManagedImageAnalysisToInstances();
repairImageAnalysisRuntimeState();
}
res.json(await buildDashboardPayload());
+29
View File
@@ -6,6 +6,7 @@
*/
import { Router } from 'express';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
// Import domain routers
import profileRoutes from './profile-routes';
@@ -20,6 +21,7 @@ import websearchRoutes from './websearch-routes';
import imageAnalysisRoutes from './image-analysis-routes';
import cliproxyAuthRoutes from './cliproxy-auth-routes';
import cliproxyStatsRoutes from './cliproxy-stats-routes';
import cliproxyRoutingRoutes from './cliproxy-routing-routes';
import cliproxySyncRoutes from './cliproxy-sync-routes';
import aiProviderRoutes from './ai-provider-routes';
import copilotRoutes from './copilot-routes';
@@ -32,10 +34,35 @@ import authRoutes from './auth-routes';
import persistRoutes from './persist-routes';
import catalogRoutes from './catalog-routes';
import claudeExtensionRoutes from './claude-extension-routes';
import logsRoutes from './logs-routes';
// Create the main API router
export const apiRoutes = Router();
const REMOTE_WRITE_ACCESS_ERROR =
'Remote dashboard writes require localhost access when dashboard auth is disabled.';
function isMutationMethod(method: string): boolean {
const normalized = method.toUpperCase();
return (
normalized === 'POST' ||
normalized === 'PUT' ||
normalized === 'PATCH' ||
normalized === 'DELETE'
);
}
apiRoutes.use((req, res, next) => {
if (!isMutationMethod(req.method)) {
next();
return;
}
if (requireLocalAccessWhenAuthDisabled(req, res, REMOTE_WRITE_ACCESS_ERROR)) {
next();
}
});
// ==================== Profile & Settings ====================
// Profile CRUD, settings management, presets, accounts
apiRoutes.use('/profiles', profileRoutes);
@@ -59,6 +86,7 @@ apiRoutes.use('/claude-extension', claudeExtensionRoutes);
// ==================== CLIProxy ====================
// Variants, auth, accounts, stats, status, models, error logs
apiRoutes.use('/cliproxy', cliproxyRoutingRoutes);
apiRoutes.use('/cliproxy', variantRoutes);
apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
@@ -88,3 +116,4 @@ apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);
// ==================== Misc (File API, Global Env) ====================
apiRoutes.use('/', miscRoutes);
apiRoutes.use('/logs', logsRoutes);
+99
View File
@@ -0,0 +1,99 @@
import { Router, type Request, type Response } from 'express';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import { isLoggingLevel } from '../../services/logging/log-types';
import {
getDashboardLoggingConfig,
listDashboardLogEntries,
listDashboardLogSources,
updateDashboardLoggingConfig,
} from '../services/logs-dashboard-service';
const router = Router();
const LOGS_LOCAL_ACCESS_ERROR =
'Logs endpoints require localhost access when dashboard auth is disabled.';
router.use((req: Request, res: Response, next) => {
if (requireLocalAccessWhenAuthDisabled(req, res, LOGS_LOCAL_ACCESS_ERROR)) {
next();
}
});
router.get('/config', (_req: Request, res: Response) => {
res.json({ logging: getDashboardLoggingConfig() });
});
router.put('/config', (req: Request, res: Response) => {
const updates = req.body as Record<string, unknown>;
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
return;
}
const {
enabled,
level,
rotate_mb: rotateMb,
retain_days: retainDays,
redact,
live_buffer_size: liveBufferSize,
} = updates;
if (enabled !== undefined && typeof enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
if (level !== undefined && !isLoggingLevel(String(level))) {
res.status(400).json({ error: 'level must be one of error, warn, info, debug' });
return;
}
const numericPairs = [
['rotate_mb', rotateMb],
['retain_days', retainDays],
['live_buffer_size', liveBufferSize],
] as const;
for (const [field, value] of numericPairs) {
if (value !== undefined && (!Number.isInteger(value) || Number(value) < 1)) {
res.status(400).json({ error: `${field} must be a positive integer` });
return;
}
}
if (redact !== undefined && typeof redact !== 'boolean') {
res.status(400).json({ error: 'redact must be a boolean' });
return;
}
res.json({
success: true,
logging: updateDashboardLoggingConfig({
enabled: enabled as boolean | undefined,
level: level as 'error' | 'warn' | 'info' | 'debug' | undefined,
rotate_mb: rotateMb as number | undefined,
retain_days: retainDays as number | undefined,
redact: redact as boolean | undefined,
live_buffer_size: liveBufferSize as number | undefined,
}),
});
});
router.get('/sources', (_req: Request, res: Response) => {
res.json({ sources: listDashboardLogSources() });
});
router.get('/entries', (req: Request, res: Response) => {
const { source, level, search, limit } = req.query;
const parsedLimit =
typeof limit === 'string' && Number.isInteger(Number(limit)) ? Number(limit) : undefined;
res.json({
entries: listDashboardLogEntries({
source: typeof source === 'string' ? source : undefined,
level: typeof level === 'string' && isLoggingLevel(level) ? level : undefined,
search: typeof search === 'string' ? search : undefined,
limit: parsedLimit,
}),
});
});
export default router;
+2
View File
@@ -19,6 +19,7 @@ import {
} from '../../cliproxy';
import { regenerateConfig } from '../../cliproxy/config-generator';
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import { removeCcsImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-hook-utils';
import { resolveCliproxyBridgeMetadata } from '../../api/services';
import {
getImageAnalysisConfig,
@@ -508,6 +509,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
// Deduplicate CCS hooks to prevent accumulation (fixes #450)
// This handles cases where duplicate hooks were added by previous versions
deduplicateCcsHooks(normalizedSettings as Record<string, unknown>);
removeCcsImageAnalyzerHooks(normalizedSettings as Record<string, unknown>);
const ccsDir = getCcsDir();
@@ -0,0 +1,37 @@
import { mutateUnifiedConfig } from '../../config/unified-config-loader';
import {
getResolvedLoggingConfig,
invalidateLoggingConfigCache,
readLogEntries,
readLogSourceSummaries,
} from '../../services/logging';
import type { LoggingConfig } from '../../config/unified-config-types';
import type { ReadLogEntriesOptions } from '../../services/logging';
export function getDashboardLoggingConfig(): LoggingConfig {
return getResolvedLoggingConfig();
}
export function updateDashboardLoggingConfig(updates: Partial<LoggingConfig>): LoggingConfig {
const updated = mutateUnifiedConfig((config) => {
config.logging = {
...getResolvedLoggingConfig(),
...config.logging,
...updates,
};
});
invalidateLoggingConfigCache();
return {
...getResolvedLoggingConfig(),
...updated.logging,
};
}
export function listDashboardLogSources() {
return readLogSourceSummaries();
}
export function listDashboardLogEntries(options: ReadLogEntriesOptions = {}) {
return readLogEntries(options);
}
+15
View File
@@ -8,6 +8,7 @@
*/
import { Router } from 'express';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import {
handleSummary,
handleDaily,
@@ -24,6 +25,20 @@ export { prewarmUsageCache, clearUsageCache, getLastFetchTimestamp } from './agg
export const usageRoutes = Router();
const USAGE_WRITE_ACCESS_ERROR =
'Usage refresh requires localhost access when dashboard auth is disabled.';
usageRoutes.use((req, res, next) => {
if (req.method.toUpperCase() !== 'POST') {
next();
return;
}
if (requireLocalAccessWhenAuthDisabled(req, res, USAGE_WRITE_ACCESS_ERROR)) {
next();
}
});
// Summary endpoint
usageRoutes.get('/summary', handleSummary);
+24 -14
View File
@@ -7,12 +7,14 @@
import { WebSocketServer, WebSocket } from 'ws';
import { createFileWatcher, FileChangeEvent } from './file-watcher';
import { info, warn } from '../utils/ui';
import {
projectSelectionEvents,
type ProjectSelectionPrompt,
} from '../cliproxy/project-selection-handler';
import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler';
import { createLogger } from '../services/logging';
const logger = createLogger('web-server:websocket');
export interface WSMessage {
type: string;
@@ -36,7 +38,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
// Handle new connections
wss.on('connection', (ws) => {
clients.add(ws);
console.log(info(`[WS] Client connected (${clients.size} total)`));
logger.info('client.connected', 'WebSocket client connected', { clients: clients.size });
// Send welcome message
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
@@ -47,18 +49,20 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
const message = JSON.parse(data.toString());
handleClientMessage(ws, message);
} catch {
console.log(warn('[WS] Invalid message format'));
logger.warn('message.invalid', 'WebSocket client sent invalid JSON');
}
});
// Handle disconnect
ws.on('close', () => {
clients.delete(ws);
console.log(info(`[WS] Client disconnected (${clients.size} remaining)`));
logger.info('client.disconnected', 'WebSocket client disconnected', {
clients: clients.size,
});
});
ws.on('error', (err) => {
console.log(warn(`[WS] Error: ${err.message}`));
logger.warn('client.error', 'WebSocket client error', { message: err.message });
clients.delete(ws);
});
});
@@ -73,13 +77,15 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
// Future: selective subscriptions
break;
default:
console.log(warn(`[WS] Unknown message type: ${message.type}`));
logger.warn('message.unknown', 'WebSocket client sent unknown message type', {
type: String(message.type),
});
}
}
// Setup file watcher
const watcher = createFileWatcher((event: FileChangeEvent) => {
console.log(info(`[FS] ${event.type}: ${event.path}`));
logger.debug('file.changed', 'Dashboard file watcher detected a change', { ...event });
broadcast({
type: event.type,
path: event.path,
@@ -89,7 +95,9 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
// Listen for project selection events and broadcast to clients
const handleProjectSelectionRequired = (prompt: ProjectSelectionPrompt): void => {
console.log(info(`[WS] Broadcasting project selection prompt (session: ${prompt.sessionId})`));
logger.info('project-selection.required', 'Broadcasting project selection prompt', {
sessionId: prompt.sessionId,
});
broadcast({
type: 'projectSelectionRequired',
...prompt,
@@ -98,7 +106,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
};
const handleProjectSelectionTimeout = (sessionId: string): void => {
console.log(info(`[WS] Project selection timed out (session: ${sessionId})`));
logger.info('project-selection.timeout', 'Project selection prompt timed out', { sessionId });
broadcast({
type: 'projectSelectionTimeout',
sessionId,
@@ -110,7 +118,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
sessionId: string;
selectedId: string;
}): void => {
console.log(info(`[WS] Project selection submitted (session: ${response.sessionId})`));
logger.info('project-selection.submitted', 'Project selection submitted', response);
broadcast({
type: 'projectSelectionSubmitted',
...response,
@@ -120,7 +128,9 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
// Listen for device code events and broadcast to clients
const handleDeviceCodeReceived = (prompt: DeviceCodePrompt): void => {
console.log(info(`[WS] Broadcasting device code (session: ${prompt.sessionId})`));
logger.info('device-code.received', 'Broadcasting device code prompt', {
sessionId: prompt.sessionId,
});
broadcast({
type: 'deviceCodeReceived',
...prompt,
@@ -129,7 +139,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
};
const handleDeviceCodeCompleted = (sessionId: string): void => {
console.log(info(`[WS] Device code auth completed (session: ${sessionId})`));
logger.info('device-code.completed', 'Device code auth completed', { sessionId });
broadcast({
type: 'deviceCodeCompleted',
sessionId,
@@ -138,7 +148,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
};
const handleDeviceCodeFailed = (data: { sessionId: string; error?: string }): void => {
console.log(info(`[WS] Device code auth failed (session: ${data.sessionId})`));
logger.warn('device-code.failed', 'Device code auth failed', data);
broadcast({
type: 'deviceCodeFailed',
...data,
@@ -147,7 +157,7 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
};
const handleDeviceCodeExpired = (sessionId: string): void => {
console.log(info(`[WS] Device code expired (session: ${sessionId})`));
logger.info('device-code.expired', 'Device code expired', { sessionId });
broadcast({
type: 'deviceCodeExpired',
sessionId,
@@ -20,8 +20,18 @@ describe('auth-types paste-callback start path', () => {
expect(getPasteCallbackStartPath('ghcp')).toBe('/v0/management/github-auth-url?is_webui=true');
});
it('keeps Kiro on the legacy start route for paste-callback mode', () => {
expect(getPasteCallbackStartPath('kiro')).toBe('/oauth/kiro/start');
it('maps Kiro management-supported methods to the management auth-url route', () => {
expect(getPasteCallbackStartPath('kiro')).toBe(
'/v0/management/kiro-auth-url?is_webui=true&method=aws'
);
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'google' })).toBe(
'/v0/management/kiro-auth-url?is_webui=true&method=google'
);
});
it('returns null for Kiro CLI-only paste-callback modes', () => {
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'aws-authcode' })).toBeNull();
expect(getPasteCallbackStartPath('kiro', { kiroMethod: 'idc' })).toBeNull();
});
it('still exposes the generic management auth-url helper', () => {
@@ -45,7 +45,7 @@ async function importCompatibilityModule(cacheTag: string) {
const identity = (message: string) => message;
describe('codex plan compatibility reconcile', () => {
it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => {
it('keeps saved Codex settings intact and warns about runtime fallback for free-plan accounts', async () => {
const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark');
const errorSpy = spyOn(console, 'error').mockImplementation(() => {});
@@ -54,7 +54,6 @@ describe('codex plan compatibility reconcile', () => {
await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
@@ -76,12 +75,12 @@ describe('codex plan compatibility reconcile', () => {
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini');
expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.3-codex-spark');
expect(errorSpy).toHaveBeenCalledWith(
'Codex free plan detected. Switched unsupported model "gpt-5.3-codex" to "gpt-5-codex".'
'Codex free plan detected. Keeping saved model "gpt-5.3-codex" in settings; runtime requests will fall back to "gpt-5-codex" when needed.'
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -98,7 +97,6 @@ describe('codex plan compatibility reconcile', () => {
await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
@@ -134,7 +132,6 @@ describe('codex plan compatibility reconcile', () => {
await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
@@ -174,7 +171,6 @@ describe('codex plan compatibility reconcile', () => {
await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
@@ -216,7 +212,6 @@ describe('codex plan compatibility reconcile', () => {
await reconcileCodexModelForActivePlan(
{
settingsPath,
currentModel: 'gpt-5.3-codex',
verbose: false,
},
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'bun:test';
import { readOptionValue } from '../../../src/cliproxy/executor/index';
describe('readOptionValue', () => {
it('parses split-token option values', () => {
expect(readOptionValue(['--kiro-idc-start-url', 'https://d-123.awsapps.com/start'], '--kiro-idc-start-url')).toEqual({
present: true,
value: 'https://d-123.awsapps.com/start',
missingValue: false,
});
});
it('parses equals-form option values', () => {
expect(readOptionValue(['--kiro-idc-flow=device'], '--kiro-idc-flow')).toEqual({
present: true,
value: 'device',
missingValue: false,
});
});
it('marks empty or missing values as invalid', () => {
expect(readOptionValue(['--kiro-idc-region'], '--kiro-idc-region')).toEqual({
present: true,
value: undefined,
missingValue: true,
});
expect(readOptionValue(['--kiro-idc-flow='], '--kiro-idc-flow')).toEqual({
present: true,
value: undefined,
missingValue: true,
});
});
});
@@ -90,6 +90,54 @@ describe('management-api-client', () => {
});
});
describe('routing strategy helpers', () => {
it('reads the routing strategy from the management endpoint', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ strategy: 'fill-first' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
);
global.fetch = fetchMock as typeof global.fetch;
const strategy = await client.getRoutingStrategy();
expect(strategy).toBe('fill-first');
expect(fetchMock).toHaveBeenCalled();
global.fetch = originalFetch;
});
it('writes the routing strategy using the expected payload shape', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
const fetchMock = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
);
global.fetch = fetchMock as typeof global.fetch;
const strategy = await client.putRoutingStrategy('round-robin');
expect(strategy).toBe('round-robin');
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:8317/routing/strategy',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ value: 'round-robin' }),
})
);
global.fetch = originalFetch;
});
});
describe('error code mapping', () => {
it('should map ENOTFOUND to DNS_FAILED', () => {
const error = new Error('getaddrinfo ENOTFOUND example.com') as NodeJS.ErrnoException;
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'bun:test';
import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog';
import {
PROVIDER_TO_CHANNEL,
SYNCABLE_PROVIDERS,
mergeCatalog,
} from '../../../src/cliproxy/catalog-cache';
describe('model-catalog compatibility lookups', () => {
it('finds agy Claude models using dotted major.minor IDs', () => {
@@ -34,4 +39,23 @@ describe('model-catalog compatibility lookups', () => {
expect(dottedLegacy?.id).toBe('claude-sonnet-4-6');
expect(hyphenLegacy?.id).toBe('claude-sonnet-4-6');
});
it('maps all dashboard providers to upstream catalog channels', () => {
expect(SYNCABLE_PROVIDERS).toContain('qwen');
expect(SYNCABLE_PROVIDERS).toContain('iflow');
expect(SYNCABLE_PROVIDERS).toContain('kiro');
expect(SYNCABLE_PROVIDERS).toContain('ghcp');
expect(PROVIDER_TO_CHANNEL.ghcp).toBe('github-copilot');
});
it('does not re-add stale static-only models when live catalog data is present', () => {
const catalog = mergeCatalog('gemini', [
{
id: 'gemini-2.5-pro',
display_name: 'Gemini 2.5 Pro',
},
]);
expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']);
});
});
@@ -1,4 +1,7 @@
import { afterEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
@@ -39,11 +42,10 @@ describe('requestPasteCallbackStart', () => {
expect(request.headers['Content-Type']).toBeUndefined();
});
it('keeps kiro on the legacy start route with POST', async () => {
it('uses the Kiro management auth-url route for paste-callback compatible methods', async () => {
mockFetch([
{
url: /\/oauth\/kiro\/start$/,
method: 'POST',
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=aws$/,
response: { auth_url: 'https://auth.example.com/kiro' },
},
]);
@@ -51,15 +53,43 @@ describe('requestPasteCallbackStart', () => {
const { requestPasteCallbackStart } = await import(
`../../../src/cliproxy/auth/oauth-handler?request-kiro-start=${Date.now()}`
);
const startData = await requestPasteCallbackStart('kiro', remoteTarget);
const startData = await requestPasteCallbackStart('kiro', remoteTarget, {
kiroMethod: 'aws',
});
expect(startData.auth_url).toBe('https://auth.example.com/kiro');
const [request] = getCapturedFetchRequests();
expect(request.url).toBe('https://proxy.example.com:8317/oauth/kiro/start');
expect(request.method).toBe('POST');
expect(request.url).toBe(
'https://proxy.example.com:8317/v0/management/kiro-auth-url?is_webui=true&method=aws'
);
expect(request.method).toBe('GET');
expect(request.headers['Authorization']).toBe('Bearer test-mgmt-key');
expect(request.headers['Content-Type']).toBe('application/json');
expect(request.headers['Content-Type']).toBeUndefined();
});
it('throws for Kiro methods that require the local callback server flow', async () => {
const { requestPasteCallbackStart } = await import(
`../../../src/cliproxy/auth/oauth-handler?request-kiro-authcode-start=${Date.now()}`
);
await expect(
requestPasteCallbackStart('kiro', remoteTarget, { kiroMethod: 'aws-authcode' })
).rejects.toThrow(/paste-callback start is not available/i);
});
});
describe('usesKiroLocalCallbackReplay', () => {
it('limits local callback replay to CLI auth-code flows', async () => {
const { usesKiroLocalCallbackReplay } = await import(
`../../../src/cliproxy/auth/oauth-handler?kiro-local-callback-mode=${Date.now()}`
);
expect(usesKiroLocalCallbackReplay('aws-authcode', 'authcode')).toBe(true);
expect(usesKiroLocalCallbackReplay('idc', 'authcode')).toBe(true);
expect(usesKiroLocalCallbackReplay('idc', 'device')).toBe(false);
expect(usesKiroLocalCallbackReplay('google', 'authcode')).toBe(false);
expect(usesKiroLocalCallbackReplay('aws', 'authcode')).toBe(false);
});
});
@@ -103,6 +133,56 @@ describe('resolvePasteCallbackAuthUrl', () => {
});
});
describe('findNewTokenSnapshotForManualAuth', () => {
it('detects newly created provider token files', async () => {
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-manual-auth-'));
const existingFile = path.join(tokenDir, 'kiro-existing.json');
fs.writeFileSync(existingFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' }));
const existingMtimeMs = fs.statSync(existingFile).mtimeMs;
const { findNewTokenSnapshotForManualAuth } = await import(
`../../../src/cliproxy/auth/oauth-handler?manual-auth-new-token=${Date.now()}`
);
const newFile = path.join(tokenDir, 'kiro-new.json');
fs.writeFileSync(newFile, JSON.stringify({ type: 'kiro', email: 'new@example.com' }));
const snapshot = findNewTokenSnapshotForManualAuth(
'kiro',
tokenDir,
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }]
);
expect(snapshot?.file).toBe('kiro-new.json');
fs.rmSync(tokenDir, { recursive: true, force: true });
});
it('treats a modified existing token as the new token during reauth', async () => {
const tokenDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-kiro-reauth-'));
const tokenFile = path.join(tokenDir, 'kiro-existing.json');
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com' }));
const existingMtimeMs = fs.statSync(tokenFile).mtimeMs;
const { findNewTokenSnapshotForManualAuth } = await import(
`../../../src/cliproxy/auth/oauth-handler?manual-auth-updated-token=${Date.now()}`
);
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com', refreshed: true }));
const bumpedTime = new Date(existingMtimeMs + 10_000);
fs.utimesSync(tokenFile, bumpedTime, bumpedTime);
const snapshot = findNewTokenSnapshotForManualAuth(
'kiro',
tokenDir,
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }],
'kiro-existing.json'
);
expect(snapshot?.file).toBe('kiro-existing.json');
fs.rmSync(tokenDir, { recursive: true, force: true });
});
});
describe('getCliAuthNicknameError', () => {
it('allows omitted nicknames for no-email providers', async () => {
const { getCliAuthNicknameError } = await import(
@@ -1,8 +1,16 @@
import { describe, expect, it } from 'bun:test';
import { extractLikelyAuthFailureFromStderr } from '../../../src/cliproxy/auth/oauth-process';
import {
analyzeSuccessfulAuthExit,
extractLikelyAuthFailureFromLogs,
extractLikelyAuthFailureFromStderr,
extractLikelyOAuthAuthorizationUrl,
getExpectedLocalCallback,
getKiroBuilderIdSelectionInput,
validateManualCallbackUrl,
} from '../../../src/cliproxy/auth/oauth-process';
describe('oauth-process stderr parsing', () => {
it('ignores non-ghcp providers', () => {
it('does not match provider-specific patterns for other providers', () => {
const stderr =
'time="2026-03-03T10:00:00Z" level=error msg="GitHub Copilot authentication failed: example"';
@@ -32,4 +40,225 @@ describe('oauth-process stderr parsing', () => {
expect(parsed).not.toBeNull();
expect((parsed as string).length).toBe(240);
});
it('extracts kiro IDC failures from verbose stdout logs', () => {
const logData =
'[2026-04-07 11:01:21] [--------] [error] [kiro_login.go:236] Kiro IDC authentication failed: login failed: failed to register client: register client failed (status 400)';
expect(extractLikelyAuthFailureFromLogs('kiro', logData)).toBe(
'login failed: failed to register client: register client failed (status 400)'
);
});
});
describe('oauth-process successful exit analysis', () => {
it('treats unchanged existing kiro tokens as a failed add-account attempt', () => {
const result = analyzeSuccessfulAuthExit({
provider: 'kiro',
knownTokenFiles: [{ file: 'kiro-existing.json', mtimeMs: 100, fingerprint: 'same' }],
currentTokenFiles: [{ file: 'kiro-existing.json', mtimeMs: 100, fingerprint: 'same' }],
stdoutData:
'[error] Kiro IDC authentication failed: login failed: failed to register client: register client failed (status 400)',
stderrData: '',
});
expect(result.tokenSnapshot).toBeNull();
expect(result.failureReason).toBe(
'login failed: failed to register client: register client failed (status 400)'
);
});
it('treats a refreshed token file as success during reauth', () => {
const result = analyzeSuccessfulAuthExit({
provider: 'kiro',
knownTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'before',
},
],
currentTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 250,
accountId: 'kiro-existing',
fingerprint: 'after',
},
],
expectedAccountId: 'kiro-existing.json',
stdoutData: '',
stderrData: '',
});
expect(result.tokenSnapshot?.file).toBe('kiro-existing.json');
expect(result.failureReason).toBeNull();
});
it('ignores unrelated new token files during reauth', () => {
const result = analyzeSuccessfulAuthExit({
provider: 'kiro',
knownTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'before',
},
],
currentTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'before',
},
{
file: 'kiro-other.json',
mtimeMs: 150,
accountId: 'kiro-other',
fingerprint: 'other-after',
},
],
expectedAccountId: 'kiro-existing',
stdoutData: '',
stderrData: '',
});
expect(result.tokenSnapshot).toBeNull();
expect(result.failureReason).toBeNull();
});
it('treats fingerprint changes as success even when mtime is unchanged', () => {
const result = analyzeSuccessfulAuthExit({
provider: 'kiro',
knownTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'before',
},
],
currentTokenFiles: [
{
file: 'kiro-existing.json',
mtimeMs: 100,
accountId: 'kiro-existing',
fingerprint: 'after',
},
],
expectedAccountId: 'kiro-existing',
stdoutData: '',
stderrData: '',
});
expect(result.tokenSnapshot?.file).toBe('kiro-existing.json');
expect(result.failureReason).toBeNull();
});
});
describe('oauth-process manual callback validation', () => {
const authUrl =
'https://oidc.example.com/authorize?redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state';
it('extracts the expected local callback target from the auth URL', () => {
expect(getExpectedLocalCallback(authUrl)).toEqual({
origin: 'http://127.0.0.1:9876',
pathname: '/oauth/callback',
state: 'test-state',
});
});
it('accepts matching loopback callback URLs', () => {
expect(
validateManualCallbackUrl(
'http://127.0.0.1:9876/oauth/callback?code=abc123&state=test-state',
authUrl
)
).toBeNull();
});
it('rejects non-loopback callback URLs', () => {
expect(
validateManualCallbackUrl(
'https://evil.example.com/oauth/callback?code=abc123&state=test-state',
authUrl
)
).toContain('local OAuth callback server');
});
it('rejects callback URLs with the wrong path or state', () => {
expect(
validateManualCallbackUrl(
'http://127.0.0.1:9876/not-the-callback?code=abc123&state=test-state',
authUrl
)
).toContain('expected local OAuth callback target');
expect(
validateManualCallbackUrl(
'http://127.0.0.1:9876/oauth/callback?code=abc123&state=wrong-state',
authUrl
)
).toContain('state does not match');
});
});
describe('oauth-process Kiro Builder ID menu parsing', () => {
it('selects Builder ID when it is the first option', () => {
const output = `
? Select login method:
1) Use with Builder ID (personal AWS account)
2) Use with IDC Account (organization SSO)
`;
expect(getKiroBuilderIdSelectionInput(output)).toBe('1\n');
});
it('selects the Builder ID option even when upstream reorders the menu', () => {
const output = `
Select login method
1. IAM Identity Center
2. AWS Builder ID
`;
expect(getKiroBuilderIdSelectionInput(output)).toBe('2\n');
});
it('returns null when the Builder ID option is not present in the prompt window', () => {
const output = `
Select login method
1. IAM Identity Center
2. Google
`;
expect(getKiroBuilderIdSelectionInput(output)).toBeNull();
});
});
describe('oauth-process OAuth URL extraction', () => {
it('prefers the real auth URL over the IDC start URL banner', () => {
const authUrl =
'https://oidc.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256';
const output = `
Using IDC with Start URL: https://d-123.awsapps.com/start
Region: us-east-1
URL: ${authUrl}
`;
expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl);
});
it('ignores local callback server URLs when the auth URL is also present', () => {
const authUrl =
'https://device.sso.us-east-1.amazonaws.com/authorize?response_type=code&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A9876%2Foauth%2Fcallback&state=test-state&code_challenge=test-challenge&code_challenge_method=S256';
const output = `
Callback server started, redirect URI: http://127.0.0.1:9876/oauth/callback
URL: ${authUrl}
`;
expect(extractLikelyOAuthAuthorizationUrl(output)).toBe(authUrl);
});
});
@@ -21,7 +21,9 @@ import {
import {
DEFAULT_KIRO_AUTH_METHOD,
getKiroCallbackPort,
getKiroCLIAuthArgs,
getKiroCLIAuthFlag,
normalizeKiroIDCFlow,
normalizeKiroAuthMethod,
OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS,
toKiroManagementMethod,
@@ -136,20 +138,30 @@ describe('provider-capabilities', () => {
expect(DEFAULT_KIRO_AUTH_METHOD).toBe('aws');
expect(normalizeKiroAuthMethod()).toBe('aws');
expect(normalizeKiroAuthMethod('GOOGLE')).toBe('google');
expect(normalizeKiroAuthMethod('IDC')).toBe('idc');
expect(normalizeKiroAuthMethod('not-valid')).toBe('aws');
expect(normalizeKiroIDCFlow()).toBe('authcode');
expect(normalizeKiroIDCFlow('DEVICE')).toBe('device');
expect(getKiroCLIAuthFlag('aws')).toBe('--kiro-aws-login');
expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode');
expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login');
expect(getKiroCLIAuthFlag('idc')).toBe('--kiro-idc-login');
expect(getKiroCLIAuthArgs('idc', { idcStartUrl: 'https://d-123.awsapps.com/start' })).toEqual(
['--kiro-idc-login', '--kiro-idc-start-url', 'https://d-123.awsapps.com/start', '--kiro-idc-flow', 'authcode']
);
expect(getKiroCallbackPort('aws')).toBeNull();
expect(getKiroCallbackPort('google')).toBe(9876);
expect(getKiroCallbackPort('github')).toBe(9876);
expect(getKiroCallbackPort('aws-authcode')).toBe(9876);
expect(getKiroCallbackPort('idc')).toBe(9876);
expect(getKiroCallbackPort('idc', { idcFlow: 'device' })).toBeNull();
expect(toKiroManagementMethod('aws')).toBe('aws');
expect(toKiroManagementMethod('aws-authcode')).toBe('aws');
expect(toKiroManagementMethod('google')).toBe('google');
expect(toKiroManagementMethod('github')).toBe('github');
expect(toKiroManagementMethod('idc')).toBeNull();
});
});
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
describe('cliproxy routing strategy service', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let setGlobalConfigDir: (dir: string | undefined) => void;
let routingTarget = {
host: '127.0.0.1',
port: 8317,
protocol: 'http' as const,
isRemote: false,
};
let responseFactory: (() => Promise<Response>) | null = null;
beforeEach(async () => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-'));
process.env.CCS_HOME = tempHome;
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
setGlobalConfigDir(path.join(tempHome, '.ccs'));
});
afterEach(() => {
mock.restore();
setGlobalConfigDir(undefined);
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
async function loadRoutingModule() {
mock.module('../../../src/cliproxy/routing-strategy-http', () => ({
getCliproxyRoutingTarget: () => routingTarget,
fetchCliproxyRoutingResponse: () => {
if (!responseFactory) {
throw new Error('routing unavailable');
}
return responseFactory();
},
getRoutingErrorMessage: async (response: Response, fallback: string) => {
const body = (await response.json().catch(() => null)) as { error?: string } | null;
return body?.error || fallback;
},
}));
return import(`../../../src/cliproxy/routing-strategy?test=${Date.now()}-${Math.random()}`);
}
it('normalizes canonical and shorthand strategy values', async () => {
const mod = await loadRoutingModule();
expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin');
expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin');
expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first');
expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first');
expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull();
});
it('falls back to the saved local default when live CLIProxy is unavailable', async () => {
const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader');
mutateUnifiedConfig((config) => {
if (config.cliproxy) {
config.cliproxy.routing = { strategy: 'fill-first' };
}
});
const mod = await loadRoutingModule();
const state = await mod.readCliproxyRoutingState();
expect(state.strategy).toBe('fill-first');
expect(state.source).toBe('config');
expect(state.target).toBe('local');
expect(state.reachable).toBe(false);
});
it('persists the local startup default even when the live proxy is down', async () => {
const mod = await loadRoutingModule();
const result = await mod.applyCliproxyRoutingStrategy('fill-first');
expect(result.applied).toBe('config-only');
expect(result.strategy).toBe('fill-first');
const configPath = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml');
const configContent = fs.readFileSync(configPath, 'utf8');
expect(configContent).toContain('routing:');
expect(configContent).toContain('strategy: fill-first');
});
it('reads and writes remote strategy without mutating the local default', async () => {
routingTarget = {
host: 'remote.example.com',
port: 8080,
protocol: 'http',
isRemote: true,
};
let methodCount = 0;
responseFactory = async () => {
methodCount += 1;
return new Response(JSON.stringify({ strategy: 'fill-first' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
const mod = await loadRoutingModule();
const readState = await mod.readCliproxyRoutingState();
const writeState = await mod.applyCliproxyRoutingStrategy('fill-first');
expect(readState.strategy).toBe('fill-first');
expect(readState.target).toBe('remote');
expect(writeState.applied).toBe('live');
expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin');
expect(methodCount).toBe(2);
});
});
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { handleCleanupCommand } from '../../../src/commands/cleanup-command';
import { getCliproxyDir } from '../../../src/cliproxy/config-generator';
import { getLogArchiveDir, getNativeLogsDir } from '../../../src/services/logging';
describe('cleanup command', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cleanup-command-'));
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('reports CCS archives alongside current logs in dry-run mode', async () => {
const ccsLogsDir = getNativeLogsDir();
const archiveDir = getLogArchiveDir();
const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs');
fs.mkdirSync(archiveDir, { recursive: true });
fs.mkdirSync(cliproxyLogsDir, { recursive: true });
fs.writeFileSync(path.join(ccsLogsDir, 'current.jsonl'), 'x'.repeat(100));
fs.writeFileSync(path.join(archiveDir, 'archived.jsonl.gz'), 'y'.repeat(2_000));
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
await handleCleanupCommand(['--dry-run']);
const output = logSpy.mock.calls
.flatMap((call) => call.map((value) => String(value)))
.join('\n');
expect(output).toContain('CCS Logs: 1 files (100.00 B)');
expect(output).toContain('CCS Log Archives: 1 files (1.95 KB)');
expect(output).toContain('Would delete 2 files (2.05 KB)');
} finally {
logSpy.mockRestore();
}
});
});
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
describe('cliproxy routing command dispatch', () => {
let calls: string[] = [];
beforeEach(() => {
calls = [];
mock.module('../../../src/commands/cliproxy/routing-subcommand', () => ({
handleRoutingStatus: async () => {
calls.push('status');
},
handleRoutingExplain: async () => {
calls.push('explain');
},
handleRoutingSet: async (args: string[]) => {
calls.push(`set:${args.join(' ')}`);
},
}));
});
afterEach(() => {
mock.restore();
});
async function loadHandleCliproxyCommand() {
const mod = await import(`../../../src/commands/cliproxy/index?test=${Date.now()}-${Math.random()}`);
return mod.handleCliproxyCommand;
}
it('shows routing status by default', async () => {
const handleCliproxyCommand = await loadHandleCliproxyCommand();
await handleCliproxyCommand(['routing']);
expect(calls).toEqual(['status']);
});
it('shows the routing explainer', async () => {
const handleCliproxyCommand = await loadHandleCliproxyCommand();
await handleCliproxyCommand(['routing', 'explain']);
expect(calls).toEqual(['explain']);
});
it('passes the explicit strategy to set', async () => {
const handleCliproxyCommand = await loadHandleCliproxyCommand();
await handleCliproxyCommand(['routing', 'set', 'fill-first']);
expect(calls).toEqual(['set:fill-first']);
});
});
+13 -4
View File
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test';
import { ROOT_COMMAND_CATALOG, getAllRootCommandTokens } from '../../../src/commands/command-catalog';
import {
ROOT_COMMAND_CATALOG,
getAllRootCommandTokens,
} from '../../../src/commands/command-catalog';
import { ROOT_COMMAND_ROUTES } from '../../../src/commands/root-command-router';
describe('command catalog', () => {
@@ -16,12 +19,18 @@ describe('command catalog', () => {
});
test('keeps hidden operational hooks out of the public help surface', () => {
const hiddenCommands = ROOT_COMMAND_CATALOG.filter((entry) => entry.visibility === 'hidden').map(
(entry) => entry.name
);
const hiddenCommands = ROOT_COMMAND_CATALOG.filter(
(entry) => entry.visibility === 'hidden'
).map((entry) => entry.name);
expect(hiddenCommands).toContain('--install');
expect(hiddenCommands).toContain('--uninstall');
expect(hiddenCommands).toContain('__complete');
});
test('describes cleanup as removing both CCS and CLIProxy logs', () => {
const cleanupCommand = ROOT_COMMAND_CATALOG.find((entry) => entry.name === 'cleanup');
expect(cleanupCommand?.summary).toBe('Remove old CCS and CLIProxy logs');
});
});
@@ -38,7 +38,8 @@ describe('docker up subcommand', () => {
expect(rendered).toContain('Docker stack is running on docker-box.');
expect(rendered).toContain('Dashboard port: 4000');
expect(rendered).toContain('CLIProxy port: 9317');
expect(rendered).toContain('Remote access requires dashboard auth');
expect(rendered).toContain('Full remote management requires dashboard auth');
expect(rendered).toContain('Without it, remote access stays read-only.');
expect(capture.errorLines).toEqual([]);
expect(process.exitCode).toBe(0);
} finally {
@@ -46,11 +46,23 @@ describe('help command parity', () => {
expect(rendered.includes('Built-in OAuth Providers')).toBe(true);
expect(rendered.includes('ccs cliproxy --help')).toBe(true);
expect(rendered.includes('ccs help kiro')).toBe(true);
expect(rendered.includes('gemini')).toBe(true);
expect(rendered.includes('codex')).toBe(true);
expect(rendered.includes('ghcp')).toBe(true);
});
test('kiro topic documents IDC and callback flags', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['kiro'], writeLine));
expect(rendered.includes('CCS Kiro Help')).toBe(true);
expect(rendered.includes('--kiro-idc-start-url <url>')).toBe(true);
expect(rendered.includes('--kiro-idc-region <region>')).toBe(true);
expect(rendered.includes('--kiro-idc-flow <authcode|device>')).toBe(true);
expect(rendered.includes('--paste-callback')).toBe(true);
expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true);
});
test('completion topic documents install and verification paths', async () => {
const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine));
@@ -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 { getImageAnalysisConfig } from '../../../../src/config/unified-config-loader';
import { fixImageAnalysisConfig } from '../../../../src/management/checks/image-analysis-check';
describe('image-analysis-check', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-check-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(path.join(ccsDir, 'instances', 'demo'), { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'image_analysis:',
' enabled: true',
' timeout: 5',
' provider_models: {}',
'',
].join('\n'),
'utf8'
);
fs.writeFileSync(
path.join(ccsDir, 'glm.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini',
ANTHROPIC_AUTH_TOKEN: 'glm-token',
},
hooks: {
PreToolUse: [
{
matcher: 'Read',
hooks: [
{
type: 'command',
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
timeout: 65000,
},
],
},
],
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'instances', 'demo', '.claude.json'),
JSON.stringify(
{
mcpServers: {
custom: {
type: 'stdio',
command: 'node',
args: ['custom-server.cjs'],
env: {},
},
},
},
null,
2
) + '\n'
);
});
afterEach(() => {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('repairs invalid config, removes stale hooks, and syncs managed MCP entries into instances', async () => {
const fixed = await fixImageAnalysisConfig();
const ccsDir = path.join(tempHome, '.ccs');
expect(fixed).toBe(true);
const config = getImageAnalysisConfig();
expect(config.timeout).toBe(60);
expect(Object.keys(config.provider_models).length).toBeGreaterThan(0);
const repairedSettings = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'glm.settings.json'), 'utf8')
) as {
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
};
expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe(
false
);
const globalClaudeConfig = JSON.parse(
fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8')
) as {
mcpServers?: Record<string, unknown>;
};
expect(globalClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined();
const instanceClaudeConfig = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'instances', 'demo', '.claude.json'), 'utf8')
) as {
mcpServers?: Record<string, unknown>;
};
expect(instanceClaudeConfig.mcpServers?.custom).toEqual({
type: 'stdio',
command: 'node',
args: ['custom-server.cjs'],
env: {},
});
expect(instanceClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined();
});
});
+123
View File
@@ -0,0 +1,123 @@
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 RecoveryManager from '../../src/management/recovery-manager';
function createDirectorySymlink(targetDir: string, linkPath: string): void {
const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
try {
fs.symlinkSync(targetDir, linkPath, symlinkType as fs.symlink.Type);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'EPERM' || code === 'EACCES') {
throw new Error(
`Symlink creation is not permitted in this environment (${code}) for ${linkPath}`
);
}
throw error;
}
}
describe('RecoveryManager', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-recovery-manager-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('recreates ~/.ccs/shared when recovery finds a dangling symlink', () => {
const ccsDir = path.join(tempHome, '.ccs');
const sharedDir = path.join(ccsDir, 'shared');
fs.mkdirSync(ccsDir, { recursive: true });
createDirectorySymlink(path.join(tempHome, 'missing-shared'), sharedDir);
const recovery = new RecoveryManager();
expect(() => recovery.ensureSharedDirectories()).not.toThrow();
expect(fs.statSync(sharedDir).isDirectory()).toBe(true);
expect(fs.statSync(path.join(sharedDir, 'commands')).isDirectory()).toBe(true);
expect(recovery.getRecoverySummary()).toContain(`Removed broken symlink: ${sharedDir}`);
});
it('recreates ~/.ccs/shared/commands when recovery finds a dangling symlink', () => {
const sharedDir = path.join(tempHome, '.ccs', 'shared');
const commandsDir = path.join(sharedDir, 'commands');
fs.mkdirSync(sharedDir, { recursive: true });
createDirectorySymlink(path.join(tempHome, 'missing-commands'), commandsDir);
const recovery = new RecoveryManager();
expect(() => recovery.ensureSharedDirectories()).not.toThrow();
expect(fs.statSync(commandsDir).isDirectory()).toBe(true);
expect(recovery.getRecoverySummary()).toContain(`Removed broken symlink: ${commandsDir}`);
});
it('preserves valid shared command symlinks', () => {
const sharedDir = path.join(tempHome, '.ccs', 'shared');
const commandsDir = path.join(sharedDir, 'commands');
const externalCommandsDir = path.join(tempHome, 'external-commands');
fs.mkdirSync(sharedDir, { recursive: true });
fs.mkdirSync(externalCommandsDir, { recursive: true });
createDirectorySymlink(externalCommandsDir, commandsDir);
const recovery = new RecoveryManager();
expect(() => recovery.ensureSharedDirectories()).not.toThrow();
expect(fs.lstatSync(commandsDir).isSymbolicLink()).toBe(true);
expect(path.resolve(path.dirname(commandsDir), fs.readlinkSync(commandsDir))).toBe(
externalCommandsDir
);
expect(recovery.getRecoverySummary()).not.toContain(`Removed broken symlink: ${commandsDir}`);
});
it('does not delete a valid shared command symlink when target access is denied', () => {
const sharedDir = path.join(tempHome, '.ccs', 'shared');
const commandsDir = path.join(sharedDir, 'commands');
const externalCommandsDir = path.join(tempHome, 'external-commands');
fs.mkdirSync(sharedDir, { recursive: true });
fs.mkdirSync(externalCommandsDir, { recursive: true });
createDirectorySymlink(externalCommandsDir, commandsDir);
const originalStatSync = fs.statSync.bind(fs);
spyOn(fs, 'statSync').mockImplementation((targetPath: fs.PathLike) => {
if (String(targetPath) === commandsDir) {
const error = new Error('simulated permission failure') as NodeJS.ErrnoException;
error.code = 'EACCES';
throw error;
}
return originalStatSync(targetPath);
});
const recovery = new RecoveryManager();
expect(() => recovery.ensureSharedDirectories()).not.toThrow();
expect(fs.lstatSync(commandsDir).isSymbolicLink()).toBe(true);
expect(path.resolve(path.dirname(commandsDir), fs.readlinkSync(commandsDir))).toBe(
externalCommandsDir
);
expect(recovery.getRecoverySummary()).not.toContain(`Removed broken symlink: ${commandsDir}`);
expect(recovery.getRecoverySummary()).toContain(
`Skipped ${commandsDir}: symlink target is not accessible (EACCES)`
);
});
});
@@ -0,0 +1,57 @@
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 {
ensureLoggingDirectories,
getCurrentLogPath,
getLogArchiveDir,
getNativeLogsDir,
isPathInsideDirectory,
} from '../../../../src/services/logging';
describe('logging path helpers', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-paths-'));
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('resolves native log paths inside the scoped CCS directory', () => {
expect(getNativeLogsDir()).toBe(path.join(tempHome, '.ccs', 'logs'));
expect(getCurrentLogPath()).toBe(path.join(tempHome, '.ccs', 'logs', 'current.jsonl'));
expect(getLogArchiveDir()).toBe(path.join(tempHome, '.ccs', 'logs', 'archive'));
});
it('rejects path escapes outside the CCS log root', () => {
const logsDir = getNativeLogsDir();
expect(isPathInsideDirectory(path.join(logsDir, 'archive', 'entry.gz'), logsDir)).toBe(true);
expect(isPathInsideDirectory(path.join(logsDir, '..', '..', 'etc', 'passwd'), logsDir)).toBe(
false
);
});
it('creates log directories with restrictive permissions', () => {
ensureLoggingDirectories();
const logsMode = fs.statSync(getNativeLogsDir()).mode & 0o777;
const archiveMode = fs.statSync(getLogArchiveDir()).mode & 0o777;
expect(logsMode).toBe(0o700);
expect(archiveMode).toBe(0o700);
});
});
@@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
clearRecentLogEntries,
pushRecentLogEntry,
} from '../../../../src/services/logging/log-buffer';
import { getCurrentLogPath } from '../../../../src/services/logging/log-paths';
import { readLogEntries } from '../../../../src/services/logging/log-reader';
import type { LogEntry } from '../../../../src/services/logging/log-types';
function createEntry(overrides: Partial<LogEntry>): LogEntry {
return {
id: overrides.id ?? `entry-${Math.random().toString(36).slice(2, 8)}`,
timestamp: overrides.timestamp ?? new Date().toISOString(),
level: overrides.level ?? 'info',
source: overrides.source ?? 'unit:test',
event: overrides.event ?? 'test.event',
message: overrides.message ?? 'message',
processId: overrides.processId ?? 1234,
runId: overrides.runId ?? 'run-1',
context: overrides.context ?? {},
};
}
describe('log reader', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-reader-'));
process.env.CCS_HOME = tempHome;
clearRecentLogEntries();
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
clearRecentLogEntries();
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('caches unchanged current log file parses between reads', () => {
const currentLogPath = getCurrentLogPath();
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
fs.writeFileSync(
currentLogPath,
`${JSON.stringify(
createEntry({
id: 'disk-entry',
message: 'Newest on-disk entry',
timestamp: '2026-04-08T11:00:00.000Z',
})
)}\n`
);
pushRecentLogEntry(
createEntry({
id: 'recent-1',
message: 'Buffered entry',
timestamp: '2026-04-08T10:00:00.000Z',
}),
250
);
const readSpy = spyOn(fs, 'readFileSync');
try {
const first = readLogEntries({ limit: 2 });
const second = readLogEntries({ limit: 2 });
expect(first.map((entry) => entry.id)).toEqual(['disk-entry', 'recent-1']);
expect(second.map((entry) => entry.id)).toEqual(['disk-entry', 'recent-1']);
expect(readSpy).toHaveBeenCalledTimes(1);
} finally {
readSpy.mockRestore();
}
});
it('refreshes the cached parse when the current log file changes', () => {
const currentLogPath = getCurrentLogPath();
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
fs.writeFileSync(
currentLogPath,
`${JSON.stringify(
createEntry({
id: 'disk-old',
message: 'Older on-disk entry',
timestamp: '2026-04-08T11:00:00.000Z',
})
)}\n`
);
const readSpy = spyOn(fs, 'readFileSync');
try {
expect(readLogEntries({ limit: 1 }).map((entry) => entry.id)).toEqual(['disk-old']);
fs.writeFileSync(
currentLogPath,
`${JSON.stringify(
createEntry({
id: 'disk-new',
message: 'Newer on-disk entry',
timestamp: '2026-04-08T12:00:00.000Z',
})
)}\n`
);
const futureTimestamp = new Date(Date.now() + 10_000);
fs.utimesSync(currentLogPath, futureTimestamp, futureTimestamp);
expect(readLogEntries({ limit: 1 }).map((entry) => entry.id)).toEqual(['disk-new']);
expect(readSpy).toHaveBeenCalledTimes(2);
} finally {
readSpy.mockRestore();
}
});
it('keeps file-backed matches when buffered entries already satisfy the limit', () => {
const currentLogPath = getCurrentLogPath();
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
fs.writeFileSync(
currentLogPath,
[
JSON.stringify(
createEntry({
id: 'disk-newest',
source: 'dashboard',
message: 'Newest dashboard entry on disk',
timestamp: '2026-04-08T12:30:00.000Z',
})
),
JSON.stringify(
createEntry({
id: 'disk-older',
source: 'dashboard',
message: 'Older dashboard entry on disk',
timestamp: '2026-04-08T10:30:00.000Z',
})
),
].join('\n') + '\n'
);
pushRecentLogEntry(
createEntry({
id: 'recent-middle',
source: 'dashboard',
message: 'Buffered dashboard entry',
timestamp: '2026-04-08T11:30:00.000Z',
}),
250
);
pushRecentLogEntry(
createEntry({
id: 'recent-oldest',
source: 'dashboard',
message: 'Oldest buffered dashboard entry',
timestamp: '2026-04-08T09:30:00.000Z',
}),
250
);
const entries = readLogEntries({ source: 'dashboard', limit: 2 });
expect(entries.map((entry) => entry.id)).toEqual(['disk-newest', 'recent-middle']);
});
});
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'bun:test';
import { redactContext } from '../../../../src/services/logging/log-redaction';
describe('log redaction', () => {
it('redacts sensitive keys and preserves non-sensitive values', () => {
const redacted = redactContext({
token: 'secret-token',
api_key: 'secret-key',
safe: 'kept',
count: 3,
enabled: true,
});
expect(redacted).toEqual({
token: '[redacted]',
api_key: '[redacted]',
safe: 'kept',
count: 3,
enabled: true,
});
});
it('sanitizes nested objects and arrays recursively', () => {
const redacted = redactContext({
request: {
headers: {
authorization: 'Bearer abc',
cookie: 'session=123',
},
steps: [
{ secret: 'hidden' },
{ label: 'safe-step' },
['nested-array', { password_hash: 'hidden-hash' }],
],
},
});
expect(redacted).toEqual({
request: {
headers: {
authorization: '[redacted]',
cookie: '[redacted]',
},
steps: [
{ secret: '[redacted]' },
{ label: 'safe-step' },
['nested-array', { password_hash: '[redacted]' }],
],
},
});
});
it('caps recursive depth, truncates long strings, and preserves nullish values', () => {
const deeplyNested = {
first: {
second: {
third: {
fourth: {
fifth: {
sixth: 'too-deep',
},
},
},
},
},
};
const longValue = 'a'.repeat(2_500);
const redacted = redactContext({
nested: deeplyNested,
longValue,
nothing: null,
missing: undefined,
});
expect(redacted.nested).toEqual({
first: {
second: {
third: {
fourth: '[max-depth]',
},
},
},
});
expect(redacted.longValue).toBe(`${'a'.repeat(2_000)}...[truncated]`);
expect(redacted.nothing).toBeNull();
expect(redacted.missing).toBeUndefined();
});
it('reduces Error instances to safe name and message fields', () => {
const error = new Error('boom'.repeat(700));
error.name = 'ExplodedError';
const redacted = redactContext({ error });
expect(redacted).toEqual({
error: {
name: 'ExplodedError',
message: `${'boom'.repeat(500)}...[truncated]`,
},
});
});
});
@@ -0,0 +1,118 @@
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 * as zlib from 'zlib';
import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types';
import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader';
import { clearRecentLogEntries } from '../../../../src/services/logging/log-buffer';
import { invalidateLoggingConfigCache } from '../../../../src/services/logging/log-config';
import { getCurrentLogPath, getLogArchiveDir } from '../../../../src/services/logging/log-paths';
import {
appendStructuredLogEntry,
pruneExpiredLogArchives,
} from '../../../../src/services/logging/log-storage';
import type { LogEntry } from '../../../../src/services/logging/log-types';
function createEntry(overrides: Partial<LogEntry>): LogEntry {
return {
id: overrides.id ?? 'entry-1',
timestamp: overrides.timestamp ?? new Date().toISOString(),
level: overrides.level ?? 'info',
source: overrides.source ?? 'unit:test',
event: overrides.event ?? 'test.event',
message: overrides.message ?? 'message',
processId: overrides.processId ?? 1234,
runId: overrides.runId ?? 'run-1',
context: overrides.context ?? {},
};
}
describe('log storage', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-log-storage-'));
process.env.CCS_HOME = tempHome;
clearRecentLogEntries();
invalidateLoggingConfigCache();
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
clearRecentLogEntries();
invalidateLoggingConfigCache();
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
});
it('rotates the current log into the archive when the file exceeds the age threshold', () => {
const config = createEmptyUnifiedConfig();
config.logging.retain_days = 7;
config.logging.rotate_mb = 10;
saveUnifiedConfig(config);
invalidateLoggingConfigCache();
const currentLogPath = getCurrentLogPath();
fs.mkdirSync(path.dirname(currentLogPath), { recursive: true });
fs.writeFileSync(
currentLogPath,
`${JSON.stringify(createEntry({ id: 'old-entry' }))}\n`,
'utf8'
);
const staleTimestamp = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
fs.utimesSync(currentLogPath, staleTimestamp, staleTimestamp);
appendStructuredLogEntry(
createEntry({
id: 'new-entry',
message: 'new log entry after rotation',
})
);
const archiveDir = getLogArchiveDir();
const archives = fs.readdirSync(archiveDir);
expect(archives).toHaveLength(1);
const archivedContent = zlib
.gunzipSync(fs.readFileSync(path.join(archiveDir, archives[0])))
.toString('utf8');
expect(archivedContent).toContain('"id":"old-entry"');
const currentContent = fs.readFileSync(currentLogPath, 'utf8');
expect(currentContent).toContain('"id":"new-entry"');
expect(currentContent).not.toContain('"id":"old-entry"');
});
it('prunes expired archives according to retention settings', () => {
const config = createEmptyUnifiedConfig();
config.logging.retain_days = 1;
saveUnifiedConfig(config);
invalidateLoggingConfigCache();
const archiveDir = getLogArchiveDir();
fs.mkdirSync(archiveDir, { recursive: true });
const oldArchive = path.join(archiveDir, 'ccs-old.jsonl.gz');
const freshArchive = path.join(archiveDir, 'ccs-fresh.jsonl.gz');
fs.writeFileSync(oldArchive, zlib.gzipSync('old archive'), { mode: 0o600 });
fs.writeFileSync(freshArchive, zlib.gzipSync('fresh archive'), { mode: 0o600 });
const oldTimestamp = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const freshTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000);
fs.utimesSync(oldArchive, oldTimestamp, oldTimestamp);
fs.utimesSync(freshArchive, freshTimestamp, freshTimestamp);
pruneExpiredLogArchives();
expect(fs.existsSync(oldArchive)).toBe(false);
expect(fs.existsSync(freshArchive)).toBe(true);
});
});
@@ -92,6 +92,9 @@ describe('settings profile ImageAnalysis launch', () => {
`#!/bin/sh
printf "%s\n" "$@" > "${claudeArgsLogPath}"
{
printf "currentProvider=%s\n" "$CCS_CURRENT_PROVIDER"
printf "skip=%s\n" "$CCS_IMAGE_ANALYSIS_SKIP"
printf "skipHook=%s\n" "$CCS_IMAGE_ANALYSIS_SKIP_HOOK"
printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY"
printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL"
printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH"
@@ -167,6 +170,73 @@ exit 0
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
});
it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'stale-token',
ANTHROPIC_MODEL: 'glm-5',
},
hooks: {
PreToolUse: [
{
matcher: 'Read',
hooks: [
{
type: 'command',
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
timeout: 65000,
},
],
},
],
},
},
null,
2
) + '\n'
);
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
};
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
expect(launchedEnv).toContain('skipHook=1');
expect(
persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false
).toBe(false);
});
it('keeps the legacy hook available when MCP provisioning fails', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(path.join(tmpHome, '.claude.json'), '{not-json', 'utf8');
const result = runCcs(['glm', 'smoke'], baseEnv);
expect(result.status).toBe(0);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
};
expect(launchedEnv).not.toContain('skipHook=1');
expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(
true
);
});
it('pins bridge-backed image analysis to the current CLIProxy auth token', () => {
if (process.platform === 'win32') return;
@@ -384,7 +384,7 @@ describe('CLAUDECODE environment stripping', () => {
});
});
it('headless executor prepares image-analysis MCP and compatibility hook fallback', async () => {
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
const settingsPath = path.join(ccsDir, 'glm.settings.json');
@@ -398,11 +398,16 @@ describe('CLAUDECODE environment stripping', () => {
expect(result.success).toBe(true);
expect(spawnCalls.length).toBeGreaterThan(0);
const launch = spawnCalls[0];
const env = launch.options?.env as NodeJS.ProcessEnv;
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
};
expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true);
expect(
persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false
).toBe(false);
expect(env.CCS_IMAGE_ANALYSIS_SKIP_HOOK).toBe('1');
const claudeUserConfig = JSON.parse(
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
@@ -415,8 +420,10 @@ describe('CLAUDECODE environment stripping', () => {
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
env: {},
});
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(true);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(true);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(
false
);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false);
});
it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => {
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'bun:test';
import {
deduplicateCcsImageAnalyzerHooks,
isCcsImageAnalyzerHook,
removeCcsImageAnalyzerHooks,
} from '../../../../src/utils/hooks/image-analyzer-hook-utils';
describe('image-analyzer-hook-utils', () => {
it('detects CCS-managed image hooks across current and legacy path variants', () => {
expect(
isCcsImageAnalyzerHook({
matcher: 'Read',
hooks: [
{
type: 'command',
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
},
],
})
).toBe(true);
expect(
isCcsImageAnalyzerHook({
matcher: 'Read',
hooks: [
{
type: 'command',
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
},
],
})
).toBe(true);
expect(
isCcsImageAnalyzerHook({
matcher: 'Read',
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
})
).toBe(false);
expect(
isCcsImageAnalyzerHook({
matcher: 'Read',
hooks: [
{
type: 'command',
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer-custom.cjs"',
},
],
})
).toBe(false);
});
it('deduplicates only CCS-managed image hooks', () => {
const settings = {
hooks: {
PreToolUse: [
{
matcher: 'Read',
hooks: [
{
type: 'command',
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
},
],
},
{
matcher: 'Read',
hooks: [
{
type: 'command',
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
},
],
},
{
matcher: 'Read',
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
},
],
},
} satisfies Record<string, unknown>;
expect(deduplicateCcsImageAnalyzerHooks(settings)).toBe(true);
expect((settings.hooks.PreToolUse as unknown[])).toHaveLength(2);
});
it('removes only CCS-managed image hooks and preserves unrelated hooks', () => {
const settings = {
hooks: {
PreToolUse: [
{
matcher: 'Read',
hooks: [
{
type: 'command',
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
},
],
},
{
matcher: 'Read',
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
},
{
matcher: 'WebSearch',
hooks: [
{
type: 'command',
command: 'node "/Users/kaitran/.ccs/hooks/websearch-transformer.cjs"',
},
],
},
],
},
} satisfies Record<string, unknown>;
expect(removeCcsImageAnalyzerHooks(settings)).toBe(true);
expect(settings.hooks.PreToolUse).toEqual([
{
matcher: 'Read',
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
},
{
matcher: 'WebSearch',
hooks: [
{
type: 'command',
command: 'node "/Users/kaitran/.ccs/hooks/websearch-transformer.cjs"',
},
],
},
]);
});
});
@@ -0,0 +1,216 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import bcrypt from 'bcrypt';
import express from 'express';
import type { Server } from 'http';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { apiRoutes } from '../../../src/web-server/routes';
import {
authMiddleware,
createSessionMiddleware,
} from '../../../src/web-server/middleware/auth-middleware';
describe('api-routes remote write guard', () => {
let server: Server;
let baseUrl = '';
let forcedRemoteAddress = '127.0.0.1';
let tempHome = '';
let originalDashboardAuthEnabled: string | undefined;
let originalCcsHome: string | undefined;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
Object.defineProperty(req.socket, 'remoteAddress', {
value: forcedRemoteAddress,
configurable: true,
});
next();
});
app.use('/api', apiRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
server.once('error', reject);
server.once('listening', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-routes-remote-write-guard-'));
process.env.CCS_HOME = tempHome;
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
forcedRemoteAddress = '10.10.0.24';
});
afterEach(() => {
if (originalDashboardAuthEnabled !== undefined) {
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
} else {
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
}
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
tempHome = '';
}
});
it('allows remote read-only GET requests when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/profiles`);
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'demo',
baseUrl: 'https://api.example.com',
apiKey: 'token',
}),
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Remote dashboard writes require localhost access when dashboard auth is disabled.',
});
});
it('blocks remote backup restore when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/persist/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Remote dashboard writes require localhost access when dashboard auth is disabled.',
});
});
it('blocks remote PUT requests when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy-server`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Remote dashboard writes require localhost access when dashboard auth is disabled.',
});
});
it('blocks remote PATCH requests when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/codex/config/patch`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Remote dashboard writes require localhost access when dashboard auth is disabled.',
});
});
it('blocks remote DELETE requests when dashboard auth is disabled', async () => {
const response = await fetch(`${baseUrl}/api/profiles/demo`, {
method: 'DELETE',
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Remote dashboard writes require localhost access when dashboard auth is disabled.',
});
});
it('allows remote writes again when dashboard auth is enabled', async () => {
const password = 'testpassword123';
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
process.env.CCS_DASHBOARD_USERNAME = 'admin';
process.env.CCS_DASHBOARD_PASSWORD_HASH = await bcrypt.hash(password, 10);
const authApp = express();
authApp.use(express.json());
authApp.use((req, _res, next) => {
Object.defineProperty(req.socket, 'remoteAddress', {
value: forcedRemoteAddress,
configurable: true,
});
next();
});
authApp.use(createSessionMiddleware());
authApp.use(authMiddleware);
authApp.use('/api', apiRoutes);
const authServer = await new Promise<Server>((resolve, reject) => {
const instance = authApp.listen(0, '127.0.0.1');
instance.once('error', reject);
instance.once('listening', () => resolve(instance));
});
const address = authServer.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve auth-enabled test server port');
}
const authBaseUrl = `http://127.0.0.1:${address.port}`;
const loginResponse = await fetch(`${authBaseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: 'admin',
password,
}),
});
const cookie = loginResponse.headers.get('set-cookie');
expect(loginResponse.status).toBe(200);
expect(cookie).toBeTruthy();
const response = await fetch(`${authBaseUrl}/api/profiles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookie as string,
},
body: JSON.stringify({
name: 'demo',
baseUrl: 'https://api.example.com',
apiKey: 'token',
}),
});
expect(response.status).toBe(201);
await new Promise<void>((resolve) => authServer.close(() => resolve()));
delete process.env.CCS_DASHBOARD_USERNAME;
delete process.env.CCS_DASHBOARD_PASSWORD_HASH;
});
});
@@ -1,13 +1,14 @@
/**
* Auth Check Route — Remote Access Detection Tests
*
* Verifies that /api/auth/check returns effectiveAuthRequired=true
* for remote clients when auth is disabled, preventing a silently
* broken dashboard.
* Verifies that /api/auth/check produces a distinct setup state for
* remote clients or incomplete host config instead of always showing
* a login form.
*/
import { describe, it, expect } from 'bun:test';
import { isLoopbackRemoteAddress } from '../../../src/web-server/middleware/auth-middleware';
import { resolveDashboardAccessState } from '../../../src/web-server/routes/auth-routes';
describe('isLoopbackRemoteAddress', () => {
it('returns true for IPv4 localhost', () => {
@@ -37,27 +38,69 @@ describe('isLoopbackRemoteAddress', () => {
});
});
describe('effectiveAuthRequired logic', () => {
// Mirrors the logic in auth-routes.ts GET /api/auth/check:
// effectiveAuthRequired = authConfig.enabled || !isLocal
function computeEffectiveAuthRequired(authEnabled: boolean, remoteAddress: string | undefined) {
const isLocal = isLoopbackRemoteAddress(remoteAddress);
return authEnabled || !isLocal;
}
it('localhost + auth disabled -> authRequired=false', () => {
expect(computeEffectiveAuthRequired(false, '127.0.0.1')).toBe(false);
describe('resolveDashboardAccessState', () => {
it('allows localhost through when auth is disabled', () => {
expect(
resolveDashboardAccessState(
{ enabled: false, username: '', password_hash: '', session_timeout_hours: 24 },
'127.0.0.1'
)
).toEqual({
authRequired: false,
authEnabled: false,
authConfigured: false,
isLocalAccess: true,
accessMode: 'open',
});
});
it('remote + auth disabled -> authRequired=true', () => {
expect(computeEffectiveAuthRequired(false, '192.168.2.100')).toBe(true);
it('keeps remote access open when auth is disabled', () => {
expect(
resolveDashboardAccessState(
{ enabled: false, username: '', password_hash: '', session_timeout_hours: 24 },
'192.168.2.100'
)
).toEqual({
authRequired: false,
authEnabled: false,
authConfigured: false,
isLocalAccess: false,
accessMode: 'open',
});
});
it('remote + auth enabled -> authRequired=true', () => {
expect(computeEffectiveAuthRequired(true, '192.168.2.100')).toBe(true);
it('shows login state only when auth is enabled and fully configured', () => {
expect(
resolveDashboardAccessState(
{
enabled: true,
username: 'admin',
password_hash: '$2b$10$123456789012345678901u4cPFsKnzGWxZmfq6OnpZnN0UiM6Qf7e',
session_timeout_hours: 24,
},
'192.168.2.100'
)
).toEqual({
authRequired: true,
authEnabled: true,
authConfigured: true,
isLocalAccess: false,
accessMode: 'login',
});
});
it('localhost + auth enabled -> authRequired=true', () => {
expect(computeEffectiveAuthRequired(true, '127.0.0.1')).toBe(true);
it('shows setup state when auth is enabled but credentials are incomplete', () => {
expect(
resolveDashboardAccessState(
{ enabled: true, username: 'admin', password_hash: '', session_timeout_hours: 24 },
'127.0.0.1'
)
).toEqual({
authRequired: true,
authEnabled: true,
authConfigured: false,
isLocalAccess: true,
accessMode: 'setup',
});
});
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test';
import {
getKiroStartIDCValidationError,
getStartAuthFailureMessage,
getStartAuthNicknameError,
getStartUrlUnsupportedReason,
@@ -25,6 +26,12 @@ describe('cliproxy-auth-routes start-url guard', () => {
);
});
it('rejects Kiro idc method on start-url', () => {
expect(getStartUrlUnsupportedReason('kiro', { kiroMethod: 'idc' })).toContain(
"Kiro method 'idc' uses CLI auth flow"
);
});
it('allows authorization code providers', () => {
expect(getStartUrlUnsupportedReason('gemini')).toBeNull();
expect(getStartUrlUnsupportedReason('codex')).toBeNull();
@@ -32,6 +39,44 @@ describe('cliproxy-auth-routes start-url guard', () => {
});
});
describe('cliproxy-auth-routes Kiro IDC start validation', () => {
it('requires an IDC start URL when idc auth is selected', () => {
expect(
getKiroStartIDCValidationError({
kiroMethod: 'idc',
kiroIDCStartUrl: undefined,
invalidKiroIDCFlow: false,
})
).toEqual({
error: 'Kiro IDC login requires kiroIDCStartUrl',
code: 'MISSING_KIRO_IDC_START_URL',
});
});
it('rejects invalid IDC flow values before triggerOAuth is called', () => {
expect(
getKiroStartIDCValidationError({
kiroMethod: 'idc',
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
invalidKiroIDCFlow: true,
})
).toEqual({
error: 'Invalid kiroIDCFlow. Supported: authcode, device',
code: 'INVALID_KIRO_IDC_FLOW',
});
});
it('allows valid IDC start payloads through', () => {
expect(
getKiroStartIDCValidationError({
kiroMethod: 'idc',
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
invalidKiroIDCFlow: false,
})
).toBeNull();
});
});
describe('cliproxy-auth-routes start failure messaging', () => {
it('returns ghcp-specific guidance for Copilot verification failures', () => {
expect(getStartAuthFailureMessage('ghcp')).toContain(
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more