diff --git a/.github/workflows/bar-release.yml b/.github/workflows/bar-release.yml index 9e2e2e09..78dc3b2a 100644 --- a/.github/workflows/bar-release.yml +++ b/.github/workflows/bar-release.yml @@ -5,7 +5,7 @@ name: Bar Release # # Scoped deliberately so it NEVER burdens other PRs or CI: # - Triggers ONLY on push to `main` that changes `macos-bar/**`, or a manual -# run. Regular PRs, dev pushes, and non-bar changes do not start it. +# run from `main`. Regular PRs, dev pushes, and non-bar changes do not start it. # - Runs ONLY on the dedicated self-hosted macOS runner (label `ccs-bar` on # kai-minim4). The Linux CI runners never match this job, and this job never # competes for them. @@ -32,10 +32,14 @@ concurrency: jobs: release: name: Build and publish CCS Bar + if: github.ref == 'refs/heads/main' runs-on: [self-hosted, macos, ccs-bar] steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false - name: Read bar version id: ver diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4315973..ecd4a563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,7 @@ jobs: # Single always-reporting status that branch protection requires. # - # The validate/build/test jobs are gated to trusted author associations so + # The validate/build/test/compose-parity jobs are gated to trusted author associations so # untrusted fork code never executes on the self-hosted runners. A job skipped # by a job-level `if` reports no status, so requiring those job names directly # leaves fork PRs stuck on "Expected - waiting for status to be reported" @@ -176,7 +176,7 @@ jobs: # third-party code and is safe on the self-hosted runner. ci-gate: if: always() - needs: [validate, build, test] + needs: [validate, build, test, compose-parity] runs-on: [self-hosted, linux, x64] name: CI Gate steps: @@ -185,9 +185,10 @@ jobs: VALIDATE: ${{ needs.validate.result }} BUILD: ${{ needs.build.result }} TEST: ${{ needs.test.result }} + COMPOSE_PARITY: ${{ needs.compose-parity.result }} run: | - echo "validate=$VALIDATE build=$BUILD test=$TEST" - for result in "$VALIDATE" "$BUILD" "$TEST"; do + echo "validate=$VALIDATE build=$BUILD test=$TEST compose-parity=$COMPOSE_PARITY" + for result in "$VALIDATE" "$BUILD" "$TEST" "$COMPOSE_PARITY"; do if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then echo "[X] A required CI job did not pass (result: $result)" exit 1 diff --git a/docs/ccs-bar.md b/docs/ccs-bar.md index cc9e12b3..3226d6ec 100644 --- a/docs/ccs-bar.md +++ b/docs/ccs-bar.md @@ -126,10 +126,10 @@ The app ships as a single floating GitHub release asset, `CCS-Bar.app.zip` under The `Bar Release` workflow (`.github/workflows/bar-release.yml`) builds and publishes the asset automatically. It is scoped tightly so it never affects other PRs or CI: -- It runs only on a push to `main` that touches `macos-bar/**`, or a manual run from the Actions tab (`workflow_dispatch`). +- It runs only on a push to `main` that touches `macos-bar/**`, or a manual run from the Actions tab (`workflow_dispatch`) when the selected ref is `main`. - It runs only on the dedicated self-hosted macOS runner (label `ccs-bar`); the Linux CI runners never pick it up and it never competes for them. -So bar changes reach users when they land on `main` (the stable cadence). To cut a release without a code change, or to re-publish, trigger the workflow manually. +So bar changes reach users when they land on `main` (the stable cadence). To cut a release without a code change, or to re-publish, trigger the workflow manually with `main` selected as the workflow ref. ### Manual fallback diff --git a/docs/websearch.md b/docs/websearch.md index 29dcf740..f83ede4c 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -70,9 +70,10 @@ That shared launch helper applies to normal third-party settings profiles, CLIPr | Brave Search | HTTP API | `BRAVE_API_KEY` | No | Cleaner snippets and metadata | | SearXNG | JSON API | `providers.searxng.url` | No | Self-hosted/public SearXNG backend via `/search?format=json` | | DuckDuckGo | HTML fetch | None | Yes | Built-in zero-setup fallback | -| Gemini CLI | Legacy CLI | `npm i -g @google/gemini-cli` | No | Optional compatibility fallback | -| OpenCode | Legacy CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback | -| Grok CLI | Legacy CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback | +| Antigravity (agy) | LLM CLI | `curl -fsSL https://antigravity.google/cli/install.sh \| bash` | No | Recommended LLM CLI fallback (Gemini CLI successor) | +| Gemini CLI | LLM CLI | Deprecated, use Antigravity (agy) | No | Deprecated. Google retired the gemini CLI on 2026-06-18 | +| OpenCode | LLM CLI | `curl -fsSL https://opencode.ai/install \| bash` | No | Optional compatibility fallback | +| Grok CLI | LLM CLI | `npm i -g @vibe-kit/grok-cli` + `GROK_API_KEY` | No | Optional compatibility fallback | ## Configuration @@ -111,6 +112,10 @@ websearch: duckduckgo: enabled: true max_results: 5 + agy: + enabled: false + model: gemini-2.5-flash + timeout: 90 gemini: enabled: false model: gemini-2.5-flash diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index 9cbbe875..e95bfe53 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -99,19 +99,19 @@ "src/codex-auth/commands/import-default-command.ts:134", "src/codex-auth/commands/import-default-command.ts:146", "src/codex-auth/commands/import-default-command.ts:167", - "src/commands/bar/install-subcommand.ts:137", - "src/commands/bar/install-subcommand.ts:141", - "src/commands/bar/install-subcommand.ts:149", - "src/commands/bar/install-subcommand.ts:172", - "src/commands/bar/install-subcommand.ts:181", - "src/commands/bar/install-subcommand.ts:187", - "src/commands/bar/install-subcommand.ts:228", - "src/commands/bar/install-subcommand.ts:249", - "src/commands/bar/install-subcommand.ts:259", - "src/commands/bar/install-subcommand.ts:271", - "src/commands/bar/install-subcommand.ts:289", - "src/commands/bar/install-subcommand.ts:316", - "src/commands/bar/launch-subcommand.ts:212", + "src/commands/bar/install-subcommand.ts:138", + "src/commands/bar/install-subcommand.ts:142", + "src/commands/bar/install-subcommand.ts:150", + "src/commands/bar/install-subcommand.ts:173", + "src/commands/bar/install-subcommand.ts:182", + "src/commands/bar/install-subcommand.ts:188", + "src/commands/bar/install-subcommand.ts:229", + "src/commands/bar/install-subcommand.ts:250", + "src/commands/bar/install-subcommand.ts:260", + "src/commands/bar/install-subcommand.ts:272", + "src/commands/bar/install-subcommand.ts:290", + "src/commands/bar/install-subcommand.ts:317", + "src/commands/bar/launch-subcommand.ts:207", "src/commands/config-channels-command.ts:431", "src/commands/config-channels-command.ts:436", "src/commands/config-channels-command.ts:447", @@ -220,13 +220,13 @@ "src/shared/claude-extension-setup.ts:247", "src/shared/claude-extension-setup.ts:257", "src/shared/claude-extension-setup.ts:317", - "src/shared/provider-preset-catalog.ts:325", - "src/shared/provider-preset-catalog.ts:328", - "src/shared/provider-preset-catalog.ts:337", - "src/shared/provider-preset-catalog.ts:340", - "src/shared/provider-preset-catalog.ts:343", - "src/shared/provider-preset-catalog.ts:348", + "src/shared/provider-preset-catalog.ts:339", + "src/shared/provider-preset-catalog.ts:342", "src/shared/provider-preset-catalog.ts:351", + "src/shared/provider-preset-catalog.ts:354", + "src/shared/provider-preset-catalog.ts:357", + "src/shared/provider-preset-catalog.ts:362", + "src/shared/provider-preset-catalog.ts:365", "src/shared/toml-object.ts:23", "src/targets/codex-adapter.ts:103", "src/targets/codex-adapter.ts:275", diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index eb198cba..39996352 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -9,8 +9,9 @@ * - SearXNG JSON API * - DuckDuckGo HTML search * - * Legacy compatibility fallback: - * - Gemini CLI + * Optional LLM CLI fallback: + * - Antigravity CLI (agy) - recommended (Gemini CLI successor) + * - Gemini CLI - deprecated (Google retired the gemini CLI on 2026-06-18) * - OpenCode * - Grok CLI */ @@ -49,6 +50,11 @@ const SHARED_INSTRUCTIONS = `Instructions: 7. Format output clearly with sections if the topic is complex`; const PROVIDER_CONFIG = { + agy: { + model: 'gemini-2.5-flash', + toolInstruction: 'Use your web search tool to find current information.', + quirks: null, + }, gemini: { model: 'gemini-2.5-flash', toolInstruction: 'Use the google_web_search tool to find current information.', @@ -124,7 +130,10 @@ function readProviderState() { const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')); const cooldowns = - parsed && typeof parsed === 'object' && parsed.cooldowns && typeof parsed.cooldowns === 'object' + parsed && + typeof parsed === 'object' && + parsed.cooldowns && + typeof parsed.cooldowns === 'object' ? parsed.cooldowns : {}; return { cooldowns }; @@ -856,6 +865,66 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { } } +function runAgyCommand(args, timeoutMs) { + const result = spawnSync('agy', args, { + encoding: 'utf8', + timeout: timeoutMs, + maxBuffer: 1024 * 1024 * 2, + stdio: ['pipe', 'pipe', 'pipe'], + // Never route query-derived prompts through a shell. Node concatenates + // arguments for shell-backed Windows spawns, which lets shell metacharacters + // in WebSearch queries escape the intended CLI invocation. + shell: false, + }); + + if (result.error) { + if (result.error.code === 'ENOENT') + return { success: false, error: 'Antigravity CLI (agy) not installed' }; + throw result.error; + } + if (result.status !== 0) { + return { + success: false, + error: (result.stderr || '').trim() || `Antigravity CLI exited with code ${result.status}`, + }; + } + + const output = (result.stdout || '').trim(); + if (!output || output.length < MIN_VALID_RESPONSE_LENGTH) { + return { success: false, error: 'Empty or too short response from Antigravity CLI' }; + } + return { success: true, content: output }; +} + +function tryAgySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + try { + const timeoutMs = timeoutSec * 1000; + const model = process.env.CCS_WEBSEARCH_AGY_MODEL || PROVIDER_CONFIG.agy.model; + const prompt = buildPrompt('agy', query); + // gemini flag mapping: `--yolo` -> `--dangerously-skip-permissions`, `-m` -> `--model`, + // shell `timeout N` -> native `--print-timeout Ns`. The prompt is passed to `-p` (print mode). + const args = [ + '--model', + model, + '--dangerously-skip-permissions', + '--print-timeout', + `${timeoutSec}s`, + '-p', + prompt, + ]; + + debug(`Executing Antigravity (agy) fallback with model ${model}`); + return runAgyCommand(args, timeoutMs); + } catch (error) { + return { + success: false, + error: error.killed + ? 'Antigravity CLI timed out' + : error.message || 'Unknown Antigravity error', + }; + } +} + function shouldRetryGeminiWithLegacyPrompt(errorMessage) { const lower = (errorMessage || '').toLowerCase(); return ( @@ -1042,6 +1111,12 @@ function getConfiguredProviders() { available: () => isProviderEnabled('duckduckgo'), fn: tryDuckDuckGoSearch, }, + { + name: 'Antigravity CLI', + id: 'agy', + available: () => isProviderEnabled('agy') && isCliAvailable('agy'), + fn: tryAgySearch, + }, { name: 'Gemini CLI', id: 'gemini', @@ -1230,7 +1305,9 @@ async function runProviderWithPolicy(provider, query, timeoutSec, fingerprint) { } function getActiveProviders() { - return getConfiguredProviders().filter((provider) => !getProviderCooldown(provider.id) && provider.available()); + return getConfiguredProviders().filter( + (provider) => !getProviderCooldown(provider.id) && provider.available() + ); } function getActiveProviderIds() { diff --git a/macos-bar/Package.swift b/macos-bar/Package.swift index 8e8a642f..1e06fdc1 100644 --- a/macos-bar/Package.swift +++ b/macos-bar/Package.swift @@ -10,7 +10,7 @@ import PackageDescription // the core is verified. let package = Package( name: "CCSBar", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], products: [ .executable(name: "CCSBar", targets: ["CCSBarApp"]), .executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]), diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 80017740..1f0ea18f 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -5,11 +5,13 @@ import CCSBarCore // MARK: - Content height preference key /// Preference key used to bubble the measured content VStack height up to the -/// ScrollView parent without a feedback loop. The reduction takes the MAX so -/// that intermediate layout passes that report a smaller size don't cause the -/// frame to shrink, which would trigger another layout pass (the classic -/// GeometryReader loop). A `@State` var is updated only when the value changes, -/// keeping SwiftUI's diffing stable. +/// ScrollView parent. The reduction takes the MAX only to combine multiple +/// simultaneous reporters into one value (in practice a single background +/// GeometryReader reports here, so the max is just that reader's height). The +/// consumer (`onPreferenceChange`) then tracks the height in both directions so +/// the popover frame can shrink back when content collapses. This is safe from +/// the classic GeometryReader feedback loop because the reader measures the +/// intrinsic content VStack, whose height does not depend on the consumer frame. private struct ContentHeightKey: PreferenceKey { static let defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { @@ -36,6 +38,19 @@ struct BarMenuView: View { /// monotonically (ContentHeightKey.reduce takes the max), so the frame never /// thrashes downward. @State private var contentHeight: CGFloat = 0 + /// Multi-profile carousel: which provider page is currently visible. Resets to + /// first provider on popover re-open (KISS — no UserDefaults persistence needed). + // Per-provider carousel position: provider -> selected profile row id. Each + // provider has its own profile carousel, so selection is tracked per provider. + @State private var selectedProfileByProvider: [String: String] = [:] + /// Live horizontal drag translation per provider while a swipe is in progress. + /// Reset to 0 (and committed to a page change) on drag release. Keyed by + /// provider so each carousel tracks its own in-flight swipe independently. + @State private var dragByProvider: [String: CGFloat] = [:] + /// Whether the Alerts section is expanded to show every alert. Collapsed by + /// default: only the most-severe few render, with a "+N more" toggle, so a + /// burst of conditions never buries the cockpit under a wall of rows. + @State private var alertsExpanded = false // MARK: - Screen cap @@ -84,14 +99,10 @@ struct BarMenuView: View { // (2) ALERTS — urgent quota crossings surface above accounts. // Spend-cap alerts are opt-in OFF by default, so by default only - // quota/reauth/cooldown conditions appear here. + // quota/reauth/cooldown conditions appear here. Deduped, severity- + // ranked, compact, and collapsed past a few — see alertsSection. if !viewModel.activeAlerts.isEmpty { - VStack(alignment: .leading, spacing: 8) { - SectionLabel("Alerts") - ForEach(viewModel.activeAlerts) { alert in - AlertRow(alert: alert) - } - } + alertsSection } // (3) SUBSCRIPTIONS — the dominant section, opens here. @@ -146,9 +157,14 @@ struct BarMenuView: View { // until the first preference fires. .frame(height: scrollAreaHeight, alignment: .top) .onPreferenceChange(ContentHeightKey.self) { measured in - // Only grow, never shrink — prevents a feedback loop where a smaller - // frame triggers a re-layout that reports an even smaller height. - if measured > contentHeight { + // Track the measured content height in BOTH directions so the popover + // collapses back when content shrinks (e.g. alert / reauth rows clear) + // instead of staying stuck at a past peak and rendering blank space + // below. The background GeometryReader measures the intrinsic content + // VStack, whose height does not depend on this frame, so updating in + // either direction cannot feed the classic GeometryReader layout loop. + // The 0.5pt deadband avoids churn on sub-pixel remeasures. + if abs(measured - contentHeight) > 0.5 { contentHeight = measured } } @@ -164,11 +180,16 @@ struct BarMenuView: View { viewModel.onOpen() // Disarm quit on every popover open so a stale armed state never persists. quitArmed = false + // Reset each provider's carousel to its first profile on every open — KISS, + // no persistence needed. Clear any in-flight drag and re-collapse alerts. + selectedProfileByProvider = [:] + dragByProvider = [:] + alertsExpanded = false } } /// "Update available" banner. Shown when `viewModel.updateAvailable` is true. - /// Styled to match the existing AlertRow / ErrorBanner patterns (tinted + /// Styled to match the existing CompactAlertRow / ErrorBanner patterns (tinted /// background card, section label, borderless button). @ViewBuilder private var updateBanner: some View { VStack(alignment: .leading, spacing: 8) { @@ -218,7 +239,7 @@ struct BarMenuView: View { /// present, preserving the single "Accounts" header for a CLIProxy-only setup. @ViewBuilder private var accountsSection: some View { let parts = BarFormatting.partitionSubscriptions(viewModel.rows) - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { if let error = viewModel.lastError { ErrorBanner(message: error) } @@ -234,9 +255,40 @@ struct BarMenuView: View { BarRowView(row: row, viewModel: viewModel) } } else { + // Per-provider profile carousels: group subscription rows by provider, and + // render each provider as its OWN horizontally-paged carousel of PROFILE + // cards — one profile visible at a time, swipe left/right between that + // provider's profiles, page dots indicate count. Provider sections stack + // vertically. A provider with a single profile shows just its card (no + // carousel, no dots). + let groups = Dictionary( + grouping: orderedSubscriptions(parts.subscriptions), by: { $0.provider }) + let providers = groups.keys.sorted() // stable: "claude-code" < "codex" + let multiProvider = providers.count > 1 subscriptionsHeader(parts.subscriptions) - ForEach(orderedSubscriptions(parts.subscriptions)) { row in - BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() }) + ForEach(providers, id: \.self) { prov in + let rows = groups[prov] ?? [] + VStack(alignment: .leading, spacing: 4) { + // Provider caption to delineate sections when more than one provider. + if multiProvider { + Text(BarFormatting.providerLabel(prov)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + } + if rows.count <= 1 { + // Single profile — render the card directly, no carousel. + if let row = rows.first { + BarSubscriptionCard( + row: row, isParked: row.paused, + onRefresh: { viewModel.forceRefresh() }) + } + } else { + profileCarousel(prov: prov, rows: rows) + // Page controls — clickable prev/next arrows + dots so the carousel + // is also navigable by MOUSE click, not only by drag/swipe. + carouselControls(prov: prov, rows: rows) + } + } } } } @@ -274,11 +326,14 @@ struct BarMenuView: View { } } - /// Order subscription cards by tightest binding window ascending (closest to - /// empty on top). Rows with no binding window (error/reauth) sink to the bottom - /// so the actionable quota always leads. + /// Order subscription cards so the default/base account leads its provider + /// carousel (it is the account the user runs by default), then by tightest + /// binding window ascending (closest to empty next). Rows with no binding + /// window (error/reauth) sink to the bottom so actionable quota leads. private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] { subs.sorted { a, b in + // Default account first within its provider group. + if a.isDefault != b.isDefault { return a.isDefault } let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent switch (ra, rb) { @@ -295,6 +350,218 @@ struct BarMenuView: View { } } + /// Swipeable profile pager: one full-width card visible at a time, an HStack of + /// the provider's cards offset to the current page. Two complementary inputs + /// move between pages so no device is left out: + /// - a `DragGesture` for a mouse/trackpad press-drag (the previous ScrollView + /// never paged on a plain mouse click-drag), committing on release past a + /// 20% threshold, with the ends rubber-banding so an over-drag resists; + /// - a `CarouselScrollPager` overlay that turns a non-clicking horizontal + /// trackpad / Magic Mouse swipe (delivered as scroll-wheel events, which a + /// DragGesture does not see) into a page step. + /// No ScrollView, so there is no paging drift and the card is always centered. + @ViewBuilder private func profileCarousel(prov: String, rows: [BarSummaryRow]) -> some View { + let currentId = selectedProfileByProvider[prov] ?? rows.first?.id + let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 + GeometryReader { geo in + let pageWidth = geo.size.width + HStack(spacing: 0) { + ForEach(rows) { row in + BarSubscriptionCard( + row: row, isParked: row.paused, + onRefresh: { viewModel.forceRefresh() }) + .frame(width: pageWidth) + } + } + .offset(x: -CGFloat(curIdx) * pageWidth + (dragByProvider[prov] ?? 0)) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 8) + .onChanged { value in + // Rubber-band at the ends: an over-drag past the first/last card moves + // at a third the rate so it springs back instead of revealing a gap. + let raw = value.translation.width + let atStart = curIdx == 0 && raw > 0 + let atEnd = curIdx == rows.count - 1 && raw < 0 + dragByProvider[prov] = (atStart || atEnd) ? raw / 3 : raw + } + .onEnded { value in + // Commit a page change when the swipe passes 20% of the page width; + // otherwise snap back to the current card. + let threshold = pageWidth * 0.2 + var newIdx = curIdx + if value.translation.width <= -threshold { newIdx = min(curIdx + 1, rows.count - 1) } + else if value.translation.width >= threshold { newIdx = max(curIdx - 1, 0) } + withAnimation(.easeOut(duration: 0.2)) { + dragByProvider[prov] = 0 + selectedProfileByProvider[prov] = rows[newIdx].id + } + } + ) + // Trackpad / Magic Mouse horizontal swipe (scroll-wheel events) → page step. + // Transparent to clicks and to the DragGesture; only observes scroll. + .overlay( + CarouselScrollPager { step in page(prov: prov, by: step, rows: rows) } + ) + } + .frame(height: carouselHeight(rows)) + .clipped() // hide the neighbouring cards that sit outside the page viewport + } + + /// Step the given provider's carousel by ±1 page, clamped to the ends. Used by + /// the horizontal scroll-swipe overlay; the arrows/dots call `selectPage` + /// directly. + private func page(prov: String, by step: Int, rows: [BarSummaryRow]) { + let currentId = selectedProfileByProvider[prov] ?? rows.first?.id + let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 + let newIdx = min(max(curIdx + step, 0), rows.count - 1) + if newIdx != curIdx { selectPage(prov, rows[newIdx].id) } + } + + /// Prev/next arrows + clickable dots for a provider's profile carousel, so it + /// is navigable by MOUSE click, not only by drag/swipe. Selecting a page sets + /// `selectedProfileByProvider`, which animates the pager offset to that card. + @ViewBuilder private func carouselControls(prov: String, rows: [BarSummaryRow]) -> some View { + let currentId = selectedProfileByProvider[prov] ?? rows.first?.id + let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 + HStack(spacing: 7) { + pageArrow(systemName: "chevron.left", enabled: curIdx > 0) { + if curIdx > 0 { selectPage(prov, rows[curIdx - 1].id) } + } + ForEach(rows) { row in + Circle() + .fill(currentId == row.id ? theme.subscription : Color.secondary.opacity(0.3)) + .frame(width: 6, height: 6) + .padding(4) // larger mouse hit target than the 6pt dot + .contentShape(Rectangle()) + .onTapGesture { selectPage(prov, row.id) } + } + pageArrow(systemName: "chevron.right", enabled: curIdx < rows.count - 1) { + if curIdx < rows.count - 1 { selectPage(prov, rows[curIdx + 1].id) } + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + + /// Animate the carousel to the given profile card (mouse click or dot tap). + private func selectPage(_ prov: String, _ id: String) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedProfileByProvider[prov] = id + } + } + + @ViewBuilder private func pageArrow( + systemName: String, enabled: Bool, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 9, weight: .bold)) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!enabled) + .opacity(enabled ? 0.7 : 0.25) + } + + /// Height of the tallest single card in a carousel — only one card is visible + /// at a time, so the paged frame is sized to fit it WITHOUT reserving blank + /// space beneath. Card = vertical padding (16) + title row (~22) + one bar per + /// quota window (~20) + an optional stale footnote (~16); a parked/reauth card + /// is just the title row plus a one-line status. + private func carouselHeight(_ rows: [BarSummaryRow]) -> CGFloat { + let maxWindows = rows.map { $0.quotaWindows?.count ?? 0 }.max() ?? 0 + if maxWindows == 0 { return 60 } + let hasFootnote = rows.contains { $0.staleAsOf != nil } + return 40 + CGFloat(maxWindows) * 20 + (hasFootnote ? 16 : 0) + } + + // MARK: Alerts + + /// One displayed alert after de-duplication: the representative notification + /// plus how many identical conditions it stands for (e.g. the same "ck needs + /// re-authentication" firing on two surfaces collapses to one row with ×2). + private struct GroupedAlert: Identifiable { + let id: String + let alert: BarNotification + let count: Int + } + + /// De-duplicate alerts by their visible text and rank by severity so the most + /// actionable condition leads. Two alerts that render identically (same title + + /// body) collapse into one group with a count, killing the "ck reauth" / + /// "ck reauth" / "ck paused" / "ck paused" repetition seen with multi-surface + /// profiles. + private func groupedAlerts(_ alerts: [BarNotification]) -> [GroupedAlert] { + var order: [String] = [] + var byKey: [String: (alert: BarNotification, count: Int)] = [:] + for a in alerts { + let key = a.title + "\u{1F}" + a.body + if let hit = byKey[key] { + byKey[key] = (hit.alert, hit.count + 1) + } else { + byKey[key] = (a, 1) + order.append(key) + } + } + return order + .map { GroupedAlert(id: $0, alert: byKey[$0]!.alert, count: byKey[$0]!.count) } + .sorted { alertSeverityRank($0.alert.kind) < alertSeverityRank($1.alert.kind) } + } + + /// Severity order for alert ranking: reauth (account unusable) first, spend + /// caps next, then quota, then the soft paused/cooldown note. + private func alertSeverityRank(_ kind: BarAlertKind) -> Int { + switch kind { + case .reauthNeeded: return 0 + case .dailySpendAbove, .monthSpendAbove: return 1 + case .quotaRemainingBelow: return 2 + case .accountCooldownOrPaused: return 3 + } + } + + /// Calm, compact alerts: a labelled header with a total count, then a few + /// single-line rows (most-severe first). Collapsed past `collapsedCap` behind a + /// "+N more" toggle so a burst of conditions never floods the popover. Replaces + /// the previous stack of tall two-line cards. + @ViewBuilder private var alertsSection: some View { + let groups = groupedAlerts(viewModel.activeAlerts) + let collapsedCap = 3 + let overflow = groups.count - collapsedCap + let visible = alertsExpanded ? groups : Array(groups.prefix(collapsedCap)) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + SectionLabel("Alerts") + Text("\(groups.count)") + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.18), in: Capsule()) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + ForEach(visible) { g in + CompactAlertRow(alert: g.alert, count: g.count) + } + if overflow > 0 { + Button { + withAnimation(.easeInOut(duration: 0.15)) { alertsExpanded.toggle() } + } label: { + HStack(spacing: 4) { + Image(systemName: alertsExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + Text(alertsExpanded ? "Show less" : "\(overflow) more") + .font(.caption2) + } + .foregroundStyle(.secondary) + .padding(.vertical, 2) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + private var header: some View { HStack(spacing: 8) { Image(nsImage: MenuBarIcon.headerImage()) @@ -654,33 +921,38 @@ struct ErrorBanner: View { } } -/// One in-dropdown alert row. Mirrors a delivered notification so the conditions -/// are visible even when system notifications are denied. The icon is keyed off -/// the alert kind so each rule reads at a glance. -struct AlertRow: View { +/// Compact, single-line alert row used by the condensed Alerts section. One +/// glanceable line — kind icon + the self-describing body (the title is dropped +/// as redundant with the icon) + an optional ×N when several identical +/// conditions were merged. The tint is softer than the old `AlertRow` card so a +/// list of them reads as informative, not alarming. +struct CompactAlertRow: View { @Environment(\.barTheme) private var theme let alert: BarNotification + let count: Int var body: some View { - HStack(alignment: .top, spacing: 6) { + HStack(spacing: 6) { Image(systemName: icon) .foregroundStyle(tint) - .font(.caption) - .padding(.top, 1) - VStack(alignment: .leading, spacing: 1) { - Text(alert.title) - .font(.caption.weight(.medium)) - Text(alert.body) - .font(.caption2) + .font(.caption2) + .frame(width: 12) + Text(alert.body) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if count > 1 { + Text("×\(count)") + .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) - .lineLimit(2) } Spacer(minLength: 0) } - .padding(.vertical, 5) + .padding(.vertical, 4) .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: .leading) - .background(tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 7)) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) } private var icon: String { @@ -693,8 +965,6 @@ struct AlertRow: View { } private var tint: Color { - // Themed: quota warnings take the brand accent, reauth the critical band, - // so alert chips match the rest of the dropdown on both plates. switch alert.kind { case .quotaRemainingBelow: return theme.accent case .dailySpendAbove, .monthSpendAbove: return theme.accent @@ -733,3 +1003,93 @@ struct Chip: View { .foregroundStyle(textColor) } } + +/// Adds horizontal trackpad / Magic Mouse swipe paging to the profile carousel. +/// SwiftUI's `DragGesture` handles a mouse or trackpad press-drag, but a +/// non-clicking two-finger swipe arrives as scroll-wheel events it never sees. +/// +/// This hosts a transparent AppKit anchor view (click- and drag-transparent via +/// a nil `hitTest`, so it never blocks the cards' buttons or the DragGesture) and +/// a local scroll-wheel monitor scoped to that view's on-screen frame. A +/// predominantly horizontal scroll pages once per gesture; a vertical scroll is +/// passed straight through so the popover still scrolls. If the frame math ever +/// fails to match, the worst case is that scroll-swipe simply does nothing — +/// drag and the arrows/dots still work — so the failure mode is benign. +struct CarouselScrollPager: NSViewRepresentable { + /// Called with +1 (next) or -1 (previous) when a horizontal swipe commits. + let onPage: (Int) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(onPage: onPage) } + + func makeNSView(context: Context) -> NSView { + let view = PassthroughView() + context.coordinator.attach(to: view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.onPage = onPage + } + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.detach() + } + + /// Anchor view that is transparent to all mouse hit-testing, so clicks and the + /// SwiftUI DragGesture pass through to the cards beneath it. + final class PassthroughView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } + + /// Owns the local scroll-wheel monitor and the per-gesture accumulator. + final class Coordinator { + var onPage: (Int) -> Void + private weak var view: NSView? + private var monitor: Any? + private var accumulated: CGFloat = 0 + private var firedThisGesture = false + private let threshold: CGFloat = 40 + + init(onPage: @escaping (Int) -> Void) { self.onPage = onPage } + + func attach(to view: NSView) { + self.view = view + monitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in + self?.handle(event) ?? event + } + } + + func detach() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + } + + /// Return nil to consume a horizontal swipe inside the carousel; return the + /// event unchanged otherwise so vertical popover scroll is never swallowed. + private func handle(_ event: NSEvent) -> NSEvent? { + guard let view, let window = view.window, event.window === window else { return event } + let frameInWindow = view.convert(view.bounds, to: nil) + guard frameInWindow.contains(event.locationInWindow) else { return event } + + let dx = event.scrollingDeltaX + let dy = event.scrollingDeltaY + guard abs(dx) > abs(dy) else { return event } // vertical → let the popover scroll + + if event.phase.contains(.began) || event.momentumPhase.contains(.began) { + accumulated = 0 + firedThisGesture = false + } + accumulated += dx + if !firedThisGesture && abs(accumulated) >= threshold { + firedThisGesture = true + // Natural scrolling: content moving left (negative dx) advances to next. + onPage(accumulated < 0 ? 1 : -1) + } + if event.phase.contains(.ended) || event.momentumPhase.contains(.ended) { + accumulated = 0 + firedThisGesture = false + } + return nil + } + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift b/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift index 7d5a7c64..b4e680d7 100644 --- a/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift +++ b/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift @@ -105,14 +105,21 @@ struct BarServerLauncher: Sendable { } private func isUnderCcsDir(_ path: String) -> Bool { - let ccsPath = URL(fileURLWithPath: home) - .appendingPathComponent(".ccs") - .standardizedFileURL - .path - let targetPath = URL(fileURLWithPath: path).standardizedFileURL.path + let ccsPath = pathForComparison( + URL(fileURLWithPath: home) + .appendingPathComponent(".ccs") + ) + let targetPath = pathForComparison(URL(fileURLWithPath: path)) return targetPath == ccsPath || targetPath.hasPrefix(ccsPath + "/") } + private func pathForComparison(_ url: URL) -> String { + url.standardizedFileURL + .resolvingSymlinksInPath() + .path + .lowercased() + } + private func isAbsolutePath(_ path: String) -> Bool { path.hasPrefix("/") } diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift index cf96fdde..9363fb2a 100644 --- a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -13,6 +13,9 @@ import CCSBarCore struct BarSubscriptionCard: View { @Environment(\.barTheme) private var theme let row: BarSummaryRow + /// When true this profile is parked (not the active/default profile for the + /// surface). The card is dimmed to 50% opacity to signal it is not live. + var isParked: Bool = false /// Injected clock — defaults to live Date() in production, pinned in previews /// and tests so countdown math is deterministic. var now: Date = Date() @@ -44,20 +47,30 @@ struct BarSubscriptionCard: View { .background( theme.cardSurface, in: RoundedRectangle(cornerRadius: 9)) + // Dim parked (non-active) profiles so the active profile clearly dominates. + .opacity(isParked ? 0.5 : 1.0) } // MARK: Title row - /// Health dot + product name + reauth chip + tier chip. No pause toggle — - /// subscriptions are not routable pool accounts. + /// Health dot + profile name (or provider label for legacy rows) + surface chip + /// + reauth chip + tier chip. No pause toggle — subscriptions are not routable + /// pool accounts. private var titleRow: some View { HStack(spacing: 8) { Circle() .fill(healthColor) .frame(width: 8, height: 8) - Text(BarFormatting.providerLabel(row.provider)) + // Default account shows the bare command ("ccsx"); a named profile shows its + // name ("ck"). Falls back to the provider label for legacy rows. + Text(BarFormatting.accountTitle(row)) .font(.system(.body, design: .default).weight(.semibold)) .lineLimit(1) + // Tag: "default" for the base account, else the owning surface ("ccsx") so a + // named profile reads as e.g. "ck · ccsx". + if let tag = BarFormatting.accountTag(row) { + Chip(tag, tint: theme.subscription.opacity(isParked ? 0.5 : 1)) + } if row.needsReauth { Chip("reauth", tint: theme.bandRed) } diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 0c9d5833..72612c59 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1686,6 +1686,182 @@ do { check(!BarUpdateChecker.isNewer("1.7", than: "1.7.0"), "isNewer: fewer than 3 parts rejected") } +// MARK: Multi-profile fields decode (GH-1595) +// +// Three new optional wire keys: surface, profile, is_subscription. +// Native rows carry all three; CLIProxy pool rows omit them — legacy decoders +// must not fail and must yield nil for the absent fields. + +let multiProfileJSON = """ +[ + { + "account_id": "ccs:work", + "provider": "claude-code", + "surface": "ccs", + "profile": "work", + "is_subscription": true, + "displayName": "work", + "tier": "max", + "paused": false, + "quota_percentage": 62, + "quotaStatus": "ok", + "next_reset": "2026-06-24T01:00:00.000Z", + "is_default": true, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": false, + "fetchedAt": "2026-06-23T20:40:00.000Z", + "needsReauth": false + }, + { + "account_id": "ccsx:ck", + "provider": "codex", + "surface": "ccsx", + "profile": "ck", + "is_subscription": true, + "displayName": "ck", + "tier": "pro", + "paused": true, + "quota_percentage": 80, + "quotaStatus": "ok", + "next_reset": null, + "is_default": false, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": true, + "fetchedAt": "2026-06-23T16:19:00.000Z", + "needsReauth": false, + "stale_as_of": "2026-06-23T16:19:00.000Z" + }, + { + "account_id": "alice@example.com", + "provider": "agy", + "displayName": "Alice (Ultra)", + "tier": "ultra", + "paused": false, + "quota_percentage": 70, + "quotaStatus": "ok", + "next_reset": null, + "is_default": false, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": true, + "fetchedAt": "2026-06-23T20:00:00.000Z", + "needsReauth": false + } +] +""" + +do { + let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(multiProfileJSON.utf8)) + check(rows.count == 3, "mp: decodes 3 rows (2 native + 1 legacy CLIProxy)") + + // (MP1) Native Claude active row + let claudeRow = rows[0] + check(claudeRow.accountId == "ccs:work", "mp: native Claude account_id = 'ccs:work'") + check(claudeRow.surface == "ccs", "mp: native Claude surface = 'ccs'") + check(claudeRow.profile == "work", "mp: native Claude profile = 'work'") + check(claudeRow.isSubscription == true, "mp: native Claude is_subscription = true") + check(claudeRow.paused == false, "mp: active Claude profile is not paused") + + // (MP2) Parked Codex row + let codexRow = rows[1] + check(codexRow.accountId == "ccsx:ck", "mp: parked Codex account_id = 'ccsx:ck'") + check(codexRow.surface == "ccsx", "mp: parked Codex surface = 'ccsx'") + check(codexRow.profile == "ck", "mp: parked Codex profile = 'ck'") + check(codexRow.isSubscription == true, "mp: parked Codex is_subscription = true") + check(codexRow.paused == true, "mp: parked Codex row has paused = true") + + // (MP3) Legacy CLIProxy row — new fields decode to nil without failure + let legacyRow = rows[2] + check(legacyRow.surface == nil, "mp: CLIProxy row surface decodes to nil (backward compat)") + check(legacyRow.profile == nil, "mp: CLIProxy row profile decodes to nil (backward compat)") + check(legacyRow.isSubscription == nil, "mp: CLIProxy row is_subscription decodes to nil") + + // (MP4) isNativeSubscription uses is_subscription flag when present + check( + BarFormatting.isNativeSubscription(claudeRow), + "mp: isNativeSubscription true when is_subscription=true (new flag path)") + check( + BarFormatting.isNativeSubscription(codexRow), + "mp: isNativeSubscription true for parked Codex row (is_subscription=true)") + check( + !BarFormatting.isNativeSubscription(legacyRow), + "mp: isNativeSubscription false for CLIProxy pool row (no is_subscription)") + + // (MP5) Legacy rows without is_subscription use fallback heuristic + let legacyClaudeRow = BarSummaryRow(accountId: "claude-code", provider: "claude-code") + check( + BarFormatting.isNativeSubscription(legacyClaudeRow), + "mp: legacy claude-code row uses heuristic fallback -> is native") + let legacyCodexRow = BarSummaryRow(accountId: "codex", provider: "codex") + check( + BarFormatting.isNativeSubscription(legacyCodexRow), + "mp: legacy codex row uses heuristic fallback -> is native") + let poolCodexRow = BarSummaryRow(accountId: "pool-codex-oauth-1", provider: "codex") + check( + !BarFormatting.isNativeSubscription(poolCodexRow), + "mp: CLIProxy codex pool row with no is_subscription -> not native (heuristic)") + + // (MP6) partitionSubscriptions correctly splits with new flag + let parts = BarFormatting.partitionSubscriptions(rows) + check(parts.subscriptions.count == 2, "mp: partition yields 2 native subscriptions") + check(parts.pool.count == 1, "mp: partition yields 1 CLIProxy pool row") + check( + parts.subscriptions.map { $0.accountId } == ["ccs:work", "ccsx:ck"], + "mp: subscriptions keep backend order") + + // (MP7) surfaceProfileLabel helper + check( + BarFormatting.surfaceProfileLabel(claudeRow) == "ccs · work", + "mp: surfaceProfileLabel for ccs:work -> 'ccs · work'") + check( + BarFormatting.surfaceProfileLabel(codexRow) == "ccsx · ck", + "mp: surfaceProfileLabel for ccsx:ck -> 'ccsx · ck'") + check( + BarFormatting.surfaceProfileLabel(legacyRow) == nil, + "mp: surfaceProfileLabel for CLIProxy row (no profile) -> nil") + + // (MP8) surfaceProfileLabel falls back to provider when surface is absent + let noSurfaceRow = BarSummaryRow( + accountId: "ccsx:personal", provider: "codex", + surface: nil, profile: "personal", isSubscription: true) + check( + BarFormatting.surfaceProfileLabel(noSurfaceRow) == "codex · personal", + "mp: surfaceProfileLabel falls back to provider when surface is nil") + + // (MP9) base/default account vs named profile presentation + let defaultCodexRow = BarSummaryRow( + accountId: "ccsx:default", provider: "codex", + surface: "ccsx", profile: "default", isSubscription: true) + check( + BarFormatting.isBaseAccount(defaultCodexRow), + "mp: 'default' profile is the base account") + check( + !BarFormatting.isBaseAccount(codexRow), + "mp: named profile 'ck' is not the base account") + check( + BarFormatting.accountTitle(defaultCodexRow) == "ccsx", + "mp: accountTitle for default account -> 'ccsx' (bare command)") + check( + BarFormatting.accountTitle(codexRow) == "ck", + "mp: accountTitle for named profile -> 'ck'") + check( + BarFormatting.accountTag(defaultCodexRow) == "default", + "mp: accountTag for default account -> 'default'") + check( + BarFormatting.accountTag(codexRow) == "ccsx", + "mp: accountTag for named profile -> 'ccsx' (surface)") + check( + BarFormatting.accountTag(legacyRow) == nil, + "mp: accountTag for CLIProxy pool row -> nil") +} catch { + check(false, "mp: multi-profile JSON decode failed: \(error)") +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift index e996c9bf..f08530fc 100644 --- a/macos-bar/Sources/CCSBarCore/BarFormatting.swift +++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift @@ -165,11 +165,49 @@ public enum BarFormatting { /// Code or Codex plan) rather than a CLIProxy-managed OAuth pool account. Drives /// the "Subscriptions" grouping + badge so a user reads "this is MY plan quota", /// not one of the rotating pool credentials. + /// + /// Prefers the explicit `is_subscription` flag from the server (multi-profile + /// feature). Falls back to the legacy heuristic (`accountId == provider`) so + /// old single-profile payloads still work without `is_subscription`. public static func isNativeSubscription(_ row: BarSummaryRow) -> Bool { - (row.provider == "claude-code" && row.accountId == "claude-code") + if let s = row.isSubscription { return s } + // Legacy fallback for old payloads that omit is_subscription. + return (row.provider == "claude-code" && row.accountId == "claude-code") || (row.provider == "codex" && row.accountId == "codex") } + /// Surface + profile chip label for multi-profile cards, e.g. "ccs · work" or + /// "ccsx · ck". Returns nil for CLIProxy pool rows that have no profile. + public static func surfaceProfileLabel(_ row: BarSummaryRow) -> String? { + guard let p = row.profile else { return nil } + let s = row.surface ?? row.provider + return "\(s) · \(p)" + } + + /// True when this row is the surface's default/base account — the bare login + /// (e.g. `ccsx` => ~/.codex), as opposed to a named `ccsx ` profile. + /// The server names this account "default". + public static func isBaseAccount(_ row: BarSummaryRow) -> Bool { + row.profile == "default" + } + + /// Primary card title. For the default/base account it is the bare command the + /// user actually runs ("ccsx" / "ccs"); for a named profile it is the profile + /// name ("ck"). Falls back to the provider product label for legacy rows. + public static func accountTitle(_ row: BarSummaryRow) -> String { + if isBaseAccount(row) { return row.surface ?? providerLabel(row.provider) } + return row.profile ?? providerLabel(row.provider) + } + + /// Secondary chip beside the title. The default/base account is tagged + /// "default"; a named profile is tagged with the surface that owns it + /// ("ccs"/"ccsx") so "ck" reads as a ccsx profile. Nil for pool rows. + public static func accountTag(_ row: BarSummaryRow) -> String? { + guard row.profile != nil else { return nil } + if isBaseAccount(row) { return "default" } + return row.surface ?? row.provider + } + /// Friendly product label for a provider key. Native subscription keys read as /// products ("Claude Code", "Codex"); any other provider passes through verbatim /// (so "agy"/"ghcp"/"kiro" keep their established short chip text). diff --git a/macos-bar/Sources/CCSBarCore/BarSummary.swift b/macos-bar/Sources/CCSBarCore/BarSummary.swift index 930f06ed..f76bd5f8 100644 --- a/macos-bar/Sources/CCSBarCore/BarSummary.swift +++ b/macos-bar/Sources/CCSBarCore/BarSummary.swift @@ -65,6 +65,16 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { public let cached: Bool public let fetchedAt: String? public let needsReauth: Bool + /// Multi-profile: which CCS surface owns this profile ("ccs" = Claude via + /// `ccs auth`, "ccsx" = Codex via `ccsx auth`). nil on CLIProxy pool rows. + public let surface: String? + /// Multi-profile: the profile name (e.g. "work", "ck", "personal"). nil on + /// CLIProxy pool rows. + public let profile: String? + /// Explicit native-subscription flag. true on all native rows (Claude/Codex + /// own subscription), nil/false on CLIProxy pool rows. Replaces the brittle + /// `accountId == "claude-code"` heuristic. + public let isSubscription: Bool? /// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet, /// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows" /// entirely — so legacy payloads decode unchanged (backward compatible). @@ -93,6 +103,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { case cached case fetchedAt case needsReauth + case surface + case profile + case isSubscription = "is_subscription" case quotaWindows = "quota_windows" case staleAsOf = "stale_as_of" } @@ -113,6 +126,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { cached: Bool = false, fetchedAt: String? = nil, needsReauth: Bool = false, + surface: String? = nil, + profile: String? = nil, + isSubscription: Bool? = nil, quotaWindows: [QuotaWindowDetail]? = nil, staleAsOf: String? = nil ) { @@ -131,6 +147,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { self.cached = cached self.fetchedAt = fetchedAt self.needsReauth = needsReauth + self.surface = surface + self.profile = profile + self.isSubscription = isSubscription self.quotaWindows = quotaWindows self.staleAsOf = staleAsOf } @@ -156,6 +175,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { cached = try c.decode(Bool.self, forKey: .cached) fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt) needsReauth = try c.decode(Bool.self, forKey: .needsReauth) + surface = try c.decodeIfPresent(String.self, forKey: .surface) + profile = try c.decodeIfPresent(String.self, forKey: .profile) + isSubscription = try c.decodeIfPresent(Bool.self, forKey: .isSubscription) quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows) staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf) } diff --git a/macos-bar/VERSION b/macos-bar/VERSION index a8fdfda1..8fdcf386 100644 --- a/macos-bar/VERSION +++ b/macos-bar/VERSION @@ -1 +1 @@ -1.8.1 +1.9.2 diff --git a/package.json b/package.json index 323d626e..7ea7ddf8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.6.1", + "version": "8.6.1-dev.13", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 8fa35701..48948a8e 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -427,6 +427,14 @@ class ProfileDetector { // Check if account-based default exists (legacy) const profiles = this.readProfiles(); + if (unifiedConfig?.default && profiles.profiles[unifiedConfig.default]) { + return { + type: 'account', + name: unifiedConfig.default, + profile: profiles.profiles[unifiedConfig.default], + }; + } + if (profiles.default && profiles.profiles[profiles.default]) { return { type: 'account', diff --git a/src/cliproxy/config/provider-route.ts b/src/cliproxy/config/provider-route.ts index 0ccac5c3..e6f23a0e 100644 --- a/src/cliproxy/config/provider-route.ts +++ b/src/cliproxy/config/provider-route.ts @@ -40,3 +40,44 @@ export function buildLocalProviderBaseUrl( const rootUrl = `http://127.0.0.1:${port}`; return `${rootUrl}${buildCliproxyProviderPath(provider, backend)}`; } + +export function buildCodexResponsesProviderPath( + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + return usesScopedProviderRoutes(backend) ? '/api/provider/codex' : '/backend-api/codex'; +} + +export function buildLocalCodexResponsesBaseUrl( + port: number, + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + return `http://127.0.0.1:${port}${buildCodexResponsesProviderPath(backend)}`; +} + +export function normalizeCodexResponsesBaseUrl( + baseUrl: string, + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + const trimmed = baseUrl.trim(); + if (!trimmed) return baseUrl; + + try { + const parsed = new URL(trimmed); + if (!['http:', 'https:'].includes(parsed.protocol)) return baseUrl; + + const currentPath = parsed.pathname.replace(/\/+$/, '') || '/'; + const expectedPath = buildCodexResponsesProviderPath(backend); + if (currentPath === expectedPath) return trimmed; + + const legacyCodexPath = '/api/provider/codex'; + const managedPaths = new Set(['/', legacyCodexPath]); + if (!managedPaths.has(currentPath)) return trimmed; + + parsed.pathname = expectedPath; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString().replace(/\/$/, ''); + } catch { + return baseUrl; + } +} diff --git a/src/cliproxy/executor/__tests__/claude-launcher.test.ts b/src/cliproxy/executor/__tests__/claude-launcher.test.ts index e6111e50..3e587ca9 100644 --- a/src/cliproxy/executor/__tests__/claude-launcher.test.ts +++ b/src/cliproxy/executor/__tests__/claude-launcher.test.ts @@ -87,6 +87,16 @@ mock.module('../../quota/quota-manager', () => ({ stopQuotaMonitor: jest.fn(), })); +const mockCleanupLaunchSettings = jest.fn(); +const mockPrepareLaunchSettings = jest.fn().mockReturnValue({ + settingsPath: '/tmp/fake-settings-overlay.json', + cleanup: mockCleanupLaunchSettings, +}); + +mock.module('../launch-settings', () => ({ + prepareLaunchSettings: mockPrepareLaunchSettings, +})); + // ── Subject under test ──────────────────────────────────────────────────────── import { launchClaude } from '../claude-launcher'; @@ -131,6 +141,12 @@ describe('launchClaude', () => { mockSpawn.mockClear(); mockSetupCleanupHandlers.mockClear(); mockEscapeShellArg.mockClear(); + mockCleanupLaunchSettings.mockClear(); + mockPrepareLaunchSettings.mockClear(); + mockPrepareLaunchSettings.mockReturnValue({ + settingsPath: '/tmp/fake-settings-overlay.json', + cleanup: mockCleanupLaunchSettings, + }); }); it('calls spawn with claudeCli and includes --settings arg', async () => { @@ -189,6 +205,16 @@ describe('launchClaude', () => { expect(result).toBe(mockSpawnResult); }); + it('calls cleanup and rethrows when spawn throws synchronously', async () => { + const spawnErr = new Error('ERR_INVALID_ARG_VALUE'); + mockSpawn.mockImplementationOnce(() => { + throw spawnErr; + }); + + await expect(launchClaude(baseContext())).rejects.toThrow('ERR_INVALID_ARG_VALUE'); + expect(mockCleanupLaunchSettings).toHaveBeenCalledTimes(1); + }); + describe('Windows shell escaping', () => { const originalPlatform = process.platform; diff --git a/src/cliproxy/executor/__tests__/executor-option-value.test.ts b/src/cliproxy/executor/__tests__/executor-option-value.test.ts index bb147205..a04b2ed5 100644 --- a/src/cliproxy/executor/__tests__/executor-option-value.test.ts +++ b/src/cliproxy/executor/__tests__/executor-option-value.test.ts @@ -141,7 +141,7 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { } }); - it('degrades WebSearch provisioning failures for CLIProxy launches', async () => { + it('fails closed on WebSearch provisioning failures for CLIProxy launches', async () => { makeWebSearchProvisioningFail(); const markerPath = path.join(tmpHome, 'fake-claude-launched'); @@ -176,26 +176,30 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); try { - await execClaudeWithCLIProxy( - fakeClaudePath, - 'gemini', - [ - '--proxy-host', - '127.0.0.1', - '--proxy-port', - String(address.port), - '--proxy-auth-token', - 'SECRET_TOKEN_FOR_VALIDATION', - '--remote-only', - '--print', - 'hello', - ], - {} + await expect( + execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--print', + 'hello', + ], + {} + ) + ).rejects.toThrow( + 'WebSearch is enabled, but CCS could not prepare the local WebSearch tool.' ); - expect(await waitForFile(markerPath)).toBe(true); + expect(await waitForFile(markerPath)).toBe(false); expect(requestCount).toBeGreaterThan(0); - expect(exitSpy).toHaveBeenCalledWith(0); + expect(exitSpy).not.toHaveBeenCalledWith(0); } finally { exitSpy.mockRestore(); logSpy.mockRestore(); diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts new file mode 100644 index 00000000..b08d27bc --- /dev/null +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests for launch-settings.ts + * + * Verifies that the runtime settings overlay keeps the resolved proxy-chain + * `ANTHROPIC_BASE_URL` (and related routing keys) authoritative when Claude is + * launched with `--settings`, instead of the persisted CLIProxy-direct URL. + * + * The overlay is written to an isolated os.tmpdir() directory, so these tests + * never touch the real ~/.ccs. + */ + +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { buildLaunchSettingsOverlay, prepareLaunchSettings } from '../launch-settings'; + +let tmpDir: string; +let settingsPath: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-launch-settings-test-')); + settingsPath = path.join(tmpDir, 'codex.settings.json'); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writePersisted(settings: unknown): void { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); +} + +describe('buildLaunchSettingsOverlay', () => { + it('overlays routing env keys from the resolved environment', () => { + writePersisted({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_MODEL: 'gpt-5.5', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + }); + + const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', + ANTHROPIC_MODEL: 'gpt-5.5-high', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(true); + const env = settings.env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5-high'); + // Untouched keys are preserved. + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + }); + + it('preserves non-env settings (permissions, hooks, etc.)', () => { + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + permissions: { allow: ['Bash'] }, + statusLine: { type: 'command' }, + }); + + const { settings } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:60000/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(settings.permissions).toEqual({ allow: ['Bash'] }); + expect(settings.statusLine).toEqual({ type: 'command' }); + }); + + it('reports changed=false when resolved env matches persisted values', () => { + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + }); + + const { changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(false); + }); + + it('falls back to an env-only overlay when the settings file is missing', () => { + const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(true); + expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe( + 'http://127.0.0.1:50118/api/provider/codex' + ); + }); + + it('falls back to an env-only overlay when the settings file is corrupt', () => { + fs.writeFileSync(settingsPath, '{ not valid json'); + const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', + } as NodeJS.ProcessEnv); + expect(changed).toBe(true); + expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe( + 'http://127.0.0.1:50118/api/provider/codex' + ); + }); +}); + +describe('prepareLaunchSettings', () => { + it('writes a runtime overlay file and cleans it up when routing changes', () => { + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + }); + + const result = prepareLaunchSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(result.settingsPath).not.toBe(settingsPath); + expect(fs.existsSync(result.settingsPath)).toBe(true); + + const written = JSON.parse(fs.readFileSync(result.settingsPath, 'utf8')); + expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex'); + + result.cleanup(); + expect(fs.existsSync(result.settingsPath)).toBe(false); + }); + + it('returns the original path and a no-op cleanup when nothing changes', () => { + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + }); + + const result = prepareLaunchSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + } as NodeJS.ProcessEnv); + + expect(result.settingsPath).toBe(settingsPath); + // Cleanup must not remove the persisted settings file. + result.cleanup(); + expect(fs.existsSync(settingsPath)).toBe(true); + }); + + it('writes the overlay with 0600 file mode inside a 0700 dir (POSIX)', () => { + if (process.platform === 'win32') return; // chmod modes are not enforced on Windows + writePersisted({ + env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, + }); + const result = prepareLaunchSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', + } as NodeJS.ProcessEnv); + try { + expect(fs.statSync(result.settingsPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.dirname(result.settingsPath)).mode & 0o777).toBe(0o700); + } finally { + result.cleanup(); + } + }); +}); diff --git a/src/cliproxy/executor/claude-launcher.ts b/src/cliproxy/executor/claude-launcher.ts index ab74f0a4..ff189bbf 100644 --- a/src/cliproxy/executor/claude-launcher.ts +++ b/src/cliproxy/executor/claude-launcher.ts @@ -23,6 +23,7 @@ import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy'; import { setupCleanupHandlers } from './session-bridge'; +import { prepareLaunchSettings } from './launch-settings'; import { resolveRuntimeQuotaMonitorProviders } from './account-resolution'; import { isClaudeSubcommandInvocation, @@ -92,7 +93,7 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise {} } + : prepareLaunchSettings(persistedSettingsPath, env); + // Assemble final args: image analysis tools → browser tools → web search tools → settings const imageAnalysisArgs = imageAnalysisMcpReady ? appendThirdPartyImageAnalysisToolArgs(claudeSessionArgs) @@ -133,22 +144,35 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise key.startsWith('CCS_BROWSER_')) .sort(), - ws: env.CCS_BROWSER_DEVTOOLS_WS_URL || '', + hasDevtoolsWsUrl: Boolean(env.CCS_BROWSER_DEVTOOLS_WS_URL), }); } logEnvironment(env, webSearchEnv, verbose); diff --git a/src/cliproxy/executor/launch-settings.ts b/src/cliproxy/executor/launch-settings.ts new file mode 100644 index 00000000..5e33bf7e --- /dev/null +++ b/src/cliproxy/executor/launch-settings.ts @@ -0,0 +1,143 @@ +/** + * Launch settings overlay. + * + * CCS routes third-party providers through an ephemeral local proxy chain + * (tool-sanitization + codex-reasoning). The resolved `ANTHROPIC_BASE_URL` + * (and model/auth overrides) for that chain only exists in the spawned Claude + * process environment, because the proxy ports are random per launch and cannot + * be persisted to the on-disk provider settings file. + * + * Claude CLI is launched with `--settings `. Recent + * Claude Code releases apply the settings file's `env` block on top of the + * inherited process environment, so the persisted `ANTHROPIC_BASE_URL` (which + * points straight at CLIProxy) overrides the proxy-chain URL CCS injected via + * env. Claude then bypasses the proxy chain entirely — breaking tool-name + * sanitization and Codex system-message folding (the latter surfaces as + * `400 {"detail":"System messages are not allowed"}`). + * + * To keep the proxy chain authoritative regardless of Claude's env precedence, + * we write a runtime copy of the settings file whose `env` routing keys are + * overlaid with the resolved launch environment, and pass that copy to + * `--settings`. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../utils/shell-executor'; + +// SIBLING HELPER: src/utils/openai-compat-launch-settings.ts solves the same +// "persisted --settings env clobbers runtime routing env" problem by STRIPPING +// routing keys (process env wins by absence). This module instead OVERLAYS the +// resolved values (settings wins by overwrite). The two differ intentionally: +// strip vs overlay diverge when a key is present on disk but absent from the +// process env. Unifying them needs an explicit force-absent API — see issue #1609. + +/** + * Environment keys that control provider routing/model selection and are read + * by Claude from the settings `env` block. These must reflect the resolved + * proxy-chain environment, not the persisted on-disk values. Reuses the + * canonical routing/model key lists from shell-executor. + */ +const ROUTING_ENV_KEYS = [...ANTHROPIC_ROUTING_ENV_KEYS, ...ANTHROPIC_MODEL_ENV_KEYS]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Build a settings object based on the persisted settings file, with routing + * `env` keys overlaid from the resolved launch environment. + * + * @returns The merged settings and whether any routing key actually changed. + */ +export function buildLaunchSettingsOverlay( + settingsPath: string, + env: NodeJS.ProcessEnv +): { settings: Record; changed: boolean } { + let base: Record = {}; + try { + if (fs.existsSync(settingsPath)) { + const parsed: unknown = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + if (isRecord(parsed)) { + base = parsed; + } + } + } catch { + // Corrupt/unreadable settings file: fall back to an env-only overlay. + base = {}; + } + + const mergedEnv: Record = isRecord(base.env) ? { ...base.env } : {}; + + let changed = false; + for (const key of ROUTING_ENV_KEYS) { + const resolved = env[key]; + if (typeof resolved === 'string' && mergedEnv[key] !== resolved) { + mergedEnv[key] = resolved; + changed = true; + } + } + + return { settings: { ...base, env: mergedEnv }, changed }; +} + +/** + * Prepare the settings file path to hand to `claude --settings`. + * + * When the resolved launch environment changes any routing key relative to the + * persisted settings file (i.e. a proxy chain is active), a runtime overlay + * file is written and its path returned together with a cleanup callback that + * removes it. Otherwise the original `settingsPath` is returned unchanged and + * cleanup is a no-op. + */ +export function prepareLaunchSettings( + settingsPath: string, + env: NodeJS.ProcessEnv +): { settingsPath: string; cleanup: () => void } { + const noop = { settingsPath, cleanup: () => {} }; + + let overlay: { settings: Record; changed: boolean }; + try { + overlay = buildLaunchSettingsOverlay(settingsPath, env); + } catch { + return noop; + } + + if (!overlay.changed) { + return noop; + } + + try { + // Write to a private temp dir (matches createOpenAICompatLaunchSettings) so + // the overlay never lands in the user's ~/.ccs and is trivially isolated. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-launch-settings-')); + fs.chmodSync(tempDir, 0o700); + + const runtimePath = path.join(tempDir, path.basename(settingsPath) || 'settings.json'); + fs.writeFileSync(runtimePath, JSON.stringify(overlay.settings, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); + + let cleanedUp = false; + const cleanup = (): void => { + if (cleanedUp) { + return; + } + cleanedUp = true; + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }; + + return { settingsPath: runtimePath, cleanup }; + } catch { + // If we cannot write the overlay, fall back to the persisted file. Routing + // may bypass the proxy chain, but the session still launches. + return noop; + } +} diff --git a/src/commands/bar/bar-paths.ts b/src/commands/bar/bar-paths.ts index c8edfe93..78780733 100644 --- a/src/commands/bar/bar-paths.ts +++ b/src/commands/bar/bar-paths.ts @@ -41,7 +41,7 @@ export interface LaunchJson { schema: typeof LAUNCH_JSON_SCHEMA; /** Absolute path to the node/bun binary (process.execPath). */ runtime: string; - /** Absolute CCS entry point + subcommand args: [process.argv[1], 'bar', 'serve']. */ + /** Absolute private CCS launcher shim + subcommand args: [ccs.js, 'bar', 'serve']. */ args: string[]; /** os.homedir() — cwd for the spawned server. */ home: string; diff --git a/src/commands/bar/bar-server-probe.ts b/src/commands/bar/bar-server-probe.ts index 2d489d9d..496521e6 100644 --- a/src/commands/bar/bar-server-probe.ts +++ b/src/commands/bar/bar-server-probe.ts @@ -8,7 +8,16 @@ import * as fs from 'fs'; import * as path from 'path'; -import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; +import { + BAR_AUTH_NONCE_HEADER, + BAR_AUTH_TOKEN_HEADER, + createBarAuthNonce, + isMatchingBarAuthProof, + getOrCreateBarAuthToken, +} from '../../utils/bar-auth-token'; + +const PROBE_TIMEOUT_MS = 1500; +const MAX_PROBE_RESPONSE_BYTES = 8192; export interface DashboardInfo { port: number; @@ -46,12 +55,10 @@ export function resolveBarPort(ccsDir: string): number | null { * from a healthy one (200) without depending on a higher-level HTTP client. * * Token authentication: the probe does NOT send the token in the request. - * The real CCS Bar server reads the token from the 0600 file and includes it - * unconditionally in the x-ccs-bar-token response header. The probe then checks - * that the echoed value matches the locally-read token. A rogue loopback process - * that has not read the 0600 file cannot produce the correct value, so a 200 - * without a matching token header is rejected. Sending the token in the request - * would defeat this — any process could echo what it received. + * Instead, it sends a fresh nonce. The real CCS Bar server reads the token from + * the 0600 file and returns HMAC(token, nonce) in the x-ccs-bar-token response + * header. The probe verifies the nonce-bound proof, so a captured proof cannot + * be replayed for a future probe. */ export async function defaultFindRunningServer(ccsDir: string): Promise { const token = getOrCreateBarAuthToken(ccsDir); @@ -61,13 +68,17 @@ export async function defaultFindRunningServer(ccsDir: string): Promise { let rawResponse = ''; let settled = false; + const absoluteDeadline = setTimeout(() => finish(), PROBE_TIMEOUT_MS); + absoluteDeadline.unref?.(); const finish = (statusCode = 0, headerSection = '') => { if (settled) return; settled = true; + clearTimeout(absoluteDeadline); // Tear down the socket the moment we have enough to decide. The summary // endpoint only needs the status code for liveness, so a non-CCS // loopback service that streams forever cannot block discovery from @@ -79,28 +90,30 @@ export async function defaultFindRunningServer(ccsDir: string): Promise { - // Do NOT include the token in the request — sending the secret to the - // party being authenticated lets any reflector trivially pass the check. + // Do NOT include the token in the request; only send a fresh nonce so + // the server can prove it knows the token without disclosing it. socket.write( - `GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\nConnection: close\r\n\r\n` + `GET ${parsed.pathname}${parsed.search} HTTP/1.1\r\nHost: ${parsed.host}\r\n${BAR_AUTH_NONCE_HEADER}: ${nonce}\r\nConnection: close\r\n\r\n` ); }); - socket.setTimeout(1500, () => finish()); + socket.setTimeout(PROBE_TIMEOUT_MS, () => finish()); socket.on('data', (chunk) => { rawResponse += chunk.toString('utf8'); + if (rawResponse.length > MAX_PROBE_RESPONSE_BYTES) { + finish(); + return; + } const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); if (statusMatch) { const code = Number(statusMatch[1]); diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index d0fd2903..fc435278 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -20,8 +20,9 @@ import * as os from 'os'; import * as path from 'path'; import { getCcsDir } from '../../config/config-loader-facade'; import { hasAnyFlag } from '../arg-extractor'; -import { getLaunchJsonPath, LAUNCH_JSON_SCHEMA } from './bar-paths'; +import { getLaunchJsonPath } from './bar-paths'; import type { LaunchJson } from './bar-paths'; +import { createBarLaunchDescriptor } from './launch-descriptor'; // --------------------------------------------------------------------------- // Constants @@ -671,13 +672,7 @@ export async function handleBarInstall( // Non-fatal — install has already succeeded at this point. try { const launchJsonPath = getLaunchJsonPath(ccsDir); - const launchDescriptor: LaunchJson = { - schema: LAUNCH_JSON_SCHEMA, - runtime: process.execPath, - args: [process.argv[1], 'bar', 'serve'], - home: os.homedir(), - ...(process.env.CCS_HOME ? { ccsHome: process.env.CCS_HOME } : {}), - }; + const launchDescriptor = createBarLaunchDescriptor(); writeLaunchDescriptor(launchJsonPath, launchDescriptor); } catch (err) { const msg = err instanceof Error ? err.message : String(err); diff --git a/src/commands/bar/launch-descriptor.ts b/src/commands/bar/launch-descriptor.ts new file mode 100644 index 00000000..61ededb0 --- /dev/null +++ b/src/commands/bar/launch-descriptor.ts @@ -0,0 +1,68 @@ +/** + * Safe launch descriptor builder for the native CCS Bar app. + * + * The Swift app intentionally distrusts `~/.ccs/bar/launch.json`; it only + * accepts a regular, non-group-writable/non-world-writable `ccs.js` entrypoint. + * Bun global installs expose `~/.bun/bin/ccs` as a symlink and the target file + * can be group/world writable, so the descriptor points at a private shim + * instead of the package-manager entrypoint. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ConfigError } from '../../errors/error-types'; +import { LAUNCH_JSON_SCHEMA } from './bar-paths'; +import type { LaunchJson } from './bar-paths'; + +const SHIM_MODE = 0o700; + +export interface LaunchDescriptorOptions { + entrypointPath?: string; + runtime?: string; + home?: string; + ccsHome?: string; +} + +export function getLaunchShimPath(home: string = os.homedir()): string { + return path.join(home, 'Library', 'Application Support', 'CCS Bar', 'launcher', 'ccs.js'); +} + +function resolveEntrypoint(entrypointPath?: string): string { + const candidate = entrypointPath ?? process.argv[1]; + if (!candidate) { + throw new ConfigError('Unable to resolve the current CCS entrypoint for CCS Bar launch.json.'); + } + return fs.realpathSync(candidate); +} + +export function writeLaunchShim(home: string, entrypointPath?: string): string { + const resolvedEntrypoint = resolveEntrypoint(entrypointPath); + const shimPath = getLaunchShimPath(home); + const shimDir = path.dirname(shimPath); + const contents = [ + '#!/usr/bin/env node', + `require(${JSON.stringify(resolvedEntrypoint)});`, + '', + ].join('\n'); + + fs.mkdirSync(shimDir, { recursive: true, mode: SHIM_MODE }); + fs.writeFileSync(shimPath, contents, { mode: SHIM_MODE }); + fs.chmodSync(shimDir, SHIM_MODE); + fs.chmodSync(shimPath, SHIM_MODE); + + return shimPath; +} + +export function createBarLaunchDescriptor(options: LaunchDescriptorOptions = {}): LaunchJson { + const home = options.home ?? os.homedir(); + const entrypoint = writeLaunchShim(home, options.entrypointPath); + const ccsHome = options.ccsHome ?? process.env.CCS_HOME; + return { + schema: LAUNCH_JSON_SCHEMA, + runtime: options.runtime ?? process.execPath, + args: [entrypoint, 'bar', 'serve'], + home, + ...(ccsHome ? { ccsHome } : {}), + }; +} diff --git a/src/commands/bar/launch-subcommand.ts b/src/commands/bar/launch-subcommand.ts index 132c7563..2d1f1897 100644 --- a/src/commands/bar/launch-subcommand.ts +++ b/src/commands/bar/launch-subcommand.ts @@ -21,21 +21,25 @@ import * as os from 'os'; import * as path from 'path'; import type { ChildProcess } from 'child_process'; import { getCcsDir } from '../../config/config-loader-facade'; -import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; import { - getBarDir, - getBarJsonPath, - getLaunchJsonPath, - getServeLogPath, - LAUNCH_JSON_SCHEMA, -} from './bar-paths'; + BAR_AUTH_NONCE_HEADER, + BAR_AUTH_TOKEN_HEADER, + createBarAuthNonce, + isMatchingBarAuthProof, + getOrCreateBarAuthToken, +} from '../../utils/bar-auth-token'; +import { getBarDir, getBarJsonPath, getLaunchJsonPath, getServeLogPath } from './bar-paths'; import type { LaunchJson } from './bar-paths'; +import { createBarLaunchDescriptor } from './launch-descriptor'; import { defaultFindRunningServer as _defaultFindRunningServer, resolveBarPort as _resolveBarPort, } from './bar-server-probe'; import type { DashboardInfo as _DashboardInfo } from './bar-server-probe'; +const BAR_PROBE_TIMEOUT_MS = 1500; +const MAX_BAR_PROBE_RESPONSE_BYTES = 8192; + // --------------------------------------------------------------------------- // Re-exports — backward compat for tests that import from this module. // resolveBarPort + defaultFindRunningServer are canonical in bar-server-probe.ts; @@ -134,6 +138,13 @@ export class BarServerAuthRequiredError extends Error { } } +export class BarServerTimeoutError extends Error { + constructor(baseUrl: string, timeoutSeconds: number) { + super(`CCS Bar server did not become live at ${baseUrl} within ${timeoutSeconds}s`); + this.name = 'BarServerTimeoutError'; + } +} + function isAuthRequiredStatus(statusCode: number): boolean { return statusCode === 401 || statusCode === 403; } @@ -147,19 +158,23 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { async function probe(): Promise<{ statusCode: number | null; tokenMatched: boolean }> { const url = new URL(`${baseUrl}/api/bar/summary`); + const nonce = createBarAuthNonce(); return new Promise((resolve) => { let rawResponse = ''; let settled = false; + const absoluteDeadline = setTimeout(() => finish(), BAR_PROBE_TIMEOUT_MS); + absoluteDeadline.unref?.(); const finish = (statusCode: number | null = null, headerSection = '') => { if (settled) return; settled = true; + clearTimeout(absoluteDeadline); socket.destroy(); if (statusCode === 200) { const echoMatch = headerSection.match( new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i') ); - const echoedToken = echoMatch ? echoMatch[1].trim() : ''; - resolve({ statusCode, tokenMatched: echoedToken === token }); + const proof = echoMatch ? echoMatch[1].trim() : ''; + resolve({ statusCode, tokenMatched: isMatchingBarAuthProof(token, nonce, proof) }); return; } resolve({ statusCode, tokenMatched: false }); @@ -167,16 +182,20 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { const socket = net.connect( { host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port) }, () => { - // Do NOT include the token in the request — sending the secret to the - // party being authenticated lets any reflector trivially pass the check. + // Do NOT include the token in the request; only send a fresh nonce so + // the server can prove it knows the token without disclosing it. socket.write( - `GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n` + `GET ${url.pathname}${url.search} HTTP/1.1\r\nHost: ${url.host}\r\n${BAR_AUTH_NONCE_HEADER}: ${nonce}\r\nConnection: close\r\n\r\n` ); } ); - socket.setTimeout(1500, () => finish()); + socket.setTimeout(BAR_PROBE_TIMEOUT_MS, () => finish()); socket.on('data', (chunk) => { rawResponse += chunk.toString('utf8'); + if (rawResponse.length > MAX_BAR_PROBE_RESPONSE_BYTES) { + finish(); + return; + } const statusMatch = rawResponse.match(/^HTTP\/\d(?:\.\d)?\s+(\d{3})/); if (statusMatch) { const code = Number(statusMatch[1]); @@ -209,7 +228,7 @@ export async function defaultWaitForServerLive(baseUrl: string): Promise { await new Promise((resolve) => setTimeout(resolve, INTERVAL_MS)); } - throw new Error(`CCS Bar server did not become live at ${baseUrl} within ${TIMEOUT_MS / 1000}s`); + throw new BarServerTimeoutError(baseUrl, TIMEOUT_MS / 1000); } function defaultWriteLaunchDescriptor(jsonPath: string, descriptor: LaunchJson): void { @@ -305,14 +324,8 @@ export async function handleBarLaunch( } // 2b. Write/refresh launch.json so the Swift app can self-start next time. - const launchDescriptor: LaunchJson = { - schema: LAUNCH_JSON_SCHEMA, - runtime: process.execPath, - args: [process.argv[1], 'bar', 'serve'], - home: os.homedir(), - ...(process.env.CCS_HOME ? { ccsHome: process.env.CCS_HOME } : {}), - }; try { + const launchDescriptor = createBarLaunchDescriptor(); writeLaunchDescriptor(launchJsonPath, launchDescriptor); } catch (err) { // Non-fatal — the Swift app falls back to resolving `ccs` via PATH. diff --git a/src/commands/cliproxy/__tests__/proxy-lifecycle-control-panel.test.ts b/src/commands/cliproxy/__tests__/proxy-lifecycle-control-panel.test.ts index 48aa735b..c799c30b 100644 --- a/src/commands/cliproxy/__tests__/proxy-lifecycle-control-panel.test.ts +++ b/src/commands/cliproxy/__tests__/proxy-lifecycle-control-panel.test.ts @@ -1,12 +1,12 @@ /** * Tests for printControlPanelAccess: the Control Panel (API Management Center) - * URL + login-key surface shown by `ccs cliproxy status` / `start`. + * URL + masked login-key hint shown by `ccs cliproxy status` / `start`. * * Why this matters: - * - The Control Panel login screen only asks for a "Management Key" with no - * hint. Users who don't know the default (`ccs`) cannot get in. This surface - * is the fix, so it must print the panel URL on the active port and the - * EFFECTIVE key (default `ccs`, or the user's custom management_secret). + * - Routine lifecycle output is commonly pasted into logs and support tickets, + * so it must not disclose the raw management key. The helper should print + * the panel URL, a masked key for orientation, and the explicit command users + * can run when they intentionally need the full key. */ import * as fs from 'fs'; import * as os from 'os'; @@ -56,12 +56,14 @@ describe('printControlPanelAccess', () => { } }); - it('prints the panel URL on the given port and the default key (ccs)', () => { + it('prints the panel URL on the given port and masks the default key', () => { printControlPanelAccess(8317); const out = lines.join('\n'); expect(out).toContain('http://127.0.0.1:8317/management.html'); expect(out).toContain('Panel login key:'); - expect(out).toContain('ccs'); + expect(out).toContain('Panel login key: ****'); + expect(out).toContain('ccs tokens --show'); + expect(out).not.toContain('Panel login key: ccs'); }); it('uses the active port in the URL', () => { @@ -69,7 +71,7 @@ describe('printControlPanelAccess', () => { expect(lines.join('\n')).toContain('http://127.0.0.1:9000/management.html'); }); - it('prints a custom management_secret when configured', () => { + it('masks a custom management_secret when configured', () => { mutateConfig((config) => { if (!config.cliproxy) { config.cliproxy = {}; @@ -84,6 +86,8 @@ describe('printControlPanelAccess', () => { printControlPanelAccess(8317); const out = lines.join('\n'); expect(out).toContain('Panel login key:'); - expect(out).toContain('my-custom-key'); + expect(out).toContain('my-c...-key'); + expect(out).toContain('ccs tokens --show'); + expect(out).not.toContain('my-custom-key'); }); }); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 9fb96480..87296a0c 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -91,7 +91,7 @@ export async function showHelp(): Promise { ['restart', 'Restart CLIProxy instance'], [ 'status [--verbose]', - 'Show CLIProxy status + Control Panel URL and login key (--verbose adds uptime)', + 'Show CLIProxy status + Control Panel URL and masked login key (--verbose adds uptime)', ], ['stop', 'Stop running CLIProxy instance'], ['doctor | diag', 'Quota diagnostics and shared project detection'], diff --git a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts index 6794260d..92133e0a 100644 --- a/src/commands/cliproxy/proxy-lifecycle-subcommand.ts +++ b/src/commands/cliproxy/proxy-lifecycle-subcommand.ts @@ -12,22 +12,24 @@ import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui'; import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services'; import { detectRunningProxy } from '../../cliproxy/proxy/proxy-detector'; import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; -import { getEffectiveManagementSecret } from '../../cliproxy/auth/auth-token-manager'; +import { getEffectiveManagementSecret, maskToken } from '../../cliproxy/auth/auth-token-manager'; /** * Print how to reach the local CLIProxy Control Panel (a.k.a. API Management - * Center) and the key needed to log in. + * Center) without exposing the management secret in routine output. * * The panel is served by CLIProxy at `/management.html` on the proxy port and - * its login is gated by the management secret (default `ccs`). The login screen - * only asks for a "Management Key" with no hint, so users frequently cannot get - * in. Surfacing the URL + resolved key here removes that guesswork. + * its login is gated by the management secret (default `ccs`). Keep the resolved + * key masked here because `start` and `status` output is commonly shared in + * support tickets and logs. Users can explicitly reveal tokens with + * `ccs tokens --show` when they need the raw value. */ export function printControlPanelAccess(port: number): void { const secret = getEffectiveManagementSecret(); console.log(''); console.log(` Control Panel: http://127.0.0.1:${port}/management.html`); - console.log(` Panel login key: ${secret}`); + console.log(` Panel login key: ${maskToken(secret)}`); + console.log(dim(' To show the full key: ccs tokens --show')); } export async function handleStart(verbose = false): Promise { diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 7510f4a7..85e2488f 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -18,6 +18,7 @@ import { } from '../cursor'; import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types'; +import { getCursorDaemonToken } from '../cursor/cursor-daemon-auth'; import { renderCursorHelp, renderCursorModels, @@ -253,6 +254,7 @@ async function handleStart(): Promise { const result = await startDaemon({ port: cursorConfig.port, ghost_mode: cursorConfig.ghost_mode, + daemon_token: getCursorDaemonToken(), }); if (result.success) { diff --git a/src/config/loader/config-getters.ts b/src/config/loader/config-getters.ts index f8e77492..1081188b 100644 --- a/src/config/loader/config-getters.ts +++ b/src/config/loader/config-getters.ts @@ -90,6 +90,7 @@ export function getWebSearchConfig(): { brave?: { enabled?: boolean; max_results?: number }; searxng?: { enabled?: boolean; url?: string; max_results?: number }; duckduckgo?: { enabled?: boolean; max_results?: number }; + agy?: { enabled?: boolean; model?: string; timeout?: number }; gemini?: GeminiWebSearchInfo; opencode?: { enabled?: boolean; model?: string; timeout?: number }; grok?: { enabled?: boolean; timeout?: number }; @@ -126,6 +127,12 @@ export function getWebSearchConfig(): { max_results: config.websearch?.providers?.searxng?.max_results ?? 5, }; + const agyConfig = { + enabled: config.websearch?.providers?.agy?.enabled ?? false, + model: config.websearch?.providers?.agy?.model ?? 'gemini-2.5-flash', + timeout: config.websearch?.providers?.agy?.timeout ?? 90, + }; + const geminiConfig: GeminiWebSearchInfo = { enabled: config.websearch?.providers?.gemini?.enabled ?? config.websearch?.gemini?.enabled ?? false, @@ -152,6 +159,7 @@ export function getWebSearchConfig(): { braveConfig.enabled || searxngConfig.enabled || duckDuckGoConfig.enabled || + agyConfig.enabled || geminiConfig.enabled || opencodeConfig.enabled || grokConfig.enabled; @@ -165,6 +173,7 @@ export function getWebSearchConfig(): { brave: braveConfig, searxng: searxngConfig, duckduckgo: duckDuckGoConfig, + agy: agyConfig, gemini: geminiConfig, opencode: opencodeConfig, grok: grokConfig, diff --git a/src/config/loader/defaults-merger.ts b/src/config/loader/defaults-merger.ts index 7b45f4a1..b6cd7c9a 100644 --- a/src/config/loader/defaults-merger.ts +++ b/src/config/loader/defaults-merger.ts @@ -160,6 +160,11 @@ export function mergeWithDefaults(partial: Partial): UnifiedConfi enabled: partial.websearch?.providers?.duckduckgo?.enabled ?? true, max_results: partial.websearch?.providers?.duckduckgo?.max_results ?? 5, }, + agy: { + enabled: partial.websearch?.providers?.agy?.enabled ?? false, + model: partial.websearch?.providers?.agy?.model ?? 'gemini-2.5-flash', + timeout: partial.websearch?.providers?.agy?.timeout ?? 90, + }, gemini: { enabled: partial.websearch?.providers?.gemini?.enabled ?? diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts index 7fc878e9..bd511b53 100644 --- a/src/config/loader/yaml-serializer.ts +++ b/src/config/loader/yaml-serializer.ts @@ -149,8 +149,11 @@ export function generateYamlWithComments(config: UnifiedConfig): string { lines.push('# Brave requires BRAVE_API_KEY in your environment.'); lines.push('# DuckDuckGo works with zero extra setup and is enabled by default.'); lines.push('#'); - lines.push('# Legacy LLM fallbacks remain optional if you still want them:'); - lines.push('# gemini: npm i -g @google/gemini-cli'); + lines.push('# Optional LLM CLI fallbacks:'); + lines.push( + '# agy (recommended): curl -fsSL https://antigravity.google/cli/install.sh | bash' + ); + lines.push('# gemini (deprecated, retired upstream 2026-06-18): use agy instead'); lines.push('# opencode: curl -fsSL https://opencode.ai/install | bash'); lines.push('# grok: npm i -g @vibe-kit/grok-cli'); lines.push('# ----------------------------------------------------------------------------'); diff --git a/src/config/schemas/index.ts b/src/config/schemas/index.ts index a25aa156..43a437b5 100644 --- a/src/config/schemas/index.ts +++ b/src/config/schemas/index.ts @@ -66,6 +66,7 @@ export type { ExaWebSearchConfig, TavilyWebSearchConfig, SearxngWebSearchConfig, + AgyWebSearchConfig, GeminiWebSearchConfig, GrokWebSearchConfig, OpenCodeWebSearchConfig, diff --git a/src/config/schemas/unified-config.ts b/src/config/schemas/unified-config.ts index a38db23a..4b4ce886 100644 --- a/src/config/schemas/unified-config.ts +++ b/src/config/schemas/unified-config.ts @@ -154,6 +154,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { enabled: true, max_results: 5, }, + agy: { + enabled: false, + model: 'gemini-2.5-flash', + timeout: 90, + }, gemini: { enabled: false, model: 'gemini-2.5-flash', diff --git a/src/config/schemas/websearch.ts b/src/config/schemas/websearch.ts index d2714c41..42012a67 100644 --- a/src/config/schemas/websearch.ts +++ b/src/config/schemas/websearch.ts @@ -5,7 +5,7 @@ * - API-backed: Exa, Tavily, Brave * - Self-hosted: SearXNG * - Zero-setup: DuckDuckGo - * - Legacy CLI fallbacks: Gemini, Grok, OpenCode + * - LLM CLI fallbacks: Antigravity (agy, recommended), Gemini (deprecated), Grok, OpenCode */ /** @@ -60,8 +60,25 @@ export interface SearxngWebSearchConfig { max_results?: number; } +/** + * Antigravity CLI (agy) WebSearch configuration. + * + * Recommended LLM CLI fallback. `agy` is Google's successor to the retired + * `gemini` CLI. Install: `curl -fsSL https://antigravity.google/cli/install.sh | bash`. + */ +export interface AgyWebSearchConfig { + /** Enable Antigravity CLI fallback (default: false) */ + enabled?: boolean; + /** Model to use (default: gemini-2.5-flash; accepts legacy gemini ids) */ + model?: string; + /** Timeout in seconds (default: 90) */ + timeout?: number; +} + /** * Gemini CLI WebSearch configuration. + * + * @deprecated Google retired the gemini CLI on 2026-06-18. Use Antigravity (agy) instead. */ export interface GeminiWebSearchConfig { /** Enable Gemini CLI legacy fallback (default: false) */ @@ -109,7 +126,9 @@ export interface WebSearchProvidersConfig { searxng?: SearxngWebSearchConfig; /** DuckDuckGo HTML search - zero setup default backend */ duckduckgo?: DuckDuckGoWebSearchConfig; - /** Gemini CLI - optional legacy LLM fallback */ + /** Antigravity CLI (agy) - recommended LLM CLI fallback (Gemini CLI successor) */ + agy?: AgyWebSearchConfig; + /** Gemini CLI - deprecated legacy LLM fallback (retired upstream) */ gemini?: GeminiWebSearchConfig; /** Grok CLI - optional legacy LLM fallback */ grok?: GrokWebSearchConfig; diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index a22a2a0a..db633f0f 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -22,6 +22,7 @@ import { translateAnthropicRequest } from './cursor-anthropic-translator'; import { checkAuthStatus } from './cursor-auth'; import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; import type { CursorTool } from './cursor-protobuf-schema'; +import { ValidationError } from '../errors/error-types'; interface DaemonRuntimeOptions { port: number; @@ -132,22 +133,46 @@ function readJsonBody(req: http.IncomingMessage): Promise { }); } +function headerMatchesToken(header: string | string[] | undefined, expectedToken: string): boolean { + if (typeof header === 'string') { + return header === expectedToken; + } + + if (Array.isArray(header)) { + return header.includes(expectedToken); + } + + return false; +} + +function authorizationMatchesToken( + header: string | string[] | undefined, + expectedToken: string +): boolean { + const values = Array.isArray(header) ? header : header ? [header] : []; + return values.some((value) => { + const trimmed = value.trim(); + if (trimmed === expectedToken) { + return true; + } + + const match = /^Bearer\s+(.+)$/i.exec(trimmed); + return match?.[1] === expectedToken; + }); +} + function hasValidDaemonToken(req: http.IncomingMessage): boolean { const expectedToken = process.env.CCS_CURSOR_DAEMON_TOKEN; if (!expectedToken) { return false; } - const provided = req.headers['x-ccs-cursor-token']; - if (typeof provided === 'string') { - return provided === expectedToken; - } - - if (Array.isArray(provided)) { - return provided.includes(expectedToken); - } - - return false; + return ( + headerMatchesToken(req.headers['x-ccs-cursor-token'], expectedToken) || + headerMatchesToken(req.headers['anthropic-auth-token'], expectedToken) || + headerMatchesToken(req.headers['x-api-key'], expectedToken) || + authorizationMatchesToken(req.headers.authorization, expectedToken) + ); } function resolveInboundRequestId(req: http.IncomingMessage): string | undefined { @@ -180,17 +205,20 @@ function withCursorDaemonRequestContext( function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] { if (!Array.isArray(raw)) { - throw new Error('messages must be an array'); + throw new ValidationError('messages must be an array', 'messages'); } return raw.map((message, index) => { if (typeof message !== 'object' || message === null) { - throw new Error(`messages[${index}] must be an object`); + throw new ValidationError(`messages[${index}] must be an object`, `messages[${index}]`); } const m = message as Record; if (typeof m.role !== 'string' || !m.role) { - throw new Error(`messages[${index}].role must be a non-empty string`); + throw new ValidationError( + `messages[${index}].role must be a non-empty string`, + `messages[${index}].role` + ); } const content = m.content; @@ -200,7 +228,10 @@ function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] { typeof content !== 'string' && !Array.isArray(content) ) { - throw new Error(`messages[${index}].content must be string, array, or null`); + throw new ValidationError( + `messages[${index}].content must be string, array, or null`, + `messages[${index}].content` + ); } return { @@ -364,7 +395,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } if (isAnthropicRoute) { - const expectedToken = (process.env.ANTHROPIC_AUTH_TOKEN || 'cursor-managed').trim(); + const expectedToken = ( + process.env.ANTHROPIC_AUTH_TOKEN || + process.env.CCS_CURSOR_DAEMON_TOKEN || + 'cursor-managed' + ).trim(); const requestToken = getAnthropicRequestToken(req.headers); if (!expectedToken || requestToken !== expectedToken) { await pipeWebResponseToNode( diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index 41b41f3c..0d46da81 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -12,6 +12,7 @@ import * as path from 'path'; import * as http from 'http'; import type { CursorDaemonConfig, CursorDaemonStatus } from './types'; import { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid'; +import { getCursorDaemonToken } from './cursor-daemon-auth'; import { verifyDaemonOwnership } from './daemon-process-ownership'; import { createLogger, forwardRequestIdEnv } from '../services/logging'; export { getPidFromFile, writePidToFile, removePidFile } from './cursor-daemon-pid'; @@ -53,6 +54,15 @@ async function resolveDaemonEntrypoint(): Promise { return null; } +export function buildDaemonProcessEnv(daemonToken: string): NodeJS.ProcessEnv { + return { + ...process.env, + ...forwardRequestIdEnv(), + CCS_CURSOR_DAEMON_TOKEN: daemonToken, + ANTHROPIC_AUTH_TOKEN: daemonToken, + }; +} + /** * Check if cursor daemon is running on the specified port. * Uses 127.0.0.1 instead of localhost for more reliable local connections. @@ -108,8 +118,11 @@ export async function isDaemonRunning(port: number, daemonToken?: string): Promi /** * Get daemon status. */ -export async function getDaemonStatus(port: number): Promise { - const running = await isDaemonRunning(port); +export async function getDaemonStatus( + port: number, + daemonToken = getCursorDaemonToken() +): Promise { + const running = await isDaemonRunning(port, daemonToken); const pid = getPidFromFile(); return { @@ -224,11 +237,7 @@ export async function startDaemon( proc = spawn(process.execPath, args, { stdio: 'ignore', detached: true, - env: { - ...process.env, - ...forwardRequestIdEnv(), - CCS_CURSOR_DAEMON_TOKEN: effectiveConfig.daemon_token || '', - }, + env: buildDaemonProcessEnv(effectiveConfig.daemon_token || ''), }); // Unref so parent can exit diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts index 65b8feb5..c21bddbe 100644 --- a/src/cursor/cursor-runtime-probe.ts +++ b/src/cursor/cursor-runtime-probe.ts @@ -143,6 +143,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise; @@ -95,12 +95,9 @@ export interface TransformResult { error?: string; } -export interface ThinkingSignature { - type: string; - hash: string; - length: number; - timestamp: number; -} +// Anthropic contract: a thinking block's signature is a non-empty opaque +// string. Kept as a named alias so existing imports stay intact. +export type ThinkingSignature = string; // OpenAI response types export interface OpenAIChoice { diff --git a/src/services/logging/log-redaction.ts b/src/services/logging/log-redaction.ts index 79a65dd7..8218e4e4 100644 --- a/src/services/logging/log-redaction.ts +++ b/src/services/logging/log-redaction.ts @@ -12,7 +12,14 @@ const SENSITIVE_KEY_PATTERN = /** CLI flags whose following argument should be redacted in argv arrays. */ const SENSITIVE_ARGV_FLAG_PATTERN = - /^--(token|api[_-]?key|auth|auth[_-]?token|secret|bearer|password|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token)$/i; + /^--(token|api[_-]?key|auth|auth[_-]?token|secret|bearer|password|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token|prompt)$/i; + +/** Short CLI flags whose following argument should be redacted in argv arrays. */ +const SENSITIVE_SHORT_ARGV_FLAG_PATTERN = /^-p$/; + +/** CLI flags whose inline `--flag=value` payload should be redacted in argv arrays. */ +const SENSITIVE_ARGV_ASSIGNMENT_PATTERN = + /^--(token|api[_-]?key|auth|auth[_-]?token|secret|bearer|password|client[_-]?secret|refresh[_-]?token|access[_-]?token|id[_-]?token|prompt)=/i; /** Bearer/Basic/Token auth-scheme prefix in raw string values. */ const AUTH_SCHEME_VALUE_PATTERN = /^(Bearer|Basic|Token)\s+\S+/; @@ -126,8 +133,16 @@ export function redactArgv(argv: readonly string[]): string[] { const out: string[] = []; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; + if (SENSITIVE_ARGV_ASSIGNMENT_PATTERN.test(arg)) { + const separatorIndex = arg.indexOf('='); + out.push(`${arg.slice(0, separatorIndex + 1)}[redacted]`); + continue; + } out.push(arg); - if (SENSITIVE_ARGV_FLAG_PATTERN.test(arg) && i + 1 < argv.length) { + if ( + (SENSITIVE_ARGV_FLAG_PATTERN.test(arg) || SENSITIVE_SHORT_ARGV_FLAG_PATTERN.test(arg)) && + i + 1 < argv.length + ) { out.push('[redacted]'); i++; } diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index b2072f2d..5880588a 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -25,6 +25,7 @@ export const PROVIDER_PRESET_IDS = [ 'ollama-cloud', 'novita', 'fireworks', + 'requesty', ] as const; export type ProviderPresetId = (typeof PROVIDER_PRESET_IDS)[number]; @@ -297,6 +298,19 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ requiresApiKey: true, badge: 'Anthropic-compatible', }, + { + id: 'requesty', + name: 'Requesty', + description: 'OpenAI-compatible LLM gateway (provider/model naming, e.g. openai/gpt-4o-mini)', + baseUrl: 'https://router.requesty.ai/v1', + defaultProfileName: 'requesty', + defaultModel: 'openai/gpt-4o-mini', + apiKeyPlaceholder: 'rqsty-sk-...', + apiKeyHint: 'Create an API key at app.requesty.ai/api-keys', + category: 'alternative', + requiresApiKey: true, + badge: 'OpenAI-compatible', + }, ]; function clonePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition { diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 9e91f4db..9ef937b5 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -1,7 +1,7 @@ import { ChildProcess, spawn } from 'child_process'; import * as fs from 'fs'; import type { ProfileType } from '../types/profile'; -import { runCleanup } from '../errors'; +import { ConfigError, runCleanup } from '../errors'; import { expandPath } from '../utils/helpers'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { @@ -25,6 +25,7 @@ import { } from './codex-detector'; import { createLogger } from '../services/logging'; import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager'; +import { normalizeCodexResponsesBaseUrl } from '../cliproxy/config/provider-route'; import { resolveLifecyclePort } from '../cliproxy/config/port-manager'; import { getModelMaxLevel } from '../cliproxy/model-catalog'; import { parseCodexModelTuningAlias } from '../cliproxy/ai-providers/model-id-normalizer'; @@ -53,7 +54,7 @@ function buildConfigOverrideArgs(overrides: string[]): string[] { function buildConfigOverrideSupportError(binaryInfo?: TargetBinaryInfo): Error { const versionSummary = binaryInfo?.version ? ` (${binaryInfo.version})` : ''; - return new Error( + return new ConfigError( `Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles or runtime reasoning overrides.` ); } @@ -100,7 +101,7 @@ function normalizeCodexReasoningOverride(value: string | number | undefined): st if (typeof value === 'string' && CODEX_REASONING_LEVELS.has(value)) { return value; } - throw new Error( + throw new ConfigError( 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.' ); } @@ -272,7 +273,7 @@ export class CodexAdapter implements TargetAdapter { const providerRepair = await ensureCodexCliproxyProviderConfig(resolveLifecyclePort()); this.ccsxpCliproxyEnvKey = providerRepair.envKey; } catch (error) { - throw new Error( + throw new ConfigError( `ccsxp could not repair the native Codex cliproxy provider: ${(error as Error).message}` ); } @@ -316,14 +317,14 @@ export class CodexAdapter implements TargetAdapter { } if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) { - throw new Error( + throw new ConfigError( 'Codex target requires base URL and API key for CCS-backed profile launches.' ); } const disallowedFlags = findDisallowedCodexManagedFlags(userArgs); if (disallowedFlags.length > 0) { - throw new Error( + throw new ConfigError( `Codex target does not allow ${disallowedFlags.join(', ')} when CCS manages the runtime provider. Remove native Codex provider selection flags and retry.` ); } @@ -331,7 +332,9 @@ export class CodexAdapter implements TargetAdapter { const overrides = [ `model_provider=${formatTomlString(CODEX_RUNTIME_PROVIDER_ID)}`, `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.name=${formatTomlString('CCS Runtime')}`, - `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.base_url=${formatTomlString(creds.baseUrl)}`, + `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.base_url=${formatTomlString( + normalizeCodexResponsesBaseUrl(creds.baseUrl) + )}`, `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.env_key=${formatTomlString(CODEX_RUNTIME_ENV_KEY)}`, `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.wire_api=${formatTomlString('responses')}`, ]; @@ -360,7 +363,7 @@ export class CodexAdapter implements TargetAdapter { } if (profileType !== 'default') { if (!creds.apiKey?.trim()) { - throw new Error('Codex target requires an API key for CCS-backed profile launches.'); + throw new ConfigError('Codex target requires an API key for CCS-backed profile launches.'); } env[CODEX_RUNTIME_ENV_KEY] = creds.apiKey; } diff --git a/src/targets/codex-cliproxy-provider-config.ts b/src/targets/codex-cliproxy-provider-config.ts index e7525696..750e113b 100644 --- a/src/targets/codex-cliproxy-provider-config.ts +++ b/src/targets/codex-cliproxy-provider-config.ts @@ -8,7 +8,10 @@ import { } from '../web-server/services/compatible-cli-toml-file-service'; import { getModelMaxLevel } from '../cliproxy/model-catalog'; import { parseCodexModelTuningAlias } from '../cliproxy/ai-providers/model-id-normalizer'; -import { buildLocalProviderBaseUrl } from '../cliproxy/config/provider-route'; +import { + buildLocalCodexResponsesBaseUrl, + getConfiguredCliproxyBackend, +} from '../cliproxy/config/provider-route'; import { ConfigError } from '../errors/error-types'; export const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT'; @@ -39,7 +42,16 @@ function resolveCodexConfigPath(env: NodeJS.ProcessEnv = process.env): { } export function buildCodexCliproxyProviderBaseUrl(port: number): string { - return buildLocalProviderBaseUrl('codex', port); + // The Codex CLI provider uses wire_api = "responses", so the Codex CLI appends + // "/responses" to this base_url. The local CLIProxy backends do NOT serve the + // Codex Responses API at the bare root: + // - original backend: only "/v1/responses" and "/backend-api/codex/responses" + // - plus backend: additionally "/api/provider/codex/responses" + // Returning the root makes Codex call "http://127.0.0.1:/responses" -> 404 + // (issue #1597). Use the chatgpt_base_url-compatible "/backend-api/codex" alias, + // which both backends serve; keep the provider-scoped alias for the Plus backend + // to preserve its existing per-provider routing. + return buildLocalCodexResponsesBaseUrl(port, getConfiguredCliproxyBackend()); } export function isCcsxpCliproxyShortcut(env: NodeJS.ProcessEnv = process.env): boolean { @@ -133,6 +145,7 @@ function isProviderReady( typeof provider.base_url === 'string' && resolveProviderBaseUrl(provider, expectedBaseUrl) === provider.base_url.trim() && provider.env_key === envKey && + provider.auth === undefined && provider.wire_api === 'responses' && provider.requires_openai_auth === false && provider.supports_websockets === false @@ -244,6 +257,7 @@ export async function ensureCodexCliproxyProviderConfig( ...currentProvider, ...buildProviderConfig(resolveProviderBaseUrl(currentProvider, expectedBaseUrl), envKey), }; + delete (providers[CODEX_CLIPROXY_PROVIDER_ID] as Record).auth; } if (providerReady && !normalizedModelAlias) { diff --git a/src/utils/bar-auth-token.ts b/src/utils/bar-auth-token.ts index bd8da5ca..0c9f4b58 100644 --- a/src/utils/bar-auth-token.ts +++ b/src/utils/bar-auth-token.ts @@ -4,7 +4,29 @@ import * as path from 'path'; import { getCcsDir } from '../config/config-loader-facade'; export const BAR_AUTH_TOKEN_HEADER = 'x-ccs-bar-token'; +export const BAR_AUTH_NONCE_HEADER = 'x-ccs-bar-nonce'; const TOKEN_BYTE_LENGTH = 32; +const NONCE_MIN_LENGTH = 16; + +export function createBarAuthNonce(): string { + return crypto.randomBytes(TOKEN_BYTE_LENGTH).toString('hex'); +} + +export function isValidBarAuthNonce(nonce: string): boolean { + return /^[a-f0-9]+$/i.test(nonce) && nonce.length >= NONCE_MIN_LENGTH && nonce.length <= 128; +} + +export function createBarAuthProof(token: string, nonce: string): string { + return crypto.createHmac('sha256', token).update(nonce).digest('hex'); +} + +export function isMatchingBarAuthProof(token: string, nonce: string, proof: string): boolean { + if (!isValidBarAuthNonce(nonce) || !/^[a-f0-9]{64}$/i.test(proof)) { + return false; + } + const expected = createBarAuthProof(token, nonce); + return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(proof, 'hex')); +} export function getBarAuthTokenPath(ccsDir = getCcsDir()): string { return path.join(ccsDir, 'bar', '.auth-token'); diff --git a/src/utils/openai-compat-launch-settings.ts b/src/utils/openai-compat-launch-settings.ts index bfb0887d..1f305419 100644 --- a/src/utils/openai-compat-launch-settings.ts +++ b/src/utils/openai-compat-launch-settings.ts @@ -10,6 +10,12 @@ export interface OpenAICompatLaunchSettings { cleanup: () => void; } +// SIBLING HELPER: src/cliproxy/executor/launch-settings.ts (prepareLaunchSettings) +// solves the same problem by OVERLAYING resolved routing values instead of +// stripping them. This strip-based variant is required where callers deliberately +// delete a routing key (e.g. ANTHROPIC_API_KEY in settings-flow) and need it +// ABSENT from the launch settings. Do not unify without an explicit force-absent +// key list — see issue #1609. export function createOpenAICompatLaunchSettings( settingsPath: string, settings: Settings diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 69c94916..68a9f1f6 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -34,13 +34,17 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } -const ANTHROPIC_ROUTING_ENV_KEYS = [ +export const ANTHROPIC_ROUTING_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', ]; const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS); -const ANTHROPIC_MODEL_ENV_KEYS = [ +// NOTE: This is the intentional routing-overlay SUPERSET of model env keys +// (includes ANTHROPIC_SMALL_FAST_MODEL). A separate 4-key `ANTHROPIC_MODEL_ENV_KEYS` +// exists in src/shared/extended-context-utils.ts (re-exported as MODEL_ENV_VAR_KEYS). +// The two are NOT interchangeable — import deliberately by purpose. See issue #1609. +export const ANTHROPIC_MODEL_ENV_KEYS = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 77450d34..8b021ca2 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -16,6 +16,7 @@ // Re-export types export type { + AgyCliStatus, GeminiCliStatus, GrokCliStatus, OpenCodeCliStatus, @@ -25,6 +26,8 @@ export type { } from './websearch/types'; // Re-export CLI detection functions +export { getAgyCliStatus, hasAgyCli, clearAgyCliCache } from './websearch/agy'; + export { getGeminiCliStatus, hasGeminiCli, @@ -92,12 +95,18 @@ export { export { ensureProfileHooks, ensureProfileHooksOrThrow } from './websearch/profile-hook-injector'; // Import for local use -import { clearGeminiCliCache, clearGrokCliCache, clearOpenCodeCliCache } from './websearch'; +import { + clearAgyCliCache, + clearGeminiCliCache, + clearGrokCliCache, + clearOpenCodeCliCache, +} from './websearch'; /** * Clear all CLI caches */ export function clearAllCliCaches(): void { + clearAgyCliCache(); clearGeminiCliCache(); clearGrokCliCache(); clearOpenCodeCliCache(); diff --git a/src/utils/websearch/agy.ts b/src/utils/websearch/agy.ts new file mode 100644 index 00000000..f5528fdf --- /dev/null +++ b/src/utils/websearch/agy.ts @@ -0,0 +1,87 @@ +/** + * Antigravity CLI (agy) Detection + * + * Detects and manages Antigravity CLI installation status. `agy` is Google's + * successor to the retired `gemini` CLI (Google retired the gemini CLI on + * 2026-06-18) and is the recommended LLM CLI WebSearch fallback. + * + * @module utils/websearch/agy + */ + +import { execSync } from 'child_process'; +import type { AgyCliStatus } from './types'; + +// Cache for Antigravity CLI status (per process) +let agyCliCache: AgyCliStatus | null = null; + +/** + * Check if Antigravity CLI (agy) is installed globally. + * + * Install: `curl -fsSL https://antigravity.google/cli/install.sh | bash` + * (Unix installs to ~/.local/bin/agy). Must be in PATH. + * + * @returns Antigravity CLI status with path and version + */ +export function getAgyCliStatus(): AgyCliStatus { + // Return cached result if available + if (agyCliCache) { + return agyCliCache; + } + + const result: AgyCliStatus = { + installed: false, + path: undefined, + version: undefined, + }; + + try { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where agy' : 'which agy'; + + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const agyPath = pathResult.trim().split('\n')[0]; // First result on Windows + + if (agyPath) { + result.installed = true; + result.path = agyPath; + + // Try to get version + try { + const versionResult = execSync('agy --version', { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); + result.version = versionResult.trim(); + } catch { + // Version check failed, but CLI is installed + result.version = 'unknown'; + } + } + } catch { + // Command not found - Antigravity CLI not installed + } + + // Cache result + agyCliCache = result; + return result; +} + +/** + * Check if Antigravity CLI is available (quick boolean check) + */ +export function hasAgyCli(): boolean { + return getAgyCliStatus().installed; +} + +/** + * Clear Antigravity CLI cache (for testing or after installation) + */ +export function clearAgyCliCache(): void { + agyCliCache = null; +} diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 83b605ab..c1342df4 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -41,6 +41,9 @@ export function getWebSearchHookConfig(): Record { // Compute max timeout from enabled providers const timeouts: number[] = []; + if (wsConfig.providers?.agy?.enabled && wsConfig.providers.agy.timeout) { + timeouts.push(wsConfig.providers.agy.timeout); + } if (wsConfig.providers?.gemini?.enabled && wsConfig.providers.gemini.timeout) { timeouts.push(wsConfig.providers.gemini.timeout); } diff --git a/src/utils/websearch/hook-env.ts b/src/utils/websearch/hook-env.ts index f3b8887f..8a95bcbe 100644 --- a/src/utils/websearch/hook-env.ts +++ b/src/utils/websearch/hook-env.ts @@ -27,6 +27,7 @@ export function getWebSearchHookEnv(): Record { CCS_WEBSEARCH_BRAVE: '0', CCS_WEBSEARCH_SEARXNG: '0', CCS_WEBSEARCH_DUCKDUCKGO: '0', + CCS_WEBSEARCH_AGY: '0', CCS_WEBSEARCH_GEMINI: '0', CCS_WEBSEARCH_OPENCODE: '0', CCS_WEBSEARCH_GROK: '0', @@ -80,12 +81,24 @@ export function getWebSearchHookEnv(): Record { env.CCS_WEBSEARCH_SEARXNG_MAX_RESULTS = String(wsConfig.providers.searxng.max_results || 5); } + if (wsConfig.providers?.agy?.enabled) { + env.CCS_WEBSEARCH_AGY = '1'; + if (wsConfig.providers.agy.model) { + env.CCS_WEBSEARCH_AGY_MODEL = wsConfig.providers.agy.model; + } + // Antigravity is the primary CLI fallback, so its timeout wins. + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.agy.timeout || 90); + } + if (wsConfig.providers?.gemini?.enabled) { env.CCS_WEBSEARCH_GEMINI = '1'; if (wsConfig.providers.gemini.model) { env.CCS_WEBSEARCH_GEMINI_MODEL = wsConfig.providers.gemini.model; } - env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55); + // Only set if Antigravity (primary) has not already chosen the timeout. + if (!env.CCS_WEBSEARCH_TIMEOUT) { + env.CCS_WEBSEARCH_TIMEOUT = String(wsConfig.providers.gemini.timeout || 55); + } } if (wsConfig.providers?.opencode?.enabled) { diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 4111cad1..8d746801 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -8,6 +8,7 @@ // Types export type { + AgyCliStatus, GeminiCliStatus, GrokCliStatus, OpenCodeCliStatus, @@ -20,7 +21,10 @@ export type { export type { WebSearchApiKeyState } from './provider-secrets'; -// Gemini CLI +// Antigravity CLI (agy) - recommended Gemini CLI successor +export { getAgyCliStatus, hasAgyCli, clearAgyCliCache } from './agy'; + +// Gemini CLI (deprecated) export { getGeminiCliStatus, hasGeminiCli, diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts index 419ab925..5944e500 100644 --- a/src/utils/websearch/mcp-installer.ts +++ b/src/utils/websearch/mcp-installer.ts @@ -440,28 +440,14 @@ export function ensureWebSearchMcpOrThrow(): void { } /** - * Prepare WebSearch for a user launch without blocking Claude startup. + * Prepare WebSearch for a user launch. * - * Returns true when the normal WebSearch status line is still accurate. A - * failed MCP prepare already prints a degraded-path warning, so callers should - * skip the ready/status line when this returns false. + * WebSearch-enabled launches must fail closed when the managed local MCP + * runtime cannot be prepared. Otherwise CCS would still suppress Claude's + * native WebSearch and inject fallback steering while the constrained MCP + * search path is unavailable. */ export function ensureWebSearchMcpForLaunch(): boolean { - const wsConfig = getWebSearchConfig(); - if (!wsConfig.enabled) { - return true; - } - - const ready = ensureWebSearchMcp(); - if (!ready) { - process.stderr.write( - String( - warn( - 'WebSearch is enabled, but CCS could not prepare the local WebSearch tool. This session will continue without local WebSearch.' - ) - ) + '\n' - ); - } - - return ready; + ensureWebSearchMcpOrThrow(); + return true; } diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 1cf9e57b..af16138c 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -11,6 +11,7 @@ import { join } from 'path'; import { ok, warn, fail, info } from '../ui'; import { getCcsDir } from '../config-manager'; +import { getAgyCliStatus } from './agy'; import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli'; import { getGrokCliStatus } from './grok-cli'; import { getOpenCodeCliStatus } from './opencode-cli'; @@ -113,12 +114,31 @@ function applyCooldownStatus( function getLegacyProviderStatuses(): WebSearchCliInfo[] { const wsConfig = getWebSearchConfig(); + const agyStatus = getAgyCliStatus(); const geminiStatus = getGeminiCliStatus(); const grokStatus = getGrokCliStatus(); const opencodeStatus = getOpenCodeCliStatus(); const geminiAuthed = geminiStatus.installed && isGeminiAuthenticated(); return [ + { + id: 'agy', + kind: 'legacy-cli', + name: 'Antigravity CLI', + command: 'agy', + enabled: wsConfig.providers?.agy?.enabled ?? false, + available: agyStatus.installed, + version: agyStatus.version ?? null, + installCommand: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + docsUrl: 'https://antigravity.google/cli', + requiresApiKey: false, + description: 'Recommended LLM CLI fallback with Google web search (Gemini CLI successor).', + detail: agyStatus.installed + ? agyStatus.version + ? `Installed (${agyStatus.version})` + : 'Installed' + : 'Not installed', + }, { id: 'gemini', kind: 'legacy-cli', @@ -127,15 +147,16 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] { enabled: wsConfig.providers?.gemini?.enabled ?? false, available: geminiAuthed, version: geminiStatus.version ?? null, - installCommand: 'npm install -g @google/gemini-cli', - docsUrl: 'https://github.com/google-gemini/gemini-cli', + installCommand: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + docsUrl: 'https://antigravity.google/cli', requiresApiKey: false, - description: 'Optional legacy LLM fallback with Google web search.', + description: + 'Deprecated legacy fallback (Google retired the gemini CLI). Prefer Antigravity.', detail: geminiStatus.installed ? geminiAuthed ? 'Authenticated' : "Run 'gemini' to login" - : 'Not installed', + : 'Not installed (retired - use Antigravity)', }, { id: 'opencode', @@ -287,7 +308,7 @@ export function getCliInstallHints(): string[] { ' Enable DuckDuckGo in Settings > WebSearch for zero-setup search', ' Or enable SearXNG and set a valid base URL (must support /search?format=json)', ' Or export EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY for API-backed search', - ' Optional legacy fallback: npm i -g @google/gemini-cli', + ' Optional LLM CLI fallback: curl -fsSL https://antigravity.google/cli/install.sh | bash', ]; } diff --git a/src/utils/websearch/types.ts b/src/utils/websearch/types.ts index 38f94499..57dd08a9 100644 --- a/src/utils/websearch/types.ts +++ b/src/utils/websearch/types.ts @@ -8,6 +8,12 @@ import type { ComponentStatus } from '../../types/utils'; +/** + * Antigravity CLI (agy) installation status + * @deprecated Use ComponentStatus directly + */ +export type AgyCliStatus = ComponentStatus; + /** * Gemini CLI installation status * @deprecated Use ComponentStatus directly @@ -40,6 +46,7 @@ export type WebSearchProviderId = | 'brave' | 'searxng' | 'duckduckgo' + | 'agy' | 'gemini' | 'grok' | 'opencode'; @@ -112,6 +119,7 @@ export interface WebSearchConfig { brave?: WebSearchProviderConfig; searxng?: WebSearchProviderConfig; duckduckgo?: WebSearchProviderConfig; + agy?: WebSearchProviderConfig; gemini?: WebSearchProviderConfig; opencode?: WebSearchProviderConfig; grok?: WebSearchProviderConfig; diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index c6d8af96..3acc5fb9 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -96,6 +96,22 @@ export interface BarSummaryRow { fetchedAt: string; /** True if account token is expired and needs re-authentication */ needsReauth: boolean; + /** + * Native subscription surface: "ccs" (Claude Code) or "ccsx" (Codex). + * Present ONLY on native subscription rows; omitted on CLIProxy pool rows. + */ + surface?: string; + /** + * Native profile name (e.g. "work", "ck", "personal"). + * Present ONLY on native subscription rows; omitted on CLIProxy pool rows. + */ + profile?: string; + /** + * Explicit native-subscription flag. true on all native rows; omitted on + * CLIProxy pool rows (decodes to false/nil). Replaces the brittle + * accountId == "claude-code" heuristic in Swift. + */ + is_subscription?: boolean; /** * Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet, * Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index 4b45ab36..2e4ef062 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -17,6 +17,7 @@ import { } from '../../cursor'; import cursorSettingsRoutes from './cursor-settings-routes'; +import { getCursorDaemonToken } from '../../cursor/cursor-daemon-auth'; import { getCursorConfig } from '../../config/config-loader-facade'; import { isDashboardWebSocketOriginAllowed } from '../middleware/auth-middleware'; @@ -233,6 +234,7 @@ router.post('/daemon/start', async (_req: Request, res: Response): Promise const result = await startDaemon({ port: cursorConfig.port, ghost_mode: cursorConfig.ghost_mode, + daemon_token: getCursorDaemonToken(), }); const { daemonToken: _redactedDaemonToken, ...publicResult } = result; void _redactedDaemonToken; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 79d39161..a54cb44d 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -7,7 +7,13 @@ import { Router } from 'express'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; -import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../utils/bar-auth-token'; +import { + BAR_AUTH_NONCE_HEADER, + BAR_AUTH_TOKEN_HEADER, + createBarAuthProof, + getOrCreateBarAuthToken, + isValidBarAuthNonce, +} from '../../utils/bar-auth-token'; // Import domain routers import profileRoutes from './profile-routes'; @@ -69,11 +75,13 @@ apiRoutes.use((req, res, next) => { // Exact segment match so a future sibling like '/barbaz' isn't accidentally gated. if (req.path === '/bar' || req.path.startsWith('/bar/')) { if (requireLocalAccessWhenAuthDisabled(req, res, BAR_LOCAL_ACCESS_ERROR)) { - // Echo the token unconditionally so the probe can verify it without - // having sent the secret in the request. Only the real CCS Bar process - // (which owns the 0600 file) can produce this value — a rogue loopback - // process that hasn't read the file cannot replicate it. - res.setHeader(BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken()); + // Authenticate liveness probes with a nonce-bound HMAC so normal Bar + // responses never disclose the persistent file token, and captured probe + // proofs cannot be replayed for a future probe. + const nonce = req.header(BAR_AUTH_NONCE_HEADER)?.trim() ?? ''; + if (isValidBarAuthNonce(nonce)) { + res.setHeader(BAR_AUTH_TOKEN_HEADER, createBarAuthProof(getOrCreateBarAuthToken(), nonce)); + } next(); } return; diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index e8758538..7e5ace2d 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -377,7 +377,9 @@ export function updateSettingsFile( */ function normalizePathForComparison(filePath: string): string { const normalized = path.resolve(path.normalize(filePath)); - return process.platform === 'win32' ? normalized.toLowerCase() : normalized; + return process.platform === 'win32' || process.platform === 'darwin' + ? normalized.toLowerCase() + : normalized; } function isPathWithin(basePath: string, targetPath: string): boolean { @@ -437,7 +439,8 @@ export function validateFilePath(filePath: string): { // Block access to sensitive subdirectories const relativePath = path.relative(ccsDir, normalizedPath); const pathSegments = relativePath.split(path.sep).filter(Boolean); - if (pathSegments.includes('.git') || pathSegments.includes('node_modules')) { + const comparisonSegments = pathSegments.map((segment) => segment.toLowerCase()); + if (comparisonSegments.includes('.git') || comparisonSegments.includes('node_modules')) { return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; } @@ -445,9 +448,9 @@ export function validateFilePath(filePath: string): { // It must only be written by trusted bar install/launch code paths, not the // generic dashboard file API. if ( - pathSegments.length === 2 && - pathSegments[0] === 'bar' && - pathSegments[1] === 'launch.json' + comparisonSegments.length === 2 && + comparisonSegments[0] === 'bar' && + comparisonSegments[1] === 'launch.json' ) { return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; } diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index 4b2220ca..a136a58b 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -197,6 +197,15 @@ router.put('/', (req: Request, res: Response): void => { config.websearch?.providers?.searxng?.max_results ?? DEFAULT_WEBSEARCH_MAX_RESULTS ), }, + agy: { + enabled: + providers.agy?.enabled ?? config.websearch?.providers?.agy?.enabled ?? false, + model: + providers.agy?.model ?? + config.websearch?.providers?.agy?.model ?? + 'gemini-2.5-flash', + timeout: providers.agy?.timeout ?? config.websearch?.providers?.agy?.timeout ?? 90, + }, gemini: { enabled: providers.gemini?.enabled ?? diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index bbddccf1..78a59d58 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -14,10 +14,24 @@ * - circuit breaker stops calling after repeated 429s for a cooldown * - serve-stale-on-failure; only omit a row when there is genuinely no data * - * Claude path: reads native credentials + polls api.anthropic.com/api/oauth/usage. + * Claude path: reads per-profile .credentials.json (file-only, NO keychain) + * and polls api.anthropic.com/api/oauth/usage. If the file is absent the + * profile is emitted as a parked row (paused:true) — never a keychain call. + * * Codex path: PRIMARY = live network (chatgpt.com/backend-api/wham/usage, via * fetchCodexQuota), FALLBACK = local session logs (getCodexLocalQuota), mirroring * the same safety pattern as the Claude path. + * + * Multi-profile: each Claude or Codex profile gets its own ProviderState so a + * 429 on one profile never trips the breaker of another. The active/default + * profile for each surface is live-polled (paused:false); all other profiles are + * cache-only (paused:true, force=false hardcoded) so the 2.5s /summary deadline + * is maintained — at most 2 live upstream calls per /summary regardless of + * profile count. + * + * NO macOS Keychain access anywhere in this module. The old global-default + * Claude reader (readClaudeCredentials) is kept for back-compat but is no longer + * used by the multi-profile path. */ import { @@ -29,11 +43,16 @@ import { } from './claude-native-credentials'; import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude'; import { fetchCodexQuota } from '../../cliproxy/quota/quota-fetcher-codex'; -import { getDefaultAccount } from '../../cliproxy/accounts/query'; +import { getDefaultAccount, getProviderAccounts } from '../../cliproxy/accounts/query'; import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector'; import type { ClaudeQuotaResult, CodexQuotaResult } from '../../cliproxy/quota/quota-types'; import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { getCcsDir } from '../../utils/config-manager'; + // ============================================================================ // Safety constants (concrete, named, module-level) // ============================================================================ @@ -41,6 +60,15 @@ import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; /** On-demand cache TTL. Floor is 5 min; we use 10 min because the bar polls * /summary far more often than a hook fires. */ const NATIVE_QUOTA_TTL_MS = 600_000; // 10 minutes +// After a 401/expired result, cache the reauth row and cool the profile down so +// an expired account is shown dimmed and re-checked at most this often instead +// of being re-polled (and re-401'd) on every /summary refresh. +const REAUTH_COOLDOWN_MS = NATIVE_QUOTA_TTL_MS; // 10 minutes +// Parked rows (no on-disk creds, quotaStatus 'unsupported') re-check on a short +// TTL — re-statting credentials is cheap, and this lets a profile that the user +// just logged into appear within seconds instead of staying dimmed for the full +// quota TTL. (Reauth/error rows keep the full TTL + cooldown to avoid re-401s.) +const PARKED_TTL_MS = 30_000; // 30 seconds /** Exponential backoff base; delay = min(base * 2^n, MAX) + jitter. */ const RETRY_BASE_MS = 1_000; @@ -57,16 +85,36 @@ const CB_TRIP_THRESHOLD = 3; /** How long the breaker stays open (zero network) once tripped. */ const CB_COOLDOWN_MS = 900_000; // 15 minutes -const CLAUDE_PROVIDER = 'claude-code'; -const CODEX_PROVIDER = 'codex'; +// Surface identifiers +const SURFACE_CLAUDE = 'ccs'; +const SURFACE_CODEX = 'ccsx'; + +// The "default way of running" a surface — the bare login (e.g. ~/.codex for +// ccsx), as opposed to a named `ccsx ` profile. Rendered in the Bar as +// the base command ("ccsx") with a "default" badge, not as a named profile. +const DEFAULT_PROFILE = 'default'; + +// Provider values on the wire (unchanged from before) +const CLAUDE_NATIVE_PROVIDER = 'claude-code'; +const CODEX_NATIVE_PROVIDER = 'codex'; + +// Keep old names as aliases to avoid breaking the existing global collector path +const CLAUDE_PROVIDER = CLAUDE_NATIVE_PROVIDER; +const CODEX_PROVIDER = CODEX_NATIVE_PROVIDER; // ============================================================================ // Injectable dependencies (tests inject mocks; never live endpoints in CI) // ============================================================================ export interface NativeQuotaDeps { - /** Read the native Claude Code credentials. */ + /** Read the native Claude Code credentials (global default path). */ readCredentials?: () => ClaudeNativeCredentials | null; + /** + * Read credentials for a specific Claude profile (file-only, no keychain). + * Injected so tests never touch real fs or Keychain. + * profile: the profile name (e.g. "work"); returns null when absent/unparseable. + */ + readClaudeCredentialsForProfile?: (profile: string) => ClaudeNativeCredentials | null; /** Fetch Claude quota with a directly-supplied native token. */ fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise; /** @@ -81,6 +129,20 @@ export interface NativeQuotaDeps { fetchCodexNetworkQuota?: (accountId: string) => Promise; /** Read Codex quota from local session logs (zero network, fallback). */ getCodexQuota?: () => Promise; + /** + * Read the native Codex auth for a profile (file-only, no keychain). + * DEFAULT_PROFILE ('default') reads ~/.codex/auth.json; other names read codex-instances//auth.json. + * Returns null when absent/unparseable. + */ + readCodexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null; + /** Enumerate Claude profile names. Injected so tests never touch real fs. */ + listClaudeProfiles?: () => string[]; + /** Enumerate Codex profile names (including DEFAULT_PROFILE for bare ~/.codex). */ + listCodexProfiles?: () => string[]; + /** Resolve the default Claude profile name. */ + defaultClaudeProfile?: () => string | null; + /** Resolve the default Codex profile name. */ + defaultCodexProfile?: () => string | null; /** Clock seam for deterministic backoff/TTL/breaker tests. */ now?: () => number; /** Sleep seam (no real delay in tests). */ @@ -120,13 +182,23 @@ function freshProviderState(): ProviderState { }; } -const claudeState = freshProviderState(); -const codexState = freshProviderState(); +// Per-profile state maps (key = profile name) +const claudeProfileStates = new Map(); +const codexProfileStates = new Map(); + +function getState(map: Map, key: string): ProviderState { + let s = map.get(key); + if (!s) { + s = freshProviderState(); + map.set(key, s); + } + return s; +} /** Reset all module state. Tests call this to avoid cross-test pollution. */ export function resetNativeQuotaState(): void { - Object.assign(claudeState, freshProviderState()); - Object.assign(codexState, freshProviderState()); + claudeProfileStates.clear(); + codexProfileStates.clear(); } // ============================================================================ @@ -252,12 +324,21 @@ function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] return windows; } -function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow { +function buildClaudeRow( + quota: ClaudeQuotaResult, + tier: string | null, + now: number, + surface: string, + profile: string +): BarSummaryRow { const quotaWindows = buildClaudeQuotaWindows(quota); return { - account_id: CLAUDE_PROVIDER, - provider: CLAUDE_PROVIDER, - displayName: 'Claude Code', + account_id: `${surface}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier, paused: false, quota_percentage: deriveClaudeQuotaPercentage(quota), @@ -288,12 +369,20 @@ function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] { })); } -function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { +function buildCodexRow( + quota: CodexLocalQuota, + now: number, + surface: string, + profile: string +): BarSummaryRow { const quotaWindows = buildCodexQuotaWindows(quota); return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + account_id: `${surface}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier: quota.tier, paused: false, quota_percentage: quota.quotaPercentage, @@ -321,7 +410,12 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { * stable keys as the Claude path. quota_percentage = min remaining across present * core windows. next_reset = soonest core resetAt. No staleAsOf on a live result. */ -function buildCodexNetworkRow(quota: CodexQuotaResult, now: number): BarSummaryRow { +function buildCodexNetworkRow( + quota: CodexQuotaResult, + now: number, + surface: string, + profile: string +): BarSummaryRow { const windows: QuotaWindowDetail[] = []; const fiveHour = quota.coreUsage?.fiveHour; @@ -363,9 +457,12 @@ function buildCodexNetworkRow(quota: CodexQuotaResult, now: number): BarSummaryR const nextReset = resets.length > 0 ? resets[0].iso : null; return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + account_id: `${surface}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier: quota.planType ?? null, paused: false, quota_percentage: quotaPercentage, @@ -390,19 +487,273 @@ function serveCached(state: ProviderState): BarSummaryRow | null { } // ============================================================================ -// Claude path with full safety controls +// File-only Claude credentials reader for per-profile paths (NO keychain) // ============================================================================ -async function collectClaudeRow( +/** + * Read credentials for a specific Claude Code profile (file-only, no keychain). + * + * Looks for .credentials.json in the profile's instance directory. If the file + * is absent or unparseable, returns null — the caller emits a parked row. + * Never calls security/Keychain — zero new keychain access from this feature. + */ +function readClaudeCredentialsForProfileFromDisk(profile: string): ClaudeNativeCredentials | null { + // The bare `ccs` default login uses the standard global credential lookup: + // ~/.claude/.credentials.json, falling back to the single global + // "Claude Code-credentials" Keychain item that Claude Code itself maintains. + // This is the ONE pre-existing global read the shipped Bar already performs -- + // NOT a per-profile Keychain scan. Isolated `ccs auth` profiles below stay + // file-only and never touch the Keychain. + if (profile === DEFAULT_PROFILE) { + return readClaudeCredentials(); + } + try { + const instanceDir = path.join(getCcsDir(), 'instances', profile); + const credFile = path.join(instanceDir, '.credentials.json'); + if (!fs.existsSync(credFile)) return null; + const raw = fs.readFileSync(credFile, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as ClaudeNativeCredentials; + } + return null; + } catch { + return null; + } +} + +// ============================================================================ +// Profile enumeration helpers (production implementations, DI-overridable) +// ============================================================================ + +/** + * Read Codex native auth from the profile's on-disk auth.json. + * 'personal' reads ~/.codex/auth.json; other names read codex-instances//auth.json. + * Returns null when absent or unparseable. + */ +function readCodexNativeAuthFromDisk( + profile: string +): { accessToken: string; accountId: string } | null { + try { + let authPath: string; + if (profile === DEFAULT_PROFILE) { + authPath = path.join(os.homedir(), '.codex', 'auth.json'); + } else { + // resolveCodexProfileDir would validate, but we do it inline to avoid the + // import coupling and to handle invalid names gracefully (return null). + const instancesDir = path.join(getCcsDir(), 'codex-instances'); + authPath = path.join(instancesDir, profile, 'auth.json'); + } + + if (!fs.existsSync(authPath)) return null; + const raw = fs.readFileSync(authPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + const tokens = parsed.tokens as Record | undefined; + if (!tokens) return null; + const accessToken = tokens.access_token; + const accountId = tokens.account_id; + if (typeof accessToken !== 'string' || !accessToken) return null; + return { + accessToken, + accountId: typeof accountId === 'string' ? accountId : '', + }; + } catch { + return null; + } +} + +/** + * List all Claude profiles from the profile registry (merged legacy + unified). + * Returns [] on any read error so the collector degrades gracefully. + */ +function listClaudeProfilesFromDisk(): string[] { + try { + // Import lazily inside function to avoid circular dep and DI override in tests + const { ProfileRegistry } = require('../../auth/profile-registry') as { + ProfileRegistry: new () => { + getAllProfilesMerged: () => Record; + }; + }; + const registry = new ProfileRegistry(); + const profiles = Object.keys(registry.getAllProfilesMerged()); + // Add the bare `ccs` default login (DEFAULT_PROFILE) when ~/.claude exists -- + // the default way of running `ccs`, distinct from named `ccs ` + // profiles. unshift so it leads before alphabetical sorting downstream. + if (fs.existsSync(path.join(os.homedir(), '.claude'))) { + if (!profiles.includes(DEFAULT_PROFILE)) profiles.unshift(DEFAULT_PROFILE); + } + return profiles; + } catch { + return []; + } +} + +/** + * Resolve the default Claude profile. A registry-designated default wins; + * otherwise the bare ~/.claude login (DEFAULT_PROFILE) is the default `ccs`. + */ +function getDefaultClaudeProfileFromDisk(): string | null { + try { + const { ProfileRegistry } = require('../../auth/profile-registry') as { + ProfileRegistry: new () => { + getDefaultResolved: () => string | null; + }; + }; + const registry = new ProfileRegistry(); + const reg = registry.getDefaultResolved(); + if (reg) return reg; + } catch { + // fall through to the bare default below + } + if (fs.existsSync(path.join(os.homedir(), '.claude'))) return DEFAULT_PROFILE; + return null; +} + +/** + * List all Codex profiles from the registry, plus DEFAULT_PROFILE when the bare + * ~/.codex/auth.json exists. Returns [] on any read error. + */ +function listCodexProfilesFromDisk(): string[] { + try { + const { CodexProfileRegistry } = require('../../codex-auth/codex-profile-registry') as { + CodexProfileRegistry: new () => { + listProfiles: () => string[]; + getProfile: (name: string) => { email?: string; account_id?: string }; + }; + }; + const registry = new CodexProfileRegistry(); + const pausedIdentities = getPausedCodexAccountIdentities(); + const profiles = registry + .listProfiles() + .filter( + (name) => + !codexProfileMatchesPausedAccount(name, registry.getProfile(name), pausedIdentities) + ); + // Add the bare ~/.codex/auth.json (the default `ccsx` invocation) as the + // DEFAULT_PROFILE account when it exists. It is the "default way of running", + // distinct from named `ccsx ` profiles. + if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) { + if (!profiles.includes(DEFAULT_PROFILE)) profiles.push(DEFAULT_PROFILE); + } + return profiles; + } catch { + return []; + } +} + +/** + * Resolve the default Codex profile. Falls back to DEFAULT_PROFILE when the bare + * ~/.codex/auth.json exists and no registry default is set. + */ +function getDefaultCodexProfileFromDisk(): string | null { + try { + const { CodexProfileRegistry } = require('../../codex-auth/codex-profile-registry') as { + CodexProfileRegistry: new () => { + getDefault: () => string | null; + getProfile: (name: string) => { email?: string; account_id?: string }; + listProfiles: () => string[]; + }; + }; + const registry = new CodexProfileRegistry(); + const pausedIdentities = getPausedCodexAccountIdentities(); + const def = registry.getDefault(); + if (def && !codexProfileMatchesPausedAccount(def, registry.getProfile(def), pausedIdentities)) { + return def; + } + const fallback = registry + .listProfiles() + .find( + (name) => + !codexProfileMatchesPausedAccount(name, registry.getProfile(name), pausedIdentities) + ); + if (fallback) return fallback; + // Fall back to the bare ~/.codex account (DEFAULT_PROFILE) when no registry + // default is set — it is the default `ccsx` invocation. + if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) return DEFAULT_PROFILE; + return null; + } catch { + return null; + } +} + +function getPausedCodexAccountIdentities(): Set | null { + try { + const accounts = getProviderAccounts('codex'); + if (accounts.length === 0) return null; + const identities = new Set(); + for (const account of accounts) { + if (!account.paused) continue; + identities.add(account.id); + if (account.email) identities.add(account.email); + } + return identities; + } catch { + return null; + } +} + +function codexProfileMatchesPausedAccount( + profileName: string, + profile: { email?: string; account_id?: string }, + pausedIdentities: Set | null +): boolean { + if (!pausedIdentities || pausedIdentities.size === 0) return false; + return [profileName, profile.email, profile.account_id].some( + (identity): identity is string => typeof identity === 'string' && pausedIdentities.has(identity) + ); +} + +// ============================================================================ +// Per-profile collectors with full safety controls +// ============================================================================ + +/** + * Tag a row with whether it is the surface's default profile (drives the + * "active" badge only). The row's own `paused` flag is authoritative for + * dimming: it is already set by the collector to reflect LIVENESS — parked + * (no on-disk creds / unsupported) rows arrive with paused:true; profiles with + * a usable token arrive with paused:false regardless of default status. We must + * NOT override `paused` from `isDefault`, or a valid non-default subscription + * (e.g. an isolated `ccsx` profile) would render dimmed despite live quota. + */ +function markDefault(row: BarSummaryRow, isDefault: boolean): BarSummaryRow { + return { ...row, is_default: isDefault }; +} + +/** + * Tag the row with is_default AND write the flag back onto the cached copy. The + * collector caches a row before the default profile is known (the default is + * resolved in the enumerator), so without this the cache-fallback path + * (getCachedNativeAccountRows) would serve the default account with + * is_default:false and the UI would stop ordering/tagging it as the default. + */ +function markDefaultAndSyncCache( + map: Map, + profile: string, + row: BarSummaryRow, + isDefault: boolean +): BarSummaryRow { + const state = map.get(profile); + if (state?.cachedRow) { + state.cachedRow = { ...state.cachedRow, is_default: isDefault }; + } + return markDefault(row, isDefault); +} + +async function collectClaudeRowForProfile( + profile: string, deps: NativeQuotaDeps, force = false ): Promise { const now = (deps.now ?? Date.now)(); - const state = claudeState; + const state = getState(claudeProfileStates, profile); - // Serve from cache while within TTL — force bypasses TTL short-circuit. - if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { - return serveCached(state); + // Serve from cache while within TTL — force bypasses the short-circuit. Parked + // rows (no creds -> quotaStatus 'unsupported') use a short TTL so a fresh login + // is picked up within seconds instead of staying dimmed for the full quota TTL. + if (!force && state.cachedRow) { + const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; + if (now - state.cachedAt < ttl) return serveCached(state); } // Breaker open or cooldown active -> zero network, serve stale (may be null). @@ -416,15 +767,56 @@ async function collectClaudeRow( return state.pending; } - const readCredentials = deps.readCredentials ?? readClaudeCredentials; + // For per-profile reads: use the injected seam (file-only, no keychain). + const readCreds = + deps.readClaudeCredentialsForProfile ?? + ((p: string) => readClaudeCredentialsForProfileFromDisk(p)); const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken; const sleep = deps.sleep ?? defaultSleep; state.pending = (async (): Promise => { try { - const creds = readCredentials(); + // Yield once so the `state.pending = (...)()` assignment above completes + // before any synchronous return below runs the finally that nulls it. + // Without this, a sync return (e.g. the no-creds parked path) clears + // pending DURING assignment, leaving a stale resolved promise that the + // next call's coalescing check would return instead of re-evaluating. + await Promise.resolve(); + const creds = readCreds(profile); + + // No credentials file found -> emit parked row (needs auth, file absent). + // This is the expected case when the profile exists in the registry but the + // user has not logged in via 'ccs auth' for this machine or the credentials + // are stored only in keychain (which we deliberately do not access here). + if (!creds) { + const parkedRow: BarSummaryRow = { + account_id: `${SURFACE_CLAUDE}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface: SURFACE_CLAUDE, + profile, + is_subscription: true, + displayName: profile, + tier: null, + paused: true, + quota_percentage: null, + quotaStatus: 'unsupported', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + // Cache the parked row so repeated calls don't re-stat the fs. + state.cachedRow = parkedRow; + state.cachedAt = now; + return parkedRow; + } + // No token / unsupported subscription -> never spend a call, omit the row. - if (!creds || !hasSupportedSubscription(creds)) { + if (!hasSupportedSubscription(creds)) { return serveCached(state); } const token = getAccessToken(creds); @@ -433,7 +825,7 @@ async function collectClaudeRow( } const tier = getSubscriptionTier(creds); - const quota = await fetchQuota(token, CLAUDE_PROVIDER); + const quota = await fetchQuota(token, `${SURFACE_CLAUDE}:${profile}`); if (quota.success) { // Success closes the breaker and clears backoff. @@ -441,21 +833,26 @@ async function collectClaudeRow( state.breakerOpenUntil = 0; state.cooldownUntil = 0; state.backoffAttempt = 0; - const row = buildClaudeRow(quota, tier, now); + const row = buildClaudeRow(quota, tier, now, SURFACE_CLAUDE, profile); state.cachedRow = row; state.cachedAt = now; return { ...row, cached: false }; } - // 401 -> token expired. Emit a reauth row so the bar can prompt; this is - // a real, actionable state distinct from a transient failure. + // 401 -> token expired. Emit a dimmed reauth row so the bar can prompt. + // Cache it and open a cooldown so the expired account is NOT re-polled + // (and re-401'd) on every refresh; it re-checks after REAUTH_COOLDOWN_MS, + // picking up a successful re-auth. if (quota.needsReauth) { const row: BarSummaryRow = { - account_id: CLAUDE_PROVIDER, - provider: CLAUDE_PROVIDER, - displayName: 'Claude Code', + account_id: `${SURFACE_CLAUDE}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface: SURFACE_CLAUDE, + profile, + is_subscription: true, + displayName: profile, tier, - paused: false, + paused: true, quota_percentage: null, quotaStatus: 'error', next_reset: null, @@ -467,9 +864,10 @@ async function collectClaudeRow( fetchedAt: new Date(now).toISOString(), needsReauth: true, }; - // Do not cache the reauth row as a good value; it should re-evaluate - // once the user re-auths. But return it now. - return row; + state.cachedRow = row; + state.cachedAt = now; + state.cooldownUntil = now + REAUTH_COOLDOWN_MS; + return { ...row, cached: false }; } // 429 / 5xx / transient. Apply backoff + breaker, then serve stale. @@ -508,20 +906,20 @@ async function collectClaudeRow( return state.pending; } -// ============================================================================ -// Codex path with live network as PRIMARY, local logs as FALLBACK -// ============================================================================ - -async function collectCodexRow( +async function collectCodexRowForProfile( + profile: string, deps: NativeQuotaDeps, force = false ): Promise { const now = (deps.now ?? Date.now)(); - const state = codexState; + const state = getState(codexProfileStates, profile); - // Serve from cache while within TTL — force bypasses TTL short-circuit. - if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { - return serveCached(state); + // Serve from cache while within TTL — force bypasses the short-circuit. Parked + // rows (no auth -> quotaStatus 'unsupported') use a short TTL so a fresh login + // is picked up within seconds instead of staying dimmed for the full quota TTL. + if (!force && state.cachedRow) { + const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; + if (now - state.cachedAt < ttl) return serveCached(state); } // Breaker open or cooldown active -> skip network, go to LOCAL fallback. @@ -533,8 +931,12 @@ async function collectCodexRow( return state.pending; } - const getDefaultAccountId = - deps.getDefaultCodexAccountId ?? (() => getDefaultAccount('codex')?.id ?? null); + // Resolve the native auth for this profile to get a network accountId. + const readNativeAuth = + deps.readCodexNativeAuth ?? ((p: string) => readCodexNativeAuthFromDisk(p)); + + // For the network fallback: the legacy getDefaultCodexAccountId is the + // CLIProxy-registry path; for native profiles we use the on-disk auth directly. const fetchNetwork = deps.fetchCodexNetworkQuota ?? ((accountId: string) => fetchCodexQuota(accountId)); const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; @@ -542,13 +944,19 @@ async function collectCodexRow( state.pending = (async (): Promise => { try { + // Yield once so the `state.pending = (...)()` assignment completes before + // any synchronous return below runs the finally that nulls it (otherwise a + // sync return leaves a stale resolved promise the next call would reuse). + await Promise.resolve(); // ---------------------------------------------------------------- // PRIMARY: live network fetch (skipped when breaker/cooldown active) // ---------------------------------------------------------------- if (!breakerOrCooldownActive) { - const accountId = getDefaultAccountId(); - if (accountId) { - const quota = await fetchNetwork(accountId); + const nativeAuth = readNativeAuth(profile); + // Use the on-disk accountId for the network call; fall through to local + // when the auth file is absent (parked profile). + if (nativeAuth) { + const quota = await fetchNetwork(nativeAuth.accountId || profile); if (quota.success) { // A healthy response closes the breaker and clears backoff, @@ -557,26 +965,37 @@ async function collectCodexRow( state.breakerOpenUntil = 0; state.cooldownUntil = 0; state.backoffAttempt = 0; - // Only usable when at least one core window (5h/weekly) resolved. A - // success with empty coreUsage (only code-review/additional windows, - // or a changed payload) carries no glanceable signal — do NOT cache - // a contentless "ok" row or clobber a good cache; fall through to - // the local fallback so the bar shows real data instead. + // At least one core window (5h/weekly) resolved -> full quota row. if (quota.coreUsage?.fiveHour || quota.coreUsage?.weekly) { - const row = buildCodexNetworkRow(quota, now); + const row = buildCodexNetworkRow(quota, now, SURFACE_CODEX, profile); state.cachedRow = row; state.cachedAt = now; return { ...row, cached: false }; } - // else: fall through to LOCAL fallback below. + // Success but no core window. The token authenticated, so this is a + // VALID active subscription with a sparse payload — emit an active + // (quota-less) row instead of parking it. Only the bare default keeps + // falling through to the global local session data below. + if (profile !== DEFAULT_PROFILE) { + const row = buildCodexNetworkRow(quota, now, SURFACE_CODEX, profile); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + // else (default): fall through to LOCAL fallback below. } else if (quota.needsReauth) { - // Token expired -> reauth row; do NOT cache as a good value. - return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + // Token expired -> dimmed reauth row. Cache it and cool down so the + // expired account is NOT re-polled (and re-401'd) every refresh; it + // re-checks after REAUTH_COOLDOWN_MS to pick up a re-auth. + const reauthRow: BarSummaryRow = { + account_id: `${SURFACE_CODEX}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface: SURFACE_CODEX, + profile, + is_subscription: true, + displayName: profile, tier: null, - paused: false, + paused: true, quota_percentage: null, quotaStatus: 'error', next_reset: null, @@ -588,6 +1007,10 @@ async function collectCodexRow( fetchedAt: new Date(now).toISOString(), needsReauth: true, }; + state.cachedRow = reauthRow; + state.cachedAt = now; + state.cooldownUntil = now + REAUTH_COOLDOWN_MS; + return { ...reauthRow, cached: false }; } else if (quota.httpStatus === 429) { // 429: apply breaker + backoff, then fall through to local. state.consecutive429 += 1; @@ -614,23 +1037,54 @@ async function collectCodexRow( } // Fall through to LOCAL fallback below. } - // No configured accountId -> fall through to local fallback. + // No on-disk auth for this profile -> fall through to the fallback below. } // ---------------------------------------------------------------- - // LOCAL FALLBACK: session log read (zero network, always attempted - // when network is unavailable / no accountId / breaker active) + // LOCAL FALLBACK: session-log read (zero network). The session logs live + // in the GLOBAL ~/.codex, so they represent ONLY the bare default account, + // never a named profile. Using them for a named profile would misattribute + // the default's usage to the profile, so only the default falls back to + // local; named profiles park instead. // ---------------------------------------------------------------- - const localQuota = await getCodex(); - if (localQuota) { - const row = buildCodexRow(localQuota, now); - state.cachedRow = row; - state.cachedAt = now; - return { ...row, cached: false }; + if (profile === DEFAULT_PROFILE) { + const localQuota = await getCodex(); + if (localQuota) { + const row = buildCodexRow(localQuota, now, SURFACE_CODEX, profile); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } } - // No local data either: serve stale (may be null). - return serveCached(state); + // Named profile (or default with no local data): serve the last-known row + // if any, else a dimmed parked row -- never global local data for a named + // profile. + const stale = serveCached(state); + if (stale) return stale; + const parkedRow: BarSummaryRow = { + account_id: `${SURFACE_CODEX}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface: SURFACE_CODEX, + profile, + is_subscription: true, + displayName: profile, + tier: null, + paused: true, + quota_percentage: null, + quotaStatus: 'unsupported', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + state.cachedRow = parkedRow; + state.cachedAt = now; + return parkedRow; } catch { // Network/parse rejection -> treat as transient, serve stale. const backoff = computeBackoffMs(state.backoffAttempt); @@ -645,25 +1099,402 @@ async function collectCodexRow( return state.pending; } +// ============================================================================ +// Legacy single-profile collectors (unchanged; used by old tests + back-compat) +// ============================================================================ + +/** + * @deprecated Use collectClaudeRowForProfile with the 'default' or appropriate + * profile name. Kept for backward compatibility with existing tests that stub + * readCredentials/getDefaultCodexAccountId directly. + */ +async function collectClaudeRow( + deps: NativeQuotaDeps, + force = false +): Promise { + const now = (deps.now ?? Date.now)(); + + // Use the legacy single-state approach via profile key '__legacy__' to avoid + // breaking state isolation with the per-profile maps. + const state = getState(claudeProfileStates, '__legacy__'); + + // Serve from cache while within TTL — force bypasses TTL short-circuit. + if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + return serveCached(state); + } + + // Breaker open or cooldown active -> zero network, serve stale (may be null). + if (now < state.breakerOpenUntil || now < state.cooldownUntil) { + return serveCached(state); + } + + // Coalesce: concurrent callers past TTL share one in-flight fetch. + if (state.pending) { + return state.pending; + } + + const readCredentialsFn = deps.readCredentials ?? readClaudeCredentials; + const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken; + const sleep = deps.sleep ?? defaultSleep; + + state.pending = (async (): Promise => { + try { + const creds = readCredentialsFn(); + // No token / unsupported subscription -> never spend a call, omit the row. + if (!creds || !hasSupportedSubscription(creds)) { + return serveCached(state); + } + const token = getAccessToken(creds); + if (!token) { + return serveCached(state); + } + const tier = getSubscriptionTier(creds); + + const quota = await fetchQuota(token, CLAUDE_PROVIDER); + + if (quota.success) { + // Success closes the breaker and clears backoff. + state.consecutive429 = 0; + state.breakerOpenUntil = 0; + state.cooldownUntil = 0; + state.backoffAttempt = 0; + const row = buildClaudeRowLegacy(quota, tier, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + + // 401 -> token expired. + if (quota.needsReauth) { + const row: BarSummaryRow = { + account_id: CLAUDE_PROVIDER, + provider: CLAUDE_PROVIDER, + displayName: 'Claude Code', + tier, + paused: false, + quota_percentage: null, + quotaStatus: 'error', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'error', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + return row; + } + + // 429 / 5xx / transient. + const is429 = quota.httpStatus === 429; + if (is429) { + state.consecutive429 += 1; + if (state.consecutive429 >= CB_TRIP_THRESHOLD) { + state.breakerOpenUntil = now + CB_COOLDOWN_MS; + } + const retryAfter = parseRetryAfterMs(quota.errorDetail, now); + const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + void sleep; + } else if (quota.retryable) { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } + + return serveCached(state); + } catch { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + return serveCached(state); + } finally { + state.pending = null; + } + })(); + + return state.pending; +} + +/** Legacy row builder — no surface/profile/is_subscription fields. */ +function buildClaudeRowLegacy( + quota: ClaudeQuotaResult, + tier: string | null, + now: number +): BarSummaryRow { + const quotaWindows = buildClaudeQuotaWindows(quota); + return { + account_id: CLAUDE_PROVIDER, + provider: CLAUDE_PROVIDER, + displayName: 'Claude Code', + tier, + paused: false, + quota_percentage: deriveClaudeQuotaPercentage(quota), + quotaStatus: 'ok', + next_reset: deriveClaudeNextReset(quota), + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), + }; +} + +/** + * Legacy Codex collector — uses the old getDefaultCodexAccountId dep. + * Kept for backward compatibility with existing tests. + */ +async function collectCodexRow( + deps: NativeQuotaDeps, + force = false +): Promise { + const now = (deps.now ?? Date.now)(); + const state = getState(codexProfileStates, '__legacy__'); + + // Serve from cache while within TTL — force bypasses TTL short-circuit. + if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + return serveCached(state); + } + + // Breaker open or cooldown active -> skip network, go to LOCAL fallback. + const breakerOrCooldownActive = now < state.breakerOpenUntil || now < state.cooldownUntil; + + // Coalesce: concurrent callers past TTL share one in-flight resolution. + if (state.pending) { + return state.pending; + } + + const getDefaultAccountId = + deps.getDefaultCodexAccountId ?? (() => getDefaultAccount('codex')?.id ?? null); + const fetchNetwork = + deps.fetchCodexNetworkQuota ?? ((accountId: string) => fetchCodexQuota(accountId)); + const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; + const sleep = deps.sleep ?? defaultSleep; + + state.pending = (async (): Promise => { + try { + // Yield once so the `state.pending = (...)()` assignment completes before + // any synchronous return below runs the finally that nulls it (otherwise a + // sync return leaves a stale resolved promise the next call would reuse). + await Promise.resolve(); + // ---------------------------------------------------------------- + // PRIMARY: live network fetch (skipped when breaker/cooldown active) + // ---------------------------------------------------------------- + if (!breakerOrCooldownActive) { + const accountId = getDefaultAccountId(); + if (accountId) { + const quota = await fetchNetwork(accountId); + + if (quota.success) { + state.consecutive429 = 0; + state.breakerOpenUntil = 0; + state.cooldownUntil = 0; + state.backoffAttempt = 0; + if (quota.coreUsage?.fiveHour || quota.coreUsage?.weekly) { + const row = buildCodexNetworkRowLegacy(quota, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + // else: fall through to LOCAL fallback below. + } else if (quota.needsReauth) { + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: null, + paused: false, + quota_percentage: null, + quotaStatus: 'error', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'error', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + } else if (quota.httpStatus === 429) { + state.consecutive429 += 1; + if (state.consecutive429 >= CB_TRIP_THRESHOLD) { + state.breakerOpenUntil = now + CB_COOLDOWN_MS; + } + const retryAfter = parseRetryAfterMs(quota.errorDetail, now); + const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + void sleep; + } else if (quota.retryable) { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } else { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } + // Fall through to LOCAL fallback below. + } + } + + // ---------------------------------------------------------------- + // LOCAL FALLBACK + // ---------------------------------------------------------------- + const localQuota = await getCodex(); + if (localQuota) { + const row = buildCodexRowLegacy(localQuota, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + + return serveCached(state); + } catch { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + return serveCached(state); + } finally { + state.pending = null; + } + })(); + + return state.pending; +} + +/** Legacy Codex row builder (local quota). */ +function buildCodexRowLegacy(quota: CodexLocalQuota, now: number): BarSummaryRow { + const quotaWindows = buildCodexQuotaWindows(quota); + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: quota.tier, + paused: false, + quota_percentage: quota.quotaPercentage, + quotaStatus: 'ok', + next_reset: quota.nextReset, + is_default: false, + last_activity_at: null, + today_cost: null, + health: quota.stale ? 'warning' : 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), + ...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}), + }; +} + +/** Legacy Codex network row builder. */ +function buildCodexNetworkRowLegacy(quota: CodexQuotaResult, now: number): BarSummaryRow { + const windows: QuotaWindowDetail[] = []; + const fiveHour = quota.coreUsage?.fiveHour; + if (fiveHour) { + windows.push({ + key: 'five_hour', + label: '5h', + usedPercent: 100 - fiveHour.remainingPercent, + remainingPercent: fiveHour.remainingPercent, + resetAt: fiveHour.resetAt, + windowMinutes: FIVE_HOUR_MINUTES, + }); + } + const weekly = quota.coreUsage?.weekly; + if (weekly) { + windows.push({ + key: 'seven_day', + label: 'week', + usedPercent: 100 - weekly.remainingPercent, + remainingPercent: weekly.remainingPercent, + resetAt: weekly.resetAt, + windowMinutes: SEVEN_DAY_MINUTES, + }); + } + const coreWindows = [fiveHour, weekly].filter((w): w is NonNullable => !!w); + const quotaPercentage = + coreWindows.length > 0 ? Math.min(...coreWindows.map((w) => w.remainingPercent)) : null; + const resets = coreWindows + .map((w) => w.resetAt) + .filter((r): r is string => typeof r === 'string') + .map((r) => ({ iso: r, ms: new Date(r).getTime() })) + .filter((r) => Number.isFinite(r.ms)) + .sort((a, b) => a.ms - b.ms); + const nextReset = resets.length > 0 ? resets[0].iso : null; + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: quota.planType ?? null, + paused: false, + quota_percentage: quotaPercentage, + quotaStatus: 'ok', + next_reset: nextReset, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(windows.length > 0 ? { quotaWindows: windows } : {}), + }; +} + // ============================================================================ // Public entry point // ============================================================================ /** - * Build the native subscription rows (Claude Code + Codex) for /summary. + * Build the native subscription rows for /summary. * - * Each path is independently try/caught so one failing source never blocks the - * other or the response. Returns only rows that represent real data. + * When profile-enumeration deps are injected (listClaudeProfiles / listCodexProfiles + * etc.), all profiles are enumerated and the active/default profile is live-polled + * while non-default profiles are cache-only (parked). This keeps the 2.5s deadline: + * at most 2 live upstream calls per /summary regardless of profile count. * - * `opts.force` bypasses the TTL short-circuit on both paths so a debounce- - * passing refresh re-pulls native rows live. The circuit breaker is always - * respected regardless of force (account protection). + * When no enumeration deps are injected (legacy mode / old tests that stub only + * readCredentials + getDefaultCodexAccountId), the old single-profile collectors + * are used for backward compatibility. + * + * `opts.force` bypasses the TTL short-circuit on the ACTIVE profile. The circuit + * breaker is always respected regardless of force (account protection). */ export async function getNativeAccountRows( deps: NativeQuotaDeps = {}, opts?: { force?: boolean } ): Promise { const force = opts?.force ?? false; + + // Multi-profile enumeration is the DEFAULT (production) behavior. The legacy + // single-profile path is retained ONLY for backward-compat with old tests that + // inject readCredentials / getDefaultCodexAccountId and assert the original + // single-row output. Those harnesses never inject the enumeration seams; the + // new multi-profile tests pair both seams, and production injects neither — so + // everything except the legacy harness takes the multi-profile path below. + const hasProfileEnumeration = + deps.listClaudeProfiles !== undefined || + deps.listCodexProfiles !== undefined || + deps.defaultClaudeProfile !== undefined || + deps.defaultCodexProfile !== undefined; + + const isLegacyTestHarness = + !hasProfileEnumeration && + (deps.readCredentials !== undefined || deps.getDefaultCodexAccountId !== undefined); + + if (!isLegacyTestHarness) { + return getNativeAccountRowsMultiProfile(deps, force); + } + + // Legacy path: backward-compatible with old tests that only inject + // readCredentials / getDefaultCodexAccountId. Produces the old single-row + // output (account_id='claude-code'/'codex', no surface/profile). const [claude, codex] = await Promise.all([ collectClaudeRow(deps, force).catch(() => null), collectCodexRow(deps, force).catch(() => null), @@ -675,6 +1506,79 @@ export async function getNativeAccountRows( return rows; } +async function getNativeAccountRowsMultiProfile( + deps: NativeQuotaDeps, + force: boolean +): Promise { + const listClaude = deps.listClaudeProfiles ?? listClaudeProfilesFromDisk; + const listCodex = deps.listCodexProfiles ?? listCodexProfilesFromDisk; + const defaultClaude = deps.defaultClaudeProfile ?? getDefaultClaudeProfileFromDisk; + const defaultCodex = deps.defaultCodexProfile ?? getDefaultCodexProfileFromDisk; + + const claudeProfiles = (() => { + try { + return listClaude(); + } catch { + return []; + } + })(); + const codexProfiles = (() => { + try { + return listCodex(); + } catch { + return []; + } + })(); + const claudeDefault = (() => { + try { + return defaultClaude(); + } catch { + return null; + } + })(); + const codexDefault = (() => { + try { + return defaultCodex(); + } catch { + return null; + } + })(); + + const tasks: Promise[] = []; + + // Forced refresh applies to every profile: parked profiles (no creds) short- + // circuit to a parked row with zero network, so forcing them is free, while + // every profile that has a usable token gets live quota — not just the + // default. Per-profile TTL + breaker still protect each account. + for (const p of claudeProfiles) { + const isDefault = p === claudeDefault; + tasks.push( + collectClaudeRowForProfile(p, deps, force) + .then((r) => (r ? markDefaultAndSyncCache(claudeProfileStates, p, r, isDefault) : null)) + .catch(() => null) + ); + } + + for (const p of codexProfiles) { + const isDefault = p === codexDefault; + tasks.push( + collectCodexRowForProfile(p, deps, force) + .then((r) => (r ? markDefaultAndSyncCache(codexProfileStates, p, r, isDefault) : null)) + .catch(() => null) + ); + } + + const results = await Promise.all(tasks); + const rows = results.filter((r): r is BarSummaryRow => r !== null); + + // Sort by (surface, profile) for stable ordering. + return rows.sort((a, b) => { + const sa = (a.surface ?? '') + ':' + (a.profile ?? ''); + const sb = (b.surface ?? '') + ':' + (b.profile ?? ''); + return sa.localeCompare(sb); + }); +} + /** * Last-known native rows from cache, WITHOUT any fetch (instant, no network). * @@ -685,7 +1589,11 @@ export async function getNativeAccountRows( */ export function getCachedNativeAccountRows(): BarSummaryRow[] { const rows: BarSummaryRow[] = []; - if (claudeState.cachedRow) rows.push({ ...claudeState.cachedRow, cached: true }); - if (codexState.cachedRow) rows.push({ ...codexState.cachedRow, cached: true }); + for (const state of claudeProfileStates.values()) { + if (state.cachedRow) rows.push({ ...state.cachedRow, cached: true }); + } + for (const state of codexProfileStates.values()) { + if (state.cachedRow) rows.push({ ...state.cachedRow, cached: true }); + } return rows; } diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts index acb21499..b4b724e6 100644 --- a/tests/integration/cursor-daemon-lifecycle.test.ts +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -77,6 +77,16 @@ describe('cursor daemon lifecycle smoke', () => { expect(await isDaemonRunning(port, daemonToken)).toBe(true); + const standardAuthHealthResponse = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { 'x-api-key': daemonToken }, + }); + expect(standardAuthHealthResponse.status).toBe(200); + + const bearerAuthHealthResponse = await fetch(`http://127.0.0.1:${port}/health`, { + headers: { Authorization: `Bearer ${daemonToken}` }, + }); + expect(bearerAuthHealthResponse.status).toBe(200); + const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); expect(modelsResponse.status).toBe(200); const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; diff --git a/tests/unit/api/provider-presets-requesty.test.ts b/tests/unit/api/provider-presets-requesty.test.ts new file mode 100644 index 00000000..5716c613 --- /dev/null +++ b/tests/unit/api/provider-presets-requesty.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'bun:test'; +import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; +import { PROVIDER_PRESET_IDS } from '../../../src/shared/provider-preset-catalog'; + +describe('provider-presets-requesty', () => { + it('resolves requesty preset id', () => { + const preset = getPresetById('requesty'); + expect(preset?.id).toBe('requesty'); + expect(preset?.baseUrl).toBe('https://router.requesty.ai/v1'); + expect(preset?.defaultProfileName).toBe('requesty'); + }); + + it('registers requesty in PROVIDER_PRESET_IDS', () => { + expect(PROVIDER_PRESET_IDS).toContain('requesty'); + }); + + it('uses the OpenAI-compatible base URL with a /v1 suffix', () => { + const preset = getPresetById('requesty'); + expect(preset?.baseUrl).toBe('https://router.requesty.ai/v1'); + expect(preset?.baseUrl.endsWith('/v1')).toBe(true); + }); + + it('pins a provider/model default (openai/gpt-4o-mini)', () => { + const preset = getPresetById('requesty'); + expect(preset?.defaultModel).toBe('openai/gpt-4o-mini'); + }); + + it('validates requesty preset requires an API key', () => { + const preset = getPresetById('requesty'); + expect(preset?.requiresApiKey).toBe(true); + }); + + it('is a plain (non-featured) alternative provider', () => { + const preset = getPresetById('requesty'); + expect(preset?.category).toBe('alternative'); + expect(preset?.featured).toBeUndefined(); + }); + + it('treats requesty as a valid preset id', () => { + expect(isValidPresetId('requesty')).toBe(true); + }); + + it('handles whitespace in requesty preset id', () => { + const preset = getPresetById(' requesty '); + expect(preset?.id).toBe('requesty'); + }); + + it('handles uppercase requesty preset id', () => { + const preset = getPresetById('REQUESTY'); + expect(preset?.id).toBe('requesty'); + }); + + it('does not resolve partial or invalid requesty ids', () => { + expect(getPresetById('requesty-invalid')).toBeUndefined(); + expect(isValidPresetId('requesty-invalid')).toBe(false); + }); +}); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 7bdb344a..8769615b 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -190,6 +190,51 @@ describe('ProfileDetector', () => { } }); + it('should resolve a unified default that points to a legacy-only account profile', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify({ + default: 'oldDefault', + profiles: { + oldDefault: { created: '2025-01-01', last_used: '2025-01-02' }, + legacyOnly: { created: '2025-02-01', last_used: '2025-02-02' }, + }, + }) + ); + + const mockUnifiedConfig = { + version: 2, + default: 'legacyOnly', + profiles: {}, + accounts: {}, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + const localDetector = new ProfileDetector(); + const result = localDetector.resolveDefaultProfileResult(); + expect(result.type).toBe('account'); + expect(result.name).toBe('legacyOnly'); + expect(result.profile).toEqual({ created: '2025-02-01', last_used: '2025-02-02' }); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + it('should return null for unknown profile (throws error)', () => { const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); // Mock readConfig/readProfiles to return empty diff --git a/tests/unit/commands/bar-command.test.ts b/tests/unit/commands/bar-command.test.ts index de542930..5f649ef1 100644 --- a/tests/unit/commands/bar-command.test.ts +++ b/tests/unit/commands/bar-command.test.ts @@ -13,7 +13,12 @@ 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'; -import { BAR_AUTH_TOKEN_HEADER, getOrCreateBarAuthToken } from '../../../src/utils/bar-auth-token'; +import { + BAR_AUTH_NONCE_HEADER, + BAR_AUTH_TOKEN_HEADER, + createBarAuthProof, + getOrCreateBarAuthToken, +} from '../../../src/utils/bar-auth-token'; // --------------------------------------------------------------------------- // Helpers @@ -99,6 +104,7 @@ beforeEach(() => { afterEach(() => { restoreConsole(); mock.restore(); + process.exitCode = 0; if (originalCcsHome === undefined) { delete process.env.CCS_HOME; @@ -197,6 +203,8 @@ describe('bar command dispatcher (index.ts)', () => { const handleBarCommand = await loadHandleBarCommand(); // Should print help or error but not crash await expect(handleBarCommand(['unknown-subcommand'])).resolves.toBeUndefined(); + expect(process.exitCode).toBe(1); + process.exitCode = 0; }); it('dispatches `ccs bar --help` to help subcommand and does not launch', async () => { @@ -1749,14 +1757,12 @@ describe('defaultFindRunningServer (GH-1500)', () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); - // Start an ephemeral server that responds 200 to /api/bar/summary with the shared token. - // The server echoes the token unconditionally (reading it from the file), mirroring - // production behavior: only a process that owns the 0600 file can produce the value. - const server = http.createServer((_req, res) => { + const server = http.createServer((req, res) => { const token = getOrCreateBarAuthToken(ccsDir); + const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? ''); res.writeHead(200, { 'Content-Type': 'application/json', - [BAR_AUTH_TOKEN_HEADER]: token, + [BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce), }); res.end('{}'); }); @@ -1967,11 +1973,12 @@ describe('defaultFindRunningServer (GH-1500)', () => { // This simulates `ccs config` starting the web-server with host 'localhost' // on macOS, where 'localhost' resolves to ::1. // The server echoes the token unconditionally (from the file), mirroring production. - const server = http.createServer((_req, res) => { + const server = http.createServer((req, res) => { const token = getOrCreateBarAuthToken(ccsDir); + const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? ''); res.writeHead(200, { 'Content-Type': 'application/json', - [BAR_AUTH_TOKEN_HEADER]: token, + [BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce), }); res.end('{}'); }); @@ -2023,11 +2030,12 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', () // Lower-priority server (default port candidate): responds immediately with 200. // Echoes token unconditionally (from file), mirroring production behavior. - const fastServer = http.createServer((_req, res) => { + const fastServer = http.createServer((req, res) => { const token = getOrCreateBarAuthToken(ccsDir); + const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? ''); res.writeHead(200, { 'Content-Type': 'application/json', - [BAR_AUTH_TOKEN_HEADER]: token, + [BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce), }); res.end('{}'); }); @@ -2036,12 +2044,13 @@ describe('defaultFindRunningServer: priority over response speed (GH-1500)', () // Higher-priority server (bar.json port): adds ~300 ms artificial delay, // but still responds 200 within the 1500 ms timeout. - const slowServer = http.createServer((_req, res) => { + const slowServer = http.createServer((req, res) => { const token = getOrCreateBarAuthToken(ccsDir); + const nonce = String(req.headers[BAR_AUTH_NONCE_HEADER] ?? ''); setTimeout(() => { res.writeHead(200, { 'Content-Type': 'application/json', - [BAR_AUTH_TOKEN_HEADER]: token, + [BAR_AUTH_TOKEN_HEADER]: createBarAuthProof(token, nonce), }); res.end('{}'); }, 300); @@ -3149,7 +3158,17 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired setTimeout(_ms: number, _cb: () => void) { return socket; }, - write() { + write(data: string) { + const nonce = + data.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? ''; + for (const cb of listeners.data ?? []) { + cb( + Buffer.from( + `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(token, nonce)}\r\n\r\n`, + 'utf8' + ) + ); + } return true; }, destroy() { @@ -3158,14 +3177,6 @@ describe('defaultFindRunningServer: socket-level 401/403 classifies authRequired }; setImmediate(() => { onConnect(); - for (const cb of listeners.data ?? []) { - cb( - Buffer.from( - `HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${token}\r\n\r\n`, - 'utf8' - ) - ); - } }); return socket; }, @@ -3207,7 +3218,7 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected // fully synchronous and immune to OS socket state. The invariant is // behaviour-coupled: removing the token check causes both tests to fail. - function buildNetMock(responseHeaders: string) { + function buildNetMock(responseHeaders: string | ((request: string) => string)) { // Returns a `net` mock whose connect() immediately delivers the response, // then emits 'end'. return { @@ -3221,7 +3232,12 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected setTimeout(_ms: number, _cb: () => void) { return socket; }, - write() { + write(data: string) { + const response = + typeof responseHeaders === 'function' ? responseHeaders(data) : responseHeaders; + for (const cb of listeners.data ?? []) { + cb(Buffer.from(response, 'utf8')); + } return true; }, destroy() { @@ -3230,9 +3246,6 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected }; setImmediate(() => { onConnect(); - for (const cb of listeners.data ?? []) { - cb(Buffer.from(responseHeaders, 'utf8')); - } for (const cb of listeners.end ?? []) { cb(); } @@ -3296,11 +3309,15 @@ describe('defaultWaitForServerLive: rogue 200 without matching token is rejected const { getOrCreateBarAuthToken: getToken } = await import( `../../../src/utils/bar-auth-token?test=${Date.now()}-legit-net` ); - const realToken = getToken(ccsDir); + const realToken = getToken(); - // Mock net: every probe gets 200 with the CORRECT token. + // Mock net: every probe gets 200 with the CORRECT nonce-bound proof. mock.module('net', () => - buildNetMock(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${realToken}\r\n\r\n`) + buildNetMock((request) => { + const nonce = + request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? ''; + return `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(realToken, nonce)}\r\n\r\n`; + }) ); moduleSeq++; @@ -3365,7 +3382,19 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { setTimeout() { return socket; }, - write() { + write(data: string) { + if (opts.port === 41235) { + const nonce = + data.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? ''; + for (const cb of listeners.data ?? []) { + cb( + Buffer.from( + `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(expectedToken, nonce)}\r\n\r\n`, + 'utf8' + ) + ); + } + } return true; }, destroy() { @@ -3377,16 +3406,6 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { // Fire the connect callback asynchronously, mirroring net.connect. setImmediate(() => { onConnect(); - if (opts.port === 41235) { - const data = listeners.data ?? []; - // The mock server includes the token unconditionally in the response - // (read from the 0600 file, not echoed from the request) — this is - // exactly what the production CCS Bar server does, and is the property - // that prevents a rogue reflector from passing the check. - for (const cb of data) { - cb(Buffer.from(`HTTP/1.1 200 OK\r\nx-ccs-bar-token: ${expectedToken}\r\n\r\n`, 'utf8')); - } - } // Port 3000 never emits a status line: simulate an endlessly // streaming service that must not block the higher-priority hit. // Any other port stays silent and is settled by the 1.5s timeout, @@ -3421,6 +3440,158 @@ describe('defaultFindRunningServer: streaming lower-priority probes', () => { }); }); +describe('bar raw socket probes: absolute deadline for malformed streaming peers', () => { + it('continues past a higher-priority trickling non-HTTP response and returns a lower-priority hit', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'bar.json'), + JSON.stringify({ port: 41236, baseUrl: 'http://127.0.0.1:41236', authMode: 'loopback' }) + ); + + const { getOrCreateBarAuthToken: getToken } = await import( + `../../../src/utils/bar-auth-token?test=${Date.now()}-deadline-find` + ); + const expectedToken = getToken(ccsDir); + + mock.module('net', () => ({ + connect: (opts: { host: string; port: number }, onConnect: () => void): unknown => { + const listeners: Record void>> = {}; + let interval: ReturnType | undefined; + let request = ''; + const socket = { + on(event: string, cb: (arg?: unknown) => void) { + (listeners[event] ??= []).push(cb); + return socket; + }, + setTimeout() { + return socket; + }, + write(data: string) { + request = data; + return true; + }, + destroy() { + if (interval) clearInterval(interval); + return socket; + }, + }; + + setImmediate(() => { + onConnect(); + if (opts.port === 41236) { + interval = setInterval(() => { + for (const cb of listeners.data ?? []) cb(Buffer.from('x', 'utf8')); + }, 25); + interval.unref?.(); + return; + } + if (opts.port === 3000) { + const nonce = + request.match(new RegExp(`${BAR_AUTH_NONCE_HEADER}:\\s*([^\\r\\n]+)`, 'i'))?.[1] ?? + ''; + for (const cb of listeners.data ?? []) { + cb( + Buffer.from( + `HTTP/1.1 200 OK\r\n${BAR_AUTH_TOKEN_HEADER}: ${createBarAuthProof(expectedToken, nonce)}\r\n\r\n`, + 'utf8' + ) + ); + } + } + }); + + return socket; + }, + })); + + moduleSeq++; + const { defaultFindRunningServer } = (await import( + `../../../src/commands/bar/bar-server-probe?test=${Date.now()}-${moduleSeq}` + )) as { + defaultFindRunningServer: ( + ccsDir: string + ) => Promise<{ port: number; baseUrl: string; authRequired?: boolean } | null>; + }; + + const result = await Promise.race([ + defaultFindRunningServer(ccsDir), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 2500)), + ]); + + expect(result).toEqual({ + port: 3000, + baseUrl: 'http://127.0.0.1:3000', + authRequired: false, + }); + }); + + it('does not let a single launch wait probe outlive the outer deadline', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + const realDateNow = Date.now; + let dateCall = 0; + Date.now = () => { + dateCall++; + return dateCall < 4 ? 0 : 10_001; + }; + + mock.module('net', () => ({ + connect: (_opts: { host: string; port: number }, onConnect: () => void): unknown => { + const listeners: Record void>> = {}; + let interval: ReturnType | undefined; + const socket = { + on(event: string, cb: (arg?: unknown) => void) { + (listeners[event] ??= []).push(cb); + return socket; + }, + setTimeout() { + return socket; + }, + write() { + return true; + }, + destroy() { + if (interval) clearInterval(interval); + return socket; + }, + }; + + setImmediate(() => { + onConnect(); + interval = setInterval(() => { + for (const cb of listeners.data ?? []) cb(Buffer.from('x', 'utf8')); + }, 25); + interval.unref?.(); + }); + + return socket; + }, + })); + + try { + moduleSeq++; + const { defaultWaitForServerLive } = (await import( + `../../../src/commands/bar/launch-subcommand?test=${Date.now()}-${moduleSeq}` + )) as { + defaultWaitForServerLive: (baseUrl: string) => Promise; + }; + + const result = await Promise.race([ + defaultWaitForServerLive('http://127.0.0.1:9996') + .then(() => 'resolved' as const) + .catch(() => 'rejected' as const), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 2500)), + ]); + + expect(result).toBe('rejected'); + } finally { + Date.now = realDateNow; + } + }); +}); + // --------------------------------------------------------------------------- // GH-1588 — `--await-quit` waits for a running app to exit, then swaps + relaunches // --------------------------------------------------------------------------- diff --git a/tests/unit/commands/bar-lifecycle-subcommands.test.ts b/tests/unit/commands/bar-lifecycle-subcommands.test.ts index 89ac7747..d7a4d285 100644 --- a/tests/unit/commands/bar-lifecycle-subcommands.test.ts +++ b/tests/unit/commands/bar-lifecycle-subcommands.test.ts @@ -98,6 +98,11 @@ async function loadInstallSubcommand() { }; } +async function loadLaunchDescriptor() { + moduleSeq++; + return import(`../../../src/commands/bar/launch-descriptor?test=${Date.now()}-${moduleSeq}`); +} + // --------------------------------------------------------------------------- // Setup / teardown // --------------------------------------------------------------------------- @@ -718,6 +723,8 @@ describe('launch: detached-spawn model', () => { }; expect(desc.schema).toBe(1); expect(desc.runtime).toBe(process.execPath); + expect(path.basename(desc.args[0])).toBe('ccs.js'); + expect(desc.args[0]).not.toContain(`${path.sep}.ccs${path.sep}`); expect(desc.args).toContain('bar'); expect(desc.args).toContain('serve'); expect(desc.home).toBe(os.homedir()); @@ -754,6 +761,56 @@ describe('launch: detached-spawn model', () => { }); }); +// --------------------------------------------------------------------------- +// launch-descriptor: safe shim for native app self-start +// --------------------------------------------------------------------------- + +describe('launch descriptor shim', () => { + it('creates a private ccs.js shim for symlinked Bun-style entrypoints', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + const packageDist = path.join( + tempHome, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs', + 'dist' + ); + const binDir = path.join(tempHome, '.bun', 'bin'); + const realEntrypoint = path.join(packageDist, 'ccs.js'); + const symlinkedEntrypoint = path.join(binDir, 'ccs'); + + fs.mkdirSync(packageDist, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(realEntrypoint, 'console.log("ccs");\n', { mode: 0o777 }); + fs.symlinkSync(realEntrypoint, symlinkedEntrypoint); + + const { createBarLaunchDescriptor, getLaunchShimPath } = await loadLaunchDescriptor(); + const descriptor = createBarLaunchDescriptor({ + entrypointPath: symlinkedEntrypoint, + runtime: '/usr/local/bin/node', + home: tempHome, + ccsHome: ccsDir, + }); + + const shimPath = getLaunchShimPath(tempHome); + expect(descriptor.runtime).toBe('/usr/local/bin/node'); + expect(descriptor.args).toEqual([shimPath, 'bar', 'serve']); + expect(path.basename(descriptor.args[0])).toBe('ccs.js'); + expect(descriptor.args[0]).not.toContain(`${path.sep}.ccs${path.sep}`); + expect(fs.lstatSync(descriptor.args[0]).isSymbolicLink()).toBe(false); + + const mode = fs.statSync(descriptor.args[0]).mode & 0o777; + expect((mode & 0o022) === 0).toBe(true); + const resolvedEntrypoint = fs.realpathSync(realEntrypoint); + expect(fs.readFileSync(descriptor.args[0], 'utf8')).toContain( + `require(${JSON.stringify(resolvedEntrypoint)});` + ); + }); +}); + // --------------------------------------------------------------------------- // install-subcommand: writeLaunchDescriptor after successful install // --------------------------------------------------------------------------- @@ -800,6 +857,8 @@ describe('install: writeLaunchDescriptor called after successful install', () => }; expect(desc.schema).toBe(1); expect(desc.runtime).toBe(process.execPath); + expect(path.basename(desc.args[0])).toBe('ccs.js'); + expect(desc.args[0]).not.toContain(`${path.sep}.ccs${path.sep}`); expect(desc.args).toContain('bar'); expect(desc.args).toContain('serve'); expect(desc.home).toBe(os.homedir()); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 3724bc39..d8fc2482 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -16,14 +16,13 @@ import { getDaemonStatus, stopDaemon, startDaemon, + buildDaemonProcessEnv, } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; -import { - renderCursorHelp, - renderCursorStatus, -} from '../../../src/commands/cursor-command-display'; +import { renderCursorHelp, renderCursorStatus } from '../../../src/commands/cursor-command-display'; import { loadCredentials } from '../../../src/cursor/cursor-auth'; +import { getCursorDaemonToken } from '../../../src/cursor/cursor-daemon-auth'; import { DEFAULT_CURSOR_CONFIG } from '../../../src/config/unified-config-types'; // Test isolation @@ -148,6 +147,26 @@ describe('removePidFile', () => { }); }); +describe('buildDaemonProcessEnv', () => { + it('uses the daemon token for both daemon and Anthropic caller auth', () => { + const originalAnthropicToken = process.env.ANTHROPIC_AUTH_TOKEN; + process.env.ANTHROPIC_AUTH_TOKEN = 'inherited-stale-token'; + + try { + const env = buildDaemonProcessEnv('generated-daemon-token'); + + expect(env.CCS_CURSOR_DAEMON_TOKEN).toBe('generated-daemon-token'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('generated-daemon-token'); + } finally { + if (originalAnthropicToken !== undefined) { + process.env.ANTHROPIC_AUTH_TOKEN = originalAnthropicToken; + } else { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } + } + }); +}); + describe('startDaemon', () => { it('rejects invalid port (0)', async () => { const result = await startDaemon({ port: 0 }); @@ -197,7 +216,7 @@ describe('isDaemonRunning', () => { throw new Error('Unable to resolve test server port'); } - const result = await isDaemonRunning(address.port, "bad-token"); + const result = await isDaemonRunning(address.port, 'bad-token'); expect(result).toBe(false); } finally { await new Promise((resolve) => { @@ -222,6 +241,41 @@ describe('getDaemonStatus', () => { expect(status.port).toBe(19999); expect(status.pid).toBeUndefined(); }); + + it('uses the persisted daemon token when checking status', async () => { + const daemonToken = getCursorDaemonToken(); + const server = http.createServer((req, res) => { + if (req.url === '/health' && req.headers['x-ccs-cursor-token'] === daemonToken) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, service: 'cursor-daemon' })); + return; + } + + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + writePidToFile(12345); + const status = await getDaemonStatus(address.port); + expect(status.running).toBe(true); + expect(status.port).toBe(address.port); + expect(status.pid).toBe(12345); + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + }); }); describe('stopDaemon', () => { @@ -275,17 +329,18 @@ describe('stopDaemon', () => { }); it('refuses to stop when daemon ownership cannot be verified', async () => { - const killSpy = spyOn(process, 'kill').mockImplementation( - ((pid: number, signal?: NodeJS.Signals | number) => { - if (pid === process.pid && signal === 0) { - const err = new Error('EPERM') as NodeJS.ErrnoException; - err.code = 'EPERM'; - throw err; - } + const killSpy = spyOn(process, 'kill').mockImplementation((( + pid: number, + signal?: NodeJS.Signals | number + ) => { + if (pid === process.pid && signal === 0) { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } - return true; - }) as typeof process.kill - ); + return true; + }) as typeof process.kill); writePidToFile(process.pid); @@ -388,11 +443,13 @@ describe('renderCursorStatus', () => { true ); expect( - logs.some((line) => line.includes('Chat route: http://127.0.0.1:20129/v1/chat/completions')) - ).toBe(true); - expect( - logs.some((line) => line.includes('Anthropic base: http://127.0.0.1:20129')) + logs.some((line) => + line.includes('Chat route: http://127.0.0.1:20129/v1/chat/completions') + ) ).toBe(true); + expect(logs.some((line) => line.includes('Anthropic base: http://127.0.0.1:20129'))).toBe( + true + ); expect( logs.some((line) => line.includes(`Raw settings: ${getCcsDir()}/cursor.settings.json`)) ).toBe(true); @@ -460,9 +517,9 @@ describe('renderCursorStatus', () => { { running: true, port: 20129, pid: 1234 } ); - expect(logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json'))).toBe( - true - ); + expect( + logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json')) + ).toBe(true); } finally { if (originalCcsHomeValue !== undefined) { process.env.CCS_HOME = originalCcsHomeValue; @@ -496,15 +553,11 @@ describe('renderCursorHelp', () => { line.includes('Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.') ) ).toBe(true); - expect(logs.some((line) => line.includes('probe Run a live authenticated runtime probe'))).toBe( - true - ); expect( - logs.some((line) => line.includes('ccs cursor --auth')) - ).toBe(true); - expect( - logs.some((line) => line.includes('ccs legacy cursor [claude args]')) + logs.some((line) => line.includes('probe Run a live authenticated runtime probe')) ).toBe(true); + expect(logs.some((line) => line.includes('ccs cursor --auth'))).toBe(true); + expect(logs.some((line) => line.includes('ccs legacy cursor [claude args]'))).toBe(true); } finally { console.log = originalLog; } diff --git a/tests/unit/glmt/glmt-transformer.test.js b/tests/unit/glmt/glmt-transformer.test.js index 05950ba7..930fbb7e 100644 --- a/tests/unit/glmt/glmt-transformer.test.js +++ b/tests/unit/glmt/glmt-transformer.test.js @@ -161,16 +161,17 @@ describe('GlmtTransformer', () => { }); describe('Thinking signature', () => { - it('generates thinking signature', () => { + it('generates thinking signature as a non-empty opaque string', () => { const transformer = new GlmtTransformer(); const thinking = 'This is my reasoning process'; const signature = transformer.generateThinkingSignature(thinking); - assert.strictEqual(signature.type, 'thinking_signature'); - assert.ok(signature.hash); - assert.strictEqual(signature.hash.length, 16); - assert.strictEqual(signature.length, thinking.length); - assert.ok(signature.timestamp); + // Anthropic contract: a thinking block's signature is a non-empty + // opaque string, not an object. + assert.strictEqual(typeof signature, 'string'); + assert.ok(signature.length > 0); + // Deterministic: same reasoning yields the same signature (no Date.now()). + assert.strictEqual(signature, transformer.generateThinkingSignature(thinking)); }); }); diff --git a/tests/unit/glmt/test-thinking-signature-race.js b/tests/unit/glmt/test-thinking-signature-race.js index c9a7b40f..1537ceb5 100755 --- a/tests/unit/glmt/test-thinking-signature-race.js +++ b/tests/unit/glmt/test-thinking-signature-race.js @@ -12,6 +12,9 @@ const GlmtTransformer = require('../../../dist/glmt/glmt-transformer').default; const { DeltaAccumulator } = require('../../../dist/glmt/delta-accumulator'); +const { ResponseBuilder } = require('../../../dist/glmt/pipeline'); + +const responseBuilder = new ResponseBuilder(false); // Test runner class TestRunner { @@ -59,7 +62,6 @@ function assert(condition, message) { // Test 1: Signature not generated for empty thinking block runner.test('Signature not generated for empty thinking block', () => { - const transformer = new GlmtTransformer({ verbose: false }); const accumulator = new DeltaAccumulator({ thinking: true }); // Start thinking block but add no content @@ -67,7 +69,7 @@ runner.test('Signature not generated for empty thinking block', () => { const block = accumulator.startBlock('thinking'); // Try to generate signature for empty block - const signatureEvent = transformer._createSignatureDeltaEvent(block); + const signatureEvent = responseBuilder.createSignatureDeltaEvent(block); // Should return null for empty block (fix for race condition) assert(signatureEvent === null, 'Expected null for empty thinking block'); @@ -75,7 +77,6 @@ runner.test('Signature not generated for empty thinking block', () => { // Test 2: Signature generated correctly after content accumulated runner.test('Signature generated correctly after content accumulated', () => { - const transformer = new GlmtTransformer({ verbose: false }); const accumulator = new DeltaAccumulator({ thinking: true }); // Start thinking block and add content @@ -85,15 +86,14 @@ runner.test('Signature generated correctly after content accumulated', () => { accumulator.addDelta('Second thinking delta.'); // Generate signature - const signatureEvent = transformer._createSignatureDeltaEvent(block); + const signatureEvent = responseBuilder.createSignatureDeltaEvent(block); // Should return valid signature event assert(signatureEvent !== null, 'Expected signature event for non-empty block'); assert(signatureEvent.event === 'content_block_delta', 'Expected content_block_delta event'); assert(signatureEvent.data.delta.type === 'thinking_signature_delta', 'Expected thinking_signature_delta type'); + assert(typeof signatureEvent.data.delta.signature === 'string', 'Expected signature string'); assert(signatureEvent.data.delta.signature.length > 0, 'Expected signature length > 0'); - assert(signatureEvent.data.delta.signature.hash, 'Expected signature hash'); - assert(signatureEvent.data.delta.signature.hash.length === 16, 'Expected 16-char hash'); }); // Test 3: transformDelta skips signature for empty thinking blocks @@ -179,8 +179,8 @@ runner.test('transformDelta generates signature for non-empty thinking blocks', // Verify signature structure const sig = signatureEvents[0].data.delta.signature; - assert(sig.hash && sig.hash.length === 16, 'Expected valid 16-char hash'); - assert(sig.length > 0, 'Expected content length > 0'); + assert(typeof sig === 'string', 'Expected signature string'); + assert(sig.length > 0, 'Expected signature length > 0'); }); // Test 5: Loop detection handles empty thinking blocks @@ -217,7 +217,7 @@ runner.test('Loop detection handles empty thinking blocks without signature', () const currentBlock = accumulator.getCurrentBlock(); if (currentBlock && currentBlock.type === 'thinking') { - const signatureEvent = transformer._createSignatureDeltaEvent(currentBlock); + const signatureEvent = responseBuilder.createSignatureDeltaEvent(currentBlock); // Should return null for empty block assert(signatureEvent === null, 'Expected null signature for empty thinking block during loop detection'); } diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index f05f9765..822cd4a2 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -1,12 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; @@ -32,11 +25,7 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { query: string, errors: Array<{ provider: string; error: string }> ) => HookOutput; - buildSuccessHookOutput: ( - query: string, - providerName: string, - content: string - ) => HookOutput; + buildSuccessHookOutput: (query: string, providerName: string, content: string) => HookOutput; classifyDuckDuckGoHtml: ( html: string, count: number @@ -45,7 +34,10 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { kind: 'results' | 'no_results' | 'non_result_html'; results: Array<{ title: string; url: string; description: string }>; }; - extractDuckDuckGoResults: (html: string, count: number) => Array<{ + extractDuckDuckGoResults: ( + html: string, + count: number + ) => Array<{ title: string; url: string; description: string; @@ -62,7 +54,10 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as { results: Array<{ title: string; url: string; description: string }> ) => string; parseRetryAfterSeconds: (rawValue: string) => number | null; - trySearxngSearch: (query: string, timeoutSec?: number) => Promise<{ + trySearxngSearch: ( + query: string, + timeoutSec?: number + ) => Promise<{ content?: string; error?: string; statusCode?: number; @@ -136,7 +131,7 @@ describe('websearch-transformer legacy CLI safety', () => { const source = readFileSync(hookPath, 'utf8'); expect(source).not.toContain('shell: isWindows'); - expect(source.match(/shell: false/g) || []).toHaveLength(3); + expect(source.match(/shell: false/g) || []).toHaveLength(4); }); }); @@ -411,9 +406,7 @@ describe('websearch-transformer hook helpers', () => { const output = JSON.parse(result.stdout.trim()) as HookOutput; expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse'); expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); - expect(output.hookSpecificOutput.additionalContext).toContain( - 'CCS local WebSearch evidence' - ); + expect(output.hookSpecificOutput.additionalContext).toContain('CCS local WebSearch evidence'); expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo'); expect(output.hookSpecificOutput.additionalContext).toContain( 'URL: https://example.com/article' @@ -782,8 +775,7 @@ global.fetch = async (url) => { expect( traceEvents.some( (event) => - event.event === 'websearch_provider_success' && - event.providerId === 'duckduckgo' + event.event === 'websearch_provider_success' && event.providerId === 'duckduckgo' ) ).toBe(true); } finally { @@ -994,8 +986,7 @@ global.fetch = async (url) => { ).toBe(true); expect( traceEvents.some( - (event) => - event.event === 'websearch_provider_success' && event.providerId === 'exa' + (event) => event.event === 'websearch_provider_success' && event.providerId === 'exa' ) ).toBe(true); } finally { diff --git a/tests/unit/proxy/transformers/sse-stream-transformer.test.ts b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts index 9faebabc..6a946d78 100644 --- a/tests/unit/proxy/transformers/sse-stream-transformer.test.ts +++ b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts @@ -67,4 +67,109 @@ describe('proxy SSE stream transformer', () => { expect(body).toContain('"type":"text_delta"'); expect(body).toContain('event: message_stop'); }); + + // Helper: extract all JSON `data:` payloads from an Anthropic SSE response body, + // skipping the `[DONE]` sentinel and any non-JSON lines. + function parseSseDataEvents(text: string): Record[] { + const events: Record[] = []; + for (const line of text.split('\n')) { + if (!line.startsWith('data: ')) continue; + const raw = line.slice('data: '.length).trim(); + if (raw.length === 0 || raw === '[DONE]') continue; + try { + events.push(JSON.parse(raw) as Record); + } catch { + // skip non-JSON data lines + } + } + return events; + } + + it('emits thinking.signature as a non-empty string — JSON (Anthropic contract)', async () => { + // Anthropic requires a thinking block's `signature` to be a non-empty opaque + // STRING. This test asserts that contract; it is intentionally RED on the + // current code, which fabricates the signature as an object via + // generateThinkingSignature(). After the fix it becomes a regression guard. + const response = new Response( + JSON.stringify({ + id: 'chatcmpl_sig_json', + model: 'gpt-5.x', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Final answer.', + reasoning_content: 'Step-by-step reasoning before answering.', + }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + + const transformed = await createAnthropicProxyResponse(response); + const body = (await transformed.json()) as { + content: Array<{ type: string; signature?: unknown }>; + }; + + const thinkingBlock = body.content.find((block) => block.type === 'thinking'); + expect( + thinkingBlock, + `no thinking block found; content = ${JSON.stringify(body.content)}` + ).toBeDefined(); + + const signature = thinkingBlock?.signature; + expect( + typeof signature, + `expected non-empty string signature, received ${JSON.stringify(signature)}` + ).toBe('string'); + expect((signature as string).length).toBeGreaterThan(0); + }); + + it('emits thinking signature as a non-empty string — SSE (Anthropic contract)', async () => { + // Streaming counterpart of the contract test above: mirrors the failed + // prod path where reasoning_content streams in and ccs closes the thinking + // block with a fabricated signature via createSignatureDeltaEvent(). + const openAISse = [ + 'data: {"id":"chatcmpl_sig_stream","object":"chat.completion.chunk","created":1,"model":"gpt-5.x","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Planning the verification step by step."},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_sig_stream","object":"chat.completion.chunk","created":1,"model":"gpt-5.x","choices":[{"index":0,"delta":{"content":"Final answer."},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_sig_stream","object":"chat.completion.chunk","created":1,"model":"gpt-5.x","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}\n\n', + 'data: [DONE]\n\n', + ].join(''); + + const transformed = await createAnthropicProxyResponse( + new Response(openAISse, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + const payloads = parseSseDataEvents(body); + + // ccs emits the thinking signature through createSignatureDeltaEvent, + // which produces a content_block_delta with delta.type === 'thinking_signature_delta'. + const signatureDelta = payloads.find((payload) => { + const delta = (payload as { delta?: { type?: string } }).delta; + return delta?.type === 'thinking_signature_delta'; + }); + expect( + signatureDelta, + `no thinking_signature_delta event in stream; payloads = ${JSON.stringify(payloads)}` + ).toBeDefined(); + + const signature = (signatureDelta as { delta?: { signature?: unknown } }).delta?.signature; + // Anthropic contract: streaming thinking signature MUST be a non-empty opaque string. + expect( + typeof signature, + `expected non-empty string signature, received ${JSON.stringify(signature)}` + ).toBe('string'); + expect((signature as string).length).toBeGreaterThan(0); + }); }); diff --git a/tests/unit/services/logging/log-redaction-extended.test.ts b/tests/unit/services/logging/log-redaction-extended.test.ts index 44b87849..7b5b2402 100644 --- a/tests/unit/services/logging/log-redaction-extended.test.ts +++ b/tests/unit/services/logging/log-redaction-extended.test.ts @@ -136,4 +136,24 @@ describe('redactArgv', () => { '[redacted]', ]); }); + + it('redacts prompt values passed with -p and --prompt', () => { + expect(redactArgv(['glm', '-p', 'summarize secret account notes'])).toEqual([ + 'glm', + '-p', + '[redacted]', + ]); + + expect(redactArgv(['glm', '--prompt', 'summarize secret account notes'])).toEqual([ + 'glm', + '--prompt', + '[redacted]', + ]); + }); + + it('redacts inline prompt and sensitive flag assignments', () => { + expect( + redactArgv(['glm', '--prompt=summarize secret account notes', '--api-key=plainsecret']) + ).toEqual(['glm', '--prompt=[redacted]', '--api-key=[redacted]']); + }); }); diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 6d4927d9..86a3f2fb 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -128,7 +128,7 @@ describe('CodexAdapter', () => { profileType: 'cliproxy', creds: { profile: 'codex', - baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + baseUrl: 'http://127.0.0.1:8317', apiKey: 'cliproxy-token', model: 'gpt-5.4', reasoningOverride: 'high', @@ -143,6 +143,9 @@ describe('CodexAdapter', () => { expect(args).toContain('-c'); expect(args).toContain('model_provider="ccs_runtime"'); + expect(args).toContain( + 'model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/backend-api/codex"' + ); expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); expect(args).toContain('model="gpt-5.4"'); expect(args).toContain( diff --git a/tests/unit/targets/codex-cliproxy-provider-config.test.ts b/tests/unit/targets/codex-cliproxy-provider-config.test.ts index 62792f81..a064cd5f 100644 --- a/tests/unit/targets/codex-cliproxy-provider-config.test.ts +++ b/tests/unit/targets/codex-cliproxy-provider-config.test.ts @@ -50,8 +50,19 @@ describe('codex cliproxy provider config repair', () => { clearConfigCache(); } - it('uses the original backend root URL by default', () => { - expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe('http://127.0.0.1:8317'); + it('uses the original backend chatgpt_base_url alias by default', () => { + // Codex CLI (wire_api = "responses") appends "/responses" to base_url. The + // original CLIProxy backend serves the Codex Responses API only under the + // "/backend-api/codex" alias, never at the bare root. Returning the root here + // makes Codex call "http://127.0.0.1:8317/responses" -> 404 (issue #1597). + expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe('http://127.0.0.1:8317/backend-api/codex'); + }); + + it('produces a Codex Responses endpoint the original backend actually serves (regression #1597)', () => { + const baseUrl = buildCodexCliproxyProviderBaseUrl(8317); + const responsesEndpoint = `${baseUrl.replace(/\/+$/, '')}/responses`; + expect(responsesEndpoint).toBe('http://127.0.0.1:8317/backend-api/codex/responses'); + expect(responsesEndpoint).not.toBe('http://127.0.0.1:8317/responses'); }); it('uses the plus backend scoped Codex URL when configured', () => { @@ -69,7 +80,7 @@ describe('codex cliproxy provider config repair', () => { const rawText = fs.readFileSync(configPath, 'utf8'); expect(rawText).toContain('[model_providers.cliproxy]'); expect(rawText).toContain('name = "CLIProxy Codex"'); - expect(rawText).toContain('base_url = "http://127.0.0.1:8317"'); + expect(rawText).toContain('base_url = "http://127.0.0.1:8317/backend-api/codex"'); expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); expect(rawText).not.toContain('model_provider = "cliproxy"'); }); @@ -116,12 +127,42 @@ wire_api = "responses" expect(result.changed).toBe(true); expect(result.envKey).toBe('CLIPROXY_API_KEY'); const rawText = fs.readFileSync(configPath, 'utf8'); - expect(rawText).toContain('base_url = "http://127.0.0.1:9321"'); + expect(rawText).toContain('base_url = "http://127.0.0.1:9321/backend-api/codex"'); expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); expect(rawText).toContain('requires_openai_auth = false'); expect(rawText).toContain('supports_websockets = false'); }); + it('removes native provider auth when ccsxp injects the token through env_key', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + configPath, + `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "http://127.0.0.1:8317/backend-api/codex" +env_key = "CLIPROXY_API_KEY" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false + +[model_providers.cliproxy.auth] +command = "/tmp/cliproxy-token" +timeout_ms = 5000 +refresh_interval_ms = 300000 +`, + 'utf8' + ); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); + expect(rawText).not.toContain('[model_providers.cliproxy.auth]'); + expect(rawText).not.toContain('cliproxy-token'); + }); + it('preserves custom cliproxy provider values while repairing other fields', async () => { fs.mkdirSync(codexHome, { recursive: true }); fs.writeFileSync( @@ -180,7 +221,7 @@ supports_websockets = false expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); }); - it('repairs a stale ready localhost provider to the original backend root URL', async () => { + it('repairs a stale ready localhost provider to the original backend codex alias', async () => { fs.mkdirSync(codexHome, { recursive: true }); const rawText = `[model_providers.cliproxy] name = "CLIProxy Codex" @@ -197,7 +238,7 @@ supports_websockets = false expect(result.changed).toBe(true); expect(result.envKey).toBe('CLIPROXY_API_KEY'); const repairedText = fs.readFileSync(configPath, 'utf8'); - expect(repairedText).toContain('base_url = "http://127.0.0.1:8317"'); + expect(repairedText).toContain('base_url = "http://127.0.0.1:8317/backend-api/codex"'); expect(repairedText).not.toContain('/api/provider/codex'); }); @@ -299,7 +340,7 @@ supports_websockets = false [model_providers.cliproxy] name = "CLIProxy Codex" -base_url = "http://127.0.0.1:8317" +base_url = "http://127.0.0.1:8317/backend-api/codex" env_key = "CLIPROXY_API_KEY" wire_api = "responses" requires_openai_auth = false diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts index 6bf1c75c..384226d0 100644 --- a/tests/unit/targets/codex-settings-bridge-launch.test.ts +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -124,21 +124,26 @@ exit 0 const argsLog = fs.readFileSync(codexArgsLogPath, 'utf8'); expect(argsLog).toContain('model_provider="ccs_runtime"'); - expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"'); + expect(argsLog).toContain( + 'model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/backend-api/codex"' + ); expect(argsLog).toContain('model_reasoning_effort="high"'); expect(argsLog).not.toContain('mcp_servers.ccs_browser.command='); expect(argsLog).not.toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]'); expect(argsLog).toContain('smoke'); expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token'); - }); + }, 15000); it('rejects native Codex profile flags when CCS manages the bridge runtime', () => { if (process.platform === 'win32') return; - const result = runCcs(['codex-api', '--target', 'codex', '--profile', 'other', 'smoke'], baseEnv); + const result = runCcs( + ['codex-api', '--target', 'codex', '--profile', 'other', 'smoke'], + baseEnv + ); expect(result.status).toBe(1); expect(result.stderr).toContain('does not allow --profile/-p'); expect(fs.existsSync(codexArgsLogPath)).toBe(false); - }); + }, 15000); }); diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index 9061efb5..7e3f5346 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -61,6 +61,11 @@ describe('settings profile WebSearch launch', () => { path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n' ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: true', ''].join('\n'), + 'utf8' + ); fs.writeFileSync( settingsPath, JSON.stringify( @@ -93,6 +98,7 @@ exit 0 CCS_HOME: tmpHome, CCS_CLAUDE_PATH: fakeClaudePath, CCS_DEBUG: '1', + CCS_SKIP_PREFLIGHT: '1', }; }); @@ -104,40 +110,30 @@ exit 0 fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('continues without local WebSearch when the tool runtime cannot be prepared', () => { + it('fails closed when the local WebSearch tool runtime cannot be prepared', () => { if (process.platform === 'win32') return; fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); const result = runCcs(['glm', 'smoke'], baseEnv); - expect(result.status).toBe(0); + expect(result.status).not.toBe(0); expect(result.stderr).toContain('could not prepare the local WebSearch tool'); - expect(result.stderr).toContain('This session will continue without local WebSearch'); - expect(fs.existsSync(claudeArgsLogPath)).toBe(true); - const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); - expect(launchedArgs).toContain('--disallowedTools'); - expect(launchedArgs).toContain('WebSearch'); - expect(launchedArgs).toContain('--append-system-prompt'); - expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET); + expect(result.stderr).not.toContain('This session will continue without local WebSearch'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); }); - it('continues delegated headless launch when the local WebSearch tool runtime cannot be prepared', () => { + it('fails closed for delegated headless launch when the local WebSearch tool runtime cannot be prepared', () => { if (process.platform === 'win32') return; fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); const result = runCcs(['glm', '-p', 'smoke'], baseEnv); - expect(result.status).toBe(0); + expect(result.status).not.toBe(0); expect(result.stderr).toContain('could not prepare the local WebSearch tool'); - expect(result.stderr).toContain('This session will continue without local WebSearch'); - expect(fs.existsSync(claudeArgsLogPath)).toBe(true); - const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); - expect(launchedArgs).toContain('--disallowedTools'); - expect(launchedArgs).toContain('WebSearch'); - expect(launchedArgs).toContain('--append-system-prompt'); - expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET); + expect(result.stderr).not.toContain('This session will continue without local WebSearch'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(false); }); it('keeps launch non-fatal when WebSearch is disabled', () => { @@ -145,7 +141,7 @@ exit 0 fs.writeFileSync( path.join(ccsDir, 'config.yaml'), - 'version: 12\nwebsearch:\n enabled: false\n', + ['version: 12', 'websearch:', ' enabled: false', ''].join('\n'), 'utf8' ); fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); @@ -165,6 +161,11 @@ exit 0 it('writes a source-side launch trace for settings profiles when tracing is enabled', () => { if (process.platform === 'win32') return; + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: false', ''].join('\n'), + 'utf8' + ); const tracePath = path.join(ccsDir, 'logs', 'websearch-trace.jsonl'); const result = runCcs(['glm', 'smoke'], { ...baseEnv, diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index a24c76a6..f625c2a2 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, spyOn } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import * as agyCli from '../../../../src/utils/websearch/agy'; import * as geminiCli from '../../../../src/utils/websearch/gemini-cli'; import * as grokCli from '../../../../src/utils/websearch/grok-cli'; import * as opencodeCli from '../../../../src/utils/websearch/opencode-cli'; @@ -13,7 +14,9 @@ import { } from '../../../../src/utils/websearch/status'; import type { WebSearchCliInfo } from '../../../../src/utils/websearch/types'; -function provider(overrides: Partial & Pick): WebSearchCliInfo { +function provider( + overrides: Partial & Pick +): WebSearchCliInfo { return { id: overrides.id, kind: overrides.kind ?? 'backend', @@ -151,6 +154,10 @@ describe('websearch readiness', () => { installed: false, version: null, } as any); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); try { const providers = getWebSearchCliProviders(); @@ -166,6 +173,7 @@ describe('websearch readiness', () => { geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); + agyStatusSpy.mockRestore(); } }); @@ -205,6 +213,10 @@ describe('websearch readiness', () => { installed: false, version: null, } as any); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); try { const providers = getWebSearchCliProviders(); @@ -220,6 +232,72 @@ describe('websearch readiness', () => { geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); + agyStatusSpy.mockRestore(); + } + }); + + it('exposes Antigravity (agy) as a recommended CLI provider when enabled and installed', () => { + const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { enabled: false, url: '', max_results: 5 }, + duckduckgo: { enabled: false, max_results: 5 }, + agy: { enabled: true, model: 'gemini-2.5-flash', timeout: 90 }, + gemini: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + } as any); + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: true, + version: '1.0.13', + } as any); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); + + try { + const providers = getWebSearchCliProviders(); + const agy = providers.find((entry) => entry.id === 'agy'); + + expect(agy?.name).toBe('Antigravity CLI'); + expect(agy?.kind).toBe('legacy-cli'); + expect(agy?.command).toBe('agy'); + expect(agy?.enabled).toBe(true); + expect(agy?.available).toBe(true); + expect(agy?.requiresApiKey).toBe(false); + expect(agy?.installCommand).toContain('antigravity.google'); + expect(agy?.detail).toContain('1.0.13'); + + const readiness = buildWebSearchReadiness(true, providers); + expect(readiness.readiness).toBe('ready'); + expect(readiness.message).toContain('Antigravity CLI'); + } finally { + getConfigSpy.mockRestore(); + apiKeySpy.mockRestore(); + agyStatusSpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); } }); @@ -293,6 +371,10 @@ describe('websearch readiness', () => { installed: false, version: null, } as any); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: false, + version: null, + } as any); try { const providers = getWebSearchCliProviders(); @@ -313,6 +395,7 @@ describe('websearch readiness', () => { geminiAuthSpy.mockRestore(); grokStatusSpy.mockRestore(); opencodeStatusSpy.mockRestore(); + agyStatusSpy.mockRestore(); if (originalCcsHome === undefined) { delete process.env.CCS_HOME; diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 83ce1956..4890820f 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -1220,3 +1220,151 @@ describe('/summary native subscription rows', () => { expect(body[0].provider).toBe('agy'); }); }); + +// ============================================================================ +// GH-1595: wire contract — native rows carry surface/profile/is_subscription; +// CLIProxy pool rows OMIT all three fields. +// ============================================================================ + +describe('/summary wire contract: surface/profile/is_subscription fields (GH-1595)', () => { + /** + * Extended wire row type that includes the new optional fields. + * BarSummaryRow in the test file omits them; extend locally here. + */ + interface WireRow extends BarSummaryRow { + surface?: string; + profile?: string; + is_subscription?: boolean; + } + + function makeNativeRow( + surface: 'ccs' | 'ccsx', + profile: string, + paused = false + ): BarSummaryRow { + return { + account_id: `${surface}:${profile}`, + provider: surface === 'ccs' ? 'claude-code' : 'codex', + displayName: profile, + tier: 'pro', + paused, + quota_percentage: 55, + quotaStatus: 'ok', + next_reset: null, + is_default: !paused, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: '2026-06-23T20:00:00.000Z', + needsReauth: false, + // The TS interface now has these optional fields — set them explicitly. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(({ surface, profile, is_subscription: true }) as any), + }; + } + + async function buildWireRouter(nativeRows: BarSummaryRow[]) { + const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import( + '../../../src/web-server/routes/bar-routes' + ); + + const app = express(); + app.use(express.json()); + + const cliproxyAccount = makeAccountInfo({ id: 'pool@example.com', provider: 'agy' }); + + const router = createBarRouter({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAllAccountsSummary: () => ({ agy: [cliproxyAccount] }) as any, + getCachedQuota: () => makeQuotaResult(), + setCachedQuota: () => {}, + invalidateQuotaCache: () => {}, + fetchAccountQuota: async () => makeQuotaResult(), + getTodayCostByAccount: () => ({}), + loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], + runHealthChecks: async () => makeHealthReport(), + getNativeAccountRows: async () => nativeRows, + }); + + app.use('/api/bar', router); + const srv = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + const addr = srv.address(); + if (!addr || typeof addr === 'string') throw new Error('No server address'); + resetDebounce(); + return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` }; + } + + it('native rows include surface, profile, is_subscription=true in the JSON response', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'work', false), + makeNativeRow('ccsx', 'personal', false), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const claudeRow = body.find((r) => r.provider === 'claude-code'); + expect(claudeRow).toBeDefined(); + expect(claudeRow?.surface).toBe('ccs'); + expect(claudeRow?.profile).toBe('work'); + expect(claudeRow?.is_subscription).toBe(true); + expect(claudeRow?.account_id).toBe('ccs:work'); + + const codexRow = body.find((r) => r.provider === 'codex'); + expect(codexRow).toBeDefined(); + expect(codexRow?.surface).toBe('ccsx'); + expect(codexRow?.profile).toBe('personal'); + expect(codexRow?.is_subscription).toBe(true); + expect(codexRow?.account_id).toBe('ccsx:personal'); + }); + + it('CLIProxy pool rows OMIT surface, profile, is_subscription', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'work', false), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + // The CLIProxy row (provider 'agy') should NOT have the new fields. + const cliproxyRow = body.find((r) => r.provider === 'agy'); + expect(cliproxyRow).toBeDefined(); + expect(cliproxyRow?.surface).toBeUndefined(); + expect(cliproxyRow?.profile).toBeUndefined(); + expect(cliproxyRow?.is_subscription).toBeUndefined(); + }); + + it('parked native row (paused:true) is present with is_subscription=true and paused=true', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccsx', 'ck', true), // parked Codex profile + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const parked = body.find((r) => r.profile === 'ck'); + expect(parked).toBeDefined(); + expect(parked?.paused).toBe(true); + expect(parked?.is_subscription).toBe(true); + expect(parked?.surface).toBe('ccsx'); + }); + + it('account_id on native rows uses the : scheme', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'ck', false), + makeNativeRow('ccsx', 'ck', true), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const claudeRow = body.find((r) => r.surface === 'ccs'); + expect(claudeRow?.account_id).toBe('ccs:ck'); + + const codexRow = body.find((r) => r.surface === 'ccsx'); + expect(codexRow?.account_id).toBe('ccsx:ck'); + }); +}); diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 344c2c7e..d66abb5f 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -7,6 +7,9 @@ */ import { beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { getNativeAccountRows, getCachedNativeAccountRows, @@ -467,6 +470,202 @@ function makeCodexDeps( // ============================================================================ describe('Codex network path', () => { + it('production profile enumeration skips paused Codex accounts before live network refresh', async () => { + const originalCcsHome = process.env.CCS_HOME; + const originalHome = process.env.HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-paused-codex-')); + process.env.CCS_HOME = tempHome; + process.env.HOME = tempHome; + + try { + const ccsDir = path.join(tempHome, '.ccs'); + const cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'codex-profiles.yaml'), + [ + 'version: "1.0"', + 'default: paused', + 'profiles:', + ' paused:', + ' type: codex', + ' created: "2026-01-01T00:00:00.000Z"', + ' last_used: null', + ' email: paused-codex@example.com', + ' account_id: paused-codex@example.com', + ' active:', + ' type: codex', + ' created: "2026-01-02T00:00:00.000Z"', + ' last_used: null', + ' email: active-codex@example.com', + ' account_id: active-codex@example.com', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + codex: { + default: 'paused-codex@example.com', + accounts: { + 'paused-codex@example.com': { + email: 'paused-codex@example.com', + tokenFile: 'paused-codex@example.com.json', + paused: true, + }, + 'active-codex@example.com': { + email: 'active-codex@example.com', + tokenFile: 'active-codex@example.com.json', + }, + }, + }, + }, + }, + null, + 2 + ) + ); + fs.mkdirSync(path.join(cliproxyDir, 'auth'), { recursive: true }); + fs.mkdirSync(path.join(cliproxyDir, 'auth-paused'), { recursive: true }); + fs.writeFileSync( + path.join(cliproxyDir, 'auth', 'active-codex@example.com.json'), + JSON.stringify({ type: 'codex' }) + ); + fs.writeFileSync( + path.join(cliproxyDir, 'auth-paused', 'paused-codex@example.com.json'), + JSON.stringify({ type: 'codex' }) + ); + + const fetchedAccountIds: string[] = []; + const deps = makeCodexDeps({ + clock: { now: 1_000_000 }, + listClaudeProfiles: () => [], + readCredentials: undefined, + getDefaultCodexAccountId: undefined, + readCodexNativeAuth: (profile: string) => ({ + accessToken: `token-${profile}`, + accountId: profile === 'active' ? 'active-codex@example.com' : 'paused-codex@example.com', + }), + fetchCodexNetworkQuota: async (accountId: string) => { + fetchedAccountIds.push(accountId); + return { ...codexSuccessQuota(), accountId }; + }, + }); + + const rows = await getNativeAccountRows(deps); + + expect(fetchedAccountIds).toEqual(['active-codex@example.com']); + expect(rows.find((row) => row.profile === 'paused')).toBeUndefined(); + const active = rows.find((row) => row.profile === 'active'); + expect(active?.paused).toBe(false); + expect(active?.is_default).toBe(true); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('production profile enumeration does not live-refresh when all Codex accounts are paused', async () => { + const originalCcsHome = process.env.CCS_HOME; + const originalHome = process.env.HOME; + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-all-paused-codex-')); + process.env.CCS_HOME = tempHome; + process.env.HOME = tempHome; + + try { + const ccsDir = path.join(tempHome, '.ccs'); + const cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'codex-profiles.yaml'), + [ + 'version: "1.0"', + 'default: paused', + 'profiles:', + ' paused:', + ' type: codex', + ' created: "2026-01-01T00:00:00.000Z"', + ' last_used: null', + ' email: paused-codex@example.com', + ' account_id: paused-codex@example.com', + '', + ].join('\n') + ); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + codex: { + default: 'paused-codex@example.com', + accounts: { + 'paused-codex@example.com': { + email: 'paused-codex@example.com', + tokenFile: 'paused-codex@example.com.json', + paused: true, + }, + }, + }, + }, + }, + null, + 2 + ) + ); + fs.mkdirSync(path.join(cliproxyDir, 'auth-paused'), { recursive: true }); + fs.writeFileSync( + path.join(cliproxyDir, 'auth-paused', 'paused-codex@example.com.json'), + JSON.stringify({ type: 'codex' }) + ); + + let networkCalls = 0; + const deps = makeCodexDeps({ + clock: { now: 1_000_000 }, + listClaudeProfiles: () => [], + readCredentials: undefined, + getDefaultCodexAccountId: undefined, + readCodexNativeAuth: (profile: string) => ({ + accessToken: `token-${profile}`, + accountId: 'paused-codex@example.com', + }), + fetchCodexNetworkQuota: async () => { + networkCalls += 1; + return codexSuccessQuota(); + }, + }); + + const rows = await getNativeAccountRows(deps); + + expect(networkCalls).toBe(0); + expect(deps.localCount()).toBe(0); + expect(rows.find((row) => row.profile === 'paused')).toBeUndefined(); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + if (originalHome !== undefined) { + process.env.HOME = originalHome; + } else { + delete process.env.HOME; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); it('network success builds a fresh row from coreUsage — no staleAsOf, health ok, correct windows', async () => { const clock = { now: 1_000_000 }; const deps = makeCodexDeps({ clock }); @@ -775,3 +974,614 @@ describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => { expect(getCachedNativeAccountRows()).toEqual([]); }); }); + +// ============================================================================ +// Multi-profile path tests (GH-1595) +// +// These tests inject listClaudeProfiles / listCodexProfiles / defaultClaudeProfile +// / defaultCodexProfile so the production profile-enumeration path is exercised +// without touching real ~/.ccs or any Keychain. The readClaudeCredentialsForProfile +// and readCodexNativeAuth seams prevent fs access. +// ============================================================================ + +/** + * Build a NativeQuotaDeps for the multi-profile path. + * + * - claudeProfiles: profile names for the Claude surface (ccs) + * - codexProfiles: profile names for the Codex surface (ccsx) + * - claudeDefault / codexDefault: the active profile per surface (paused:false) + * - credsForProfile: map from profile name to credentials (null = parked) + * - claudeFetch: network fetcher for Claude (all profiles share one implementation) + * - codexNativeAuth: map from profile name to {accessToken, accountId} + * - codexNetworkFetch: network fetcher for Codex (all profiles share one impl) + */ +function makeMultiProfileDeps(opts: { + clock: { now: number }; + claudeProfiles: string[]; + codexProfiles: string[]; + claudeDefault?: string | null; + codexDefault?: string | null; + credsForProfile?: (profile: string) => ClaudeNativeCredentials | null; + claudeFetch?: (token: string, accountId?: string) => Promise; + codexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null; + codexNetworkFetch?: (accountId: string) => Promise; + codexLocalFallback?: () => Promise; +}): NativeQuotaDeps & { + claudeFetchCount: () => number; + codexNetworkCount: () => number; +} { + let claudeFetches = 0; + let codexNetworkFetches = 0; + + const { + clock, + claudeProfiles, + codexProfiles, + claudeDefault = null, + codexDefault = null, + credsForProfile = () => null, + claudeFetch = async () => successQuota(), + codexNativeAuth = () => null, + codexNetworkFetch = async () => codexSuccessQuota(), + codexLocalFallback = async () => null, + } = opts; + + return { + // Enumeration seams + listClaudeProfiles: () => claudeProfiles, + listCodexProfiles: () => codexProfiles, + defaultClaudeProfile: () => claudeDefault, + defaultCodexProfile: () => codexDefault, + // Credential seams (file-only, no keychain) + readClaudeCredentialsForProfile: credsForProfile, + readCodexNativeAuth: codexNativeAuth, + // Fetch seams + fetchClaudeQuota: async (token: string, accountId?: string) => { + claudeFetches += 1; + return claudeFetch(token, accountId); + }, + fetchCodexNetworkQuota: async (accountId: string) => { + codexNetworkFetches += 1; + return codexNetworkFetch(accountId); + }, + getCodexQuota: codexLocalFallback, + // Disable legacy single-profile paths + readCredentials: () => null, + getDefaultCodexAccountId: () => null, + // Clock + sleep seams + now: () => clock.now, + sleep: async () => {}, + // Counters + claudeFetchCount: () => claudeFetches, + codexNetworkCount: () => codexNetworkFetches, + }; +} + +describe('multi-profile: account_id and wire fields', () => { + it('Claude profile rows carry surface="ccs", account_id="ccs:

