Skip to content

Commit 124e866

Browse files
committed
Remove the deprecated Claude subscription login path and restore a green Rust workspace
ROADMAP #37 was still open even though several earlier backlog items were already closed. This change removes the local login/logout surface, stops startup auth resolution from treating saved OAuth credentials as a supported path, and updates diagnostics/help to point users at ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN only. While proving the change with the user-requested workspace gates, clippy surfaced additional pre-existing warning failures across the Rust workspace. Those were cleaned up in-place so the required `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo test --workspace` sequence now passes end to end. Constraint: User explicitly required full-workspace fmt/clippy/test before commit/push Constraint: Existing dirty leader worktree had to be stashed before attempted OMX team worktree launch Rejected: Keep login/logout but hide them from help | left unsupported auth flow and saved OAuth fallback intact Rejected: Stop after ROADMAP #37 targeted tests | did not satisfy required full-workspace verification gate Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Do not reintroduce saved OAuth as a silent Anthropic startup fallback without an explicit supported auth policy Tested: cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: Remote push effects beyond origin/main update
1 parent 61c01ff commit 124e866

16 files changed

Lines changed: 227 additions & 635 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes
438438

439439
36. **Custom/project skill invocation disconnected from skill discovery** -- dogfooded 2026-04-09. /skills lists custom skills (e.g. caveman) but bare skill-name invocation does not dispatch them; falls through to plain model prompt. Fix: audit classify_skills_slash_command, ensure any skill listed by /skills has a deterministic invocation path, or document the correct syntax. Source: gaebal-gajae dogfood 2026-04-09.
440440

441-
37. **Claude subscription login path should be removed, not deprecated** -- dogfooded 2026-04-09. Official auth should be API key only (ANTHROPIC_API_KEY or OAuth access token via ANTHROPIC_AUTH_TOKEN). claw login with Claude subscription credentials creates legal/billing ambiguity. Fix: remove the subscription login surface entirely; update README/USAGE.md to say API key is the only supported path. Source: gaebal-gajae policy decision 2026-04-09.
441+
37. **Claude subscription login path should be removed, not deprecated** -- dogfooded 2026-04-09. Official auth should be API key only (`ANTHROPIC_API_KEY`) or OAuth bearer token via `ANTHROPIC_AUTH_TOKEN`; the local `claw login` / `claw logout` subscription-style flow created legal/billing ambiguity and a misleading saved-OAuth fallback. **Done (verified 2026-04-11):** removed the direct `claw login` / `claw logout` CLI surface, removed `/login` and `/logout` from shared slash-command discovery, changed both CLI and provider startup auth resolution to ignore saved OAuth credentials, and updated auth diagnostics to point only at `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN`. Verification: targeted `commands`, `api`, and `rusty-claude-cli` tests for removed login/logout guidance and ignored saved OAuth all pass, and `cargo check -p api -p commands -p rusty-claude-cli` passes. Source: gaebal-gajae policy decision 2026-04-09.
442442

443443
38. **Dead-session opacity: bot cannot self-detect compaction vs broken tool surface** -- dogfooded 2026-04-09. Jobdori session spent ~15h declaring itself "dead" in-channel while tools were actually returning correct results within each turn. Root cause: context compaction causes tool outputs to be summarised away between turns, making the bot interpret absence-of-remembered-output as tool failure. This is a distinct failure mode from ROADMAP #31 (executor quirks): the session is alive and tools are functional, but the agent cannot tell the difference between "my last tool call produced no output" (compaction) and "the tool is broken". Downstream: repetitive false-dead signals in the channel, work not getting done despite the execution surface being live. Fix shape: (a) probe with a short known-output command at turn start if context has been compacted; (b) gate "I am dead" declarations behind at least one within-turn tool call with a verified non-empty result; (c) consider adding a session-health canary cron that fires a wake with a minimal probe and checks the result. Source: Jobdori self-dogfood 2026-04-09; observed in #clawcode-building-in-public across multiple Clawhip nudge cycles.
444444

rust/crates/api/src/client.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,7 @@ mod tests {
232232
openai_client.base_url()
233233
);
234234
}
235-
other => panic!(
236-
"Expected ProviderClient::OpenAi for qwen-plus, got: {:?}",
237-
other
238-
),
235+
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
239236
}
240237
}
241238
}

rust/crates/api/src/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub enum ApiError {
2424
env_vars: &'static [&'static str],
2525
/// Optional, runtime-computed hint appended to the error Display
2626
/// output. Populated when the provider resolver can infer what the
27-
/// user probably intended (e.g. an OpenAI key is set but Anthropic
27+
/// user probably intended (e.g. an `OpenAI` key is set but Anthropic
2828
/// was selected because no Anthropic credentials exist).
2929
hint: Option<String>,
3030
},

