diff --git a/README.md b/README.md index 9fcd2cd6..cb981b5b 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/code-standards.md b/docs/code-standards.md index 7e45c745..3c3b35cb 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -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 diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index dbe3f73b..71d2c34a 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -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. diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index 62c77709..0745f15e 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -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) diff --git a/docs/image-analysis.md b/docs/image-analysis.md index 27217400..0e5b525d 100644 --- a/docs/image-analysis.md +++ b/docs/image-analysis.md @@ -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 diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index a9166ffd..d11bec1d 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -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//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 ` - 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 diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 0594ca32..8ef5aee2 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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 ` 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. diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 97ad2013..685d31ef 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -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 ``` diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index 77f1ee11..6449915e 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -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'); diff --git a/package.json b/package.json index a89bb5fc..754782ca 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/ccs.ts b/src/ccs.ts index 2806235f..a6edc978 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { 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 { 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 { 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 { 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 { '--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 { } 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 { 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 { : 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 { 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 ( diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index dd8ad3e6..95e44980 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -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= */ -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) */ diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index b30baf09..2185598e 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -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 { - 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 { 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 { // 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) diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 8fcdfa60..689b93b3 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -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 { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + let settled = false; + let timeout: ReturnType | 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 { + 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> = { + 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 { + 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((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 { + 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 { // 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 & { 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(); + const duplicateEmailTokenSets = new Map>(); + for (const account of existingAccounts) { + if (!account.email) continue; + const key = account.email.toLowerCase(); + const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set(); + 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(); + 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; - const { registerAccount } = require('../account-manager'); let selectedCandidate: Omit | 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(); - const duplicateEmailTokenSets = new Map>(); - for (const account of existingAccounts) { - if (!account.email) continue; - const key = account.email.toLowerCase(); - const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set(); - 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(); - 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 = diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts index 13038f2c..10436887 100644 --- a/src/cliproxy/catalog-cache.ts +++ b/src/cliproxy/catalog-cache.ts @@ -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 = { 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 = 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>; + 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 { + 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 | 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; + + 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 +): ProviderCatalog | undefined { + if (providers?.[provider]) { + return mergeCatalog(provider, providers[provider]); + } + return MODEL_CATALOG[provider]; +} + +function getAllResolvedCatalogsFromProviders( + providers?: Record +): Partial> { + const result: Partial> = {}; + const providerIds = new Set(); + + 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> { - const result: Partial> = {}; const cached = getCachedCatalog(); + return getAllResolvedCatalogsFromProviders(cached?.providers); +} - // Get all known providers from both static and cache - const providers = new Set(); - 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 { + 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, + }; } diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index ab524926..95506089 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -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 { - 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; diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index fc79958e..815b81b7 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -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 # ============================================================================= diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index b7a38327..5057e0e1 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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' ) diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index aef15b13..a2862c51 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -16,6 +16,7 @@ export type { ChecksumResult, DownloadResult, CLIProxyProvider, + CliproxyRoutingStrategy, CLIProxyConfig, ExecutorConfig, ProviderConfig, diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index 7afff06f..dc9de153 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -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 { + 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 { + 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. diff --git a/src/cliproxy/proxy-detector.ts b/src/cliproxy/proxy-detector.ts index 0e381778..8aae527f 100644 --- a/src/cliproxy/proxy-detector.ts +++ b/src/cliproxy/proxy-detector.ts @@ -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 { - 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}`); diff --git a/src/cliproxy/routing-strategy-http.ts b/src/cliproxy/routing-strategy-http.ts new file mode 100644 index 00000000..33d7b070 --- /dev/null +++ b/src/cliproxy/routing-strategy-http.ts @@ -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 +): Promise { + 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((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 { + return response + .json() + .then((data) => { + if (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string') { + return data.error; + } + return fallback; + }) + .catch(() => fallback); +} diff --git a/src/cliproxy/routing-strategy.ts b/src/cliproxy/routing-strategy.ts new file mode 100644 index 00000000..6adb15ff --- /dev/null +++ b/src/cliproxy/routing-strategy.ts @@ -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 { + 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 { + 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 { + 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 { + const response = await fetchCliproxyRoutingResponse(getCliproxyRoutingTarget(), 'PUT', { + value: strategy, + }); + if (!response.ok) { + throw new Error( + await getRoutingErrorMessage( + response, + `Failed to update routing strategy (${response.status})` + ) + ); + } +} diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 2e3b89ec..f0aa20eb 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -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; diff --git a/src/cliproxy/startup-lock.ts b/src/cliproxy/startup-lock.ts index 8d63e3d0..1bf6b569 100644 --- a/src/cliproxy/startup-lock.ts +++ b/src/cliproxy/startup-lock.ts @@ -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 { 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)`); diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index ace07e4b..fac34d03 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -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; 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 { diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 7f122cad..77462b0e 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -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; diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index 20c23cfa..223ae502 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -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 { 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 { 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 { - // 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 { + 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.'); } } diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 8ff99159..8bee3556 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -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 | 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 = {}; - - 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 { 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; } diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index cc3e60a2..9e1eae44 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -57,6 +57,9 @@ export async function showHelp(): Promise { ['resume ', 'Resume paused account'], ['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], ['quota --provider ', `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 ', 'Explicitly set round-robin or fill-first'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 972c692e..50c9013a 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -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 { 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 Promise> = { create: async () => handleCreate(remainingArgs.slice(1), effectiveBackend), edit: async () => handleEdit(remainingArgs.slice(1), effectiveBackend), diff --git a/src/commands/cliproxy/routing-subcommand.ts b/src/commands/cliproxy/routing-subcommand.ts new file mode 100644 index 00000000..f78aed21 --- /dev/null +++ b/src/commands/cliproxy/routing-subcommand.ts @@ -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 { + 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 { + await initUI(); + console.log(''); + console.log(header('CLIProxy Routing Guide')); + console.log(''); + printStrategyGuide(); +} + +export async function handleRoutingSet(args: string[]): Promise { + 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(''); +} diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 28bf315d..3b14c3b7 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -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', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 4399d9c0..523c08ad 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -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); diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index fae58460..952dd81f 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -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); } diff --git a/src/commands/docker/up-subcommand.ts b/src/commands/docker/up-subcommand.ts index fcbb4242..fa7c7bf3 100644 --- a/src/commands/docker/up-subcommand.ts +++ b/src/commands/docker/up-subcommand.ts @@ -40,7 +40,7 @@ export async function handleUp(args: string[]): Promise { 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' ) ); } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 88efc512..e3912a11 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -78,6 +78,7 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise { }, { name: 'ccs api create --preset ', 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 { writeLine(''); } +async function showKiroHelp(writeLine: HelpWriter): Promise { + 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 ', + summary: 'Select the Kiro auth method', + }, + { name: '--kiro-idc-start-url ', summary: 'Required IDC start URL when using `idc`' }, + { name: '--kiro-idc-region ', summary: 'Optional IDC region override' }, + { name: '--kiro-idc-flow ', 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 { 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; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 878dfbd1..b0c3ecfd 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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 { : 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. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index dd72d114..5ce9c304 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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; /** 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, diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 18b84154..f8e8a1e0 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -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, diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index a9d05247..569302f2 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -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 ( diff --git a/src/errors/error-handler.ts b/src/errors/error-handler.ts index a5adf988..4bb8f6be 100644 --- a/src/errors/error-handler.ts +++ b/src/errors/error-handler.ts @@ -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}`); } diff --git a/src/glmt/glmt-transformer.ts b/src/glmt/glmt-transformer.ts index afa4fe8c..1886f05a 100644 --- a/src/glmt/glmt-transformer.ts +++ b/src/glmt/glmt-transformer.ts @@ -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}`); } } diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 7ef0645c..52f5233c 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -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 } 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 { 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 { 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) + ); } diff --git a/src/management/recovery-manager.ts b/src/management/recovery-manager.ts index 313f6911..e7f6decf 100644 --- a/src/management/recovery-manager.ts +++ b/src/management/recovery-manager.ts @@ -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}`); } } diff --git a/src/services/logging/index.ts b/src/services/logging/index.ts new file mode 100644 index 00000000..68e4b6ad --- /dev/null +++ b/src/services/logging/index.ts @@ -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'; diff --git a/src/services/logging/log-buffer.ts b/src/services/logging/log-buffer.ts new file mode 100644 index 00000000..965140f6 --- /dev/null +++ b/src/services/logging/log-buffer.ts @@ -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 = []; +} diff --git a/src/services/logging/log-config.ts b/src/services/logging/log-config.ts new file mode 100644 index 00000000..3985d25e --- /dev/null +++ b/src/services/logging/log-config.ts @@ -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; + } +} diff --git a/src/services/logging/log-paths.ts b/src/services/logging/log-paths.ts new file mode 100644 index 00000000..258e256d --- /dev/null +++ b/src/services/logging/log-paths.ts @@ -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`); +} diff --git a/src/services/logging/log-reader.ts b/src/services/logging/log-reader.ts new file mode 100644 index 00000000..7d8ef1b9 --- /dev/null +++ b/src/services/logging/log-reader.ts @@ -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(); + 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(); + 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; +} diff --git a/src/services/logging/log-redaction.ts b/src/services/logging/log-redaction.ts new file mode 100644 index 00000000..ca8446f3 --- /dev/null +++ b/src/services/logging/log-redaction.ts @@ -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 = {}; + for (const [key, nestedValue] of Object.entries(value as Record)) { + sanitized[key] = SENSITIVE_KEY_PATTERN.test(key) + ? '[redacted]' + : sanitizeValue(nestedValue, depth + 1); + } + return sanitized; + } + + return String(value); +} + +export function redactContext( + context: Record | undefined +): Record { + if (!context) { + return {}; + } + + return sanitizeValue(context, 0) as Record; +} diff --git a/src/services/logging/log-storage.ts b/src/services/logging/log-storage.ts new file mode 100644 index 00000000..dc8af66b --- /dev/null +++ b/src/services/logging/log-storage.ts @@ -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. + } +} diff --git a/src/services/logging/log-types.ts b/src/services/logging/log-types.ts new file mode 100644 index 00000000..a3e08c16 --- /dev/null +++ b/src/services/logging/log-types.ts @@ -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; +} + +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 = { + 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); +} diff --git a/src/services/logging/logger.ts b/src/services/logging/logger.ts new file mode 100644 index 00000000..3506871c --- /dev/null +++ b/src/services/logging/logger.ts @@ -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 +): 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): Logger; + debug(event: string, message: string, context?: Record): void; + info(event: string, message: string, context?: Record): void; + warn(event: string, message: string, context?: Record): void; + error(event: string, message: string, context?: Record): void; +} + +export function createLogger(source: string, baseContext: Record = {}): Logger { + const write = ( + level: LoggingLevel, + event: string, + message: string, + context?: Record + ) => { + appendStructuredLogEntry( + createEntry(source, level, event, message, { ...baseContext, ...(context || {}) }) + ); + }; + + return { + child(context: Record) { + 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); + }, + }; +} diff --git a/src/utils/hooks/image-analyzer-hook-utils.ts b/src/utils/hooks/image-analyzer-hook-utils.ts new file mode 100644 index 00000000..2a779d78 --- /dev/null +++ b/src/utils/hooks/image-analyzer-hook-utils.ts @@ -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): boolean { + if (hook.matcher !== 'Read') return false; + + const hookArray = hook.hooks as Array> | 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): boolean { + const hooks = settings.hooks as Record | 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; + 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): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + const originalLength = hooks.PreToolUse.length; + hooks.PreToolUse = hooks.PreToolUse.filter((entry: unknown) => { + const hook = entry as Record; + 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; +} diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 7517dcf9..73a0517e 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -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): boolean { if (!hooks?.PreToolUse) return false; return hooks.PreToolUse.some((h: unknown) => { - const hook = h as Record; - if (hook.matcher !== 'Read') return false; - - const hookArray = hook.hooks as Array> | 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); }); } @@ -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; + 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); } diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 6f61058e..6cbe1e7e 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -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(); diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts index 852a612a..1efa150e 100644 --- a/src/utils/image-analysis/index.ts +++ b/src/utils/image-analysis/index.ts @@ -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]; diff --git a/src/utils/image-analysis/repair.ts b/src/utils/image-analysis/repair.ts new file mode 100644 index 00000000..59cadc87 --- /dev/null +++ b/src/utils/image-analysis/repair.ts @@ -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, 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; + 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) + ) { + 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, + }; +} diff --git a/src/utils/websearch/trace.ts b/src/utils/websearch/trace.ts index 6f4f9880..5629be7a 100644 --- a/src/utils/websearch/trace.ts +++ b/src/utils/websearch/trace.ts @@ -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( diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 7053b231..fd2de5a6 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -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((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 { 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 }); diff --git a/src/web-server/middleware/request-logging-middleware.ts b/src/web-server/middleware/request-logging-middleware.ts new file mode 100644 index 00000000..9faca1e5 --- /dev/null +++ b/src/web-server/middleware/request-logging-middleware.ts @@ -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(); +} diff --git a/src/web-server/routes/auth-routes.ts b/src/web-server/routes/auth-routes.ts index 70b2564f..6fcd3e22 100644 --- a/src/web-server/routes/auth-routes.ts +++ b/src/web-server/routes/auth-routes.ts @@ -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, }); diff --git a/src/web-server/routes/catalog-routes.ts b/src/web-server/routes/catalog-routes.ts index 4ff2d9af..c2cfba08 100644 --- a/src/web-server/routes/catalog-routes.ts +++ b/src/web-server/routes/catalog-routes.ts @@ -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 => { 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) { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 7341c4d0..1744da18 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -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 { + 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 => { + 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 => { + 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; diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index 970d0e02..70a76480 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -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> ): BackendState { @@ -469,9 +460,7 @@ router.put('/', async (req: Request, res: Response): Promise => { const nextEnabled = body.enabled ?? currentConfig.enabled; if (nextEnabled) { - ensureImageAnalysisMcpOrThrow(); - prepareImageAnalysisFallbackHook(); - syncManagedImageAnalysisToInstances(); + repairImageAnalysisRuntimeState(); } res.json(await buildDashboardPayload()); diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 85534e0c..5a1d7f76 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -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); diff --git a/src/web-server/routes/logs-routes.ts b/src/web-server/routes/logs-routes.ts new file mode 100644 index 00000000..15106d07 --- /dev/null +++ b/src/web-server/routes/logs-routes.ts @@ -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; + 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; diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 531b5cd9..28df3ec2 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -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); + removeCcsImageAnalyzerHooks(normalizedSettings as Record); const ccsDir = getCcsDir(); diff --git a/src/web-server/services/logs-dashboard-service.ts b/src/web-server/services/logs-dashboard-service.ts new file mode 100644 index 00000000..58c6a84c --- /dev/null +++ b/src/web-server/services/logs-dashboard-service.ts @@ -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 { + 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); +} diff --git a/src/web-server/usage/routes.ts b/src/web-server/usage/routes.ts index c4a2dabe..2aeec59f 100644 --- a/src/web-server/usage/routes.ts +++ b/src/web-server/usage/routes.ts @@ -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); diff --git a/src/web-server/websocket.ts b/src/web-server/websocket.ts index b9ae862e..b99710ce 100644 --- a/src/web-server/websocket.ts +++ b/src/web-server/websocket.ts @@ -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, diff --git a/tests/unit/cliproxy/auth-types-management-path.test.ts b/tests/unit/cliproxy/auth-types-management-path.test.ts index ee999850..160070b4 100644 --- a/tests/unit/cliproxy/auth-types-management-path.test.ts +++ b/tests/unit/cliproxy/auth-types-management-path.test.ts @@ -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', () => { diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index b58148e2..4cdca89f 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -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; }; - 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, }, diff --git a/tests/unit/cliproxy/executor-option-value.test.ts b/tests/unit/cliproxy/executor-option-value.test.ts new file mode 100644 index 00000000..1f4971f0 --- /dev/null +++ b/tests/unit/cliproxy/executor-option-value.test.ts @@ -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, + }); + }); +}); diff --git a/tests/unit/cliproxy/management-api-client.test.ts b/tests/unit/cliproxy/management-api-client.test.ts index 58edbdb2..12641a25 100644 --- a/tests/unit/cliproxy/management-api-client.test.ts +++ b/tests/unit/cliproxy/management-api-client.test.ts @@ -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; diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts index af51012f..b6fc21cc 100644 --- a/tests/unit/cliproxy/model-catalog-compat.test.ts +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -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']); + }); }); diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 9ad75161..287b95f8 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -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( diff --git a/tests/unit/cliproxy/oauth-process-error-parser.test.ts b/tests/unit/cliproxy/oauth-process-error-parser.test.ts index 84fe5331..4c3c7573 100644 --- a/tests/unit/cliproxy/oauth-process-error-parser.test.ts +++ b/tests/unit/cliproxy/oauth-process-error-parser.test.ts @@ -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); + }); }); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index e77cfc63..d6f1d565 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -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(); }); }); diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts new file mode 100644 index 00000000..9302cdac --- /dev/null +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -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) | 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); + }); +}); diff --git a/tests/unit/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts new file mode 100644 index 00000000..1c560762 --- /dev/null +++ b/tests/unit/commands/cleanup-command.test.ts @@ -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(); + } + }); +}); diff --git a/tests/unit/commands/cliproxy-routing-subcommand.test.ts b/tests/unit/commands/cliproxy-routing-subcommand.test.ts new file mode 100644 index 00000000..40d6385c --- /dev/null +++ b/tests/unit/commands/cliproxy-routing-subcommand.test.ts @@ -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']); + }); +}); diff --git a/tests/unit/commands/command-catalog.test.ts b/tests/unit/commands/command-catalog.test.ts index 4e8b0b31..ea0b8f4b 100644 --- a/tests/unit/commands/command-catalog.test.ts +++ b/tests/unit/commands/command-catalog.test.ts @@ -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'); + }); }); diff --git a/tests/unit/commands/docker-up-subcommand.test.ts b/tests/unit/commands/docker-up-subcommand.test.ts index ba290b6c..9aace1f1 100644 --- a/tests/unit/commands/docker-up-subcommand.test.ts +++ b/tests/unit/commands/docker-up-subcommand.test.ts @@ -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 { diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index bf830826..017ec18e 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -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 ')).toBe(true); + expect(rendered.includes('--kiro-idc-region ')).toBe(true); + expect(rendered.includes('--kiro-idc-flow ')).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)); diff --git a/tests/unit/management/checks/image-analysis-check.test.ts b/tests/unit/management/checks/image-analysis-check.test.ts new file mode 100644 index 00000000..7bea255e --- /dev/null +++ b/tests/unit/management/checks/image-analysis-check.test.ts @@ -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; + }; + expect(globalClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined(); + + const instanceClaudeConfig = JSON.parse( + fs.readFileSync(path.join(ccsDir, 'instances', 'demo', '.claude.json'), 'utf8') + ) as { + mcpServers?: Record; + }; + expect(instanceClaudeConfig.mcpServers?.custom).toEqual({ + type: 'stdio', + command: 'node', + args: ['custom-server.cjs'], + env: {}, + }); + expect(instanceClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined(); + }); +}); diff --git a/tests/unit/recovery-manager.test.ts b/tests/unit/recovery-manager.test.ts new file mode 100644 index 00000000..a395ace4 --- /dev/null +++ b/tests/unit/recovery-manager.test.ts @@ -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)` + ); + }); +}); diff --git a/tests/unit/services/logging/log-paths.test.ts b/tests/unit/services/logging/log-paths.test.ts new file mode 100644 index 00000000..41ef7a84 --- /dev/null +++ b/tests/unit/services/logging/log-paths.test.ts @@ -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); + }); +}); diff --git a/tests/unit/services/logging/log-reader.test.ts b/tests/unit/services/logging/log-reader.test.ts new file mode 100644 index 00000000..f7b1523d --- /dev/null +++ b/tests/unit/services/logging/log-reader.test.ts @@ -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 { + 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']); + }); +}); diff --git a/tests/unit/services/logging/log-redaction.test.ts b/tests/unit/services/logging/log-redaction.test.ts new file mode 100644 index 00000000..124165eb --- /dev/null +++ b/tests/unit/services/logging/log-redaction.test.ts @@ -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]`, + }, + }); + }); +}); diff --git a/tests/unit/services/logging/log-storage.test.ts b/tests/unit/services/logging/log-storage.test.ts new file mode 100644 index 00000000..23decb23 --- /dev/null +++ b/tests/unit/services/logging/log-storage.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts index 5154512e..1430fd72 100644 --- a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -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; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index aa13eefd..b00908fe 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -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 () => { diff --git a/tests/unit/utils/hooks/image-analyzer-hook-utils.test.ts b/tests/unit/utils/hooks/image-analyzer-hook-utils.test.ts new file mode 100644 index 00000000..428903fb --- /dev/null +++ b/tests/unit/utils/hooks/image-analyzer-hook-utils.test.ts @@ -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; + + 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; + + 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"', + }, + ], + }, + ]); + }); +}); diff --git a/tests/unit/web-server/api-routes-remote-write-guard.test.ts b/tests/unit/web-server/api-routes-remote-write-guard.test.ts new file mode 100644 index 00000000..83575173 --- /dev/null +++ b/tests/unit/web-server/api-routes-remote-write-guard.test.ts @@ -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((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((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((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((resolve) => authServer.close(() => resolve())); + + delete process.env.CCS_DASHBOARD_USERNAME; + delete process.env.CCS_DASHBOARD_PASSWORD_HASH; + }); +}); diff --git a/tests/unit/web-server/auth-check-remote-access.test.ts b/tests/unit/web-server/auth-check-remote-access.test.ts index 6d00d053..fc115673 100644 --- a/tests/unit/web-server/auth-check-remote-access.test.ts +++ b/tests/unit/web-server/auth-check-remote-access.test.ts @@ -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', + }); }); }); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index ec2ce8d3..703c2870 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -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( diff --git a/tests/unit/web-server/cliproxy-routing-routes.test.ts b/tests/unit/web-server/cliproxy-routing-routes.test.ts new file mode 100644 index 00000000..ffaa71f6 --- /dev/null +++ b/tests/unit/web-server/cliproxy-routing-routes.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import express from 'express'; +import type { Server } from 'http'; + +describe('cliproxy routing routes', () => { + let server: Server; + let baseUrl = ''; + let readStateMock: ReturnType; + let applyStrategyMock: ReturnType; + + beforeEach(async () => { + readStateMock = mock(async () => ({ + strategy: 'round-robin', + source: 'live', + target: 'local', + reachable: true, + })); + applyStrategyMock = mock(async () => ({ + strategy: 'fill-first', + source: 'live', + target: 'local', + reachable: true, + applied: 'live-and-config', + })); + + mock.module('../../../src/cliproxy/routing-strategy', () => ({ + readCliproxyRoutingState: readStateMock, + applyCliproxyRoutingStrategy: applyStrategyMock, + normalizeCliproxyRoutingStrategy: (value: unknown) => { + if (value === 'round-robin' || value === 'fill-first') { + return value; + } + return null; + }, + })); + + const { default: routingRoutes } = await import( + `../../../src/web-server/routes/cliproxy-routing-routes?test=${Date.now()}-${Math.random()}` + ); + + const app = express(); + app.use(express.json()); + app.use('/api/cliproxy', routingRoutes); + + server = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + + 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}`; + }); + + afterEach(async () => { + mock.restore(); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('returns the current routing state', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/routing/strategy`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + strategy: 'round-robin', + source: 'live', + target: 'local', + reachable: true, + }); + expect(readStateMock).toHaveBeenCalledTimes(1); + }); + + it('rejects invalid routing values', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/routing/strategy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 'auto' }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid strategy. Use: round-robin or fill-first', + }); + expect(applyStrategyMock).not.toHaveBeenCalled(); + }); + + it('applies a valid routing strategy', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/routing/strategy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 'fill-first' }), + }); + + expect(response.status).toBe(200); + expect(applyStrategyMock).toHaveBeenCalledWith('fill-first'); + expect(await response.json()).toEqual({ + strategy: 'fill-first', + source: 'live', + target: 'local', + reachable: true, + applied: 'live-and-config', + }); + }); +}); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts index 2b329b15..27a6ef6f 100644 --- a/tests/unit/web-server/image-analysis-routes.test.ts +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -192,6 +192,54 @@ describe('image-analysis routes', () => { }); it('updates the saved config through the dashboard route and provisions the local runtime', async () => { + const staleSettingsPath = path.join(tempHome, '.ccs', 'legacy.settings.json'); + fs.writeFileSync( + staleSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: 'legacy-token', + }, + hooks: { + PreToolUse: [ + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"', + timeout: 65000, + }, + ], + }, + ], + }, + }, + null, + 2 + ) + ); + const instanceDir = path.join(tempHome, '.ccs', 'instances', 'demo'); + fs.mkdirSync(instanceDir, { recursive: true }); + fs.writeFileSync( + path.join(instanceDir, '.claude.json'), + JSON.stringify( + { + mcpServers: { + custom: { + type: 'stdio', + command: 'node', + args: ['custom-server.cjs'], + env: {}, + }, + }, + }, + null, + 2 + ) + ); + const response = await fetch(`${baseUrl}/api/image-analysis`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -245,6 +293,31 @@ describe('image-analysis routes', () => { args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], env: {}, }); + + const repairedSettings = JSON.parse(fs.readFileSync(staleSettingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe( + false + ); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instanceDir, '.claude.json'), 'utf8') + ) as { + mcpServers?: Record; + }; + expect(instanceConfig.mcpServers?.custom).toEqual({ + type: 'stdio', + command: 'node', + args: ['custom-server.cjs'], + env: {}, + }); + expect(instanceConfig.mcpServers?.['ccs-image-analysis']).toEqual({ + type: 'stdio', + command: 'node', + args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], + env: {}, + }); }); it('rejects profile mappings that point to a missing backend with a client error', async () => { diff --git a/tests/unit/web-server/logs-routes.test.ts b/tests/unit/web-server/logs-routes.test.ts new file mode 100644 index 00000000..56ab9b32 --- /dev/null +++ b/tests/unit/web-server/logs-routes.test.ts @@ -0,0 +1,139 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +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 logsRoutes from '../../../src/web-server/routes/logs-routes'; +import { createLogger } from '../../../src/services/logging'; +import { clearRecentLogEntries } from '../../../src/services/logging/log-buffer'; + +describe('logs routes', () => { + let server: Server; + let baseUrl = ''; + let forcedRemoteAddress = '127.0.0.1'; + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalDashboardAuthEnabled: 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/logs', logsRoutes); + + await new Promise((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((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-logs-routes-')); + process.env.CCS_HOME = tempHome; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; + clearRecentLogEntries(); + + const logger = createLogger('unit:test'); + logger.info('seed', 'Seed log entry', { feature: 'logs-routes' }); + + const legacyDir = path.join(tempHome, '.ccs', 'cliproxy', 'logs'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'error-legacy.log'), 'legacy error\n', 'utf8'); + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + + clearRecentLogEntries(); + fs.rmSync(tempHome, { recursive: true, force: true }); + tempHome = ''; + }); + + it('returns logging config, sources, and entries', async () => { + const configResponse = await fetch(`${baseUrl}/api/logs/config`); + expect(configResponse.status).toBe(200); + const configPayload = (await configResponse.json()) as { + logging: { level: string; retain_days: number }; + }; + expect(configPayload.logging.level).toBe('info'); + expect(configPayload.logging.retain_days).toBe(7); + + const sourcesResponse = await fetch(`${baseUrl}/api/logs/sources`); + expect(sourcesResponse.status).toBe(200); + const sourcesPayload = (await sourcesResponse.json()) as { + sources: Array<{ source: string }>; + }; + expect(sourcesPayload.sources.some((source) => source.source === 'unit:test')).toBe(true); + + const entriesResponse = await fetch(`${baseUrl}/api/logs/entries?source=unit:test`); + expect(entriesResponse.status).toBe(200); + const entriesPayload = (await entriesResponse.json()) as { + entries: Array<{ source: string; message: string }>; + }; + expect(entriesPayload.entries[0]?.source).toBe('unit:test'); + expect(entriesPayload.entries[0]?.message).toBe('Seed log entry'); + }); + + it('updates logging config through the route', async () => { + const response = await fetch(`${baseUrl}/api/logs/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + level: 'debug', + retain_days: 3, + live_buffer_size: 100, + }), + }); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { + success: boolean; + logging: { level: string; retain_days: number; live_buffer_size: number }; + }; + expect(payload.success).toBe(true); + expect(payload.logging.level).toBe('debug'); + expect(payload.logging.retain_days).toBe(3); + expect(payload.logging.live_buffer_size).toBe(100); + }); + + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.42'; + + const response = await fetch(`${baseUrl}/api/logs/sources`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Logs endpoints require localhost access when dashboard auth is disabled.', + }); + }); +}); diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts index 8556e50e..defdd1d3 100644 --- a/tests/unit/web-server/shared-routes.test.ts +++ b/tests/unit/web-server/shared-routes.test.ts @@ -231,6 +231,14 @@ describe('web-server shared-routes', () => { ]); }); + it('returns an empty command list when shared commands directory is missing', async () => { + const payload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/commands'); + + expect(payload.items).toEqual([]); + }); + it('returns full content for a shared command markdown file', async () => { const commandsDir = path.join(ccsDir, 'shared', 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); diff --git a/tests/unit/web-server/usage-routes-auth.test.ts b/tests/unit/web-server/usage-routes-auth.test.ts new file mode 100644 index 00000000..424263ff --- /dev/null +++ b/tests/unit/web-server/usage-routes-auth.test.ts @@ -0,0 +1,92 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import { usageRoutes } from '../../../src/web-server/usage/routes'; + +describe('usage-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/usage', usageRoutes); + + await new Promise((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((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-usage-routes-auth-')); + 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 usage status requests when dashboard auth is disabled', async () => { + const response = await fetch(`${baseUrl}/api/usage/status`); + + expect(response.status).toBe(200); + }); + + it('blocks remote usage refresh when dashboard auth is disabled', async () => { + const response = await fetch(`${baseUrl}/api/usage/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Usage refresh requires localhost access when dashboard auth is disabled.', + }); + }); +}); diff --git a/ui/.gitignore b/ui/.gitignore index a547bf36..2005c0ca 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,5 +1,7 @@ # Logs logs +!src/components/logs/ +!src/components/logs/** *.log npm-debug.log* yarn-debug.log* diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ba45cce4..16dc92ed 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -36,6 +36,7 @@ const ClaudeExtensionPage = lazy(() => ); const CodexPage = lazy(() => import('@/pages/codex').then((m) => ({ default: m.CodexPage }))); const DroidPage = lazy(() => import('@/pages/droid').then((m) => ({ default: m.DroidPage }))); +const LogsPage = lazy(() => import('@/pages/logs').then((m) => ({ default: m.LogsPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -182,6 +183,14 @@ export default function App() { } /> + }> + + + } + /> +
+
+
+ +
+
+
+

