mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
83 files changed
+4553
-501
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
1.8.1
|
||||
1.9.2
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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('# ----------------------------------------------------------------------------');
|
||||
|
||||
@@ -66,6 +66,7 @@ export type {
|
||||
ExaWebSearchConfig,
|
||||
TavilyWebSearchConfig,
|
||||
SearxngWebSearchConfig,
|
||||
AgyWebSearchConfig,
|
||||
GeminiWebSearchConfig,
|
||||
GrokWebSearchConfig,
|
||||
OpenCodeWebSearchConfig,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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]']);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user