rust/crates/api/src/http_client.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,12 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, A
8888
.as_deref()
8989
.and_then(reqwest::NoProxy::from_string);
9090

91-
let (http_proxy_url, https_proxy_url) = match config.proxy_url.as_deref() {
91+
let (http_proxy_url, https_url) = match config.proxy_url.as_deref() {
9292
Some(unified) => (Some(unified), Some(unified)),
9393
None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()),
9494
};
9595

96-
if let Some(url) = https_proxy_url {
96+
if let Some(url) = https_url {
9797
let mut proxy = reqwest::Proxy::https(url)?;
9898
if let Some(filter) = no_proxy.clone() {
9999
proxy = proxy.no_proxy(Some(filter));

rust/crates/api/src/providers/anthropic.rs

Lines changed: 14 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -502,9 +502,8 @@ impl AnthropicClient {
502502
// Best-effort refinement using the Anthropic count_tokens endpoint.
503503
// On any failure (network, parse, auth), fall back to the local
504504
// byte-estimate result which already passed above.
505-
let counted_input_tokens = match self.count_tokens(request).await {
506-
Ok(count) => count,
507-
Err(_) => return Ok(()),
505+
let Ok(counted_input_tokens) = self.count_tokens(request).await else {
506+
return Ok(());
508507
};
509508
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
510509
if estimated_total_tokens > limit.context_window_tokens {
@@ -631,21 +630,7 @@ impl AuthSource {
631630
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
632631
return Ok(Self::BearerToken(bearer_token));
633632
}
634-
match load_saved_oauth_token() {
635-
Ok(Some(token_set)) if oauth_token_is_expired(&token_set) => {
636-
if token_set.refresh_token.is_some() {
637-
Err(ApiError::Auth(
638-
"saved OAuth token is expired; load runtime OAuth config to refresh it"
639-
.to_string(),
640-
))
641-
} else {
642-
Err(ApiError::ExpiredOAuthToken)
643-
}
644-
}
645-
Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
646-
Ok(None) => Err(anthropic_missing_credentials()),
647-
Err(error) => Err(error),
648-
}
633+
Err(anthropic_missing_credentials())
649634
}
650635
}
651636

@@ -665,14 +650,14 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
665650

666651
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
667652
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
668-
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()
669-
|| load_saved_oauth_token()?.is_some())
653+
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some())
670654
}
671655

672656
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
673657
where
674658
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
675659
{
660+
let _ = load_oauth_config;
676661
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
677662
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
678663
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
@@ -685,25 +670,7 @@ where
685670
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
686671
return Ok(AuthSource::BearerToken(bearer_token));
687672
}
688-
689-
let Some(token_set) = load_saved_oauth_token()? else {
690-
return Err(anthropic_missing_credentials());
691-
};
692-
if !oauth_token_is_expired(&token_set) {
693-
return Ok(AuthSource::BearerToken(token_set.access_token));
694-
}
695-
if token_set.refresh_token.is_none() {
696-
return Err(ApiError::ExpiredOAuthToken);
697-
}
698-
699-
let Some(config) = load_oauth_config()? else {
700-
return Err(ApiError::Auth(
701-
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
702-
));
703-
};
704-
Ok(AuthSource::from(resolve_saved_oauth_token_set(
705-
&config, token_set,
706-
)?))
673+
Err(anthropic_missing_credentials())
707674
}
708675

