Skip to content

Commit 9830465

Browse files
王超claude
authored andcommitted
fix(cc-switch-import): adopt five P1 findings from multi-role review
- Corrupt/unparseable profile.json now ABORTS the import (ok:false + reportSwallowed) instead of merging into an empty base and wiping user-created proxy_ profiles; non-object JSON shapes abort the same way. - Client success is decided strictly on resp.ok && data.ok === true; the 403 local-only gate and 400 exception paths now carry ok:false, so they can no longer render as a green "Imported 0, updated 0" toast. - mergeImportedProfiles seeds the built-in max profile at the front when missing (mirrors proxyProfilesPost's invariant; a fresh install importing first no longer loses the Default option) and preserves id-less entries. - The node:sqlite-unavailable error now names the real requirement (Node >= 22.5 with --experimental-sqlite, or >= 23.4) and ProxyModal maps it to a dedicated localized message ui.proxy.ccswitchNodeUnsupported (x18 locales); degradation contract pinned via _setDatabaseSyncForTest. - New test/api-ccswitch-import.test.js covers the previously-untested HTTP surface with a fixture SQLite db: 403 gate writes nothing, ok:false contract, first-import max seeding + 0o600 + credential-free SSE refresh broadcast, preserve/prune merge semantics, corrupt-file abort leaves the file byte-identical, off-host GET masking never leaks the plaintext key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7006871 commit 9830465

7 files changed

Lines changed: 333 additions & 31 deletions

File tree

history.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- feat(proxy): **Import providers from cc-switch** — reads AI provider credentials (baseURL/authToken/model mappings) from the local [cc-switch](https://github.com/farion1231/cc-switch) Tauri app's SQLite database and auto-generates cc-viewer proxy profiles. Cross-platform path detection probes `~/.cc-switch/cc-switch.db` **first on every platform** (cc-switch hardcodes this path via `get_app_config_dir()` in its `config.rs` on mac/linux/windows; the Tauri identifier does not affect the DB path), with platform-specific Tauri app-data paths (`~/Library/Application Support/cc-switch/`, `%APPDATA%\cc-switch\`, `~/.local/share/cc-switch/`) kept only as low-priority legacy fallbacks so a stale leftover there can never shadow the real DB. Opens `cc-switch.db` in **read-only** mode (no SQLITE_BUSY lock when cc-switch is running), queries the `providers` table for `app_type='claude'` rows, and maps `settings_config.env` → cc-viewer profile fields (`ANTHROPIC_BASE_URL`→`baseURL`, `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY`→`apiKey`, `ANTHROPIC_MODEL` + the three family-model fields, ignoring the `_NAME` suffixed variants; `CLAUDE_CODE_EFFORT_LEVEL`→`effort` so a user's effort toggle is not silently dropped on import). Codex providers are skipped (incompatible auth format). Imported profiles get a `ccs_` id prefix and `source: 'cc-switch'` marker; a `mergeImportedProfiles` pure function updates existing `ccs_` entries (credential refresh) and appends new ones while **leaving user-created `proxy_` profiles untouched** — deleted-from-cc-switch entries are pruned. New `server/lib/ccswitch-import.js` (pure functions, fully unit-tested against the real db); `GET /api/ccswitch-providers` (preview, masked off-host) + `POST /api/ccswitch-import` (local-only merge + SSE `proxy_profile` broadcast); a "从 cc-switch 导入" button in ProxyModal. 4 new `ui.proxy.ccswitch*` i18n keys × 18 locales; `test/ccswitch-import.test.js` (25 cases incl. live-db integration + cross-platform path-priority injection).
66
- fix(cc-switch-import): **the import reported a misleading `providers table not found`** whenever the DB was unreadable. The `sqlite_master` existence query's inner `catch {}` swallowed the real error (e.g. `file is not a database` for a corrupt/non-SQLite `cc-switch.db`) and collapsed every failure into the generic message, so the user could not tell a corrupt file from a genuine schema mismatch. The catch is removed — a throw now propagates to the existing outer catch, which surfaces `query failed: <real message>`; the genuine table-missing case reports `providers table not found in <resolved path>` (naming the file so a stale leftover at a probed path is distinguishable from the real cc-switch DB). Tests: a non-SQLite file asserts the error is *not* the masked string and exposes the real cause; a valid-but-tableless DB asserts the distinct `providers table not found in <path>` message.
7+
- fix(cc-switch-import): **five P1 findings from a 6-role review adopted.** (1) A corrupt/unparseable existing `profile.json` no longer wipes user-created proxies: the import previously swallowed the parse error, merged into an empty base, and overwrote the file with only `ccs_` entries — it now ABORTS with `ok:false` (`reportSwallowed('ccswitch-import.read-existing')`), and a parse to a non-object shape aborts the same way; the on-disk file is left byte-identical. (2) The client no longer misreports 403/400 as success: those response shapes carry no `imported`/`updated` counters, so the old `data.imported === 0` heuristic fell through to the green "Imported 0, updated 0" toast — success is now decided strictly on `resp.ok && data.ok === true`, and every server error path (403 gate, 400 exception) carries `ok:false`. (3) `mergeImportedProfiles` now upholds the same `max`-invariant as `proxyProfilesPost`: the built-in Default is seeded at the front when missing (a fresh install importing before ever saving a proxy used to write a list with no Default option), and existing id-less entries are preserved instead of silently dropped. (4) The Node floor mismatch is surfaced instead of silent: `node:sqlite` needs Node ≥ 22.5 (`--experimental-sqlite`) / ≥ 23.4 unflagged while the project floor is 20.14 — the unavailable-runtime error now names the requirement and ProxyModal maps it to a dedicated localized message (`ui.proxy.ccswitchNodeUnsupported` × 18 locales) instead of the misleading "cc-switch not detected"; the degradation contract is pinned by a test via the new `_setDatabaseSyncForTest` hook (CI runs Node 24, so the sqlite suites are exercised there). (5) The two HTTP endpoints — previously untested — get route coverage in `test/api-ccswitch-import.test.js` (fixture SQLite db under an injected HOME, no real cc-switch needed): local-only 403 gate writes nothing, `ok:false` contract on 400, first-import max seeding + `0o600` + credential-free SSE `refresh` broadcast, re-import preserve/prune semantics, corrupt/non-object `profile.json` abort leaves the file untouched, and off-host GET masking never leaks the plaintext key.
78
- fix(cc-switch-import): **a stale/empty leftover `cc-switch.db` at a platform-specific probe path could shadow the real DB**. `candidateDbPaths()` probed `~/Library/Application Support/cc-switch/`, `%APPDATA%\cc-switch\`, and `~/.local/share/cc-switch/` *before* `~/.cc-switch/cc-switch.db`, but cc-switch never writes to those Tauri app-data paths — a leftover file there was matched first and yielded `providers table not found`. `~/.cc-switch/cc-switch.db` is now the primary probe on all platforms; the platform paths are demoted to legacy fallbacks. `candidateDbPaths()` was refactored to accept an injectable `{plat, home, env}` and exported as `_candidateDbPathsForTest` so the win32/darwin priority ordering is exercised on any test host (previously zero coverage on non-linux branches); new cross-platform tests assert the primary path wins and a stale legacy file cannot shadow it, using a neutral home value (no hardcoded username).
89

910
## 1.7.3 (2026-07-17)

server/lib/ccswitch-import.js

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ function candidateDbPaths(opts) {
6969
// Exported for unit tests so the win32/darwin priority ordering can be exercised on any host.
7070
export { candidateDbPaths as _candidateDbPathsForTest };
7171

72+
// Test hook: override the cached DatabaseSync — pass false to simulate a runtime
73+
// without node:sqlite (the getter returns the cached falsy value and the callers
74+
// take the unavailable path), or null to restore lazy detection.
75+
export function _setDatabaseSyncForTest(v) { _DatabaseSync = v; }
76+
7277
// Returns the first existing cc-switch.db path, or null if none found.
7378
export function findCcSwitchDbPath() {
7479
for (const p of candidateDbPaths()) {
@@ -146,7 +151,9 @@ export function mapProviderToProfile(row) {
146151
export async function readCcSwitchProviders(dbPath) {
147152
const DatabaseSync = await getDatabaseSync();
148153
if (!DatabaseSync) {
149-
return { profiles: [], error: 'node:sqlite unavailable on this runtime' };
154+
// Keep the 'node:sqlite unavailable' prefix stable — the UI keys a dedicated
155+
// localized message off it (ui.proxy.ccswitchNodeUnsupported).
156+
return { profiles: [], error: 'node:sqlite unavailable on this runtime (requires Node >= 22.5 with --experimental-sqlite, or >= 23.4)' };
150157
}
151158
let db = null;
152159
try {
@@ -196,9 +203,10 @@ export async function discoverCcSwitchProviders() {
196203
// Merge cc-switch-imported profiles into cc-viewer's existing profile list.
197204
// Rules:
198205
// - ccs_-prefixed (previously imported): matched by id, updated with fresh data (credential refresh)
199-
// - proxy_-prefixed (user-created): left untouched, preserved as-is
206+
// - proxy_-prefixed (user-created) and any entry without a ccs_ id (including id-less ones): preserved as-is
200207
// - newly seen ccs_ ids: appended to the end of the list
201-
// - max (built-in default) is always kept at the front
208+
// - max (built-in default) is always present and first — seeded when missing, mirroring
209+
// proxyProfilesPost's invariant, so a first-ever import can never write a list without it
202210
// Returns { profiles, imported, updated } counts.
203211
export function mergeImportedProfiles(existing, importedList) {
204212
const existingArr = Array.isArray(existing) ? existing : [];
@@ -209,23 +217,22 @@ export function mergeImportedProfiles(existing, importedList) {
209217
let newCount = 0;
210218
let updatedCount = 0;
211219

212-
// Put built-in max first (if present)
213-
for (const p of existingArr) {
214-
if (p.id === 'max') {
215-
result.push(p);
216-
break;
217-
}
218-
}
219-
// User-created profiles (proxy_ prefix + others that are not ccs_ and not max) are preserved as-is
220+
// Built-in max always first — reuse the existing entry, or seed the same shape
221+
// proxyProfilesPost injects. Without this, a fresh install importing before ever
222+
// saving a proxy would write a profiles list with no Default option.
223+
const existingMax = existingArr.find(p => p && p.id === 'max');
224+
result.push(existingMax || { id: 'max', name: 'Default' });
225+
// Everything that is not max and not ccs_-sourced (user proxy_ profiles, unknown or
226+
// id-less entries) is preserved verbatim — this function only owns the ccs_ namespace
220227
for (const p of existingArr) {
221-
if (p.id === 'max') continue;
222-
if (p.id && !p.id.startsWith('ccs_')) {
228+
if (!p || p.id === 'max') continue;
229+
if (!p.id || !p.id.startsWith('ccs_')) {
223230
result.push(p);
224231
}
225232
}
226233
// ccs_-sourced: use the freshly imported data
227234
for (const p of existingArr) {
228-
if (p.id && p.id.startsWith('ccs_')) {
235+
if (p && p.id && p.id.startsWith('ccs_')) {
229236
if (importedMap.has(p.id)) {
230237
result.push(importedMap.get(p.id));
231238
importedMap.delete(p.id);

server/routes/preferences.js

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { LOG_DIR, setLogDir, getClaudeConfigDir } from '../../findcc.js';
66
import { PROFILE_PATH, _defaultConfig, getActiveProfileId, setActiveProfileForWorkspace, _loadProxyProfile } from '../interceptor.js';
77
import { migrateProxyProfileList } from '../lib/interceptor-core.js';
88
import { discoverCcSwitchProviders, mergeImportedProfiles } from '../lib/ccswitch-import.js';
9+
import { reportSwallowed } from '../lib/error-report.js';
910
import { setLang } from '../i18n.js';
1011
import { reconcileVoicePackPrefs as vpReconcile } from '../lib/voice-pack-manager.js';
1112
import { readClaudeProjectModel } from '../lib/context-watcher.js';
@@ -331,7 +332,7 @@ async function ccswitchProvidersGet(req, res, _parsedUrl, isLocal, _deps) {
331332
async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
332333
if (!isLocal) {
333334
res.writeHead(403, { 'Content-Type': 'application/json' });
334-
res.end(JSON.stringify({ error: 'cc-switch import is local-only' }));
335+
res.end(JSON.stringify({ ok: false, error: 'cc-switch import is local-only' }));
335336
return;
336337
}
337338
let body = '';
@@ -346,11 +347,39 @@ async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
346347
res.end(JSON.stringify({ ok: false, error: result.error, imported: 0, updated: 0 }));
347348
return;
348349
}
349-
// 读现有 profile.json
350+
// Read existing profile.json. A missing file is a legitimate first import,
351+
// but a file that exists and fails to parse must ABORT: merging into an
352+
// empty base and writing back would silently wipe every user-created
353+
// proxy_ profile (existing IS the source of truth here, unlike
354+
// proxyProfilesPost where the client sends the full desired list).
350355
let existing = { profiles: [] };
351-
try {
352-
if (existsSync(PROFILE_PATH)) existing = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
353-
} catch { /* 首次导入无文件 */ }
356+
if (existsSync(PROFILE_PATH)) {
357+
let parsed = null;
358+
try {
359+
parsed = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8'));
360+
} catch (err) {
361+
reportSwallowed('ccswitch-import.read-existing', err);
362+
res.writeHead(200, { 'Content-Type': 'application/json' });
363+
res.end(JSON.stringify({
364+
ok: false,
365+
error: 'existing profile.json is unreadable; import aborted to avoid overwriting your profiles',
366+
imported: 0, updated: 0,
367+
}));
368+
return;
369+
}
370+
// JSON that parses to a non-object (null, array, number) is just as
371+
// destructive to merge into — treat it the same as a parse failure.
372+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
373+
res.writeHead(200, { 'Content-Type': 'application/json' });
374+
res.end(JSON.stringify({
375+
ok: false,
376+
error: 'existing profile.json has an unexpected shape; import aborted to avoid overwriting your profiles',
377+
imported: 0, updated: 0,
378+
}));
379+
return;
380+
}
381+
existing = parsed;
382+
}
354383
// merge
355384
const merged = mergeImportedProfiles(existing.profiles || [], result.profiles);
356385
const toWrite = { ...existing, profiles: merged.profiles };
@@ -383,8 +412,10 @@ async function ccswitchImportPost(req, res, _parsedUrl, isLocal, deps) {
383412
dbPath: result.dbPath,
384413
}));
385414
} catch (err) {
415+
// ok:false keeps the response contract uniform — the client decides
416+
// success strictly on data.ok, never on the presence of counter fields.
386417
res.writeHead(400, { 'Content-Type': 'application/json' });
387-
res.end(JSON.stringify({ error: String(err && err.message || err) }));
418+
res.end(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
388419
}
389420
});
390421
}

src/components/settings/ProxyModal.jsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { EditOutlined, DeleteOutlined, PlusOutlined, ImportOutlined } from '@ant
44
import { t } from '../../i18n';
55
import { isMobile } from '../../env';
66
import { apiUrl, appendToken } from '../../utils/apiUrl';
7+
import { reportSwallowed } from '../../utils/errorReport';
78
import { BLUR_MASK_STYLE } from '../../utils/modalMask';
89
import ConceptHelp from '../common/ConceptHelp';
910
import styles from './ProxyModal.module.css';
@@ -59,9 +60,12 @@ export default function ProxyModal({
5960
const [deleteConfirmTarget, setDeleteConfirmTarget] = useState(null);
6061
const [importing, setImporting] = useState(false);
6162

62-
// 从 cc-switch 导入:POST /api/ccswitch-import(local-only),merge 进 profile.json。
63-
// 成功后主动重新 GET /api/proxy-profiles 刷新列表(不依赖 SSE——SSE proxy_profile
64-
// 事件在 profile:null 时不触发重新 GET,本地有时序问题,直接 fetch 最可靠)。
63+
// Import from cc-switch: POST /api/ccswitch-import (local-only) merges into
64+
// profile.json. Success is decided STRICTLY on resp.ok + data.ok — the 403
65+
// (remote client) and 400 (server exception) shapes carry no imported/updated
66+
// counters, so any counter-based heuristic misreads them as success.
67+
// After success the list is refetched directly for immediate UI update (the
68+
// server also broadcasts SSE proxy_profile {profile:'refresh'} as a fallback).
6569
const handleImportFromCcSwitch = async () => {
6670
setImporting(true);
6771
try {
@@ -70,21 +74,26 @@ export default function ProxyModal({
7074
headers: { 'Content-Type': 'application/json' },
7175
body: JSON.stringify({ setActive: false }),
7276
});
73-
const data = await resp.json();
74-
if (data.error && data.imported === 0 && data.updated === 0) {
75-
message.error(t('ui.proxy.ccswitchImportFail') + (data.error ? ': ' + data.error : ''));
76-
} else {
77+
let data = null;
78+
try { data = await resp.json(); } catch (e) { reportSwallowed('fetch.ccswitch-import-json', e); }
79+
if (resp.ok && data && data.ok === true) {
7780
message.success(t('ui.proxy.ccswitchImported', { imported: data.imported || 0, updated: data.updated || 0 }));
78-
// 主动重新拉取 profile 列表刷新 UI(导入是列表变化,SSE profile:null 不触发重 GET)
7981
try {
8082
const pr = await fetch(appendToken(apiUrl('/api/proxy-profiles')));
8183
const pd = await pr.json();
8284
if (pd.profiles && onProxyProfileChange) {
8385
onProxyProfileChange({ active: pd.active, profiles: pd.profiles });
8486
}
85-
} catch { /* 刷新失败不阻塞,SSE 兜底 */ }
87+
} catch (e) { reportSwallowed('fetch.ccswitch-refresh', e); /* SSE refresh is the fallback */ }
88+
} else if (data && typeof data.error === 'string' && data.error.startsWith('node:sqlite unavailable')) {
89+
// Runtime lacks node:sqlite (Node < 22.5, or 22.x without --experimental-sqlite):
90+
// an actionable message beats the generic "cc-switch not detected".
91+
message.error(t('ui.proxy.ccswitchNodeUnsupported'));
92+
} else {
93+
message.error(t('ui.proxy.ccswitchImportFail') + (data && data.error ? ': ' + data.error : ''));
8694
}
8795
} catch (err) {
96+
reportSwallowed('fetch.ccswitch-import', err);
8897
message.error(t('ui.proxy.ccswitchImportFail'));
8998
} finally {
9099
setImporting(false);

0 commit comments

Comments
 (0)