Skip to content

Commit e6fcc86

Browse files
Linuxdazhaoclaude
andcommitted
feat(daemon)!: make official proxy capture opt-in via --capture-official
By default the daemon no longer spawns the implicit proxy for the official Anthropic upstream, so `cc use official` traffic now flows direct to Anthropic instead of being captured. Capture is opt-in via a new `--capture-official` flag on `daemon start`/`restart`. A user-defined alias that explicitly points at https://api.anthropic.com still gets its own proxy as before — only the implicit, always-on official proxy is now gated. - dedupe_upstreams(storage, capture_official): gate the official append - thread the flag through LifecycleConfig::from_storage, DaemonAction, handle_start, and the CLI - soften the build_official_env "direct" message to be accurate whether the daemon is down or capture is simply disabled Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7771538 commit e6fcc86

6 files changed

Lines changed: 100 additions & 33 deletions

File tree

src/cli/cli.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,11 @@ pub enum DaemonCommands {
337337
/// Increase verbosity (-v info, -vv debug, -vvv trace)
338338
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
339339
verbose: u8,
340+
341+
/// Capture `cc use official` traffic through the daemon proxy.
342+
/// Off by default — official traffic flows direct to Anthropic.
343+
#[arg(long = "capture-official")]
344+
capture_official: bool,
340345
},
341346
/// Stop the running daemon
342347
Stop,
@@ -359,6 +364,11 @@ pub enum DaemonCommands {
359364
/// Increase verbosity (-v info, -vv debug, -vvv trace)
360365
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
361366
verbose: u8,
367+
368+
/// Capture `cc use official` traffic through the daemon proxy.
369+
/// Off by default — official traffic flows direct to Anthropic.
370+
#[arg(long = "capture-official")]
371+
capture_official: bool,
362372
},
363373
}
364374

src/cli/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,21 +1009,25 @@ pub fn run() -> Result<()> {
10091009
foreground,
10101010
log_level,
10111011
verbose,
1012+
capture_official,
10121013
} => DaemonAction::Start {
10131014
foreground,
10141015
log_level,
10151016
verbose,
1017+
capture_official,
10161018
},
10171019
DaemonCommands::Stop => DaemonAction::Stop,
10181020
DaemonCommands::Status { json } => DaemonAction::Status { json },
10191021
DaemonCommands::Restart {
10201022
foreground,
10211023
log_level,
10221024
verbose,
1025+
capture_official,
10231026
} => DaemonAction::Restart {
10241027
foreground,
10251028
log_level,
10261029
verbose,
1030+
capture_official,
10271031
},
10281032
};
10291033
handle_daemon_command(action, &storage)?;

src/daemon/commands.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub enum DaemonAction {
1515
foreground: bool,
1616
log_level: Option<String>,
1717
verbose: u8,
18+
capture_official: bool,
1819
},
1920
Stop,
2021
Status {
@@ -24,6 +25,7 @@ pub enum DaemonAction {
2425
foreground: bool,
2526
log_level: Option<String>,
2627
verbose: u8,
28+
capture_official: bool,
2729
},
2830
}
2931