{title}

+

{subtitle}

+
+

{firstLine}

+

+ {secondLine} +

+
+
+ +
+ + {issueLabel} + + + {showProxySettingsLink ? ( + + + {proxySettingsLabel} + + ) : null} + + High Risk + +
+
+ + ); + } + return (
void; provider: string; displayName: string; + catalog?: CliproxyProviderCatalog; /** Whether this is the first account being added (shows different toast message) */ isFirstAccount?: boolean; } @@ -70,6 +76,7 @@ export function AddAccountDialog({ onClose, provider, displayName, + catalog, isFirstAccount = false, }: AddAccountDialogProps) { const [nickname, setNickname] = useState(''); @@ -81,6 +88,9 @@ export function AddAccountDialog({ const [powerUserModeEnabled, setPowerUserModeEnabled] = useState(false); const [powerUserModeLoading, setPowerUserModeLoading] = useState(false); const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); + const [kiroIDCStartUrl, setKiroIDCStartUrl] = useState(''); + const [kiroIDCRegion, setKiroIDCRegion] = useState(''); + const [kiroIDCFlow, setKiroIDCFlow] = useState(DEFAULT_KIRO_IDC_FLOW); const { t } = useTranslation(); const wasAuthenticatingRef = useRef(false); const powerUserModeRequestIdRef = useRef(0); @@ -97,9 +107,19 @@ export function AddAccountDialog({ const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE; const defaultDeviceCode = isDeviceCodeProvider(provider); const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); - const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode; + const isKiroIdc = isKiro && kiroAuthMethod === 'idc'; + const isKiroSocial = isKiro && isKiroSocialAuthMethod(kiroAuthMethod); + const selectedKiroFlowType = isKiro + ? getKiroEffectiveFlowType(kiroAuthMethod, kiroIDCFlow) + : undefined; + const selectedKiroStartEndpoint = isKiro + ? getKiroEffectiveStartEndpoint(kiroAuthMethod) + : undefined; + const isDeviceCode = isKiro ? selectedKiroFlowType === 'device_code' : defaultDeviceCode; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; const nicknameTrimmed = nickname.trim(); + const kiroIDCStartUrlTrimmed = kiroIDCStartUrl.trim(); + const kiroIDCRegionTrimmed = kiroIDCRegion.trim(); const errorMessage = localError || authFlow.error; const fetchPowerUserModeState = useCallback(async (): Promise => { @@ -168,6 +188,9 @@ export function AddAccountDialog({ setPowerUserModeEnabled(false); setPowerUserModeLoading(false); setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); + setKiroIDCStartUrl(''); + setKiroIDCRegion(''); + setKiroIDCFlow(DEFAULT_KIRO_IDC_FLOW); powerUserModeRequestIdRef.current += 1; powerUserModeLoadErrorShownRef.current = false; wasAuthenticatingRef.current = false; @@ -224,7 +247,7 @@ export function AddAccountDialog({ wasAuthenticatingRef.current = false; const applyPresetAndClose = async () => { try { - const result = await applyDefaultPreset(provider); + const result = await applyDefaultPreset(provider, undefined, catalog); if (result.success && result.presetName && isFirstAccount) { toast.success(`Applied "${result.presetName}" preset`); } @@ -282,12 +305,19 @@ export function AddAccountDialog({ return; } setLocalError(null); + if (isKiroIdc && !kiroIDCStartUrlTrimmed) { + setLocalError('IDC Start URL is required for Kiro IAM Identity Center login.'); + return; + } wasAuthenticatingRef.current = true; authFlow.startAuth(provider, { nickname: nicknameTrimmed || undefined, kiroMethod: isKiro ? kiroAuthMethod : undefined, - flowType: isKiro ? kiroMethodOption.flowType : undefined, - startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined, + kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined, + kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined, + kiroIDCFlow: isKiroIdc ? kiroIDCFlow : undefined, + flowType: isKiro ? selectedKiroFlowType : undefined, + startEndpoint: isKiro ? selectedKiroStartEndpoint : undefined, riskAcknowledgement: requiresAgyResponsibilityFlow ? { version: ANTIGRAVITY_ACK_VERSION, @@ -304,7 +334,7 @@ export function AddAccountDialog({ wasAuthenticatingRef.current = true; kiroImportMutation.mutate(undefined, { onSuccess: async () => { - const result = await applyDefaultPreset('kiro'); + const result = await applyDefaultPreset('kiro', undefined, catalog); if (result.success && result.presetName && isFirstAccount) { toast.success(`Applied "${result.presetName}" preset`); } @@ -399,6 +429,77 @@ export function AddAccountDialog({

{kiroMethodOption.description}

+ {isKiroSocial && ( +

+ If your browser does not return automatically after login, CCS can accept the + final + + kiro://... + + callback URL in the next step. +

+ )} + + )} + + {isKiroIdc && !showAuthUI && ( +
+
+ + { + setKiroIDCStartUrl(e.target.value); + setLocalError(null); + }} + placeholder="https://d-xxx.awsapps.com/start" + disabled={isPending} + /> +

+ Required for organization IAM Identity Center login. +

+
+ +
+ + { + setKiroIDCRegion(e.target.value); + setLocalError(null); + }} + placeholder="us-east-1" + disabled={isPending} + /> +

+ Optional. Leave blank to use the upstream default region. +

+
+ +
+ + +

+ Auth Code opens a browser and may need the final callback URL pasted back. Device + Code shows a verification code instead. +

+
)} @@ -438,7 +539,9 @@ export function AddAccountDialog({

{authFlow.isDeviceCodeFlow ? t('addAccountDialog.deviceCodeHint') - : t('addAccountDialog.browserHint')} + : isKiroSocial + ? 'Complete sign-in in your browser. If it does not return automatically, paste the final kiro:// callback URL below.' + : t('addAccountDialog.browserHint')}

@@ -486,13 +589,19 @@ export function AddAccountDialog({ {/* Callback paste field */}
setCallbackUrl(e.target.value)} - placeholder={t('addAccountDialog.callbackPlaceholder')} + placeholder={ + isKiroSocial + ? 'kiro://kiro.kiroAgent/authenticate-success?code=...&state=...' + : t('addAccountDialog.callbackPlaceholder') + } className="font-mono text-xs" />
diff --git a/ui/src/components/cliproxy/index.ts b/ui/src/components/cliproxy/index.ts index e6b08629..e13cb1b8 100644 --- a/ui/src/components/cliproxy/index.ts +++ b/ui/src/components/cliproxy/index.ts @@ -12,6 +12,7 @@ export { CliproxyTabs } from './cliproxy-tabs'; export { ControlPanelEmbed } from './control-panel-embed'; export { ProviderLogo } from './provider-logo'; export { ProviderModelSelector } from './provider-model-selector'; +export { RoutingGuidanceCard } from './routing-guidance-card'; // Provider editor (from subdirectory) export { ProviderEditor } from './provider-editor'; diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index bee323a6..bf0bac22 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -38,6 +38,7 @@ export function ProviderEditor({ isRemoteMode, port, defaultTarget, + topNotice, onAddAccount, onSetDefault, onRemoveAccount, @@ -121,7 +122,7 @@ export function ProviderEditor({ conflictDialog, handleConflictResolve, missingRequiredFields, - } = useProviderEditor(provider); + } = useProviderEditor(provider, catalog); // Defensive normalization: remote/legacy payloads may omit account.provider. // Fallback to current editor provider to avoid runtime crashes in account UI. @@ -227,6 +228,7 @@ export function ProviderEditor({ onRefetch={refetch} onSave={() => saveMutation.mutate()} /> + {topNotice ?
{topNotice}
: null} {isLoading ? (
diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 7b010bbd..0f9deffb 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -58,7 +58,7 @@ export function ModelConfigSection({ const uniqueIds = [...new Set(selectedModels)]; return uniqueIds - .map((modelId) => findCatalogModel(catalog.provider, modelId)) + .map((modelId) => findCatalogModel(catalog.provider, modelId, catalog)) .filter((model): model is NonNullable => Boolean(model?.extendedContext)); }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index e470dcbf..ebb7dde4 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -2,6 +2,7 @@ * Type definitions for ProviderEditor components */ +import type { ReactNode } from 'react'; import type { AuthStatus, OAuthAccount, CliTarget } from '@/lib/api-client'; import type { ProviderCatalog } from '../provider-model-selector'; @@ -29,6 +30,8 @@ export interface ProviderEditorProps { port?: number; /** Default execution target for this profile/variant */ defaultTarget?: CliTarget; + /** Optional contextual notice shown directly under the editor header */ + topNotice?: ReactNode; onAddAccount: () => void; onSetDefault: (accountId: string) => void; onRemoveAccount: (accountId: string) => void; diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index b2073d18..1166dfbd 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -7,6 +7,7 @@ import { useState, useMemo, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { SettingsResponse, UseProviderEditorReturn } from './types'; +import type { ProviderCatalog } from '../provider-model-selector'; import { applyExtendedContextPreferenceToAnthropicModels, hasAnthropicExtendedContextEnabled, @@ -23,7 +24,10 @@ function checkMissingFields(settings: { env?: Record }): string[ return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim()); } -export function useProviderEditor(provider: string): UseProviderEditorReturn { +export function useProviderEditor( + provider: string, + catalog?: ProviderCatalog +): UseProviderEditorReturn { const [rawJsonEdits, setRawJsonEdits] = useState(null); const [conflictDialog, setConflictDialog] = useState(false); const queryClient = useQueryClient(); @@ -82,9 +86,9 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { const applySavedLongContextIntent = useCallback( (env: Record, enabled: boolean) => applyExtendedContextPreferenceToAnthropicModels(env, enabled, { - supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId), + supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId, catalog), }), - [provider] + [catalog, provider] ); // Update a single setting value diff --git a/ui/src/components/cliproxy/routing-guidance-card.tsx b/ui/src/components/cliproxy/routing-guidance-card.tsx new file mode 100644 index 00000000..73c85590 --- /dev/null +++ b/ui/src/components/cliproxy/routing-guidance-card.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react'; +import { ArrowRightLeft, ChevronDown, ChevronUp, RefreshCw } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import type { CliproxyRoutingState, RoutingStrategy } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; + +interface RoutingGuidanceCardProps { + className?: string; + compact?: boolean; + state?: CliproxyRoutingState; + isLoading: boolean; + isSaving: boolean; + error?: Error | null; + onApply: (strategy: RoutingStrategy) => void; +} + +const STRATEGY_COPY: Record = { + 'round-robin': { + title: 'Round Robin', + description: 'Spread requests across matching accounts for even usage.', + }, + 'fill-first': { + title: 'Fill First', + description: 'Drain one healthy account first and keep backups untouched until needed.', + }, +}; + +export function RoutingGuidanceCard({ + className, + compact = false, + state, + isLoading, + isSaving, + error, + onApply, +}: RoutingGuidanceCardProps) { + const currentStrategy = state?.strategy ?? 'round-robin'; + const [selected, setSelected] = useState(currentStrategy); + const [detailsOpen, setDetailsOpen] = useState(false); + const sourceLabel = state?.source === 'live' ? 'Live CLIProxy' : 'Saved startup default'; + const saveDisabled = isLoading || isSaving || !state || selected === currentStrategy; + const detailToggleLabel = detailsOpen ? 'Hide details' : 'Show details'; + + if (compact) { + const handleApply = (s: RoutingStrategy) => { + setSelected(s); + if (s !== currentStrategy) { + onApply(s); + } + }; + + return ( +
+
+
+
+ +
+ + Routing + + {isSaving && } +
+ +
+
+ {( + Object.entries(STRATEGY_COPY) as Array< + [RoutingStrategy, { title: string; description: string }] + > + ).map(([strategy, copy]) => { + const active = selected === strategy; + return ( + + ); + })} +
+
+ ); + } + + return ( +
+
+
+
+
+ +
+
Routing strategy
+ {currentStrategy} + {state ? {sourceLabel} : null} + {state ? {state.target} : null} +
+

+ Proxy-wide account rotation. CCS keeps round-robin as the default until you explicitly + change it. +

+
+ +
+
+ {( + Object.entries(STRATEGY_COPY) as Array< + [RoutingStrategy, { title: string; description: string }] + > + ).map(([strategy, copy]) => { + const active = selected === strategy; + return ( + + ); + })} +
+
+ + +
+
+ +
+ Round robin spreads usage. + • + Fill first keeps backup accounts cold until they are needed. +
+ + {error ? ( +
+ {error.message} +
+ ) : null} + {!error && state?.message ? ( +
+ {state.message} +
+ ) : null} + + {detailsOpen ? ( +
+ {( + Object.entries(STRATEGY_COPY) as Array< + [RoutingStrategy, { title: string; description: string }] + > + ).map(([strategy, copy]) => { + const current = currentStrategy === strategy; + return ( +
+
+
{copy.title}
+ {current ? Current : null} +
+

{copy.description}

+
+ ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index 963b1e9c..ac2265d4 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -10,6 +10,7 @@ import { ChevronRight, BarChart3, Gauge, + ScrollText, Github, Puzzle, TerminalSquare, @@ -127,6 +128,7 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { title: t('nav.system'), items: [ { path: '/health', icon: Activity, label: t('nav.health') }, + { path: '/logs', icon: ScrollText, label: t('nav.logs') }, { path: '/settings', icon: Settings, label: t('nav.settings') }, ], }, diff --git a/ui/src/components/logs/log-level-badge.tsx b/ui/src/components/logs/log-level-badge.tsx new file mode 100644 index 00000000..e4de4bee --- /dev/null +++ b/ui/src/components/logs/log-level-badge.tsx @@ -0,0 +1,24 @@ +import type { LogsLevel } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { getLevelLabel } from './utils'; + +const LEVEL_STYLES: Record = { + error: 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300', + warn: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300', + info: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300', + debug: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300', +}; + +export function LogLevelBadge({ level, className }: { level: LogsLevel; className?: string }) { + return ( + + {getLevelLabel(level)} + + ); +} diff --git a/ui/src/components/logs/logs-config-card.tsx b/ui/src/components/logs/logs-config-card.tsx new file mode 100644 index 00000000..4fe573a3 --- /dev/null +++ b/ui/src/components/logs/logs-config-card.tsx @@ -0,0 +1,282 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Save, Settings2, ShieldAlert, RotateCcw, Activity } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; + +function parseInteger(value: string, fallback: number) { + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + return fallback; + } + + return Math.max(0, parsed); +} + +export function LogsConfigCard({ + config, + onSave, + isPending, +}: { + config: LogsConfig; + onSave: (payload: UpdateLogsConfigPayload) => void; + isPending: boolean; +}) { + const [draft, setDraft] = useState(config); + + useEffect(() => { + setDraft(config); + }, [config]); + + const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]); + + return ( +
+
+
+
+ +
+
+

+ Logging Policy +

+

+ Retention and privacy +

+
+
+
+
+ +
+
+
+ + Active Status + +
+ + {config.enabled ? 'Live' : 'Off'} + +
+
+
+ + Redaction + + + {config.redact ? 'Enforced' : 'Plain'} + +
+
+ +
+
+
+ +

+ Enable structured logging +

+
+ + setDraft((current) => ({ ...current, enabled: checked })) + } + className="data-[state=checked]:bg-primary" + /> +
+ +
+
+ +

+ Sanitize payload data +

+
+ + setDraft((current) => ({ ...current, redact: checked })) + } + className="data-[state=checked]:bg-primary" + /> +
+
+ +
+
+
+ + +
+ +
+ +
+
+ + + setDraft((current) => ({ + ...current, + rotate_mb: parseInteger(event.target.value, current.rotate_mb), + })) + } + /> +
+
+ + + setDraft((current) => ({ + ...current, + retain_days: parseInteger(event.target.value, current.retain_days), + })) + } + /> +
+
+
+ +
+ + +
+
+ +
+
+ + + Operational Logic v3.4 + +
+ {isDirty && ( +
+
+ + Pending + +
+ )} +
+
+ ); +} diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx new file mode 100644 index 00000000..f2b219c5 --- /dev/null +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -0,0 +1,266 @@ +import { + FileJson, + Info, + ShieldCheck, + Terminal, + Fingerprint, + Database, + Cpu, + Activity, + type LucideIcon, +} from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import type { LogsEntry } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { LogLevelBadge } from './log-level-badge'; +import { formatJson } from './utils'; + +function MetaRow({ + label, + value, + icon: Icon, +}: { + label: string; + value: string | number; + icon?: LucideIcon; +}) { + return ( +
+
+
+ {Icon && ( + + )} +

+ {label} +

+
+
+
+

+ {value} +

+
+ ); +} + +export function LogsDetailPanel({ + entry, + sourceLabel, +}: { + entry: LogsEntry | null; + sourceLabel?: string; +}) { + if (!entry) { + return ( +
+
+
+
+ +
+
+
+

+ Inspector Standby +

+

+ Select a telemetry node from the active data queue to perform deep analysis of its + operational context. +

+
+
+ ); + } + + return ( +
+ {/* Tactical Inspector Header */} +
+ {/* Pattern Overlay */} +
+ +
+
+
+ +
+
+ + + {sourceLabel ?? entry.source} + +
+
+
+ + + {new Date(entry.timestamp).toLocaleTimeString(undefined, { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + +
+
+ +
+
+
+

+ Event +

+
+

+ {entry.event} +

+
+

+ {entry.message} +

+
+
+
+
+ + +
+ + + + + Details + + + + Raw Context + + + + +
+ + + + +
+ +
+ {/* Background Scanline */} +
+ +
+
+
+ +
+
+

+ Automated Summary +

+

+ Quick interpretation +

+
+
+

+ This telemetry node was captured from{' '} + + {sourceLabel ?? entry.source} + + operating at the{' '} + + {entry.level} + {' '} + threshold. The operational payload indicates an event state of{' '} + {entry.event}. +

+
+
+ + + +
+ {/* Copy HUD */} +
+
+ JSON.RAW.MODE +
+
+ + +
+                    {formatJson({
+                      id: entry.id,
+                      timestamp: entry.timestamp,
+                      level: entry.level,
+                      source: entry.source,
+                      event: entry.event,
+                      message: entry.message,
+                      processId: entry.processId,
+                      runId: entry.runId,
+                      context: entry.context ?? {},
+                    })}
+                  
+
+
+
+ +
+ + +
+
+
+
+ + Node Verified + +
+
+ + {entry.id.slice(0, 8)} + +
+
+ + CCS-TEC-v3 + +
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx new file mode 100644 index 00000000..dc205f6a --- /dev/null +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -0,0 +1,215 @@ +import { Activity, ArrowRight, Inbox, Loader2 } from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import type { LogsEntry } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { LogLevelBadge } from './log-level-badge'; + +export function LogsEntryList({ + entries, + selectedEntryId, + onSelect, + sourceLabels, + isLoading, + isFetching, +}: { + entries: LogsEntry[]; + selectedEntryId: string | null; + onSelect: (entryId: string) => void; + sourceLabels: Record; + isLoading: boolean; + isFetching: boolean; +}) { + return ( +
+
+
+
+
+

+ Live Entry Stream +

+
+
+
+ + + Live telemetry + +
+
+
+ {isFetching && ( +
+ + + Syncing + +
+ )} + + NODE.01 + +
+
+ +
+
Time
+
Lvl
+
Source
+
Message
+
Proc
+
Run
+
Open
+
+ +
+ {isLoading ? ( +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((item) => ( +
+ ))} +
+ ) : entries.length === 0 ? ( +
+
+
+
+ +
+
+
+

+ No matching entries +

+

+ Your current source, level, or search filters are hiding the stream. Adjust them to + bring entries back into view. +

+
+
+ ) : ( + +
+ {entries.map((entry) => { + const isSelected = entry.id === selectedEntryId; + + return ( + + ); + })} +
+
+ )} +
+ +
+
+ + Node: CCS-CORE + +
+ + Status: Operational + +
+
+ + Entries: {entries.length} + +
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-filters.tsx b/ui/src/components/logs/logs-filters.tsx new file mode 100644 index 00000000..f212890d --- /dev/null +++ b/ui/src/components/logs/logs-filters.tsx @@ -0,0 +1,198 @@ +import { Search, RefreshCw, Filter, Shield, Zap } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import type { LogsSource } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs'; +import { getLogLevelOptions } from '@/hooks/use-logs'; + +export function LogsFilters({ + sources, + selectedSource, + onSourceChange, + selectedLevel, + onLevelChange, + search, + onSearchChange, + limit, + onLimitChange, + onRefresh, + isRefreshing, +}: { + sources: LogsSource[]; + selectedSource: LogsSourceFilter; + onSourceChange: (value: LogsSourceFilter) => void; + selectedLevel: LogsLevelFilter; + onLevelChange: (value: LogsLevelFilter) => void; + search: string; + onSearchChange: (value: string) => void; + limit: number; + onLimitChange: (value: number) => void; + onRefresh: () => void; + isRefreshing: boolean; +}) { + const levels = getLogLevelOptions(); + const limits = [50, 100, 150, 250]; + + return ( +
+
+
+
+
+ +
+ +
+
+
+ +
+ onSearchChange(event.target.value)} + placeholder="Scan for patterns..." + className="h-11 rounded-xl border-2 border-border/40 bg-background/50 pl-10 text-[13px] font-medium text-foreground placeholder:text-foreground/35 focus-visible:border-primary/40 focus-visible:ring-0 transition-all shadow-inner" + /> +
+
+ +
+
+ + +
+
+ + +
+ {sources.map((source) => ( + + ))} +
+
+
+ + {/* Threshold Control */} +
+
+ + +
+
+ {levels.map((option) => ( + + ))} +
+
+ + {/* Operational Deck */} +
+
+
+
+

+ Operational Window +

+

+ Tail Capacity +

+
+
+ +
+
+ +
+ {limits.map((option) => ( + + ))} +
+ + +
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-overview-cards.tsx b/ui/src/components/logs/logs-overview-cards.tsx new file mode 100644 index 00000000..678f8548 --- /dev/null +++ b/ui/src/components/logs/logs-overview-cards.tsx @@ -0,0 +1,90 @@ +import { Activity, Archive, Database, RadioTower } from 'lucide-react'; +import type { LogsConfig, LogsEntry, LogsSource } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { formatCount, formatLogTimestamp, formatRelativeLogTime } from './utils'; + +function MetricCard({ + label, + value, + detail, + icon: Icon, + accent, +}: { + label: string; + value: string; + detail: string; + icon: typeof Activity; + accent: string; +}) { + return ( +
+
+
+

{label}

+

{value}

+

{detail}

+
+
+ +
+
+
+ ); +} + +export function LogsOverviewCards({ + config, + sources, + entries, + latestTimestamp, +}: { + config: LogsConfig; + sources: LogsSource[]; + entries: LogsEntry[]; + latestTimestamp: string | null; +}) { + const nativeSources = sources.filter((source) => source.kind === 'native').length; + const legacySources = sources.length - nativeSources; + const errorCount = entries.filter((entry) => entry.level === 'error').length; + + return ( +
+ + + 0 ? ` • ${legacySources} legacy` : ''}`} + icon={Database} + accent="bg-sky-500/10 text-sky-700 dark:text-sky-300" + /> + +
+ Last ingested event:{' '} + {formatLogTimestamp(latestTimestamp)} +
+
+ ); +} diff --git a/ui/src/components/logs/logs-page-skeleton.tsx b/ui/src/components/logs/logs-page-skeleton.tsx new file mode 100644 index 00000000..bd31baf6 --- /dev/null +++ b/ui/src/components/logs/logs-page-skeleton.tsx @@ -0,0 +1,56 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function LogsPageSkeleton() { + return ( +
+ + + + + + + + +
+ {[1, 2, 3, 4].map((item) => ( + + + + + + + ))} +
+ +
+ + + +
+ {[1, 2, 3, 4].map((item) => ( + + ))} +
+
+ + + + +
+ + + + + + + + {[1, 2, 3, 4].map((item) => ( + + ))} + + +
+
+ ); +} diff --git a/ui/src/components/logs/utils.ts b/ui/src/components/logs/utils.ts new file mode 100644 index 00000000..16d0cff8 --- /dev/null +++ b/ui/src/components/logs/utils.ts @@ -0,0 +1,77 @@ +import type { LogsLevel } from '@/lib/api-client'; + +export function formatLogTimestamp(timestamp: string | null | undefined) { + if (!timestamp) { + return 'No activity yet'; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return timestamp; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +export function formatRelativeLogTime(timestamp: string | null | undefined) { + if (!timestamp) { + return 'No activity yet'; + } + + const value = new Date(timestamp).getTime(); + if (Number.isNaN(value)) { + return timestamp; + } + + const diffSeconds = Math.round((value - Date.now()) / 1000); + const absSeconds = Math.abs(diffSeconds); + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + + if (absSeconds < 60) { + return formatter.format(diffSeconds, 'second'); + } + + const diffMinutes = Math.round(diffSeconds / 60); + if (Math.abs(diffMinutes) < 60) { + return formatter.format(diffMinutes, 'minute'); + } + + const diffHours = Math.round(diffMinutes / 60); + if (Math.abs(diffHours) < 24) { + return formatter.format(diffHours, 'hour'); + } + + return formatter.format(Math.round(diffHours / 24), 'day'); +} + +export function formatCount(value: number) { + return new Intl.NumberFormat().format(value); +} + +export function formatJson(value: unknown) { + if (value === null || value === undefined) { + return '{}'; + } + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export function getLevelLabel(level: LogsLevel) { + switch (level) { + case 'error': + return 'Error'; + case 'warn': + return 'Warn'; + case 'info': + return 'Info'; + case 'debug': + return 'Debug'; + } +} diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index 4d69d54b..484569fc 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -60,6 +60,8 @@ import { useCliproxyVersions, useInstallVersion, useRestartProxy, + useCliproxyRoutingStrategy, + useUpdateCliproxyRoutingStrategy, } from '@/hooks/use-cliproxy'; import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync'; import { cn } from '@/lib/utils'; @@ -67,6 +69,7 @@ import { isCliproxyVersionExperimental, isCliproxyVersionInRange, } from '@/lib/cliproxy-version-risk'; +import { RoutingGuidanceCard } from '@/components/cliproxy/routing-guidance-card'; type PendingInstallRisk = 'faulty' | 'experimental'; @@ -150,6 +153,12 @@ export function ProxyStatusWidget() { const { data: status, isLoading } = useProxyStatus(); const { data: updateCheck } = useCliproxyUpdateCheck(); const { data: versionsData, isLoading: versionsLoading } = useCliproxyVersions(); + const { + data: routingState, + isLoading: routingLoading, + error: routingError, + } = useCliproxyRoutingStrategy(); + const updateRouting = useUpdateCliproxyRoutingStrategy(); const startProxy = useStartProxy(); const stopProxy = useStopProxy(); const restartProxy = useRestartProxy(); @@ -304,6 +313,17 @@ export function ProxyStatusWidget() { {t('proxyStatusWidget.trafficAutoRouted')}

+ + updateRouting.mutate(strategy)} + />
); } @@ -450,6 +470,17 @@ export function ProxyStatusWidget() {
+ updateRouting.mutate(strategy)} + /> + {/* Expanded section: Version Management (available even when not running) */} diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index 16576155..5170c8d2 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -17,6 +17,7 @@ import { import { Sparkles } from 'lucide-react'; import { useCliproxyAuth, + useCliproxyCatalog, useCreateVariant, useStartAuth, useCancelAuth, @@ -24,6 +25,7 @@ import { import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { CLIProxyProvider } from '@/lib/provider-config'; import { applyDefaultPreset } from '@/lib/preset-utils'; +import { buildUiCatalogs } from '@/lib/model-catalogs'; import i18n from '@/lib/i18n'; import { usePrivacy } from '@/contexts/privacy-context'; import { toast } from 'sonner'; @@ -47,6 +49,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { const [isAddingNewAccount, setIsAddingNewAccount] = useState(false); const { data: authData, refetch } = useCliproxyAuth(); + const { data: catalogData } = useCliproxyCatalog(); const createMutation = useCreateVariant(); const startAuthMutation = useStartAuth(); const cancelAuthMutation = useCancelAuth(); @@ -57,6 +60,8 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { (s: AuthStatus) => s.provider === selectedProvider ); const accounts = useMemo(() => providerAuth?.accounts || [], [providerAuth?.accounts]); + const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); + const fetchedCatalogsReady = Boolean(catalogData); // Reset on close useEffect(() => { @@ -97,7 +102,11 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { { onSuccess: async (data) => { if (isFirstAccount) { - const result = await applyDefaultPreset(selectedProvider); + const result = await applyDefaultPreset( + selectedProvider, + undefined, + fetchedCatalogsReady ? catalogs[selectedProvider] : undefined + ); if (result.success && result.presetName) { toast.success(`Applied "${result.presetName}" preset`); } else if (!result.success) { @@ -228,6 +237,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { {step === 'variant' && ( m.id === modelName); const [showCustomInput, setShowCustomInput] = useState(isCustomModel); const deniedCustomModel = @@ -171,9 +173,7 @@ export function VariantStep({ {showCustomInput ? t('setupVariant.enterAnyModel') : t('setupVariant.defaultModel', { - model: - MODEL_CATALOGS[selectedProvider]?.defaultModel || - t('setupVariant.providerDefault'), + model: resolvedCatalog?.defaultModel || t('setupVariant.providerDefault'), })}
diff --git a/ui/src/components/setup/wizard/types.ts b/ui/src/components/setup/wizard/types.ts index cccbf332..7df586e1 100644 --- a/ui/src/components/setup/wizard/types.ts +++ b/ui/src/components/setup/wizard/types.ts @@ -2,7 +2,7 @@ * Types for Quick Setup Wizard */ -import type { OAuthAccount } from '@/lib/api-client'; +import type { CliproxyProviderCatalog, OAuthAccount } from '@/lib/api-client'; export type WizardStep = 'provider' | 'auth' | 'account' | 'variant' | 'success'; @@ -43,6 +43,7 @@ export interface AccountStepProps { export interface VariantStepProps { selectedProvider: string; + catalog?: CliproxyProviderCatalog; selectedAccount: OAuthAccount | null; variantName: string; modelName: string; diff --git a/ui/src/components/shared/localhost-disclaimer.tsx b/ui/src/components/shared/localhost-disclaimer.tsx index 78d5c6e1..c6efc7b4 100644 --- a/ui/src/components/shared/localhost-disclaimer.tsx +++ b/ui/src/components/shared/localhost-disclaimer.tsx @@ -1,28 +1,69 @@ import { Shield, X } from 'lucide-react'; import { useState } from 'react'; +import { useAuth } from '@/contexts/auth-context'; export function LocalhostDisclaimer() { const [dismissed, setDismissed] = useState(false); + const { authEnabled, authConfigured, isLocalAccess, loading } = useAuth(); - if (dismissed) return null; + const isRemoteReadonly = !isLocalAccess && !authEnabled; + + if ((dismissed && !isRemoteReadonly) || loading) return null; + + const wrapperClasses = isRemoteReadonly + ? 'w-full border-t border-amber-200 bg-amber-50 px-4 py-2 text-amber-900 transition-colors duration-200 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-200' + : 'w-full border-t border-yellow-200 bg-yellow-50 px-4 py-2 text-yellow-800 transition-colors duration-200 dark:border-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200'; + const dismissClasses = isRemoteReadonly + ? 'text-amber-600 hover:bg-amber-100 hover:text-amber-800 dark:text-amber-400 dark:hover:bg-amber-800/30' + : 'text-yellow-600 hover:bg-yellow-100 hover:text-yellow-800 dark:text-yellow-400 dark:hover:bg-yellow-800/30'; + const message = isRemoteReadonly ? ( + <> + {authConfigured ? ( + <> + + Remote dashboard access is read-only because dashboard auth is currently disabled on the + host. Re-enable dashboard auth on the host to unlock remote changes. + + + Remote dashboard is read-only until dashboard auth is re-enabled on the host. + + + ) : ( + <> + + Remote dashboard access is read-only until you run ccs config auth setup on the host. + + + Remote dashboard is read-only until host auth is configured. + + + )} + + ) : ( + <> + + This dashboard runs locally. All data stays on your machine. + + Local dashboard - data stays on your device. + + ); return ( -
+
-
+
- - This dashboard runs locally. All data stays on your machine. - - Local dashboard - data stays on your device. + {message}
- + {!isRemoteReadonly ? ( + + ) : null}
); diff --git a/ui/src/components/ui/searchable-select.tsx b/ui/src/components/ui/searchable-select.tsx index 9da3602e..b971bb0f 100644 --- a/ui/src/components/ui/searchable-select.tsx +++ b/ui/src/components/ui/searchable-select.tsx @@ -5,7 +5,6 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { ScrollArea } from '@/components/ui/scroll-area'; export interface SearchableSelectGroup { key: string; @@ -280,7 +279,11 @@ export function SearchableSelect({
- +
{filteredOptions.length === 0 ? (
{emptyText}
) : ( @@ -331,7 +334,7 @@ export function SearchableSelect({ ))}
)} -
+
); diff --git a/ui/src/contexts/auth-context.tsx b/ui/src/contexts/auth-context.tsx index d0e39ee7..29369785 100644 --- a/ui/src/contexts/auth-context.tsx +++ b/ui/src/contexts/auth-context.tsx @@ -13,7 +13,12 @@ import { useCallback, type ReactNode, } from 'react'; -import { checkAuth, login as apiLogin, logout as apiLogout } from '@/lib/auth-api'; +import { + checkAuth, + login as apiLogin, + logout as apiLogout, + type DashboardAccessMode, +} from '@/lib/auth-api'; interface AuthContextValue { /** Whether authentication is required for this dashboard */ @@ -24,6 +29,14 @@ interface AuthContextValue { username: string | null; /** Whether auth check is in progress */ loading: boolean; + /** Whether dashboard auth is enabled on the host */ + authEnabled: boolean; + /** Whether host credentials are fully configured */ + authConfigured: boolean; + /** Whether the current request comes from localhost/loopback */ + isLocalAccess: boolean; + /** Effective access mode for the current request */ + accessMode: DashboardAccessMode; /** Login with credentials */ login: (username: string, password: string) => Promise; /** Logout current session */ @@ -37,6 +50,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [username, setUsername] = useState(null); const [loading, setLoading] = useState(true); + const [authEnabled, setAuthEnabled] = useState(false); + const [authConfigured, setAuthConfigured] = useState(false); + const [isLocalAccess, setIsLocalAccess] = useState(false); + const [accessMode, setAccessMode] = useState('login'); // Check auth status on mount useEffect(() => { @@ -45,6 +62,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { setAuthRequired(res.authRequired); setIsAuthenticated(res.authenticated); setUsername(res.username); + setAuthEnabled(res.authEnabled); + setAuthConfigured(res.authConfigured); + setIsLocalAccess(res.isLocalAccess); + setAccessMode(res.accessMode); }) .catch(() => { // If auth check fails (network error, server down, CORS issue), @@ -52,6 +73,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Prevents silently broken dashboard when server is unreachable. setAuthRequired(true); setIsAuthenticated(false); + setAuthEnabled(true); + setAuthConfigured(true); + setIsLocalAccess(false); + setAccessMode('login'); }) .finally(() => setLoading(false)); }, []); @@ -74,10 +99,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated, username, loading, + authEnabled, + authConfigured, + isLocalAccess, + accessMode, login, logout, }), - [authRequired, isAuthenticated, username, loading, login, logout] + [ + authRequired, + isAuthenticated, + username, + loading, + authEnabled, + authConfigured, + isLocalAccess, + accessMode, + login, + logout, + ] ); return {children}; diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index dcc57e09..1ac8199f 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -26,6 +26,9 @@ interface AuthFlowState { interface StartAuthOptions { nickname?: string; kiroMethod?: string; + kiroIDCStartUrl?: string; + kiroIDCRegion?: string; + kiroIDCFlow?: 'authcode' | 'device'; flowType?: 'authorization_code' | 'device_code'; startEndpoint?: 'start' | 'start-url'; riskAcknowledgement?: { @@ -70,6 +73,7 @@ const INITIAL_STATE: AuthFlowState = { export function useCliproxyAuthFlow() { const [state, setState] = useState(INITIAL_STATE); + const stateRef = useRef(INITIAL_STATE); const attemptIdRef = useRef(0); const abortControllerRef = useRef(null); @@ -102,6 +106,10 @@ export function useCliproxyAuthFlow() { }; }, [stopPolling]); + useEffect(() => { + stateRef.current = state; + }, [state]); + // Poll OAuth status const pollStatus = useCallback( async (provider: string, oauthState: string, attemptId: number) => { @@ -253,6 +261,9 @@ export function useCliproxyAuthFlow() { const payload = { nickname: options?.nickname, kiroMethod: options?.kiroMethod, + kiroIDCStartUrl: options?.kiroIDCStartUrl, + kiroIDCRegion: options?.kiroIDCRegion, + kiroIDCFlow: options?.kiroIDCFlow, riskAcknowledgement: options?.riskAcknowledgement, }; @@ -368,6 +379,16 @@ export function useCliproxyAuthFlow() { // Start polling for completion if (oauthState) { pollStartRef.current = Date.now(); + if (!authUrl) { + await pollStatus(provider, oauthState, attemptId); + if (!isActiveAttempt(attemptId)) { + return; + } + const currentState = stateRef.current; + if (!currentState.isAuthenticating || currentState.provider !== provider) { + return; + } + } pollIntervalRef.current = setInterval(() => { void pollStatus(provider, oauthState, attemptId); }, POLL_INTERVAL); diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index be6dc613..d88e2b82 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -5,7 +5,13 @@ */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { api, type CreateVariant, type UpdateVariant, type CreatePreset } from '@/lib/api-client'; +import { + api, + type CreateVariant, + type UpdateVariant, + type CreatePreset, + type RoutingStrategy, +} from '@/lib/api-client'; import { toast } from 'sonner'; export function useCliproxy() { @@ -22,6 +28,37 @@ export function useCliproxyAuth() { }); } +export function useCliproxyCatalog() { + return useQuery({ + queryKey: ['cliproxy-catalog'], + queryFn: () => api.cliproxy.catalog(), + staleTime: 30000, + retry: 1, + }); +} + +export function useCliproxyRoutingStrategy() { + return useQuery({ + queryKey: ['cliproxy-routing'], + queryFn: () => api.cliproxy.getRoutingStrategy(), + }); +} + +export function useUpdateCliproxyRoutingStrategy() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (strategy: RoutingStrategy) => api.cliproxy.updateRoutingStrategy(strategy), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['cliproxy-routing'] }); + toast.success(result.message || `Routing strategy set to ${result.strategy}`); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + export function useCreateVariant() { const queryClient = useQueryClient(); diff --git a/ui/src/hooks/use-logs.ts b/ui/src/hooks/use-logs.ts new file mode 100644 index 00000000..2f63f4fa --- /dev/null +++ b/ui/src/hooks/use-logs.ts @@ -0,0 +1,149 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useDeferredValue, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { + api, + type LogsEntry, + type LogsLevel, + type UpdateLogsConfigPayload, +} from '@/lib/api-client'; + +export type LogsLevelFilter = 'all' | LogsLevel; +export type LogsSourceFilter = 'all' | string; + +const CONFIG_QUERY_KEY = ['logs', 'config'] as const; +const SOURCES_QUERY_KEY = ['logs', 'sources'] as const; +const DEFAULT_LIMIT = 150; + +export function useLogsWorkspace() { + const [selectedSource, setSelectedSource] = useState('all'); + const [selectedLevel, setSelectedLevel] = useState('all'); + const [search, setSearch] = useState(''); + const [limit, setLimit] = useState(DEFAULT_LIMIT); + const [selectedEntryId, setSelectedEntryId] = useState(null); + const deferredSearch = useDeferredValue(search.trim()); + + const configQuery = useQuery({ + queryKey: CONFIG_QUERY_KEY, + queryFn: async () => (await api.logs.getConfig()).logging, + refetchInterval: 30_000, + }); + + const sourcesQuery = useQuery({ + queryKey: SOURCES_QUERY_KEY, + queryFn: async () => (await api.logs.getSources()).sources, + refetchInterval: 15_000, + }); + + const entriesQuery = useQuery({ + queryKey: ['logs', 'entries', selectedSource, selectedLevel, deferredSearch, limit], + queryFn: async () => + ( + await api.logs.getEntries({ + source: selectedSource === 'all' ? undefined : selectedSource, + level: selectedLevel === 'all' ? undefined : selectedLevel, + search: deferredSearch || undefined, + limit, + }) + ).entries, + placeholderData: keepPreviousData, + refetchInterval: 10_000, + }); + + const activeSelectedEntryId = useMemo(() => { + const nextEntries = entriesQuery.data ?? []; + if (nextEntries.length === 0) { + return null; + } + + if (selectedEntryId && nextEntries.some((entry) => entry.id === selectedEntryId)) { + return selectedEntryId; + } + + return nextEntries[0]?.id ?? null; + }, [entriesQuery.data, selectedEntryId]); + + const selectedEntry = useMemo( + () => (entriesQuery.data ?? []).find((entry) => entry.id === activeSelectedEntryId) ?? null, + [activeSelectedEntryId, entriesQuery.data] + ); + + const latestTimestamp = useMemo(() => { + const timestamps = (sourcesQuery.data ?? []) + .map((source) => source.lastTimestamp) + .filter((value): value is string => Boolean(value)); + return timestamps.sort((left, right) => right.localeCompare(left))[0] ?? null; + }, [sourcesQuery.data]); + + return { + configQuery, + sourcesQuery, + entriesQuery, + selectedSource, + setSelectedSource, + selectedLevel, + setSelectedLevel, + search, + setSearch, + limit, + setLimit, + selectedEntryId: activeSelectedEntryId, + setSelectedEntryId, + selectedEntry, + latestTimestamp, + isInitialLoading: + (!configQuery.data && configQuery.isLoading) || + (!sourcesQuery.data && sourcesQuery.isLoading) || + (!entriesQuery.data && entriesQuery.isLoading), + }; +} + +export function useUpdateLogsConfig() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (payload: UpdateLogsConfigPayload) => api.logs.updateConfig(payload), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: CONFIG_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: SOURCES_QUERY_KEY }), + queryClient.invalidateQueries({ queryKey: ['logs', 'entries'] }), + ]); + toast.success('Logging configuration saved.'); + }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to save logging configuration.'); + }, + }); +} + +export function getLogLevelOptions(): Array<{ value: LogsLevelFilter; label: string }> { + return [ + { value: 'all', label: 'All levels' }, + { value: 'error', label: 'Errors' }, + { value: 'warn', label: 'Warnings' }, + { value: 'info', label: 'Info' }, + { value: 'debug', label: 'Debug' }, + ]; +} + +export function getSelectedSourceLabel( + source: LogsSourceFilter, + sources: Array<{ source: string; label: string }> +) { + if (source === 'all') { + return 'All sources'; + } + + return sources.find((entry) => entry.source === source)?.label ?? source; +} + +export function getSourceLabelMap( + sources: Array<{ source: string; label: string }> +): Record { + return Object.fromEntries(sources.map((source) => [source.source, source.label])); +} + +export function isLogsEntryListEmpty(entries: LogsEntry[] | undefined) { + return !entries || entries.length === 0; +} diff --git a/ui/src/index.css b/ui/src/index.css index b15fdb8e..88723524 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -374,3 +374,50 @@ .scrollbar-editor::-webkit-scrollbar-corner { background: transparent; } + +@keyframes scan { + 0% { + transform: translateY(0); + } + 100% { + transform: translateY(100%); + } +} + +.animate-scan { + animation: scan 8s linear infinite; +} + +@keyframes slide-in-from-right { + from { + transform: translateX(10px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slide-in-from-left { + from { + transform: translateX(-10px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.slide-in-from-right-4 { + animation: slide-in-from-right 0.5s ease-out; +} + +.slide-in-from-left-2 { + animation: slide-in-from-left 0.4s ease-out; +} + +.slide-in-from-bottom-2 { + animation: enter 0.3s ease-out; +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 7c9c5e8a..54c491c0 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -413,6 +413,20 @@ export interface AuthStatus { defaultAccount?: string; } +export type RoutingStrategy = 'round-robin' | 'fill-first'; + +export interface CliproxyRoutingState { + strategy: RoutingStrategy; + source: 'live' | 'config'; + target: 'local' | 'remote'; + reachable: boolean; + message?: string; +} + +export interface CliproxyRoutingApplyResult extends CliproxyRoutingState { + applied: 'live' | 'live-and-config' | 'config-only'; +} + /** Auth file info for Config tab */ export interface AuthFile { name: string; @@ -434,6 +448,42 @@ export interface CliproxyModelsResponse { totalCount: number; } +export interface CliproxyCatalogPresetMapping { + default: string; + opus: string; + sonnet: string; + haiku: string; +} + +export interface CliproxyCatalogModel { + id: string; + name: string; + tier?: 'free' | 'paid'; + description?: string; + broken?: boolean; + issueUrl?: string; + deprecated?: boolean; + deprecationReason?: string; + extendedContext?: boolean; + presetMapping?: CliproxyCatalogPresetMapping; +} + +export interface CliproxyProviderCatalog { + provider: string; + displayName: string; + models: CliproxyCatalogModel[]; + defaultModel: string; +} + +export interface CliproxyCatalogResponse { + catalogs: Partial>; + source: 'live' | 'cache' | 'static'; + cache: { + synced: boolean; + age: string | null; + }; +} + /** Individual model quota info from Google Cloud Code API */ export interface ModelQuota { /** Model name, e.g., "gemini-3-pro-high" */ @@ -747,6 +797,46 @@ export interface UpdateAccountContext { continuity_mode?: 'standard' | 'deeper'; } +export type LogsLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogsConfig { + enabled: boolean; + level: LogsLevel; + rotate_mb: number; + retain_days: number; + redact: boolean; + live_buffer_size: number; +} + +export interface LogsSource { + source: string; + label: string; + kind: 'native' | 'legacy'; + count: number; + lastTimestamp: string | null; +} + +export interface LogsEntry { + id: string; + timestamp: string; + level: LogsLevel; + source: string; + event: string; + message: string; + processId: number | null; + runId: string | null; + context?: unknown; +} + +export interface LogsEntriesParams { + source?: string; + level?: LogsLevel; + search?: string; + limit?: number; +} + +export type UpdateLogsConfigPayload = Partial; + // Unified config types export interface ConfigFormat { format: 'yaml' | 'json' | 'none'; @@ -950,6 +1040,37 @@ export const api = { body: JSON.stringify(data), }), }, + logs: { + getConfig: () => request<{ logging: LogsConfig }>('/logs/config'), + updateConfig: (data: UpdateLogsConfigPayload) => + request<{ success: boolean; logging: LogsConfig }>('/logs/config', { + method: 'PUT', + body: JSON.stringify(data), + }), + getSources: () => request<{ sources: LogsSource[] }>('/logs/sources'), + getEntries: ({ source, level, search, limit }: LogsEntriesParams = {}) => { + const params = new URLSearchParams(); + + if (source) { + params.set('source', source); + } + + if (level) { + params.set('level', level); + } + + if (search) { + params.set('search', search); + } + + if (typeof limit === 'number') { + params.set('limit', String(limit)); + } + + const query = params.toString(); + return request<{ entries: LogsEntry[] }>(`/logs/entries${query ? `?${query}` : ''}`); + }, + }, cliproxy: { list: () => request<{ variants: Variant[] }>('/cliproxy'), getAuthStatus: () => @@ -985,12 +1106,19 @@ export const api = { // Stats and models for Overview tab stats: () => request<{ usage: Record }>('/cliproxy/usage'), + catalog: () => request('/cliproxy/catalog'), models: () => request('/cliproxy/models'), updateModel: (provider: string, model: string) => request(`/cliproxy/models/${provider}`, { method: 'PUT', body: JSON.stringify({ model }), }), + getRoutingStrategy: () => request('/cliproxy/routing/strategy'), + updateRoutingStrategy: (strategy: RoutingStrategy) => + request('/cliproxy/routing/strategy', { + method: 'PUT', + body: JSON.stringify({ value: strategy }), + }), aiProviders: { list: () => request('/cliproxy/ai-providers'), create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) => diff --git a/ui/src/lib/auth-api.ts b/ui/src/lib/auth-api.ts index 7370932d..68ae0c7d 100644 --- a/ui/src/lib/auth-api.ts +++ b/ui/src/lib/auth-api.ts @@ -5,10 +5,16 @@ const BASE_URL = '/api/auth'; +export type DashboardAccessMode = 'open' | 'login' | 'setup'; + export interface AuthCheckResponse { authRequired: boolean; authenticated: boolean; username: string | null; + authEnabled: boolean; + authConfigured: boolean; + isLocalAccess: boolean; + accessMode: DashboardAccessMode; } export interface AuthSetupResponse { diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 36034951..72e73701 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -33,6 +33,7 @@ const resources = { factoryDroid: 'Factory Droid', system: 'System', health: 'Health', + logs: 'Logs', settings: 'Settings', openrouterTooltip: 'Featured: OpenRouter + Alibaba Coding Plan + Ollama', }, @@ -45,7 +46,11 @@ const resources = { }, auth: { dashboardTitle: 'CCS Dashboard', - loginDescription: 'Enter your credentials to access the dashboard', + protectedAccessLabel: 'Protected access', + remoteGuardLabel: 'Remote access guard', + loading: 'Checking dashboard access…', + loginDescription: + 'Use the username and password configured on the host to access the dashboard.', username: 'Username', password: 'Password', usernamePlaceholder: 'Enter username', @@ -53,6 +58,30 @@ const resources = { signIn: 'Sign In', signingIn: 'Signing in...', loginFailed: 'Login failed', + lightMode: 'Light', + darkMode: 'Dark', + noDefaultCredentials: 'No default credentials ship with CCS.', + credentialsHint: + 'Credentials are created on the host with `ccs config auth setup`, then used here.', + remoteSetupTitle: 'Remote access needs host setup', + remoteSetupDescription: + 'This dashboard was opened from a non-local address, but dashboard auth is not enabled on the host yet.', + incompleteSetupDescription: + 'Dashboard auth is turned on, but the host setup is incomplete. Finish the host configuration before signing in.', + safetyNoteRemote: + 'Remote management stays locked until the host owner enables dashboard auth.', + safetyNoteLocal: + 'If you are on the same machine, the localhost URL remains the simplest path in.', + safetyNoteSession: + 'Successful sign-ins create an HTTP-only session that stays scoped to this host.', + hostStepTitle: 'On the host machine', + hostStepDescription: + 'Create or re-enable dashboard credentials, then reopen this page from the remote device.', + localStepTitle: 'If this is your machine', + localStepDescription: + 'Open the localhost URL printed by `ccs config` instead of the LAN or Tailscale address.', + showPassword: 'Show password', + hidePassword: 'Hide password', }, commonToast: { apiKeyRequired: 'API key is required', @@ -1382,7 +1411,10 @@ const resources = { }, auth: { dashboardTitle: 'CCS 控制台', - loginDescription: '请输入凭据以访问控制台', + protectedAccessLabel: '受保护访问', + remoteGuardLabel: '远程访问保护', + loading: '正在检查控制台访问状态…', + loginDescription: '使用主机上配置的用户名和密码访问控制台。', username: '用户名', password: '密码', usernamePlaceholder: '请输入用户名', @@ -1390,6 +1422,24 @@ const resources = { signIn: '登录', signingIn: '登录中...', loginFailed: '登录失败', + lightMode: '浅色', + darkMode: '深色', + noDefaultCredentials: 'CCS 不提供默认用户名或密码。', + credentialsHint: '凭据需要在主机上通过 `ccs config auth setup` 创建,然后在这里使用。', + remoteSetupTitle: '远程访问需要在主机上完成设置', + remoteSetupDescription: '当前通过非本机地址打开控制台,但主机尚未启用控制台认证。', + incompleteSetupDescription: + '控制台认证已开启,但主机上的设置尚未完成。请先完成主机配置再登录。', + safetyNoteRemote: '在主机所有者启用控制台认证前,远程管理会保持锁定。', + safetyNoteLocal: '如果你就在这台机器上,使用 localhost 地址始终是最直接的方式。', + safetyNoteSession: '登录成功后会创建仅限此主机的 HTTP-only 会话。', + hostStepTitle: '在主机上操作', + hostStepDescription: '创建或重新启用控制台凭据,然后再从远程设备重新打开此页面。', + localStepTitle: '如果这就是你的机器', + localStepDescription: + '请使用 `ccs config` 输出的 localhost 地址,而不是局域网或 Tailscale 地址。', + showPassword: '显示密码', + hidePassword: '隐藏密码', }, commonToast: { apiKeyRequired: 'API key 为必填项', @@ -2655,7 +2705,11 @@ const resources = { }, auth: { dashboardTitle: 'Bảng điều khiển CCS', - loginDescription: 'Nhập thông tin đăng nhập của bạn để truy cập bảng điều khiển', + protectedAccessLabel: 'Truy cập được bảo vệ', + remoteGuardLabel: 'Lớp bảo vệ truy cập từ xa', + loading: 'Đang kiểm tra quyền truy cập bảng điều khiển…', + loginDescription: + 'Dùng tên người dùng và mật khẩu đã được cấu hình trên máy host để truy cập bảng điều khiển.', username: 'Tên người dùng', password: 'Mật khẩu', usernamePlaceholder: 'Nhập tên người dùng', @@ -2663,6 +2717,30 @@ const resources = { signIn: 'Đăng nhập', signingIn: 'Đang đăng nhập...', loginFailed: 'Đăng nhập không thành công', + lightMode: 'Sáng', + darkMode: 'Tối', + noDefaultCredentials: 'CCS không có sẵn tài khoản hay mật khẩu mặc định.', + credentialsHint: + 'Thông tin đăng nhập được tạo trên máy host bằng `ccs config auth setup`, rồi dùng tại đây.', + remoteSetupTitle: 'Truy cập từ xa cần được thiết lập trên máy host', + remoteSetupDescription: + 'Bảng điều khiển này đang được mở từ một địa chỉ không phải localhost, nhưng máy host chưa bật dashboard auth.', + incompleteSetupDescription: + 'Dashboard auth đã được bật nhưng cấu hình trên máy host vẫn chưa hoàn tất. Hãy hoàn thành cấu hình trước khi đăng nhập.', + safetyNoteRemote: + 'Quản trị từ xa sẽ tiếp tục bị khóa cho tới khi chủ máy host bật dashboard auth.', + safetyNoteLocal: + 'Nếu bạn đang ngồi ngay trên máy đó, đường localhost vẫn là cách đơn giản nhất để vào.', + safetyNoteSession: + 'Sau khi đăng nhập thành công, phiên HTTP-only sẽ chỉ có hiệu lực trên đúng máy host này.', + hostStepTitle: 'Trên máy host', + hostStepDescription: + 'Tạo hoặc bật lại thông tin đăng nhập cho dashboard, rồi mở lại trang này từ thiết bị từ xa.', + localStepTitle: 'Nếu đây là máy của bạn', + localStepDescription: + 'Hãy mở URL localhost mà `ccs config` in ra, thay vì địa chỉ LAN hoặc Tailscale.', + showPassword: 'Hiện mật khẩu', + hidePassword: 'Ẩn mật khẩu', }, commonToast: { apiKeyRequired: 'Cần có khóa API', @@ -4013,7 +4091,11 @@ const resources = { }, auth: { dashboardTitle: 'CCS Dashboard', - loginDescription: 'ダッシュボードにアクセスするには認証情報を入力してください', + protectedAccessLabel: '保護されたアクセス', + remoteGuardLabel: 'リモートアクセス保護', + loading: 'ダッシュボードのアクセス状態を確認しています…', + loginDescription: + 'ホストで設定されたユーザー名とパスワードを使ってダッシュボードにアクセスしてください。', username: 'ユーザー名', password: 'パスワード', usernamePlaceholder: 'ユーザー名を入力', @@ -4021,6 +4103,29 @@ const resources = { signIn: 'サインイン', signingIn: 'サインイン中...', loginFailed: 'ログインに失敗しました', + lightMode: 'ライト', + darkMode: 'ダーク', + noDefaultCredentials: 'CCS にデフォルトの認証情報はありません。', + credentialsHint: + '認証情報はホスト側で `ccs config auth setup` を実行して作成し、ここで使用します。', + remoteSetupTitle: 'リモートアクセスにはホスト側の設定が必要です', + remoteSetupDescription: + 'このダッシュボードはローカル以外のアドレスから開かれていますが、ホストでダッシュボード認証がまだ有効になっていません。', + incompleteSetupDescription: + 'ダッシュボード認証は有効ですが、ホスト側の設定が未完了です。サインイン前に設定を完了してください。', + safetyNoteRemote: + 'ホスト管理者がダッシュボード認証を有効にするまで、リモート管理はロックされたままです。', + safetyNoteLocal: '同じマシン上にいる場合は、localhost の URL を使うのが最も簡単です。', + safetyNoteSession: + 'サインインに成功すると、このホストに限定された HTTP-only セッションが作成されます。', + hostStepTitle: 'ホスト側で行うこと', + hostStepDescription: + 'ダッシュボード認証情報を作成または再有効化してから、このページをリモート端末で開き直してください。', + localStepTitle: 'もしこのマシンを使っているなら', + localStepDescription: + '`ccs config` が表示する localhost の URL を使い、LAN や Tailscale のアドレスは避けてください。', + showPassword: 'パスワードを表示', + hidePassword: 'パスワードを隠す', }, commonToast: { apiKeyRequired: 'API キーは必須です', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index d1df019a..0401b405 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -680,8 +680,7 @@ export const MODEL_CATALOGS: Record = { }, }; -export function findCatalogModel(provider: string, modelId: string) { - const catalog = MODEL_CATALOGS[provider.toLowerCase()]; +function findCatalogModelInCatalog(catalog: ProviderCatalog | undefined, modelId: string) { if (!catalog) return undefined; const normalizedModelId = normalizeModelId(modelId); @@ -711,6 +710,91 @@ export function findCatalogModel(provider: string, modelId: string) { .sort((left, right) => compareGeminiVersions(right.info.version, left.info.version))[0]?.model; } +function normalizeCatalogTier(tier: unknown): ModelEntry['tier'] { + if (tier === 'free') return 'free'; + if (typeof tier === 'string' && tier.trim().length > 0) return 'paid'; + return undefined; +} + +export function buildUiCatalog( + provider: string, + liveCatalog: ProviderCatalog | undefined +): ProviderCatalog | undefined { + const staticCatalog = MODEL_CATALOGS[provider.toLowerCase()]; + if (!liveCatalog || liveCatalog.models.length === 0) { + return staticCatalog; + } + + const availableModels = liveCatalog.models.map((model) => ({ + id: model.id, + owned_by: liveCatalog.provider, + })); + + const models = liveCatalog.models.map((model) => { + const staticModel = findCatalogModelInCatalog(staticCatalog, model.id); + return { + ...model, + name: model.name || staticModel?.name || model.id, + tier: staticModel?.tier ?? normalizeCatalogTier(model.tier), + description: model.description ?? staticModel?.description, + broken: staticModel?.broken, + issueUrl: staticModel?.issueUrl, + deprecated: staticModel?.deprecated, + deprecationReason: staticModel?.deprecationReason, + extendedContext: model.extendedContext ?? staticModel?.extendedContext, + presetMapping: staticModel?.presetMapping, + }; + }); + + const fallbackDefaultModel = staticCatalog?.defaultModel + ? resolveCatalogModelId(staticCatalog.defaultModel, availableModels) + : undefined; + const hasFallbackDefaultModel = + typeof fallbackDefaultModel === 'string' && + availableModels.some( + (model) => normalizeModelId(model.id) === normalizeModelId(fallbackDefaultModel) + ); + + return { + provider: liveCatalog.provider, + displayName: liveCatalog.displayName || staticCatalog?.displayName || provider, + defaultModel: hasFallbackDefaultModel ? fallbackDefaultModel : liveCatalog.defaultModel, + models, + }; +} + +export function buildUiCatalogs( + liveCatalogs: Partial> | undefined +): Partial> { + const catalogs: Partial> = {}; + const providers = new Set([ + ...Object.keys(MODEL_CATALOGS), + ...Object.keys(liveCatalogs ?? {}), + ]); + + for (const provider of providers) { + const catalog = buildUiCatalog(provider, liveCatalogs?.[provider]); + if (catalog) { + catalogs[provider] = catalog; + } + } + + return catalogs; +} + +export function findCatalogModel( + provider: string, + modelId: string, + catalogOverride?: ProviderCatalog +) { + const overrideMatch = findCatalogModelInCatalog(catalogOverride, modelId); + if (overrideMatch) { + return overrideMatch; + } + + return findCatalogModelInCatalog(MODEL_CATALOGS[provider.toLowerCase()], modelId); +} + export function resolveCatalogModelId( modelId: string, availableModels: CatalogAvailableModel[] = [] @@ -773,6 +857,10 @@ export function getResolvedCatalogModels( }); } -export function supportsExtendedContext(provider: string, modelId: string): boolean { - return findCatalogModel(provider, modelId)?.extendedContext === true; +export function supportsExtendedContext( + provider: string, + modelId: string, + catalogOverride?: ProviderCatalog +): boolean { + return findCatalogModel(provider, modelId, catalogOverride)?.extendedContext === true; } diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index dec9ba6a..04adc14c 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -3,7 +3,9 @@ * Shared functions for applying default presets to provider settings */ +import type { CliproxyProviderCatalog } from './api-client'; import { MODEL_CATALOGS } from './model-catalogs'; +import { buildUiCatalogs } from './model-catalogs'; import { CLIPROXY_DEFAULT_PORT } from './default-ports'; export { CLIPROXY_DEFAULT_PORT } from './default-ports'; @@ -25,6 +27,26 @@ async function fetchEffectiveApiKey(): Promise { } } +async function fetchProviderCatalog(provider: string, catalog?: CliproxyProviderCatalog) { + if (catalog) { + return catalog; + } + + try { + const response = await fetch('/api/cliproxy/catalog'); + if (!response.ok) { + return MODEL_CATALOGS[provider]; + } + + const data = (await response.json()) as { + catalogs?: Partial>; + }; + return buildUiCatalogs(data.catalogs)[provider] ?? MODEL_CATALOGS[provider]; + } catch { + return MODEL_CATALOGS[provider]; + } +} + /** * Apply default preset for a provider to its settings * Uses the catalog default model's preset mapping or falls back to using defaultModel for all tiers @@ -35,18 +57,20 @@ async function fetchEffectiveApiKey(): Promise { */ export async function applyDefaultPreset( provider: string, - port?: number + port?: number, + catalog?: CliproxyProviderCatalog ): Promise<{ success: boolean; presetName?: string }> { - const catalog = MODEL_CATALOGS[provider]; - if (!catalog) return { success: false }; + const resolvedCatalog = await fetchProviderCatalog(provider, catalog); + if (!resolvedCatalog) return { success: false }; const defaultModelEntry = - catalog.models.find((model) => model.id === catalog.defaultModel) || catalog.models[0]; + resolvedCatalog.models.find((model) => model.id === resolvedCatalog.defaultModel) || + resolvedCatalog.models[0]; const mapping = defaultModelEntry?.presetMapping || { - default: catalog.defaultModel, - opus: catalog.defaultModel, - sonnet: catalog.defaultModel, - haiku: catalog.defaultModel, + default: resolvedCatalog.defaultModel, + opus: resolvedCatalog.defaultModel, + sonnet: resolvedCatalog.defaultModel, + haiku: resolvedCatalog.defaultModel, }; // Fetch effective API key (respects user customization) @@ -72,7 +96,7 @@ export async function applyDefaultPreset( }); return { success: res.ok, - presetName: defaultModelEntry?.name || catalog.defaultModel, + presetName: defaultModelEntry?.name || resolvedCatalog.defaultModel, }; } catch { return { success: false }; diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index b1cbf0d2..516b85e8 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -234,8 +234,11 @@ export function getDeviceCodeProviderInstruction(provider: unknown): string { } /** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ -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]; +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'; export type KiroFlowType = 'authorization_code' | 'device_code'; export type KiroStartEndpoint = 'start' | 'start-url'; @@ -280,6 +283,13 @@ export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [ flowType: 'authorization_code', startEndpoint: 'start-url', }, + { + id: 'idc', + label: 'AWS Identity Center (IDC)', + description: 'Use your organization start URL with auth code or device flow.', + flowType: 'authorization_code', + startEndpoint: 'start', + }, ]; export function isKiroAuthMethod(value: string): value is KiroAuthMethod { @@ -292,7 +302,40 @@ export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod { return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD; } +export function isKiroIDCFlow(value: string): value is KiroIDCFlow { + return KIRO_IDC_FLOWS.includes(value as KiroIDCFlow); +} + +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 getKiroAuthMethodOption(method: KiroAuthMethod): KiroAuthMethodOption { const option = KIRO_AUTH_METHOD_OPTIONS.find((candidate) => candidate.id === method); return option || KIRO_AUTH_METHOD_OPTIONS[0]; } + +export function getKiroEffectiveFlowType( + method: KiroAuthMethod, + idcFlow: KiroIDCFlow = DEFAULT_KIRO_IDC_FLOW +): KiroFlowType { + if (method === 'aws') { + return 'device_code'; + } + + if (method === 'idc') { + return normalizeKiroIDCFlow(idcFlow) === 'device' ? 'device_code' : 'authorization_code'; + } + + return 'authorization_code'; +} + +export function getKiroEffectiveStartEndpoint(method: KiroAuthMethod): KiroStartEndpoint { + return method === 'google' || method === 'github' ? 'start-url' : 'start'; +} + +export function isKiroSocialAuthMethod(method: KiroAuthMethod): boolean { + return method === 'google' || method === 'github'; +} diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index ce4e2a05..ee21191f 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -20,6 +20,7 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; import { useCliproxy, useCliproxyAuth, + useCliproxyCatalog, useCliproxyUpdateCheck, useSetDefaultAccount, useRemoveAccount, @@ -31,7 +32,7 @@ import { useDeleteVariant, } from '@/hooks/use-cliproxy'; import type { AuthStatus, Variant } from '@/lib/api-client'; -import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { buildUiCatalogs } from '@/lib/model-catalogs'; import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; @@ -209,6 +210,7 @@ export function CliproxyPage() { const queryClient = useQueryClient(); const { data: authData, isLoading: authLoading } = useCliproxyAuth(); const { data: variantsData, isFetching } = useCliproxy(); + const { data: catalogData } = useCliproxyCatalog(); const { data: updateCheck } = useCliproxyUpdateCheck(); const setDefaultMutation = useSetDefaultAccount(); const removeMutation = useRemoveAccount(); @@ -261,6 +263,8 @@ export function CliproxyPage() { const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); + const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); + const fetchedCatalogsReady = Boolean(catalogData); // Wrapper to persist provider selection to localStorage const setSelectedProvider = (provider: string | null) => { @@ -300,6 +304,7 @@ export function CliproxyPage() { const handleRefresh = () => { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); }; const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => { @@ -443,11 +448,7 @@ export function CliproxyPage() {
{/* Right Panel */} -
- {showAccountSafetyWarning && ( - - )} - +
{selectedVariantData && parentAuthForVariant ? ( <> + ) : undefined + } onAddAccount={() => setAddAccountProvider({ provider: selectedVariantData.provider, @@ -505,8 +511,13 @@ export function CliproxyPage() { provider={selectedStatus.provider} displayName={selectedStatus.displayName} authStatus={selectedStatus} - catalog={MODEL_CATALOGS[selectedStatus.provider]} + catalog={catalogs[selectedStatus.provider]} isRemoteMode={isRemoteMode} + topNotice={ + showAccountSafetyWarning ? ( + + ) : undefined + } onAddAccount={() => setAddAccountProvider({ provider: selectedStatus.provider, @@ -551,6 +562,11 @@ export function CliproxyPage() { onClose={() => setAddAccountProvider(null)} provider={addAccountProvider?.provider || ''} displayName={addAccountProvider?.displayName || ''} + catalog={ + fetchedCatalogsReady && addAccountProvider?.provider + ? catalogs[addAccountProvider.provider] + : undefined + } isFirstAccount={addAccountProvider?.isFirstAccount || false} />
diff --git a/ui/src/pages/home.tsx b/ui/src/pages/home.tsx index a60041ae..42f2f231 100644 --- a/ui/src/pages/home.tsx +++ b/ui/src/pages/home.tsx @@ -3,8 +3,9 @@ import { HeroSection } from '@/components/layout/hero-section'; import { AuthMonitor } from '@/components/monitoring/auth-monitor'; import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; -import { Key, Zap, Users, Activity, AlertTriangle } from 'lucide-react'; +import { Key, Zap, Users, Activity, AlertTriangle, ArrowRight, ScrollText } from 'lucide-react'; import { useOverview } from '@/hooks/use-overview'; import { useSharedSummary } from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; @@ -179,7 +180,27 @@ export function HomePage() { {/* Auth Monitor */} - {/* Error Logs Monitor - shows only when there are errors */} +
+
+
+
+ +
+
+

Logs moved to a dedicated workspace

+

+ Use the unified logs page for source-level filtering, structured entry inspection, + and retention policy edits without crowding the home dashboard. +

+
+
+ +
+
+
); diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index 87fb1e37..d9636034 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -10,32 +10,68 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { AlertCircle, Loader2, Lock } from 'lucide-react'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { useTheme } from '@/hooks/use-theme'; +import { cn } from '@/lib/utils'; +import { + AlertCircle, + Command, + Eye, + EyeOff, + Loader2, + Lock, + MonitorSmartphone, + Moon, + ShieldCheck, + Sun, +} from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { useTranslation } from 'react-i18next'; export function LoginPage() { const { t } = useTranslation(); + const { isDark, setTheme } = useTheme(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); - const { login, authRequired, isAuthenticated } = useAuth(); + const { login, authRequired, isAuthenticated, loading, accessMode, authEnabled } = useAuth(); const navigate = useNavigate(); const location = useLocation(); + const isSetupState = accessMode === 'setup'; + const heroLabel = isSetupState ? t('auth.remoteGuardLabel') : t('auth.protectedAccessLabel'); + const heroDescription = isSetupState + ? authEnabled + ? t('auth.incompleteSetupDescription') + : t('auth.remoteSetupDescription') + : t('auth.loginDescription'); // Get redirect destination (default to home) const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'; // Redirect if already authenticated or auth not required (via useEffect to avoid render side effects) useEffect(() => { + if (loading) { + return; + } if (isAuthenticated || !authRequired) { navigate(from, { replace: true }); } - }, [isAuthenticated, authRequired, navigate, from]); + }, [isAuthenticated, authRequired, loading, navigate, from]); // Show nothing while redirecting + if (loading) { + return ( +
+
+ + {t('auth.loading')} +
+
+ ); + } + if (isAuthenticated || !authRequired) { return null; } @@ -43,7 +79,7 @@ export function LoginPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - setLoading(true); + setSubmitting(true); try { await login(username, password); @@ -51,67 +87,226 @@ export function LoginPage() { } catch (err) { setError(err instanceof Error ? err.message : t('auth.loginFailed')); } finally { - setLoading(false); + setSubmitting(false); } }; return ( -
- - -
- -
- {t('auth.dashboardTitle')} - {t('auth.loginDescription')} -
- -
- {error && ( - - - {error} - - )} -
- - setUsername(e.target.value)} - placeholder={t('auth.usernamePlaceholder')} - autoComplete="username" - disabled={loading} - required - /> +
+
+
+ +
+
+
+
+
+ + + {heroLabel} + + +
+ + +
+
+ +
+

+ {isSetupState ? t('auth.remoteSetupTitle') : t('auth.dashboardTitle')} +

+

+ {heroDescription} +

+
-
- - setPassword(e.target.value)} - placeholder={t('auth.passwordPlaceholder')} - autoComplete="current-password" - disabled={loading} - required - /> + +
+
+ +

{t('auth.safetyNoteRemote')}

+
+
+ +

{t('auth.safetyNoteLocal')}

+
+
+ +

{t('auth.safetyNoteSession')}

+
-
+ + + +
+ {isSetupState ? ( + + ) : ( + + )} +
+
+ + {isSetupState ? t('auth.remoteSetupTitle') : t('auth.dashboardTitle')} + + + {isSetupState ? heroDescription : t('auth.credentialsHint')} + +
+
+ + + {isSetupState ? ( <> - - {t('auth.signingIn')} + + + {t('auth.noDefaultCredentials')} + {t('auth.credentialsHint')} + + +
+
+
+ + {t('auth.hostStepTitle')} +
+

+ {t('auth.hostStepDescription')} +

+ + ccs config auth setup + +
+ +
+
+ + {t('auth.localStepTitle')} +
+

+ {t('auth.localStepDescription')} +

+
+
) : ( - t('auth.signIn') + + {error && ( + + + {error} + + )} + + + + {t('auth.noDefaultCredentials')} + {t('auth.credentialsHint')} + + +
+ + setUsername(e.target.value)} + placeholder={t('auth.usernamePlaceholder')} + autoComplete="username" + className="h-12 rounded-2xl border-border/80 bg-background/85 px-4 text-base md:text-base" + disabled={submitting} + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder={t('auth.passwordPlaceholder')} + autoComplete="current-password" + className="h-12 rounded-2xl border-border/80 bg-background/85 px-4 pr-14 text-base md:text-base" + disabled={submitting} + required + /> + +
+
+ + + )} - - -
-
+ + +
+
); } diff --git a/ui/src/pages/logs.tsx b/ui/src/pages/logs.tsx new file mode 100644 index 00000000..e96d1ab9 --- /dev/null +++ b/ui/src/pages/logs.tsx @@ -0,0 +1,484 @@ +import { useEffect, useState } from 'react'; +import { + ArrowRight, + ChevronLeft, + ChevronRight, + RefreshCw, + ScrollText, + TimerReset, +} from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { cn } from '@/lib/utils'; +import { ErrorLogsMonitor } from '@/components/error-logs-monitor'; +import { LogsConfigCard } from '@/components/logs/logs-config-card'; +import { LogsDetailPanel } from '@/components/logs/logs-detail-panel'; +import { LogsEntryList } from '@/components/logs/logs-entry-list'; +import { LogsFilters } from '@/components/logs/logs-filters'; +import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton'; +import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs'; + +const DESKTOP_LOGS_BREAKPOINT = 1200; +const LEFT_PANEL_WIDTH = 336; +const RIGHT_PANEL_WIDTH = 368; +const COLLAPSED_PANEL_WIDTH = 52; + +function CollapsedPaneToggle({ + side, + label, + onExpand, +}: { + side: 'left' | 'right'; + label: string; + onExpand: () => void; +}) { + return ( +
+ + + {label} + +
+ ); +} + +export function LogsPage() { + const workspace = useLogsWorkspace(); + const updateConfig = useUpdateLogsConfig(); + const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []); + const [isDesktopLayout, setIsDesktopLayout] = useState(() => + typeof window !== 'undefined' ? window.innerWidth >= DESKTOP_LOGS_BREAKPOINT : false + ); + const [isFiltersCollapsed, setIsFiltersCollapsed] = useState(false); + const [isDetailsCollapsed, setIsDetailsCollapsed] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia(`(min-width: ${DESKTOP_LOGS_BREAKPOINT}px)`); + const syncLayout = () => { + setIsDesktopLayout(window.innerWidth >= DESKTOP_LOGS_BREAKPOINT); + }; + + syncLayout(); + mediaQuery.addEventListener('change', syncLayout); + return () => mediaQuery.removeEventListener('change', syncLayout); + }, []); + + if (workspace.isInitialLoading) { + return ; + } + + const config = workspace.configQuery.data; + if (!config) { + return null; + } + + return ( +
+
+ +
+
+
+
+ +
+
+
+ + Operational + +

+ Log Operations Center +

+
+

+ CCS.TOC.LOGS.STREAM.v3 +

+
+
+ +
+ +
+
+
+
+ + Redaction + + + {config.redact ? 'Enforced' : 'Standby'} + +
+
+
+
+ +
+
+ + Retention + + + {config.retain_days}D / {config.rotate_mb}MB + +
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+ + + Telemetry Stream + + + Legacy Errors + + + +
+
+ + + + + + Connected + +
+ + {workspace.entriesQuery.data?.length ?? 0} captured + +
+
+ + + {isDesktopLayout ? ( +
+
+ {isFiltersCollapsed ? ( + setIsFiltersCollapsed(false)} + /> + ) : ( +
+
+
+

+ Filters +

+

+ Search, source, and retention controls +

+
+ +
+ + +
+ + void Promise.all([ + workspace.sourcesQuery.refetch(), + workspace.entriesQuery.refetch(), + ]) + } + isRefreshing={ + workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching + } + /> +
+ updateConfig.mutate(payload)} + isPending={updateConfig.isPending} + /> +
+
+
+
+ )} +
+ +
+ +
+ +
+ {isDetailsCollapsed ? ( + setIsDetailsCollapsed(false)} + /> + ) : ( +
+
+
+

+ Details +

+

+ Selected entry context and raw payload +

+
+ +
+
+ +
+
+ )} +
+
+ ) : ( +
+
+
+ + void Promise.all([ + workspace.sourcesQuery.refetch(), + workspace.entriesQuery.refetch(), + ]) + } + isRefreshing={ + workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching + } + /> + updateConfig.mutate(payload)} + isPending={updateConfig.isPending} + /> +
+
+ +
+ +
+ +
+ +
+
+ )} +
+ + +
+
+
+ + + +
+
+
+ +
+
+

+ Legacy Diagnostic Node +

+

+ CCS-MATRIX-FAILURE-MONITOR +

+
+
+
+ Mode: Historical +
+
+ +
+

+ CLIProxy Failure Analysis +

+

+ Maintain oversight of legacy request failures while the unified stream + consolidates system-wide telemetry. This view provides direct access to the + historical failure matrix for deep-field debugging. +

+
+ +
+ + +
+ +
+
+
+ + Realtime Monitoring Deck + +
+ +
+
+ + +
+
+ ); +} diff --git a/ui/tests/unit/components/auth/require-auth.test.tsx b/ui/tests/unit/components/auth/require-auth.test.tsx new file mode 100644 index 00000000..9707c57e --- /dev/null +++ b/ui/tests/unit/components/auth/require-auth.test.tsx @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { RequireAuth } from '@/components/auth/require-auth'; + +const { useAuthMock } = vi.hoisted(() => ({ + useAuthMock: vi.fn(), +})); + +vi.mock('@/contexts/auth-context', () => ({ + useAuth: useAuthMock, +})); + +function renderGuard(initialPath = '/') { + return render( + + + login page
} /> + }> + dashboard page
} /> + + + + ); +} + +describe('RequireAuth', () => { + beforeEach(() => { + useAuthMock.mockReset(); + }); + + it('allows remote readonly sessions through without redirecting to login', () => { + useAuthMock.mockReturnValue({ + authRequired: false, + isAuthenticated: false, + username: null, + loading: false, + authEnabled: false, + authConfigured: false, + isLocalAccess: false, + accessMode: 'open', + login: vi.fn(), + logout: vi.fn(), + }); + + renderGuard(); + + expect(screen.getByText('dashboard page')).toBeVisible(); + expect(screen.queryByText('login page')).toBeNull(); + }); + + it('redirects unauthenticated users when dashboard auth is enabled', () => { + useAuthMock.mockReturnValue({ + authRequired: true, + isAuthenticated: false, + username: null, + loading: false, + authEnabled: true, + authConfigured: true, + isLocalAccess: false, + accessMode: 'login', + login: vi.fn(), + logout: vi.fn(), + }); + + renderGuard(); + + expect(screen.getByText('login page')).toBeVisible(); + expect(screen.queryByText('dashboard page')).toBeNull(); + }); +}); diff --git a/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx new file mode 100644 index 00000000..f22a8aae --- /dev/null +++ b/ui/tests/unit/components/cliproxy/routing-guidance-card.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../setup/test-utils'; +import { RoutingGuidanceCard } from '@/components/cliproxy/routing-guidance-card'; + +describe('RoutingGuidanceCard', () => { + it('shows the current strategy and applies an explicit change', async () => { + const onApply = vi.fn(); + + render( + + ); + + expect(screen.getByText('Routing strategy')).toBeInTheDocument(); + expect(screen.getAllByText('round-robin').length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: /fill first/i })); + fireEvent.click(screen.getByRole('button', { name: /use fill-first/i })); + + expect(onApply).toHaveBeenCalledWith('fill-first'); + }); + + it('shows the error state and disables apply', () => { + render( + undefined} + /> + ); + + expect(screen.getByText('Remote CLIProxy is not reachable')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /use round-robin/i })).toBeDisabled(); + }); +}); diff --git a/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx b/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx new file mode 100644 index 00000000..abaa5de8 --- /dev/null +++ b/ui/tests/unit/components/setup/quick-setup-wizard.test.tsx @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +const hookState = vi.hoisted(() => ({ + catalogData: undefined as + | { + catalogs: Record< + string, + { + provider: string; + displayName: string; + defaultModel: string; + models: Array<{ id: string; name: string }>; + } + >; + } + | undefined, +})); + +const applyDefaultPresetMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxyAuth: () => ({ + data: { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: false, + accounts: [], + }, + ], + }, + refetch: vi.fn(), + }), + useCliproxyCatalog: () => ({ + data: hookState.catalogData, + }), + useCreateVariant: () => ({ + isPending: false, + mutateAsync: vi.fn(), + }), + useStartAuth: () => ({ + isPending: false, + mutate: (_args: unknown, options?: { onSuccess?: (data: Record) => void }) => { + options?.onSuccess?.({}); + }, + }), + useCancelAuth: () => ({ + mutate: vi.fn(), + }), +})); + +vi.mock('@/lib/preset-utils', () => ({ + applyDefaultPreset: applyDefaultPresetMock, +})); + +vi.mock('@/components/setup/wizard/steps/provider-step', () => ({ + ProviderStep: ({ onSelect }: { onSelect: (providerId: string) => void }) => ( + + ), +})); + +vi.mock('@/components/setup/wizard/steps/auth-step', () => ({ + AuthStep: ({ onStartAuth }: { onStartAuth: () => void }) => ( + + ), +})); + +vi.mock('@/components/setup/wizard/steps/account-step', () => ({ + AccountStep: () =>
account-step
, +})); + +vi.mock('@/components/setup/wizard/steps/variant-step', () => ({ + VariantStep: () =>
variant-step
, +})); + +vi.mock('@/components/setup/wizard/steps/success-step', () => ({ + SuccessStep: () =>
success-step
, +})); + +import { QuickSetupWizard } from '@/components/setup/wizard'; + +describe('QuickSetupWizard preset catalog reuse', () => { + beforeEach(() => { + hookState.catalogData = undefined; + applyDefaultPresetMock.mockReset(); + applyDefaultPresetMock.mockResolvedValue({ success: true, presetName: 'Gemini Pro' }); + }); + + it('does not pass a synthesized static catalog before the catalog query resolves', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'choose-gemini' })); + await userEvent.click(screen.getByRole('button', { name: 'start-auth' })); + + await waitFor(() => + expect(applyDefaultPresetMock).toHaveBeenCalledWith('gemini', undefined, undefined) + ); + }); + + it('passes the fetched provider catalog once the query has resolved', async () => { + hookState.catalogData = { + catalogs: { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-3.9-pro-preview', + models: [{ id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' }], + }, + }, + }; + + render(); + + await userEvent.click(screen.getByRole('button', { name: 'choose-gemini' })); + await userEvent.click(screen.getByRole('button', { name: 'start-auth' })); + + await waitFor(() => + expect(applyDefaultPresetMock).toHaveBeenCalledWith( + 'gemini', + undefined, + expect.objectContaining({ + defaultModel: 'gemini-3.9-pro-preview', + }) + ) + ); + }); +}); diff --git a/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx b/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx new file mode 100644 index 00000000..8c27d933 --- /dev/null +++ b/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { LocalhostDisclaimer } from '@/components/shared/localhost-disclaimer'; + +const { useAuthMock } = vi.hoisted(() => ({ + useAuthMock: vi.fn(), +})); + +vi.mock('@/contexts/auth-context', () => ({ + useAuth: useAuthMock, +})); + +describe('LocalhostDisclaimer', () => { + beforeEach(() => { + useAuthMock.mockReset(); + }); + + it('shows the local safety copy for loopback sessions', () => { + useAuthMock.mockReturnValue({ + authEnabled: false, + authConfigured: false, + isLocalAccess: true, + loading: false, + }); + + render(); + + expect( + screen.getByText('This dashboard runs locally. All data stays on your machine.') + ).toBeVisible(); + }); + + it('shows the remote read-only copy when auth is disabled for remote access', () => { + useAuthMock.mockReturnValue({ + authEnabled: false, + authConfigured: false, + isLocalAccess: false, + loading: false, + }); + + render(); + + expect( + screen.getByText( + 'Remote dashboard access is read-only until you run ccs config auth setup on the host.' + ) + ).toBeVisible(); + expect(screen.queryByLabelText('Dismiss disclaimer')).toBeNull(); + }); + + it('shows the re-enable message when host credentials already exist', () => { + useAuthMock.mockReturnValue({ + authEnabled: false, + authConfigured: true, + isLocalAccess: false, + loading: false, + }); + + render(); + + expect( + screen.getByText( + 'Remote dashboard access is read-only because dashboard auth is currently disabled on the host. Re-enable dashboard auth on the host to unlock remote changes.' + ) + ).toBeVisible(); + expect(screen.queryByLabelText('Dismiss disclaimer')).toBeNull(); + }); +}); diff --git a/ui/tests/unit/components/ui/searchable-select.test.tsx b/ui/tests/unit/components/ui/searchable-select.test.tsx index 4eafc880..ead6a743 100644 --- a/ui/tests/unit/components/ui/searchable-select.test.tsx +++ b/ui/tests/unit/components/ui/searchable-select.test.tsx @@ -122,6 +122,16 @@ describe('SearchableSelect', () => { }); }); + it('renders options inside a native overflow container so wheel scrolling works', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Select model' })); + + const scrollContainer = await screen.findByTestId('searchable-select-scroll-container'); + expect(scrollContainer).toHaveClass('max-h-72'); + expect(scrollContainer).toHaveClass('overflow-y-auto'); + }); + it('opens from the trigger with arrow keys', async () => { render(); diff --git a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx index d90c4593..b37b8533 100644 --- a/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx +++ b/ui/tests/unit/hooks/use-cliproxy-auth-flow.test.tsx @@ -221,6 +221,96 @@ describe('useCliproxyAuthFlow', () => { expect(toast.success).toHaveBeenCalledWith('codex authentication successful'); }); + it('promotes a state-first auth bootstrap into an immediate auth URL without waiting for the first interval', async () => { + let pollCount = 0; + + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/start-url')) { + return Promise.resolve( + createJsonResponse({ + success: true, + authUrl: null, + state: 'state-kiro-social', + }) + ); + } + + if (url.includes('/status?state=state-kiro-social')) { + pollCount += 1; + return Promise.resolve( + createJsonResponse({ + status: 'auth_url', + url: 'https://auth.example/kiro-social', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('kiro', { startEndpoint: 'start-url', kiroMethod: 'google' }); + }); + + expect(result.current.authUrl).toBe('https://auth.example/kiro-social'); + expect(result.current.oauthState).toBe('state-kiro-social'); + expect(result.current.isAuthenticating).toBe(true); + expect(pollCount).toBe(1); + }); + + it('forwards Kiro IDC options to the backend start endpoint payload', async () => { + let requestBody: Record | null = null; + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + + if (url.includes('/start')) { + requestBody = JSON.parse(String(init?.body)) as Record; + return createJsonResponse({ + success: true, + account: { + id: 'kiro-idc-account', + provider: 'kiro', + }, + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper }); + + await act(async () => { + await result.current.startAuth('kiro', { + startEndpoint: 'start', + flowType: 'authorization_code', + kiroMethod: 'idc', + kiroIDCStartUrl: 'https://d-123.awsapps.com/start', + kiroIDCRegion: 'ca-central-1', + kiroIDCFlow: 'authcode', + }); + }); + + expect(requestBody).toEqual({ + nickname: undefined, + kiroMethod: 'idc', + kiroIDCStartUrl: 'https://d-123.awsapps.com/start', + kiroIDCRegion: 'ca-central-1', + kiroIDCFlow: 'authcode', + riskAcknowledgement: undefined, + }); + }); + it('treats callback responses without an account as failures', async () => { vi.stubGlobal( 'fetch', diff --git a/ui/tests/unit/pages/cliproxy-page.test.tsx b/ui/tests/unit/pages/cliproxy-page.test.tsx new file mode 100644 index 00000000..bd651bfb --- /dev/null +++ b/ui/tests/unit/pages/cliproxy-page.test.tsx @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +const hookState = vi.hoisted(() => ({ + catalogData: undefined as + | { + catalogs: Record< + string, + { + provider: string; + displayName: string; + defaultModel: string; + models: Array<{ id: string; name: string }>; + } + >; + } + | undefined, +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useCliproxy: () => ({ + data: { variants: [] }, + isFetching: false, + }), + useCliproxyAuth: () => ({ + data: { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: true, + accounts: [{ id: 'acct-1', provider: 'gemini' }], + }, + ], + source: 'local', + }, + isLoading: false, + }), + useCliproxyCatalog: () => ({ + data: hookState.catalogData, + }), + useCliproxyUpdateCheck: () => ({ + data: undefined, + }), + useSetDefaultAccount: () => ({ mutate: vi.fn(), isPending: false }), + useRemoveAccount: () => ({ mutate: vi.fn(), isPending: false }), + usePauseAccount: () => ({ mutate: vi.fn(), isPending: false }), + useResumeAccount: () => ({ mutate: vi.fn(), isPending: false }), + useSoloAccount: () => ({ mutate: vi.fn(), isPending: false }), + useBulkPauseAccounts: () => ({ mutate: vi.fn(), isPending: false }), + useBulkResumeAccounts: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteVariant: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/components/quick-setup-wizard', () => ({ + QuickSetupWizard: () => null, +})); + +vi.mock('@/components/monitoring/proxy-status-widget', () => ({ + ProxyStatusWidget: () => null, +})); + +vi.mock('@/components/account/account-safety-warning-card', () => ({ + AccountSafetyWarningCard: () => null, +})); + +vi.mock('@/components/cliproxy/provider-logo', () => ({ + ProviderLogo: () =>
provider-logo
, +})); + +vi.mock('@/components/cliproxy/provider-editor', () => ({ + ProviderEditor: ({ onAddAccount }: { onAddAccount: () => void }) => ( + + ), +})); + +vi.mock('@/components/account/add-account-dialog', () => ({ + AddAccountDialog: ({ open, catalog }: { open: boolean; catalog?: { defaultModel?: string } }) => + open ? ( +
+ ) : null, +})); + +import { CliproxyPage } from '@/pages/cliproxy'; + +describe('CliproxyPage add-account catalog gating', () => { + beforeEach(() => { + hookState.catalogData = undefined; + }); + + it('does not pass a static fallback catalog before the catalog query resolves', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: 'open-add-account' })); + + expect(screen.getByTestId('add-account-dialog')).toHaveAttribute('data-catalog', 'missing'); + }); + + it('passes the fetched provider catalog after the catalog query resolves', async () => { + hookState.catalogData = { + catalogs: { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-3.9-pro-preview', + models: [{ id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' }], + }, + }, + }; + + render(); + + await userEvent.click(screen.getByRole('button', { name: 'open-add-account' })); + + expect(screen.getByTestId('add-account-dialog')).toHaveAttribute( + 'data-catalog', + 'gemini-3.9-pro-preview' + ); + }); +}); diff --git a/ui/tests/unit/pages/login-page.test.tsx b/ui/tests/unit/pages/login-page.test.tsx new file mode 100644 index 00000000..689e5992 --- /dev/null +++ b/ui/tests/unit/pages/login-page.test.tsx @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import i18n from '@/lib/i18n'; +import { LoginPage } from '@/pages/login'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +const { navigateMock, useAuthMock } = vi.hoisted(() => ({ + navigateMock: vi.fn(), + useAuthMock: vi.fn(), +})); + +vi.mock('@/contexts/auth-context', () => ({ + useAuth: useAuthMock, +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + + return { + ...actual, + useNavigate: () => navigateMock, + useLocation: () => ({ state: { from: { pathname: '/settings' } } }), + }; +}); + +describe('LoginPage', () => { + beforeEach(async () => { + navigateMock.mockReset(); + useAuthMock.mockReturnValue({ + authRequired: true, + isAuthenticated: false, + username: null, + loading: false, + accessMode: 'login', + authEnabled: true, + authConfigured: true, + isLocalAccess: false, + login: vi.fn(), + logout: vi.fn(), + }); + await i18n.changeLanguage('en'); + }); + + it('redirects away when dashboard auth is disabled for remote access', async () => { + useAuthMock.mockReturnValue({ + authRequired: false, + isAuthenticated: false, + username: null, + loading: false, + accessMode: 'open', + authEnabled: false, + authConfigured: false, + isLocalAccess: false, + login: vi.fn(), + logout: vi.fn(), + }); + + render(); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith('/settings', { replace: true }); + }); + expect(screen.queryByRole('heading', { name: 'Remote access needs host setup' })).toBeNull(); + }); + + it('renders the incomplete setup copy when auth is enabled without credentials', () => { + useAuthMock.mockReturnValue({ + authRequired: true, + isAuthenticated: false, + username: null, + loading: false, + accessMode: 'setup', + authEnabled: true, + authConfigured: false, + isLocalAccess: false, + login: vi.fn(), + logout: vi.fn(), + }); + + render(); + + expect( + screen.getAllByText( + 'Dashboard auth is turned on, but the host setup is incomplete. Finish the host configuration before signing in.' + ) + ).not.toHaveLength(0); + expect( + screen.getByText( + 'Create or re-enable dashboard credentials, then reopen this page from the remote device.' + ) + ).toBeVisible(); + expect(screen.queryByLabelText('Username')).not.toBeInTheDocument(); + }); + + it('toggles password visibility on the login form', async () => { + render(); + + expect(screen.getByRole('button', { name: 'Light' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Dark' })).toBeVisible(); + + const passwordInput = screen.getByLabelText('Password'); + expect(passwordInput).toHaveAttribute('type', 'password'); + + await userEvent.click(screen.getByRole('button', { name: 'Show password' })); + expect(passwordInput).toHaveAttribute('type', 'text'); + + await userEvent.click(screen.getByRole('button', { name: 'Hide password' })); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); +}); diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index e4d0c618..dbc852a2 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + buildUiCatalogs, MODEL_CATALOGS, findCatalogModel, getResolvedCatalogModels, @@ -24,6 +25,16 @@ describe('claude preset utils', () => { it('applies the default claude preset from the catalog default model mapping', async () => { const fetchMock = vi .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + catalogs: { + claude: MODEL_CATALOGS.claude, + }, + source: 'live', + cache: { synced: true, age: '0m ago' }, + }), + }) .mockResolvedValueOnce({ ok: true, json: async () => ({ apiKey: { value: 'managed-key' } }), @@ -36,7 +47,7 @@ describe('claude preset utils', () => { expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' }); - const [, requestInit] = fetchMock.mock.calls[1] ?? []; + const [, requestInit] = fetchMock.mock.calls[2] ?? []; const body = JSON.parse(String(requestInit?.body)); expect(body.settings.env).toMatchObject({ @@ -47,6 +58,62 @@ describe('claude preset utils', () => { }); }); + it('skips catalog fetch when the caller already has the provider catalog cached', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ apiKey: { value: 'managed-key' } }), + }) + .mockResolvedValueOnce({ ok: true }); + + vi.stubGlobal('fetch', fetchMock); + + const result = await applyDefaultPreset('claude', undefined, MODEL_CATALOGS.claude); + + expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/settings/auth/tokens/raw'); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/settings/claude', + expect.objectContaining({ + method: 'PUT', + }) + ); + }); + + it('builds UI catalogs from upstream provider models without requiring static dropdown edits', () => { + const liveCatalogs = { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-3.9-pro-preview', + models: [ + { id: 'gemini-3.9-pro-preview', name: 'Gemini 3.9 Pro Preview' }, + { id: 'gemini-3-9-flash-preview', name: 'Gemini 3.9 Flash Preview' }, + ], + }, + }; + + const catalogs = buildUiCatalogs(liveCatalogs); + const resolvedGeminiModels = getResolvedCatalogModels(catalogs.gemini, [ + { id: 'gemini-3.9-pro-preview', owned_by: 'google' }, + { id: 'gemini-3-9-flash-preview', owned_by: 'google' }, + ]); + + expect(catalogs.gemini?.defaultModel).toBe('gemini-3.9-pro-preview'); + expect(resolvedGeminiModels.find((model) => model.id === 'gemini-3.9-pro-preview')).toEqual( + expect.objectContaining({ + name: 'Gemini 3.9 Pro Preview', + presetMapping: expect.objectContaining({ + default: 'gemini-3.9-pro-preview', + haiku: 'gemini-3-9-flash-preview', + }), + }) + ); + }); + it('keeps Gemini presets on 3.1 Pro while resolving 3/3.1 alias variants', () => { const geminiCatalog = MODEL_CATALOGS.gemini; const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview'); diff --git a/ui/tests/unit/ui/pages/logs-page.test.tsx b/ui/tests/unit/ui/pages/logs-page.test.tsx new file mode 100644 index 00000000..32521ffa --- /dev/null +++ b/ui/tests/unit/ui/pages/logs-page.test.tsx @@ -0,0 +1,199 @@ +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LogsPage } from '@/pages/logs'; + +const fetchMock = vi.fn(); + +beforeAll(() => { + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + } + + if (!Element.prototype.setPointerCapture) { + Element.prototype.setPointerCapture = () => undefined; + } + + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => undefined; + } + + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => undefined; + } +}); + +function jsonResponse(body: unknown) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); +} + +function buildEntries(source: string) { + if (source === 'agent-runner') { + return [ + { + id: 'entry-2', + timestamp: '2026-04-07T11:10:00.000Z', + level: 'warn', + source: 'agent-runner', + event: 'task.retry', + message: 'Worker retry scheduled', + processId: 4121, + runId: 'run-2', + context: { attempt: 2, reason: 'network jitter' }, + }, + ]; + } + + return [ + { + id: 'entry-1', + timestamp: '2026-04-07T11:00:00.000Z', + level: 'error', + source: 'dashboard', + event: 'logs.bootstrap', + message: 'Boot sequence failed for dashboard logging', + processId: 25582, + runId: 'run-1', + context: { component: 'dashboard', stage: 'bootstrap' }, + }, + { + id: 'entry-2', + timestamp: '2026-04-07T11:10:00.000Z', + level: 'warn', + source: 'agent-runner', + event: 'task.retry', + message: 'Worker retry scheduled', + processId: 4121, + runId: 'run-2', + context: { attempt: 2, reason: 'network jitter' }, + }, + ]; +} + +function installFetchMock() { + fetchMock.mockImplementation((input) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const parsed = new URL(url, 'http://localhost'); + + if (parsed.pathname === '/api/logs/config') { + return jsonResponse({ + logging: { + enabled: true, + level: 'info', + rotate_mb: 10, + retain_days: 7, + redact: true, + live_buffer_size: 250, + }, + }); + } + + if (parsed.pathname === '/api/logs/sources') { + return jsonResponse({ + sources: [ + { + source: 'dashboard', + label: 'Dashboard UI', + kind: 'native', + count: 18, + lastTimestamp: '2026-04-07T11:00:00.000Z', + }, + { + source: 'agent-runner', + label: 'Agent Runner', + kind: 'legacy', + count: 9, + lastTimestamp: '2026-04-07T11:10:00.000Z', + }, + ], + }); + } + + if (parsed.pathname === '/api/logs/entries') { + const source = parsed.searchParams.get('source') ?? 'all'; + const search = parsed.searchParams.get('search'); + const entries = buildEntries(source).filter((entry) => + search ? entry.message.toLowerCase().includes(search.toLowerCase()) : true + ); + return jsonResponse({ entries }); + } + + return Promise.reject(new Error(`Unhandled request: ${url}`)); + }); +} + +describe('LogsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = fetchMock; + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: 900, + }); + }); + + it('shows the loading skeleton while the initial queries are pending', () => { + fetchMock.mockImplementation(() => new Promise(() => {})); + + render(); + + expect(screen.getByLabelText('Loading logs workspace')).toBeInTheDocument(); + }); + + it('filters by source and search query', async () => { + installFetchMock(); + + render(); + + expect( + (await screen.findAllByText('Boot sequence failed for dashboard logging')).length + ).toBeGreaterThan(0); + + await userEvent.click(screen.getByRole('button', { name: 'Agent Runner' })); + + expect((await screen.findAllByText('Worker retry scheduled')).length).toBeGreaterThan(0); + await waitFor(() => { + expect(screen.queryAllByText('Boot sequence failed for dashboard logging')).toHaveLength(0); + }); + + await userEvent.clear(screen.getByLabelText('Search')); + await userEvent.type(screen.getByLabelText('Search'), 'retry'); + + await waitFor(() => { + expect( + fetchMock.mock.calls.some((call) => + String(call[0]).includes('/api/logs/entries?source=agent-runner') + ) + ).toBe(true); + expect(fetchMock.mock.calls.some((call) => String(call[0]).includes('search=retry'))).toBe( + true + ); + }); + }); + + it('shows the selected entry detail and raw context', async () => { + installFetchMock(); + + render(); + + expect( + (await screen.findAllByText('Boot sequence failed for dashboard logging')).length + ).toBeGreaterThan(0); + + await userEvent.click(screen.getByRole('button', { name: /Worker retry scheduled/i })); + + expect((await screen.findAllByText('task.retry')).length).toBeGreaterThan(0); + expect(screen.getAllByText('4121').length).toBeGreaterThan(0); + + await userEvent.click(screen.getByRole('tab', { name: /Raw context/i })); + + expect(await screen.findByText(/network jitter/)).toBeInTheDocument(); + expect(screen.getByText(/run-2/)).toBeInTheDocument(); + }); +});