Merge pull request #1624 from kaitranntt/dev

feat: promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-30 22:52:18 -04:00
commit eccb3d1997
83 files changed
+4553 -501

No files matched your search

+5 -1
View File
@@ -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
+5 -4
View File
@@ -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
+2 -2
View File
@@ -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
+8 -3
View File
@@ -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
+19 -19
View File
@@ -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",
+81 -4
View File
@@ -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() {
+1 -1
View File
@@ -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"]),
+399 -39
View File
@@ -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
}
}
}
@@ -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("/")
}
@@ -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)
}
+176
View File
@@ -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)
@@ -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>` 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).
@@ -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)
}
+1 -1
View File
@@ -1 +1 @@
1.8.1
1.9.2
+1 -1
View File
@@ -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",
+8
View File
@@ -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',
+41
View File
@@ -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;
}
}
@@ -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;
@@ -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();
@@ -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<string, string>;
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<string, string>).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<string, string>).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();
}
});
});
+39 -15
View File
@@ -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<ChildP
const isWindows = process.platform === 'win32';
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
const settingsPath = cfg.customSettingsPath
const persistedSettingsPath = cfg.customSettingsPath
? cfg.customSettingsPath.replace(/^~/, os.homedir())
: getProviderSettingsPath(provider);
@@ -105,6 +106,16 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
? stripClaudeSubcommandSessionArgs(claudeArgs)
: claudeArgs;
// The persisted settings file pins ANTHROPIC_BASE_URL straight at CLIProxy.
// Claude applies the settings `env` block over the inherited environment, so
// without this overlay it would override the ephemeral proxy-chain URL CCS
// injected via env and bypass the tool-sanitization / codex-reasoning proxies
// (surfacing as `400 {"detail":"System messages are not allowed"}` for Codex).
// Subcommands skip `--settings`, so they keep the persisted path untouched.
const { settingsPath, cleanup: cleanupLaunchSettings } = isSubcommand
? { settingsPath: persistedSettingsPath, cleanup: () => {} }
: 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<ChildP
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
let claude: ChildProcess;
if (needsShell) {
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: getWindowsEscapedCommandShell(),
env: tracedEnv,
});
} else {
claude = spawn(claudeCli, launchArgs, {
stdio: 'inherit',
windowsHide: true,
env: tracedEnv,
});
try {
if (needsShell) {
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
claude = spawn(cmdString, {
stdio: 'inherit',
windowsHide: true,
shell: getWindowsEscapedCommandShell(),
env: tracedEnv,
});
} else {
claude = spawn(claudeCli, launchArgs, {
stdio: 'inherit',
windowsHide: true,
env: tracedEnv,
});
}
} catch (spawnError) {
// spawn() can throw synchronously (e.g. invalid arg/env). Remove the
// runtime settings overlay before propagating so the secret-bearing temp
// file is not orphaned. cleanup is idempotent.
cleanupLaunchSettings();
throw spawnError;
}
// Remove the runtime settings overlay once Claude has read it and exited.
// cleanup is idempotent, so registering on both events is safe.
claude.on('exit', cleanupLaunchSettings);
claude.on('error', cleanupLaunchSettings);
// Start runtime quota monitor (adaptive polling during session)
if (!skipLocalAuth) {
const { startQuotaMonitor } = await import('../quota/quota-manager');
+1 -1
View File
@@ -548,7 +548,7 @@ export async function execClaudeWithCLIProxy(
keys: Object.keys(env)
.filter((key) => key.startsWith('CCS_BROWSER_'))
.sort(),
ws: env.CCS_BROWSER_DEVTOOLS_WS_URL || '',
hasDevtoolsWsUrl: Boolean(env.CCS_BROWSER_DEVTOOLS_WS_URL),
});
}
logEnvironment(env, webSearchEnv, verbose);
+143
View File
@@ -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 <providerSettings.json>`. 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<string, unknown> {
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<string, unknown>; changed: boolean } {
let base: Record<string, unknown> = {};
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<string, unknown> = 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<string, unknown>; 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;
}
}
+1 -1
View File
@@ -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;
+29 -16
View File
@@ -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<DashboardInfo | null> {
const token = getOrCreateBarAuthToken(ccsDir);
@@ -61,13 +68,17 @@ export async function defaultFindRunningServer(ccsDir: string): Promise<Dashboar
const parsed = new URL(url);
const port = Number(parsed.port);
const host = parsed.hostname.replace(/^\[|\]$/g, '');
const nonce = createBarAuthNonce();
return new Promise((resolve) => {
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<Dashboar
return;
}
if (statusCode === 200) {
// Accept only when the server includes the correct token in the
// response without having received it in the request. Only the real
// CCS Bar process (which owns the 0600 file) can produce this value.
// Accept only when the server includes a correct nonce-bound proof.
const echoMatch = headerSection.match(
new RegExp(`${BAR_AUTH_TOKEN_HEADER}:\\s*([^\\r\\n]+)`, 'i')
);
const echoedToken = echoMatch ? echoMatch[1].trim() : '';
resolve({ ok: echoedToken === token, authRequired: false });
const proof = echoMatch ? echoMatch[1].trim() : '';
resolve({ ok: isMatchingBarAuthProof(token, nonce, proof), authRequired: false });
return;
}
resolve({ ok: false, authRequired: false });
};
const socket = net.connect({ host, 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 ${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]);
+3 -8
View File
@@ -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);
+68
View File
@@ -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 } : {}),
};
}
+34 -21
View File
@@ -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<void> {
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<void> {
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<void> {
await new Promise<void>((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.
@@ -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');
});
});
+1 -1
View File
@@ -91,7 +91,7 @@ export async function showHelp(): Promise<void> {
['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'],
@@ -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<void> {
+2
View File
@@ -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<number> {
const result = await startDaemon({
port: cursorConfig.port,
ghost_mode: cursorConfig.ghost_mode,
daemon_token: getCursorDaemonToken(),
});
if (result.success) {
+9
View File
@@ -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,
+5
View File
@@ -160,6 +160,11 @@ export function mergeWithDefaults(partial: Partial<UnifiedConfig>): 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 ??
+5 -2
View File
@@ -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('# ----------------------------------------------------------------------------');
+1
View File
@@ -66,6 +66,7 @@ export type {
ExaWebSearchConfig,
TavilyWebSearchConfig,
SearxngWebSearchConfig,
AgyWebSearchConfig,
GeminiWebSearchConfig,
GrokWebSearchConfig,
OpenCodeWebSearchConfig,
+5
View File
@@ -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',
+21 -2
View File
@@ -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;
+50 -15
View File
@@ -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<unknown> {
});
}
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<T>(
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<string, unknown>;
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(
+16 -7
View File
@@ -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<string | null> {
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<CursorDaemonStatus> {
const running = await isDaemonRunning(port);
export async function getDaemonStatus(
port: number,
daemonToken = getCursorDaemonToken()
): Promise<CursorDaemonStatus> {
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
+2 -1
View File
@@ -143,6 +143,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
const startResult = await startDaemon({
port: config.port,
ghost_mode: config.ghost_mode,
daemon_token: daemonToken,
});
if (!startResult.success) {
@@ -190,7 +191,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
try {
const response = await fetch(`http://127.0.0.1:${config.port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'x-ccs-cursor-token': daemonToken },
body: JSON.stringify({
model,
max_tokens: 8,
+6 -7
View File
@@ -177,17 +177,16 @@ export class ResponseBuilder {
/**
* Generate thinking signature for Claude Code UI
*
* Anthropic requires a thinking block's `signature` to be a non-empty
* opaque STRING. Emit a deterministic base64 token (no Date.now()) so the
* thinking block stays valid against reasoning backends; returning an object
* here produces an invalid thinking block and breaks the stream.
*/
generateThinkingSignature(thinking: string): ThinkingSignature {
// Generate signature hash
const hash = crypto.createHash('sha256').update(thinking).digest('hex').substring(0, 16);
return {
type: 'thinking_signature',
hash: hash,
length: thinking.length,
timestamp: Date.now(),
};
return Buffer.from(`ccs-thinking:${hash}:${thinking.length}`).toString('base64');
}
/**
+4 -7
View File
@@ -7,7 +7,7 @@ export interface ContentBlock {
type: string;
text?: string;
thinking?: string;
signature?: ThinkingSignature;
signature?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
@@ -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 {
+17 -2
View File
@@ -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++;
}
+14
View File
@@ -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 {
+11 -8
View File
@@ -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;
}
+16 -2
View File
@@ -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:<port>/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<string, unknown>).auth;
}
if (providerReady && !normalizedModelAlias) {
+22
View File
@@ -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');
@@ -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
+6 -2
View File
@@ -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',
+10 -1
View File
@@ -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();
+87
View File
@@ -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;
}
+3
View File
@@ -41,6 +41,9 @@ export function getWebSearchHookConfig(): Record<string, unknown> {
// 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);
}
+14 -1
View File
@@ -27,6 +27,7 @@ export function getWebSearchHookEnv(): Record<string, string> {
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<string, string> {
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) {
+5 -1
View File
@@ -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,
+7 -21
View File
@@ -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;
}
+26 -5
View File
@@ -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',
];
}
+8
View File
@@ -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;
+16
View File
@@ -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
+2
View File
@@ -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<void>
const result = await startDaemon({
port: cursorConfig.port,
ghost_mode: cursorConfig.ghost_mode,
daemon_token: getCursorDaemonToken(),
});
const { daemonToken: _redactedDaemonToken, ...publicResult } = result;
void _redactedDaemonToken;
+14 -6
View File
@@ -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;
+8 -5
View File
@@ -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' };
}
@@ -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 ??
File diff suppressed because it is too large. Load diff
@@ -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[] };
@@ -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);
});
});
+45
View File
@@ -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
+211 -40
View File
@@ -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<string, Array<(arg?: unknown) => void>> = {};
let interval: ReturnType<typeof setInterval> | 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<string, Array<(arg?: unknown) => void>> = {};
let interval: ReturnType<typeof setInterval> | 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<void>;
};
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
// ---------------------------------------------------------------------------
@@ -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());
+82 -29
View File
@@ -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<void>((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<void>((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<void>((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;
}
+7 -6
View File
@@ -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));
});
});
@@ -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');
}
+14 -23
View File
@@ -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 {
@@ -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<string, unknown>[] {
const events: Record<string, unknown>[] = [];
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<string, unknown>);
} 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);
});
});
@@ -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]']);
});
});
+4 -1
View File
@@ -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(
@@ -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
@@ -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);
});
@@ -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,
+84 -1
View File
@@ -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<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>): WebSearchCliInfo {
function provider(
overrides: Partial<WebSearchCliInfo> & Pick<WebSearchCliInfo, 'id' | 'name'>
): 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;
+148
View File
@@ -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<Server>((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<WireRow[]>(url, '/api/bar/summary');
await new Promise<void>((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<WireRow[]>(url, '/api/bar/summary');
await new Promise<void>((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<WireRow[]>(url, '/api/bar/summary');
await new Promise<void>((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 <surface>:<profile> scheme', async () => {
const { srv, url } = await buildWireRouter([
makeNativeRow('ccs', 'ck', false),
makeNativeRow('ccsx', 'ck', true),
]);
const { body } = await getJson<WireRow[]>(url, '/api/bar/summary');
await new Promise<void>((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');
});
});
@@ -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<ClaudeQuotaResult>;
codexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null;
codexNetworkFetch?: (accountId: string) => Promise<CodexQuotaResult>;
codexLocalFallback?: () => Promise<CodexLocalQuota | null>;
}): 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:<p>", 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:<p>", 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
});
});
@@ -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);