@@ -40,16 +42,18 @@ pub fn handle_daemon_command(action: DaemonAction, storage: &ConfigStorage) -> R
4042
foreground,
4143
log_level,
4244
verbose,
43-
} => handle_start(foreground, log_level, verbose, storage),
45+
capture_official,
46+
} => handle_start(foreground, log_level, verbose, capture_official, storage),
4447
DaemonAction::Stop => handle_stop(),
4548
DaemonAction::Status { json } => handle_status(json, storage),
4649
DaemonAction::Restart {
4750
foreground,
4851
log_level,
4952
verbose,
53+
capture_official,
5054
} => {
5155
let _ = handle_stop();
52-
handle_start(foreground, log_level, verbose, storage)
56+
handle_start(foreground, log_level, verbose, capture_official, storage)
5357
}
5458
}
5559
}
@@ -59,9 +63,10 @@ fn handle_start(
5963
foreground: bool,
6064
log_level: Option<String>,
6165
verbose: u8,
66+
capture_official: bool,
6267
storage: &ConfigStorage,
6368
) -> Result<()> {
64-
let cfg = LifecycleConfig::from_storage(storage, foreground)?;
69+
let cfg = LifecycleConfig::from_storage(storage, foreground, capture_official)?;
6570

6671
// Preflight: check for existing pidfile (spec §8 invariant table).
6772
let pidfile = Pidfile::new(cfg.pidfile_path.clone());

src/daemon/lifecycle.rs

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ pub struct LifecycleConfig {
2323
}
2424

2525
impl LifecycleConfig {
26-
pub fn from_storage(storage: &ConfigStorage, foreground: bool) -> Result<Self> {
26+
pub fn from_storage(
27+
storage: &ConfigStorage,
28+
foreground: bool,
29+
capture_official: bool,
30+
) -> Result<Self> {
2731
let home = dirs::home_dir().context("could not find home directory")?;
2832
let cc_switch_dir = home.join(".cc-switch");
2933
std::fs::create_dir_all(&cc_switch_dir)
3034
.with_context(|| format!("failed to create {}", cc_switch_dir.display()))?;
3135

32-
let upstreams = dedupe_upstreams(storage);
36+
let upstreams = dedupe_upstreams(storage, capture_official);
3337

3438
Ok(Self {
3539
state_path: cc_switch_dir.join("daemon-state.json"),
@@ -41,7 +45,7 @@ impl LifecycleConfig {
4145
}
4246
}
4347

44-
fn dedupe_upstreams(storage: &ConfigStorage) -> Vec<Upstream> {
48+
fn dedupe_upstreams(storage: &ConfigStorage, capture_official: bool) -> Vec<Upstream> {
4549
let mut seen = BTreeSet::new();
4650
let mut result = Vec::new();
4751
for config in storage.configurations.values() {
@@ -53,15 +57,19 @@ fn dedupe_upstreams(storage: &ConfigStorage) -> Vec<Upstream> {
5357
result.push(key);
5458
}
5559
}
56-
// Always include the official Anthropic upstream so `cc use official`
57-
// routes through the daemon. Dedup naturally handles the (rare) case
58-
// where a user-defined alias points at the same URL.
59-
let official = (
60-
"claude".to_string(),
61-
crate::daemon::OFFICIAL_UPSTREAM.to_string(),
62-
);
63-
if seen.insert(official.clone()) {
64-
result.push(official);
60+
// The implicit official Anthropic upstream is opt-in: only spawn a proxy
61+
// for `cc use official` when the daemon was started with
62+
// `--capture-official`. By default official traffic flows direct to
63+
// Anthropic. A user-defined alias that points at the official URL is
64+
// handled above and deduped here, so it always gets its proxy.
65+
if capture_official {
66+
let official = (
67+
"claude".to_string(),
68+
crate::daemon::OFFICIAL_UPSTREAM.to_string(),
69+
);
70+
if seen.insert(official.clone()) {
71+
result.push(official);
72+
}
6573
}
6674
result
6775
}
@@ -365,7 +373,7 @@ mod tests {
365373
"https://api.anthropic.com",
366374
"https://other.example.com/v1",
367375
]);
368-
let result = dedupe_upstreams(&storage);
376+
let result = dedupe_upstreams(&storage, false);
369377
assert_eq!(result.len(), 2);
370378
assert_eq!(result[0].1, "https://api.anthropic.com");
371379
assert_eq!(result[1].1, "https://other.example.com/v1");
@@ -374,7 +382,7 @@ mod tests {
374382
#[test]
375383
fn dedupe_upstreams_skips_empty_urls() {
376384
let storage = make_storage(&["", "https://api.anthropic.com"]);
377-
let result = dedupe_upstreams(&storage);
385+
let result = dedupe_upstreams(&storage, false);
378386
assert_eq!(result.len(), 1);
379387
assert_eq!(result[0].1, "https://api.anthropic.com");
380388
}
@@ -395,23 +403,37 @@ mod tests {
395403
}
396404

397405
#[test]
398-
fn dedupe_upstreams_always_includes_official() {
399-
let result = dedupe_upstreams(&make_storage(&[]));
406+
fn dedupe_upstreams_excludes_official_by_default() {
407+
// Default: do NOT spawn the implicit official proxy. `cc use official`
408+
// traffic flows direct to Anthropic unless capture is explicitly enabled.
409+
let result = dedupe_upstreams(&make_storage(&[]), false);
410+
assert!(
411+
!result.contains(&(
412+
"claude".to_string(),
413+
crate::daemon::OFFICIAL_UPSTREAM.to_string()
414+
)),
415+
"OFFICIAL_UPSTREAM must be absent by default, got {result:?}",
416+
);
417+
}
418+
419+
#[test]
420+
fn dedupe_upstreams_includes_official_when_capture_enabled() {
421+
let result = dedupe_upstreams(&make_storage(&[]), true);
400422
assert!(
401423
result.contains(&(
402424
"claude".to_string(),
403425
crate::daemon::OFFICIAL_UPSTREAM.to_string()
404426
)),
405-
"OFFICIAL_UPSTREAM must always be in dedupe_upstreams output, got {result:?}",
427+
"OFFICIAL_UPSTREAM must be present when capture_official=true, got {result:?}",
406428
);
407429
}
408430

409431
#[test]
410432
fn dedupe_upstreams_dedupes_when_user_has_official_url() {
411433
// Belt-and-suspenders: user shouldn't normally do this, but if they
412434
// configure an alias with the official URL, we must not spawn two
413-
// proxies for the same URL.
414-
let result = dedupe_upstreams(&make_storage(&[crate::daemon::OFFICIAL_UPSTREAM]));
435+
// proxies for the same URL even with capture enabled.
436+
let result = dedupe_upstreams(&make_storage(&[crate::daemon::OFFICIAL_UPSTREAM]), true);
415437
let count = result
416438
.iter()
417439
.filter(|(_, url)| url == crate::daemon::OFFICIAL_UPSTREAM)
@@ -421,4 +443,19 @@ mod tests {
421443
"OFFICIAL_UPSTREAM must appear exactly once, got {result:?}"
422444
);
423445
}
446+
447+
#[test]
448+
fn dedupe_upstreams_keeps_user_official_alias_without_capture() {
449+
// A user-defined alias explicitly pointing at the official URL still
450+
// gets its proxy even when implicit official capture is off.
451+
let result = dedupe_upstreams(&make_storage(&[crate::daemon::OFFICIAL_UPSTREAM]), false);
452+
let count = result
453+
.iter()
454+
.filter(|(_, url)| url == crate::daemon::OFFICIAL_UPSTREAM)
455+
.count();
456+
assert_eq!(
457+
count, 1,
458+
"user-defined official alias must still spawn its proxy, got {result:?}"
459+
);
460+
}
424461
}

src/daemon/mod.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,9 @@ pub fn try_resolve_proxy_from_paths(
114114
}
115115
}
116116

117-
/// Official Anthropic upstream URL. The daemon spawns one ccs-proxy for this
118-
/// URL at startup so `cc use official` traffic can be captured.
117+
/// Official Anthropic upstream URL. The daemon spawns a ccs-proxy for this
118+
/// URL **only** when started with `--capture-official`; by default `cc use
119+
/// official` traffic flows direct to Anthropic and is not captured.
119120
///
120121
/// MUST stay byte-identical to Claude CLI's default `ANTHROPIC_BASE_URL`, since
121122
/// `find_proxy` does literal string match.
@@ -142,11 +143,12 @@ pub fn build_official_env() -> crate::config::config::EnvironmentConfig {
142143
ProxyResolution::Direct => {
143144
eprintln!(
144145
"{}",
145-
"\u{2139} cc daemon is not running — official traffic will NOT be captured.".blue()
146+
"\u{2139} official traffic is going direct to Anthropic (not captured).".blue()
146147
);
147148
eprintln!(
148149
"{}",
149-
" Run `cc-switch daemon start` and re-run to enable capture.".blue()
150+
" Start the daemon with `cc-switch daemon start --capture-official` to capture it."
151+
.blue()
150152
);
151153
env
152154
}

tests/daemon_supervisor.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,23 +59,23 @@ mod daemon_supervisor {
5959
("personal", "https://api.anthropic.com"),
6060
("glm", "https://glm.example.com/v1"),
6161
]);
62-
let cfg = LifecycleConfig::from_storage(&storage, false).unwrap();
62+
let cfg = LifecycleConfig::from_storage(&storage, false, false).unwrap();
6363
// Two unique upstreams, not three.
6464
assert_eq!(cfg.upstreams.len(), 2);
6565
}
6666

6767
#[test]
6868
fn lifecycle_config_skips_empty_urls() {
6969
let storage = make_storage(&[("empty", ""), ("work", "https://api.anthropic.com")]);
70-
let cfg = LifecycleConfig::from_storage(&storage, false).unwrap();
70+
let cfg = LifecycleConfig::from_storage(&storage, false, false).unwrap();
7171
assert_eq!(cfg.upstreams.len(), 1);
7272
assert_eq!(cfg.upstreams[0].1, "https://api.anthropic.com");
7373
}
7474

7575
#[test]
7676
fn lifecycle_config_paths_are_in_cc_switch_dir() {
7777
let storage = make_storage(&[("x", "https://api.anthropic.com")]);
78-
let cfg = LifecycleConfig::from_storage(&storage, false).unwrap();
78+
let cfg = LifecycleConfig::from_storage(&storage, false, false).unwrap();
7979
let state_str = cfg.state_path.to_string_lossy();
8080
let pid_str = cfg.pidfile_path.to_string_lossy();
8181
assert!(state_str.contains(".cc-switch"), "state_path: {state_str}");
@@ -167,11 +167,20 @@ mod daemon_supervisor {
167167
}
168168

169169
#[test]
170-
fn empty_configurations_yields_only_official_upstream() {
171-
// Empty user config still yields exactly the official upstream, so
172-
// `cc use official` traffic can be captured by the daemon.
170+
fn empty_configurations_yields_no_upstreams_by_default() {
171+
// Default: no implicit official proxy. Empty user config yields zero
172+
// upstreams, so `cc use official` traffic flows direct to Anthropic.
173173
let storage = make_storage(&[]);
174-
let cfg = LifecycleConfig::from_storage(&storage, false).unwrap();
174+
let cfg = LifecycleConfig::from_storage(&storage, false, false).unwrap();
175+
assert_eq!(cfg.upstreams.len(), 0);
176+
}
177+
178+
#[test]
179+
fn empty_configurations_yields_official_upstream_with_capture() {
180+
// With `--capture-official`, the daemon spawns the official proxy so
181+
// `cc use official` traffic can be captured.
182+
let storage = make_storage(&[]);
183+
let cfg = LifecycleConfig::from_storage(&storage, false, true).unwrap();
175184
assert_eq!(cfg.upstreams.len(), 1);
176185
assert_eq!(
177186
cfg.upstreams[0].1,

0 commit comments

Comments
 (0)