", is_subscription=true', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + // 'work' has creds; 'ck' does not (parked) + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(2); + + const work = rows.find((r) => r.profile === 'work'); + expect(work).toBeDefined(); + expect(work?.account_id).toBe('ccs:work'); + expect(work?.surface).toBe('ccs'); + expect(work?.is_subscription).toBe(true); + expect(work?.provider).toBe('claude-code'); + + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck).toBeDefined(); + expect(ck?.account_id).toBe('ccs:ck'); + expect(ck?.surface).toBe('ccs'); + expect(ck?.is_subscription).toBe(true); + }); + + it('Codex profile rows carry surface="ccsx", account_id="ccsx:

", is_subscription=true', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['personal', 'ck'], + codexDefault: 'personal', + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(2); + + const personal = rows.find((r) => r.profile === 'personal'); + expect(personal?.account_id).toBe('ccsx:personal'); + expect(personal?.surface).toBe('ccsx'); + expect(personal?.is_subscription).toBe(true); + expect(personal?.provider).toBe('codex'); + + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck?.account_id).toBe('ccsx:ck'); + expect(ck?.surface).toBe('ccsx'); + expect(ck?.is_subscription).toBe(true); + }); + + it('paused reflects liveness (creds present), NOT default-ness; is_default marks the default independently', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + // Claude: work = default + creds (live); ck = non-default + NO creds (parked). + claudeProfiles: ['work', 'ck'], + // Codex: personal = default + creds (live); ck = NON-default + creds (live). + codexProfiles: ['personal', 'ck'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + const rows = await getNativeAccountRows(deps); + + // Claude work: default + creds -> live, not dimmed. + const claudeWork = rows.find((r) => r.surface === 'ccs' && r.profile === 'work'); + expect(claudeWork?.paused).toBe(false); + expect(claudeWork?.is_default).toBe(true); + + // Claude ck: non-default + NO creds -> parked/dimmed. + const claudeCk = rows.find((r) => r.surface === 'ccs' && r.profile === 'ck'); + expect(claudeCk?.paused).toBe(true); + expect(claudeCk?.is_default).toBe(false); + + // Codex personal: default + creds -> live. + const codexPersonal = rows.find((r) => r.surface === 'ccsx' && r.profile === 'personal'); + expect(codexPersonal?.paused).toBe(false); + expect(codexPersonal?.is_default).toBe(true); + + // Codex ck: NON-default but HAS creds -> LIVE, NOT dimmed. This is the key + // correctness guarantee: a valid isolated subscription is never dimmed just + // because it is not the surface default. + const codexCk = rows.find((r) => r.surface === 'ccsx' && r.profile === 'ck'); + expect(codexCk?.paused).toBe(false); + expect(codexCk?.is_default).toBe(false); + }); + + it('N Claude + M Codex profiles produce N+M rows', async () => { + const clock = { now: 1_000_000 }; + const claudeProfiles = ['work', 'ck', 'personal']; + const codexProfiles = ['personal', 'ck']; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles, + codexProfiles, + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length); + }); + + it('rows are sorted by (surface, profile)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: ['ck', 'personal'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + const keys = rows.map((r) => `${r.surface}:${r.profile}`); + // ccs:ck < ccs:work < ccsx:ck < ccsx:personal + expect(keys).toEqual(['ccs:ck', 'ccs:work', 'ccsx:ck', 'ccsx:personal']); + }); +}); + +describe('multi-profile: Claude file-only reader', () => { + it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(1); + const row = rows[0]; + expect(row?.profile).toBe('work'); + expect(row?.quotaStatus).toBe('ok'); + expect(row?.needsReauth).toBe(false); + expect(row?.paused).toBe(false); + expect(deps.claudeFetchCount()).toBe(1); + }); + + it('profile without .credentials.json -> parked row (needsReauth:true, no live fetch)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['ck'], + codexProfiles: [], + claudeDefault: 'ck', + credsForProfile: () => null, // no file on disk + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(1); + const row = rows[0]; + expect(row?.profile).toBe('ck'); + expect(row?.needsReauth).toBe(true); + expect(row?.quota_percentage).toBeNull(); + // No live network call when creds are absent + expect(deps.claudeFetchCount()).toBe(0); + }); + + it('absent creds row has quotaStatus unsupported (honest "needs auth" state)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['ck'], + codexProfiles: [], + claudeDefault: 'ck', + credsForProfile: () => null, + }); + + const rows = await getNativeAccountRows(deps); + const row = rows[0]; + expect(row?.quotaStatus).toBe('unsupported'); + expect(row?.is_subscription).toBe(true); + }); +}); + +describe('multi-profile: per-profile circuit breaker isolation', () => { + it("one profile's 429 does not open another profile's breaker", async () => { + const MAX_COOLDOWN_JUMP_MP = 61_000; + const clock = { now: 1_000_000 }; + let workFails = true; + + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_token, accountId) => { + // 'work' (ccs:work) always 429s; 'ck' always succeeds + if (accountId?.includes('work') && workFails) { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + accountId: accountId ?? 'ccs:work', + httpStatus: 429, + retryable: true, + error: 'rate limited', + } as ClaudeQuotaResult; + } + return successQuota(); + }, + }); + + // Trip the work breaker with 3 consecutive 429s. + for (let i = 0; i < 3; i++) { + resetNativeQuotaState(); + clock.now += i === 0 ? 0 : MAX_COOLDOWN_JUMP_MP; + // Re-inject the multi-profile deps after reset so the state maps are fresh. + await getNativeAccountRows({ + ...deps, + listClaudeProfiles: () => ['work'], + listCodexProfiles: () => [], + defaultClaudeProfile: () => 'work', + }); + } + + // After the three 429s on 'work', check that 'ck' still succeeds. + // We reset state to have a clean run where 'ck' has no prior breaker history. + resetNativeQuotaState(); + clock.now += MAX_COOLDOWN_JUMP_MP; + workFails = false; + + const rows = await getNativeAccountRows(deps); + const ckRow = rows.find((r) => r.profile === 'ck'); + const workRow = rows.find((r) => r.profile === 'work'); + + // 'ck' should succeed — its breaker was never tripped. + expect(ckRow?.quotaStatus).toBe('ok'); + // 'work' is also fine after reset (no breaker state). + expect(workRow?.quotaStatus).toBe('ok'); + }); + + it("per-profile breaker: one profile's 429s only block that profile", async () => { + const clock = { now: 1_000_000 }; + let workCall429Count = 0; + + // 'work' returns 429 each call; 'ck' returns success. + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_token, accountId) => { + if (accountId?.includes('work')) { + workCall429Count += 1; + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: clock.now, + accountId: accountId ?? '', + httpStatus: 429, + retryable: true, + error: 'rate limited', + } as ClaudeQuotaResult; + } + return successQuota(); + }, + }); + + // First call: 'work' gets a 429, 'ck' succeeds. + const rows1 = await getNativeAccountRows(deps); + const ck1 = rows1.find((r) => r.profile === 'ck'); + expect(ck1?.quotaStatus).toBe('ok'); + expect(workCall429Count).toBeGreaterThanOrEqual(1); + + // Skip past cooldown for 'work' only; 'ck' is within TTL. + clock.now += 62_000; + + // Second call past 'work' cooldown: work tries again (429 again); ck cached. + const rows2 = await getNativeAccountRows(deps); + const ck2 = rows2.find((r) => r.profile === 'ck'); + // 'ck' still has a good cached row. + expect(ck2?.quotaStatus).toBe('ok'); + }); +}); + +describe('multi-profile: displayName uses profile name', () => { + it('displayName is the profile name, not "Claude Code" or "Codex"', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['my-work'], + codexProfiles: ['my-codex'], + claudeDefault: 'my-work', + codexDefault: 'my-codex', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + const c = rows.find((r) => r.surface === 'ccs'); + const x = rows.find((r) => r.surface === 'ccsx'); + expect(c?.displayName).toBe('my-work'); + expect(x?.displayName).toBe('my-codex'); + }); +}); + +describe('multi-profile: getCachedNativeAccountRows reflects per-profile maps', () => { + it('returns cached rows from all profiles after a collect', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: ['personal'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + await getNativeAccountRows(deps); + + const cached = getCachedNativeAccountRows(); + expect(cached.every((r) => r.cached === true)).toBe(true); + // Should have rows for work, ck, and personal (parked 'ck' has no cached row + // yet because it had creds in this test so it did fetch) + const profiles = cached.map((r) => r.profile); + expect(profiles).toContain('work'); + expect(profiles).toContain('personal'); + + resetNativeQuotaState(); + expect(getCachedNativeAccountRows()).toEqual([]); + }); +}); + +describe('review focus areas: reauth caching + codex local fallback', () => { + it('Claude reauth (401) profile is dimmed, cached, and not re-polled within cooldown', async () => { + resetNativeQuotaState(); + const clock = { now: 5_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => reauthQuota(), + }); + + const first = await getNativeAccountRows(deps); + const r1 = first.find((r) => r.profile === 'work'); + expect(r1?.needsReauth).toBe(true); + expect(r1?.paused).toBe(true); // dimmed + expect(deps.claudeFetchCount()).toBe(1); + + // A forced refresh within the cooldown serves the cached reauth row and does + // NOT re-hit the endpoint (no repeated 401 on the same account). + const second = await getNativeAccountRows(deps, { force: true }); + const r2 = second.find((r) => r.profile === 'work'); + expect(r2?.needsReauth).toBe(true); + expect(r2?.cached).toBe(true); + expect(deps.claudeFetchCount()).toBe(1); + }); + + it('Codex reauth (401) profile is dimmed, cached, and not re-polled within cooldown', async () => { + resetNativeQuotaState(); + const clock = { now: 5_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['ck'], + codexDefault: 'default', + codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => ({ success: false, needsReauth: true }) as CodexQuotaResult, + }); + + const first = await getNativeAccountRows(deps); + const r1 = first.find((r) => r.profile === 'ck'); + expect(r1?.needsReauth).toBe(true); + expect(r1?.paused).toBe(true); + expect(deps.codexNetworkCount()).toBe(1); + + const second = await getNativeAccountRows(deps, { force: true }); + expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true); + expect(deps.codexNetworkCount()).toBe(1); + }); + + it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => { + resetNativeQuotaState(); + const clock = { now: 5_000_000 }; + let localCalls = 0; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['ck'], + codexDefault: 'default', + codexNativeAuth: () => null, // no auth.json for the named profile + codexLocalFallback: async () => { + localCalls += 1; + return codexLocalQuota(); + }, + }); + + const rows = await getNativeAccountRows(deps); + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck).toBeDefined(); + expect(ck?.paused).toBe(true); // parked, dimmed + expect(ck?.needsReauth).toBe(true); + expect(ck?.quota_percentage).toBeNull(); + // The global ~/.codex session data is never attributed to a named profile. + expect(localCalls).toBe(0); + }); + + it('Codex default profile without auth uses the global local session fallback', async () => { + resetNativeQuotaState(); + const clock = { now: 5_000_000 }; + let localCalls = 0; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['default'], + codexDefault: 'default', + codexNativeAuth: () => null, + codexLocalFallback: async () => { + localCalls += 1; + return codexLocalQuota(); + }, + }); + + const rows = await getNativeAccountRows(deps); + const def = rows.find((r) => r.profile === 'default'); + expect(def).toBeDefined(); + // The bare default legitimately reflects the global ~/.codex local data. + expect(localCalls).toBe(1); + expect(def?.quotaStatus).not.toBe('unsupported'); + }); + + it('cache-fallback rows keep is_default for the default profile', async () => { + resetNativeQuotaState(); + const clock = { now: 6_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: ['default', 'ck'], + claudeDefault: 'work', + codexDefault: 'default', + credsForProfile: () => maxCreds(), + claudeFetch: async () => successQuota(), + codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + await getNativeAccountRows(deps); // populate the per-profile caches + + // The cache-fallback path must preserve is_default so the UI still orders and + // tags the default account when /summary serves from cache. + const cached = getCachedNativeAccountRows(); + expect(cached.find((r) => r.surface === 'ccs' && r.profile === 'work')?.is_default).toBe(true); + expect(cached.find((r) => r.surface === 'ccsx' && r.profile === 'default')?.is_default).toBe( + true + ); + expect(cached.find((r) => r.surface === 'ccs' && r.profile === 'ck')?.is_default).toBe(false); + expect(cached.find((r) => r.surface === 'ccsx' && r.profile === 'ck')?.is_default).toBe(false); + }); + + it('parked (no-creds) rows use a short TTL so a fresh login is detected quickly', async () => { + resetNativeQuotaState(); + const clock = { now: 7_000_000 }; + let hasCreds = false; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => (hasCreds ? maxCreds() : null), + claudeFetch: async () => successQuota(), + }); + + // First poll: no creds -> parked, no network. + const first = await getNativeAccountRows(deps); + expect(first.find((r) => r.profile === 'work')?.paused).toBe(true); + expect(deps.claudeFetchCount()).toBe(0); + + // User logs in; advance past the short parked TTL (30s) but far within the + // full quota TTL (10min). The parked row must NOT be served stale. + hasCreds = true; + clock.now += 31_000; + const second = await getNativeAccountRows(deps); + const work = second.find((r) => r.profile === 'work'); + expect(work?.paused).toBe(false); // now live, not dimmed + expect(work?.quotaStatus).toBe('ok'); + expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched + }); + + it('Codex named profile with valid auth but sparse payload stays active, not parked', async () => { + resetNativeQuotaState(); + const clock = { now: 7_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['ck'], + codexDefault: 'default', + codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }), + // Successful response but NO core windows (sparse / changed payload). + codexNetworkFetch: async () => ({ success: true }) as CodexQuotaResult, + }); + + const rows = await getNativeAccountRows(deps); + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck).toBeDefined(); + expect(ck?.paused).toBe(false); // active subscription, not parked + expect(ck?.needsReauth).toBe(false); + expect(ck?.quotaStatus).toBe('ok'); + expect(ck?.quota_percentage).toBeNull(); // no windows, but still active + }); +}); diff --git a/tests/unit/web-server/route-helpers.test.ts b/tests/unit/web-server/route-helpers.test.ts index eaab554e..581d1ee9 100644 --- a/tests/unit/web-server/route-helpers.test.ts +++ b/tests/unit/web-server/route-helpers.test.ts @@ -50,6 +50,14 @@ describe('validateFilePath', () => { expect(result.readonly).toBe(false); }); + test('rejects case variants of the macOS bar launch descriptor', () => { + const filePath = path.join(tempDir, '.ccs', 'BAR', 'LAUNCH.JSON'); + const result = validateFilePath(filePath); + + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + }); + test('still allows other writes inside the bar directory', () => { const filePath = path.join(tempDir, '.ccs', 'bar', 'serve.log'); const result = validateFilePath(filePath);