709676
fn resolve_saved_oauth_token_set(
@@ -1016,7 +983,7 @@ fn strip_unsupported_beta_body_fields(body: &mut Value) {
1016983
object.remove("presence_penalty");
1017984
// Anthropic uses "stop_sequences" not "stop". Convert if present.
1018985
if let Some(stop_val) = object.remove("stop") {
1019-
if stop_val.as_array().map_or(false, |a| !a.is_empty()) {
986+
if stop_val.as_array().is_some_and(|a| !a.is_empty()) {
1020987
object.insert("stop_sequences".to_string(), stop_val);
1021988
}
1022989
}
@@ -1180,7 +1147,7 @@ mod tests {
11801147
}
11811148

11821149
#[test]
1183-
fn auth_source_from_saved_oauth_when_env_absent() {
1150+
fn auth_source_from_env_or_saved_ignores_saved_oauth_when_env_absent() {
11841151
let _guard = env_lock();
11851152
let config_home = temp_config_home();
11861153
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
@@ -1194,8 +1161,8 @@ mod tests {
11941161
})
11951162
.expect("save oauth credentials");
11961163

1197-
let auth = AuthSource::from_env_or_saved().expect("saved auth");
1198-
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
1164+
let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored");
1165+
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
11991166

12001167
clear_oauth_credentials().expect("clear credentials");
12011168
std::env::remove_var("CLAW_CONFIG_HOME");
@@ -1251,7 +1218,7 @@ mod tests {
12511218
}
12521219

12531220
#[test]
1254-
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
1221+
fn resolve_startup_auth_source_ignores_saved_oauth_without_loading_config() {
12551222
let _guard = env_lock();
12561223
let config_home = temp_config_home();
12571224
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
@@ -1265,41 +1232,9 @@ mod tests {
12651232
})
12661233
.expect("save oauth credentials");
12671234

1268-
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
1269-
.expect("startup auth");
1270-
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
1271-
1272-
clear_oauth_credentials().expect("clear credentials");
1273-
std::env::remove_var("CLAW_CONFIG_HOME");
1274-
cleanup_temp_config_home(&config_home);
1275-
}
1276-
1277-
#[test]
1278-
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
1279-
let _guard = env_lock();
1280-
let config_home = temp_config_home();
1281-
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
1282-
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
1283-
std::env::remove_var("ANTHROPIC_API_KEY");
1284-
save_oauth_credentials(&runtime::OAuthTokenSet {
1285-
access_token: "expired-access-token".to_string(),
1286-
refresh_token: Some("refresh-token".to_string()),
1287-
expires_at: Some(1),
1288-
scopes: vec!["scope:a".to_string()],
1289-
})
1290-
.expect("save expired oauth credentials");
1291-
1292-
let error =
1293-
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
1294-
assert!(
1295-
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
1296-
);
1297-
1298-
let stored = runtime::load_oauth_credentials()
1299-
.expect("load stored credentials")
1300-
.expect("stored token set");
1301-
assert_eq!(stored.access_token, "expired-access-token");
1302-
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
1235+
let error = resolve_startup_auth_source(|| panic!("config should not be loaded"))
1236+
.expect_err("saved oauth should be ignored");
1237+
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
13031238

13041239
clear_oauth_credentials().expect("clear credentials");
13051240
std::env::remove_var("CLAW_CONFIG_HOME");

rust/crates/api/src/providers/mod.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -508,9 +508,10 @@ mod tests {
508508
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
509509
// and detect_provider_kind fell through to auth-sniffer order.
510510
// The model prefix must win over env-var presence.
511-
let kind = super::metadata_for_model("openai/gpt-4.1-mini")
512-
.map(|m| m.provider)
513-
.unwrap_or_else(|| detect_provider_kind("openai/gpt-4.1-mini"));
511+
let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else(
512+
|| detect_provider_kind("openai/gpt-4.1-mini"),
513+
|m| m.provider,
514+
);
514515
assert_eq!(
515516
kind,
516517
ProviderKind::OpenAi,
@@ -519,8 +520,7 @@ mod tests {
519520

520521
// Also cover bare gpt- prefix
521522
let kind2 = super::metadata_for_model("gpt-4o")
522-
.map(|m| m.provider)
523-
.unwrap_or_else(|| detect_provider_kind("gpt-4o"));
523+
.map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider);
524524
assert_eq!(kind2, ProviderKind::OpenAi);
525525
}
526526

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@ impl OpenAiCompatConfig {
5858
}
5959
}
6060

61-
/// Alibaba DashScope compatible-mode endpoint (Qwen family models).
61+
/// Alibaba `DashScope` compatible-mode endpoint (Qwen family models).
6262
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
6363
/// Requested via Discord #clawcode-get-help: native Alibaba API for
64-
/// higher rate limits than going through OpenRouter.
64+
/// higher rate limits than going through `OpenRouter`.
6565
#[must_use]
6666
pub const fn dashscope() -> Self {
6767
Self {
@@ -170,7 +170,7 @@ impl OpenAiCompatClient {
170170
.to_string();
171171
let code = err_obj
172172
.get("code")
173-
.and_then(|c| c.as_u64())
173+
.and_then(serde_json::Value::as_u64)
174174
.map(|c| c as u16);
175175
return Err(ApiError::Api {
176176
status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
@@ -750,7 +750,7 @@ struct ErrorBody {
750750
}
751751

752752
/// Returns true for models known to reject tuning parameters like temperature,
753-
/// top_p, frequency_penalty, and presence_penalty. These are typically
753+
/// `top_p`, `frequency_penalty`, and `presence_penalty`. These are typically
754754
/// reasoning/chain-of-thought models with fixed sampling.
755755
fn is_reasoning_model(model: &str) -> bool {
756756
let lowered = model.to_ascii_lowercase();
@@ -974,12 +974,11 @@ fn sanitize_tool_message_pairing(messages: Vec<Value>) -> Vec<Value> {
974974
}
975975
let paired = preceding
976976
.and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
977-
.map(|tool_calls| {
977+
.is_some_and(|tool_calls| {
978978
tool_calls
979979
.iter()
980980
.any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
981-
})
982-
.unwrap_or(false);
981+
});
983982
if !paired {
984983
drop_indices.insert(i);
985984
}
@@ -1008,7 +1007,7 @@ fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
10081007

10091008
/// Recursively ensure every object-type node in a JSON Schema has
10101009
/// `"properties"` (at least `{}`) and `"additionalProperties": false`.
1011-
/// The OpenAI `/responses` endpoint validates schemas strictly and rejects
1010+
/// The `OpenAI` `/responses` endpoint validates schemas strictly and rejects
10121011
/// objects that omit these fields; `/chat/completions` is lenient but also
10131012
/// accepts them, so we normalise unconditionally.
10141013
fn normalize_object_schema(schema: &mut Value) {
@@ -1173,7 +1172,7 @@ fn parse_sse_frame(
11731172
.to_string();
11741173
let code = err_obj
11751174
.get("code")
1176-
.and_then(|c| c.as_u64())
1175+
.and_then(serde_json::Value::as_u64)
11771176
.map(|c| c as u16);
11781177
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
11791178
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
@@ -1185,7 +1184,7 @@ fn parse_sse_frame(
11851184
.map(str::to_owned),
11861185
message: Some(msg),
11871186
request_id: None,
1188-
body: payload.to_string(),
1187+
body: payload.clone(),
11891188
retryable: false,
11901189
});
11911190
}
@@ -1642,23 +1641,24 @@ mod tests {
16421641
/// Before the fix this produced: `invalid type: null, expected a sequence`.
16431642
#[test]
16441643
fn delta_with_null_tool_calls_deserializes_as_empty_vec() {
1645-
// Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
1646-
let json = r#"{
1647-
"content": "",
1648-
"function_call": null,
1649-
"refusal": null,
1650-
"role": "assistant",
1651-
"tool_calls": null
1652-
}"#;
1653-
16541644
use super::deserialize_null_as_empty_vec;
1645+
16551646
#[allow(dead_code)]
16561647
#[derive(serde::Deserialize, Debug)]
16571648
struct Delta {
16581649
content: Option<String>,
16591650
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
16601651
tool_calls: Vec<super::DeltaToolCall>,
16611652
}
1653+
1654+
// Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
1655+
let json = r#"{
1656+
"content": "",
1657+
"function_call": null,
1658+
"refusal": null,
1659+
"role": "assistant",
1660+
"tool_calls": null
1661+
}"#;
16621662
let delta: Delta = serde_json::from_str(json)
16631663
.expect("delta with tool_calls:null must deserialize without error");
16641664
assert!(
@@ -1670,7 +1670,7 @@ mod tests {
16701670
/// Regression: when building a multi-turn request where a prior assistant
16711671
/// turn has no tool calls, the serialized assistant message must NOT include
16721672
/// `tool_calls: []`. Some providers reject requests that carry an empty
1673-
/// tool_calls array on assistant turns (gaebal-gajae repro 2026-04-09).
1673+
/// `tool_calls` array on assistant turns (gaebal-gajae repro 2026-04-09).
16741674
#[test]
16751675
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
16761676
use crate::types::{InputContentBlock, InputMessage};
@@ -1695,13 +1695,12 @@ mod tests {
16951695
.expect("assistant message must be present");
16961696
assert!(
16971697
assistant_msg.get("tool_calls").is_none(),
1698-
"assistant message without tool calls must omit tool_calls field: {:?}",
1699-
assistant_msg
1698+
"assistant message without tool calls must omit tool_calls field: {assistant_msg:?}"
17001699
);
17011700
}
17021701

17031702
/// Regression: assistant messages WITH tool calls must still include
1704-
/// the tool_calls array (normal multi-turn tool-use flow).
1703+
/// the `tool_calls` array (normal multi-turn tool-use flow).
17051704
#[test]
17061705
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
17071706
use crate::types::{InputContentBlock, InputMessage};
@@ -1733,7 +1732,7 @@ mod tests {
17331732
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
17341733
}
17351734

1736-
/// Orphaned tool messages (no preceding assistant tool_calls) must be
1735+
/// Orphaned tool messages (no preceding assistant `tool_calls`) must be
17371736
/// dropped by the request-builder sanitizer. Regression for the second
17381737
/// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10).
17391738
#[test]

0 commit comments

Comments
 (0)