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
This commit is contained in:
Yeachan-Heo
2026-04-11 17:24:44 +00:00
parent 61c01ff7da
commit 124e8661ed
16 changed files with 227 additions and 635 deletions

View File

@@ -232,10 +232,7 @@ mod tests {
openai_client.base_url()
);
}
other => panic!(
"Expected ProviderClient::OpenAi for qwen-plus, got: {:?}",
other
),
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
}
}
}

View File

@@ -24,7 +24,7 @@ pub enum ApiError {
env_vars: &'static [&'static str],
/// Optional, runtime-computed hint appended to the error Display
/// output. Populated when the provider resolver can infer what the
/// user probably intended (e.g. an OpenAI key is set but Anthropic
/// user probably intended (e.g. an `OpenAI` key is set but Anthropic
/// was selected because no Anthropic credentials exist).
hint: Option<String>,
},

View File

@@ -88,12 +88,12 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, A
.as_deref()
.and_then(reqwest::NoProxy::from_string);
let (http_proxy_url, https_proxy_url) = match config.proxy_url.as_deref() {
let (http_proxy_url, https_url) = match config.proxy_url.as_deref() {
Some(unified) => (Some(unified), Some(unified)),
None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()),
};
if let Some(url) = https_proxy_url {
if let Some(url) = https_url {
let mut proxy = reqwest::Proxy::https(url)?;
if let Some(filter) = no_proxy.clone() {
proxy = proxy.no_proxy(Some(filter));

View File

@@ -502,9 +502,8 @@ impl AnthropicClient {
// Best-effort refinement using the Anthropic count_tokens endpoint.
// On any failure (network, parse, auth), fall back to the local
// byte-estimate result which already passed above.
let counted_input_tokens = match self.count_tokens(request).await {
Ok(count) => count,
Err(_) => return Ok(()),
let Ok(counted_input_tokens) = self.count_tokens(request).await else {
return Ok(());
};
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
if estimated_total_tokens > limit.context_window_tokens {
@@ -631,21 +630,7 @@ impl AuthSource {
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(Self::BearerToken(bearer_token));
}
match load_saved_oauth_token() {
Ok(Some(token_set)) if oauth_token_is_expired(&token_set) => {
if token_set.refresh_token.is_some() {
Err(ApiError::Auth(
"saved OAuth token is expired; load runtime OAuth config to refresh it"
.to_string(),
))
} else {
Err(ApiError::ExpiredOAuthToken)
}
}
Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
Ok(None) => Err(anthropic_missing_credentials()),
Err(error) => Err(error),
}
Err(anthropic_missing_credentials())
}
}
@@ -665,14 +650,14 @@ pub fn resolve_saved_oauth_token(config: &OAuthConfig) -> Result<Option<OAuthTok
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()
|| load_saved_oauth_token()?.is_some())
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some())
}
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
where
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
{
let _ = load_oauth_config;
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
@@ -685,25 +670,7 @@ where
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
return Ok(AuthSource::BearerToken(bearer_token));
}
let Some(token_set) = load_saved_oauth_token()? else {
return Err(anthropic_missing_credentials());
};
if !oauth_token_is_expired(&token_set) {
return Ok(AuthSource::BearerToken(token_set.access_token));
}
if token_set.refresh_token.is_none() {
return Err(ApiError::ExpiredOAuthToken);
}
let Some(config) = load_oauth_config()? else {
return Err(ApiError::Auth(
"saved OAuth token is expired; runtime OAuth config is missing".to_string(),
));
};
Ok(AuthSource::from(resolve_saved_oauth_token_set(
&config, token_set,
)?))
Err(anthropic_missing_credentials())
}
fn resolve_saved_oauth_token_set(
@@ -1016,7 +983,7 @@ fn strip_unsupported_beta_body_fields(body: &mut Value) {
object.remove("presence_penalty");
// Anthropic uses "stop_sequences" not "stop". Convert if present.
if let Some(stop_val) = object.remove("stop") {
if stop_val.as_array().map_or(false, |a| !a.is_empty()) {
if stop_val.as_array().is_some_and(|a| !a.is_empty()) {
object.insert("stop_sequences".to_string(), stop_val);
}
}
@@ -1180,7 +1147,7 @@ mod tests {
}
#[test]
fn auth_source_from_saved_oauth_when_env_absent() {
fn auth_source_from_env_or_saved_ignores_saved_oauth_when_env_absent() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
@@ -1194,8 +1161,8 @@ mod tests {
})
.expect("save oauth credentials");
let auth = AuthSource::from_env_or_saved().expect("saved auth");
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored");
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAW_CONFIG_HOME");
@@ -1251,7 +1218,7 @@ mod tests {
}
#[test]
fn resolve_startup_auth_source_uses_saved_oauth_without_loading_config() {
fn resolve_startup_auth_source_ignores_saved_oauth_without_loading_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
@@ -1265,41 +1232,9 @@ mod tests {
})
.expect("save oauth credentials");
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
.expect("startup auth");
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAW_CONFIG_HOME");
cleanup_temp_config_home(&config_home);
}
#[test]
fn resolve_startup_auth_source_errors_when_refreshable_token_lacks_config() {
let _guard = env_lock();
let config_home = temp_config_home();
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
std::env::remove_var("ANTHROPIC_API_KEY");
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: "expired-access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(1),
scopes: vec!["scope:a".to_string()],
})
.expect("save expired oauth credentials");
let error =
resolve_startup_auth_source(|| Ok(None)).expect_err("missing config should error");
assert!(
matches!(error, crate::error::ApiError::Auth(message) if message.contains("runtime OAuth config is missing"))
);
let stored = runtime::load_oauth_credentials()
.expect("load stored credentials")
.expect("stored token set");
assert_eq!(stored.access_token, "expired-access-token");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-token"));
let error = resolve_startup_auth_source(|| panic!("config should not be loaded"))
.expect_err("saved oauth should be ignored");
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
clear_oauth_credentials().expect("clear credentials");
std::env::remove_var("CLAW_CONFIG_HOME");

View File

@@ -508,9 +508,10 @@ mod tests {
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
// and detect_provider_kind fell through to auth-sniffer order.
// The model prefix must win over env-var presence.
let kind = super::metadata_for_model("openai/gpt-4.1-mini")
.map(|m| m.provider)
.unwrap_or_else(|| detect_provider_kind("openai/gpt-4.1-mini"));
let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else(
|| detect_provider_kind("openai/gpt-4.1-mini"),
|m| m.provider,
);
assert_eq!(
kind,
ProviderKind::OpenAi,
@@ -519,8 +520,7 @@ mod tests {
// Also cover bare gpt- prefix
let kind2 = super::metadata_for_model("gpt-4o")
.map(|m| m.provider)
.unwrap_or_else(|| detect_provider_kind("gpt-4o"));
.map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider);
assert_eq!(kind2, ProviderKind::OpenAi);
}

View File

@@ -58,10 +58,10 @@ impl OpenAiCompatConfig {
}
}
/// Alibaba DashScope compatible-mode endpoint (Qwen family models).
/// Alibaba `DashScope` compatible-mode endpoint (Qwen family models).
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
/// Requested via Discord #clawcode-get-help: native Alibaba API for
/// higher rate limits than going through OpenRouter.
/// higher rate limits than going through `OpenRouter`.
#[must_use]
pub const fn dashscope() -> Self {
Self {
@@ -170,7 +170,7 @@ impl OpenAiCompatClient {
.to_string();
let code = err_obj
.get("code")
.and_then(|c| c.as_u64())
.and_then(serde_json::Value::as_u64)
.map(|c| c as u16);
return Err(ApiError::Api {
status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
@@ -750,7 +750,7 @@ struct ErrorBody {
}
/// Returns true for models known to reject tuning parameters like temperature,
/// top_p, frequency_penalty, and presence_penalty. These are typically
/// `top_p`, `frequency_penalty`, and `presence_penalty`. These are typically
/// reasoning/chain-of-thought models with fixed sampling.
fn is_reasoning_model(model: &str) -> bool {
let lowered = model.to_ascii_lowercase();
@@ -974,12 +974,11 @@ fn sanitize_tool_message_pairing(messages: Vec<Value>) -> Vec<Value> {
}
let paired = preceding
.and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
.map(|tool_calls| {
.is_some_and(|tool_calls| {
tool_calls
.iter()
.any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
})
.unwrap_or(false);
});
if !paired {
drop_indices.insert(i);
}
@@ -1008,7 +1007,7 @@ fn flatten_tool_result_content(content: &[ToolResultContentBlock]) -> String {
/// Recursively ensure every object-type node in a JSON Schema has
/// `"properties"` (at least `{}`) and `"additionalProperties": false`.
/// The OpenAI `/responses` endpoint validates schemas strictly and rejects
/// The `OpenAI` `/responses` endpoint validates schemas strictly and rejects
/// objects that omit these fields; `/chat/completions` is lenient but also
/// accepts them, so we normalise unconditionally.
fn normalize_object_schema(schema: &mut Value) {
@@ -1173,7 +1172,7 @@ fn parse_sse_frame(
.to_string();
let code = err_obj
.get("code")
.and_then(|c| c.as_u64())
.and_then(serde_json::Value::as_u64)
.map(|c| c as u16);
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
@@ -1185,7 +1184,7 @@ fn parse_sse_frame(
.map(str::to_owned),
message: Some(msg),
request_id: None,
body: payload.to_string(),
body: payload.clone(),
retryable: false,
});
}
@@ -1642,6 +1641,16 @@ mod tests {
/// Before the fix this produced: `invalid type: null, expected a sequence`.
#[test]
fn delta_with_null_tool_calls_deserializes_as_empty_vec() {
use super::deserialize_null_as_empty_vec;
#[allow(dead_code)]
#[derive(serde::Deserialize, Debug)]
struct Delta {
content: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<super::DeltaToolCall>,
}
// Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
let json = r#"{
"content": "",
@@ -1650,15 +1659,6 @@ mod tests {
"role": "assistant",
"tool_calls": null
}"#;
use super::deserialize_null_as_empty_vec;
#[allow(dead_code)]
#[derive(serde::Deserialize, Debug)]
struct Delta {
content: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_as_empty_vec")]
tool_calls: Vec<super::DeltaToolCall>,
}
let delta: Delta = serde_json::from_str(json)
.expect("delta with tool_calls:null must deserialize without error");
assert!(
@@ -1670,7 +1670,7 @@ mod tests {
/// Regression: when building a multi-turn request where a prior assistant
/// turn has no tool calls, the serialized assistant message must NOT include
/// `tool_calls: []`. Some providers reject requests that carry an empty
/// tool_calls array on assistant turns (gaebal-gajae repro 2026-04-09).
/// `tool_calls` array on assistant turns (gaebal-gajae repro 2026-04-09).
#[test]
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
use crate::types::{InputContentBlock, InputMessage};
@@ -1695,13 +1695,12 @@ mod tests {
.expect("assistant message must be present");
assert!(
assistant_msg.get("tool_calls").is_none(),
"assistant message without tool calls must omit tool_calls field: {:?}",
assistant_msg
"assistant message without tool calls must omit tool_calls field: {assistant_msg:?}"
);
}
/// Regression: assistant messages WITH tool calls must still include
/// the tool_calls array (normal multi-turn tool-use flow).
/// the `tool_calls` array (normal multi-turn tool-use flow).
#[test]
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
use crate::types::{InputContentBlock, InputMessage};
@@ -1733,7 +1732,7 @@ mod tests {
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
}
/// Orphaned tool messages (no preceding assistant tool_calls) must be
/// Orphaned tool messages (no preceding assistant `tool_calls`) must be
/// dropped by the request-builder sanitizer. Regression for the second
/// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10).
#[test]

View File

@@ -257,20 +257,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "login",
aliases: &[],
summary: "Log in to the service",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "logout",
aliases: &[],
summary: "Log out of the current session",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "plan",
aliases: &[],
@@ -1291,7 +1277,6 @@ impl SlashCommand {
Self::Tag { .. } => "/tag",
Self::OutputStyle { .. } => "/output-style",
Self::AddDir { .. } => "/add-dir",
Self::Unknown(_) => "/unknown",
Self::Sandbox => "/sandbox",
Self::Mcp { .. } => "/mcp",
Self::Export { .. } => "/export",
@@ -1402,13 +1387,12 @@ pub fn validate_slash_command_input(
validate_no_args(command, &args)?;
SlashCommand::Doctor
}
"login" => {
validate_no_args(command, &args)?;
SlashCommand::Login
}
"logout" => {
validate_no_args(command, &args)?;
SlashCommand::Logout
"login" | "logout" => {
return Err(command_error(
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
command,
"",
));
}
"vim" => {
validate_no_args(command, &args)?;
@@ -1893,20 +1877,12 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
fn slash_command_category(name: &str) -> &'static str {
match name {
"help" | "status" | "cost" | "resume" | "session" | "version" | "login" | "logout"
| "usage" | "stats" | "rename" | "clear" | "compact" | "history" | "tokens" | "cache"
| "exit" | "summary" | "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind"
| "pin" | "unpin" | "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry"
| "stop" | "undo" => "Session",
"diff" | "commit" | "pr" | "issue" | "branch" | "blame" | "log" | "git" | "stash"
| "init" | "export" | "plan" | "review" | "security-review" | "bughunter" | "ultraplan"
| "teleport" | "refactor" | "fix" | "autofix" | "explain" | "docs" | "perf" | "search"
| "references" | "definition" | "hover" | "symbols" | "map" | "web" | "image"
| "screenshot" | "paste" | "listen" | "speak" | "test" | "lint" | "build" | "run"
| "format" | "parallel" | "multi" | "macro" | "alias" | "templates" | "migrate"
| "benchmark" | "cron" | "agent" | "subagent" | "agents" | "skills" | "team" | "plugin"
| "mcp" | "hooks" | "tasks" | "advisor" | "insights" | "release-notes" | "chat"
| "approve" | "deny" | "allowed-tools" | "add-dir" => "Tools",
"help" | "status" | "cost" | "resume" | "session" | "version" | "usage" | "stats"
| "rename" | "clear" | "compact" | "history" | "tokens" | "cache" | "exit" | "summary"
| "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind" | "pin" | "unpin"
| "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry" | "stop" | "undo" => {
"Session"
}
"model" | "permissions" | "config" | "memory" | "theme" | "vim" | "voice" | "color"
| "effort" | "fast" | "brief" | "output-style" | "keybindings" | "privacy-settings"
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
@@ -2477,7 +2453,8 @@ pub fn resolve_skill_invocation(
.map(|s| s.name.clone())
.collect();
if !names.is_empty() {
message.push_str(&format!("\n Available skills: {}", names.join(", ")));
message.push_str("\n Available skills: ");
message.push_str(&names.join(", "));
}
}
message.push_str("\n Usage: /skills [list|install <path>|help|<skill> [args]]");
@@ -2699,7 +2676,7 @@ pub fn render_plugins_report_with_failures(
// Show warnings for broken plugins
if !failures.is_empty() {
lines.push("".to_string());
lines.push(String::new());
lines.push("Warnings:".to_string());
for failure in failures {
lines.push(format!(
@@ -4598,6 +4575,14 @@ mod tests {
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
}
#[test]
fn removed_login_and_logout_commands_report_env_auth_guidance() {
let login_error = parse_error_message("/login");
assert!(login_error.contains("ANTHROPIC_API_KEY"));
let logout_error = parse_error_message("/logout");
assert!(logout_error.contains("ANTHROPIC_AUTH_TOKEN"));
}
#[test]
fn renders_help_from_shared_specs() {
let help = render_slash_command_help();
@@ -4639,7 +4624,9 @@ mod tests {
assert!(help.contains("/agents [list|help]"));
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
assert!(help.contains("aliases: /skill"));
assert_eq!(slash_command_specs().len(), 141);
assert!(!help.contains("/login"));
assert!(!help.contains("/logout"));
assert_eq!(slash_command_specs().len(), 139);
assert!(resume_supported_slash_commands().len() >= 39);
}

View File

@@ -135,8 +135,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
let starts_with_tool_result = first_preserved
.blocks
.first()
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
.unwrap_or(false);
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
if !starts_with_tool_result {
break;
}
@@ -741,7 +740,7 @@ mod tests {
/// Regression: compaction must not split an assistant(ToolUse) /
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
/// without the preceding assistant tool_calls causes a 400 on the
/// without the preceding assistant `tool_calls` causes a 400 on the
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
#[test]
fn compaction_does_not_split_tool_use_tool_result_pair() {
@@ -795,8 +794,7 @@ mod tests {
let curr_is_tool_result = messages[i]
.blocks
.first()
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
.unwrap_or(false);
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
if curr_is_tool_result {
let prev_has_tool_use = messages[i - 1]
.blocks

View File

@@ -1467,12 +1467,8 @@ mod tests {
/// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD.
#[allow(dead_code)]
pub fn workspace_sessions_dir(cwd: &std::path::Path) -> Result<std::path::PathBuf, SessionError> {
let store = crate::session_control::SessionStore::from_cwd(cwd).map_err(|e| {
SessionError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))
})?;
let store = crate::session_control::SessionStore::from_cwd(cwd)
.map_err(|e| SessionError::Io(std::io::Error::other(e.to_string())))?;
Ok(store.sessions_dir().to_path_buf())
}
@@ -1489,8 +1485,7 @@ mod workspace_sessions_dir_tests {
let result = workspace_sessions_dir(&tmp);
assert!(
result.is_ok(),
"workspace_sessions_dir should succeed for a valid CWD, got: {:?}",
result
"workspace_sessions_dir should succeed for a valid CWD, got: {result:?}"
);
let dir = result.unwrap();
// The returned path should be non-empty and end with a hash component

View File

@@ -74,6 +74,7 @@ impl SessionStore {
&self.workspace_root
}
#[must_use]
pub fn create_handle(&self, session_id: &str) -> SessionHandle {
let id = session_id.to_string();
let path = self

View File

@@ -575,28 +575,28 @@ fn push_event(
/// Write current worker state to `.claw/worker-state.json` under the worker's cwd.
/// This is the file-based observability surface: external observers (clawhip, orchestrators)
/// poll this file instead of requiring an HTTP route on the opencode binary.
#[derive(serde::Serialize)]
struct StateSnapshot<'a> {
worker_id: &'a str,
status: WorkerStatus,
is_ready: bool,
trust_gate_cleared: bool,
prompt_in_flight: bool,
last_event: Option<&'a WorkerEvent>,
updated_at: u64,
/// Seconds since last state transition. Clawhip uses this to detect
/// stalled workers without computing epoch deltas.
seconds_since_update: u64,
}
fn emit_state_file(worker: &Worker) {
let state_dir = std::path::Path::new(&worker.cwd).join(".claw");
if let Err(_) = std::fs::create_dir_all(&state_dir) {
if std::fs::create_dir_all(&state_dir).is_err() {
return;
}
let state_path = state_dir.join("worker-state.json");
let tmp_path = state_dir.join("worker-state.json.tmp");
#[derive(serde::Serialize)]
struct StateSnapshot<'a> {
worker_id: &'a str,
status: WorkerStatus,
is_ready: bool,
trust_gate_cleared: bool,
prompt_in_flight: bool,
last_event: Option<&'a WorkerEvent>,
updated_at: u64,
/// Seconds since last state transition. Clawhip uses this to detect
/// stalled workers without computing epoch deltas.
seconds_since_update: u64,
}
let now = now_secs();
let snapshot = StateSnapshot {
worker_id: &worker.worker_id,

View File

@@ -14,14 +14,13 @@ fn main() {
None
}
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
println!("cargo:rustc-env=GIT_SHA={}", git_sha);
println!("cargo:rustc-env=GIT_SHA={git_sha}");
// TARGET is always set by Cargo during build
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
println!("cargo:rustc-env=TARGET={}", target);
println!("cargo:rustc-env=TARGET={target}");
// Build date from SOURCE_DATE_EPOCH (reproducible builds) or current UTC date.
// Intentionally ignoring time component to keep output deterministic within a day.
@@ -48,8 +47,7 @@ fn main() {
None
}
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
});
println!("cargo:rustc-env=BUILD_DATE={build_date}");

View File

@@ -24,11 +24,10 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, UNIX_EPOCH};
use api::{
detect_provider_kind, oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient,
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
ToolResultContentBlock,
detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource,
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind,
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
};
use commands::{
@@ -43,15 +42,13 @@ use init::initialize_repo;
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
use runtime::{
check_base_commit, clear_oauth_credentials, format_stale_base_warning, format_usd,
generate_pkce_pair, generate_state, load_oauth_credentials, load_system_prompt,
parse_oauth_callback_request_target, pricing_for_model, resolve_expected_base,
resolve_sandbox_status, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent,
CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage,
ConversationRuntime, McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole,
ModelPricing, OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest,
PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode,
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status,
ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource,
ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager,
McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy,
ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage,
ToolError, ToolExecutor, UsageTracker,
};
use serde::Deserialize;
use serde_json::{json, Map, Value};
@@ -244,8 +241,6 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
cli.set_reasoning_effort(reasoning_effort);
cli.run_turn_with_output(&effective_prompt, output_format, compact)?;
}
CliAction::Login { output_format } => run_login(output_format)?,
CliAction::Logout { output_format } => run_logout(output_format)?,
CliAction::Doctor { output_format } => run_doctor(output_format)?,
CliAction::State { output_format } => run_worker_state(output_format)?,
CliAction::Init { output_format } => run_init(output_format)?,
@@ -332,12 +327,6 @@ enum CliAction {
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
},
Login {
output_format: CliOutputFormat,
},
Logout {
output_format: CliOutputFormat,
},
Doctor {
output_format: CliOutputFormat,
},
@@ -418,8 +407,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
&& matches!(
rest[0].as_str(),
"prompt"
| "login"
| "logout"
| "version"
| "state"
| "init"
@@ -667,8 +654,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
}
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
"login" => Ok(CliAction::Login { output_format }),
"logout" => Ok(CliAction::Logout { output_format }),
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
"init" => Ok(CliAction::Init { output_format }),
"export" => parse_export_args(&rest[1..], output_format),
"prompt" => {
@@ -765,8 +751,6 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
| "mcp"
| "skills"
| "system-prompt"
| "login"
| "logout"
| "init"
| "prompt"
| "export"
@@ -788,12 +772,19 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
Some(guidance)
}
fn removed_auth_surface_error(command_name: &str) -> String {
format!(
"`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead."
)
}
fn join_optional_args(args: &[String]) -> Option<String> {
let joined = args.join(" ");
let trimmed = joined.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
fn parse_direct_slash_cli_action(
rest: &[String],
model: String,
@@ -1168,7 +1159,7 @@ fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --session".to_string())?;
session_reference = value.clone();
session_reference.clone_from(value);
index += 2;
}
flag if flag.starts_with("--session=") => {
@@ -1529,79 +1520,65 @@ fn check_auth_health() -> DiagnosticCheck {
let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN")
.ok()
.is_some_and(|value| !value.trim().is_empty());
let env_details = format!(
"Environment api_key={} auth_token={}",
if api_key_present { "present" } else { "absent" },
if auth_token_present {
"present"
} else {
"absent"
}
);
match load_oauth_credentials() {
Ok(Some(token_set)) => {
let expired = oauth_token_is_expired(&api::OAuthTokenSet {
access_token: token_set.access_token.clone(),
refresh_token: token_set.refresh_token.clone(),
expires_at: token_set.expires_at,
scopes: token_set.scopes.clone(),
});
let mut details = vec![
format!(
"Environment api_key={} auth_token={}",
if api_key_present { "present" } else { "absent" },
if auth_token_present {
"present"
} else {
"absent"
}
),
format!(
"Saved OAuth expires_at={} refresh_token={} scopes={}",
token_set
.expires_at
.map_or_else(|| "<none>".to_string(), |value| value.to_string()),
if token_set.refresh_token.is_some() {
"present"
} else {
"absent"
},
if token_set.scopes.is_empty() {
"<none>".to_string()
} else {
token_set.scopes.join(",")
}
),
];
if expired {
details.push(
"Suggested action claw login to refresh local OAuth credentials".to_string(),
);
}
DiagnosticCheck::new(
"Auth",
if expired {
DiagnosticLevel::Warn
Ok(Some(token_set)) => DiagnosticCheck::new(
"Auth",
if api_key_present || auth_token_present {
DiagnosticLevel::Ok
} else {
DiagnosticLevel::Warn
},
if api_key_present || auth_token_present {
"supported auth env vars are configured; legacy saved OAuth is ignored"
} else {
"legacy saved OAuth credentials are present but unsupported"
},
)
.with_details(vec![
env_details,
format!(
"Legacy OAuth expires_at={} refresh_token={} scopes={}",
token_set
.expires_at
.map_or_else(|| "<none>".to_string(), |value| value.to_string()),
if token_set.refresh_token.is_some() {
"present"
} else {
DiagnosticLevel::Ok
"absent"
},
if expired {
"saved OAuth credentials are present but expired"
} else if api_key_present || auth_token_present {
"environment and saved credentials are available"
if token_set.scopes.is_empty() {
"<none>".to_string()
} else {
"saved OAuth credentials are available"
},
)
.with_details(details)
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("saved_oauth_present".to_string(), json!(true)),
("saved_oauth_expired".to_string(), json!(expired)),
(
"saved_oauth_expires_at".to_string(),
json!(token_set.expires_at),
),
(
"refresh_token_present".to_string(),
json!(token_set.refresh_token.is_some()),
),
("scopes".to_string(), json!(token_set.scopes)),
]))
}
token_set.scopes.join(",")
}
),
"Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed"
.to_string(),
])
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("legacy_saved_oauth_present".to_string(), json!(true)),
(
"legacy_saved_oauth_expires_at".to_string(),
json!(token_set.expires_at),
),
(
"legacy_refresh_token_present".to_string(),
json!(token_set.refresh_token.is_some()),
),
("legacy_scopes".to_string(), json!(token_set.scopes)),
])),
Ok(None) => DiagnosticCheck::new(
"Auth",
if api_key_present || auth_token_present {
@@ -1610,43 +1587,33 @@ fn check_auth_health() -> DiagnosticCheck {
DiagnosticLevel::Warn
},
if api_key_present || auth_token_present {
"environment credentials are configured"
"supported auth env vars are configured"
} else {
"no API key or saved OAuth credentials were found"
"no supported auth env vars were found"
},
)
.with_details(vec![format!(
"Environment api_key={} auth_token={}",
if api_key_present { "present" } else { "absent" },
if auth_token_present {
"present"
} else {
"absent"
}
)])
.with_details(vec![env_details])
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("saved_oauth_present".to_string(), json!(false)),
("saved_oauth_expired".to_string(), json!(false)),
("saved_oauth_expires_at".to_string(), Value::Null),
("refresh_token_present".to_string(), json!(false)),
("scopes".to_string(), json!(Vec::<String>::new())),
("legacy_saved_oauth_present".to_string(), json!(false)),
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
("legacy_refresh_token_present".to_string(), json!(false)),
("legacy_scopes".to_string(), json!(Vec::<String>::new())),
])),
Err(error) => DiagnosticCheck::new(
"Auth",
DiagnosticLevel::Fail,
format!("failed to inspect saved credentials: {error}"),
format!("failed to inspect legacy saved credentials: {error}"),
)
.with_data(Map::from_iter([
("api_key_present".to_string(), json!(api_key_present)),
("auth_token_present".to_string(), json!(auth_token_present)),
("saved_oauth_present".to_string(), Value::Null),
("saved_oauth_expired".to_string(), Value::Null),
("saved_oauth_expires_at".to_string(), Value::Null),
("refresh_token_present".to_string(), Value::Null),
("scopes".to_string(), Value::Null),
("saved_oauth_error".to_string(), json!(error.to_string())),
("legacy_saved_oauth_present".to_string(), Value::Null),
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
("legacy_refresh_token_present".to_string(), Value::Null),
("legacy_scopes".to_string(), Value::Null),
("legacy_saved_oauth_error".to_string(), json!(error.to_string())),
])),
}
}
@@ -1993,182 +1960,6 @@ fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn st
Ok(())
}
fn default_oauth_config() -> OAuthConfig {
OAuthConfig {
client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"),
authorize_url: String::from("https://platform.claude.com/oauth/authorize"),
token_url: String::from("https://platform.claude.com/v1/oauth/token"),
callback_port: None,
manual_redirect_url: None,
scopes: vec![
String::from("user:profile"),
String::from("user:inference"),
String::from("user:sessions:claude_code"),
],
}
}
fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let config = ConfigLoader::default_for(&cwd).load()?;
let default_oauth = default_oauth_config();
let oauth = config.oauth().unwrap_or(&default_oauth);
let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
let redirect_uri = runtime::loopback_redirect_uri(callback_port);
let pkce = generate_pkce_pair()?;
let state = generate_state()?;
let authorize_url =
OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce)
.build_url();
if output_format == CliOutputFormat::Text {
println!("Starting Claude OAuth login...");
println!("Listening for callback on {redirect_uri}");
}
if let Err(error) = open_browser(&authorize_url) {
emit_login_browser_open_failure(
output_format,
&authorize_url,
&error,
&mut io::stdout(),
&mut io::stderr(),
)?;
}
let callback = wait_for_oauth_callback(callback_port)?;
if let Some(error) = callback.error {
let description = callback
.error_description
.unwrap_or_else(|| "authorization failed".to_string());
return Err(io::Error::other(format!("{error}: {description}")).into());
}
let code = callback.code.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "callback did not include code")
})?;
let returned_state = callback.state.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "callback did not include state")
})?;
if returned_state != state {
return Err(io::Error::new(io::ErrorKind::InvalidData, "oauth state mismatch").into());
}
let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url());
let exchange_request = OAuthTokenExchangeRequest::from_config(
oauth,
code,
state,
pkce.verifier,
redirect_uri.clone(),
);
let runtime = tokio::runtime::Runtime::new()?;
let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?;
save_oauth_credentials(&runtime::OAuthTokenSet {
access_token: token_set.access_token,
refresh_token: token_set.refresh_token,
expires_at: token_set.expires_at,
scopes: token_set.scopes,
})?;
match output_format {
CliOutputFormat::Text => println!("Claude OAuth login complete."),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "login",
"callback_port": callback_port,
"redirect_uri": redirect_uri,
"message": "Claude OAuth login complete.",
}))?
),
}
Ok(())
}
fn emit_login_browser_open_failure(
output_format: CliOutputFormat,
authorize_url: &str,
error: &io::Error,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> io::Result<()> {
writeln!(
stderr,
"warning: failed to open browser automatically: {error}"
)?;
match output_format {
CliOutputFormat::Text => writeln!(stdout, "Open this URL manually:\n{authorize_url}"),
CliOutputFormat::Json => writeln!(stderr, "Open this URL manually:\n{authorize_url}"),
}
}
fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
clear_oauth_credentials()?;
match output_format {
CliOutputFormat::Text => println!("Claude OAuth credentials cleared."),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "logout",
"message": "Claude OAuth credentials cleared.",
}))?
),
}
Ok(())
}
fn open_browser(url: &str) -> io::Result<()> {
let commands = if cfg!(target_os = "macos") {
vec![("open", vec![url])]
} else if cfg!(target_os = "windows") {
vec![("cmd", vec!["/C", "start", "", url])]
} else {
vec![("xdg-open", vec![url])]
};
for (program, args) in commands {
match Command::new(program).args(args).spawn() {
Ok(_) => return Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
"no supported browser opener command found",
))
}
fn wait_for_oauth_callback(
port: u16,
) -> Result<runtime::OAuthCallbackParams, Box<dyn std::error::Error>> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let (mut stream, _) = listener.accept()?;
let mut buffer = [0_u8; 4096];
let bytes_read = stream.read(&mut buffer)?;
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
let request_line = request.lines().next().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "missing callback request line")
})?;
let target = request_line.split_whitespace().nth(1).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"missing callback request target",
)
})?;
let callback = parse_oauth_callback_request_target(target)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
let body = if callback.error.is_some() {
"Claude OAuth login failed. You can close this window."
} else {
"Claude OAuth login succeeded. You can close this window."
};
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes())?;
Ok(callback)
}
fn print_system_prompt(
cwd: PathBuf,
date: String,
@@ -2214,6 +2005,7 @@ fn version_json_value() -> serde_json::Value {
})
}
#[allow(clippy::too_many_lines)]
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
let session_reference = session_path.display().to_string();
let (handle, session) = match load_session_reference(&session_reference) {
@@ -3036,8 +2828,7 @@ fn detect_broad_cwd() -> Option<PathBuf> {
};
let is_home = env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(|h| PathBuf::from(h) == cwd)
.unwrap_or(false);
.is_some_and(|h| Path::new(&h) == cwd);
let is_root = cwd.parent().is_none();
if is_home || is_root {
Some(cwd)
@@ -3109,9 +2900,8 @@ fn enforce_broad_cwd_policy(
}
fn run_stale_base_preflight(flag_value: Option<&str>) {
let cwd = match env::current_dir() {
Ok(cwd) => cwd,
Err(_) => return,
let Ok(cwd) = env::current_dir() else {
return;
};
let source = resolve_expected_base(flag_value, &cwd);
let state = check_base_commit(&cwd, source.as_ref());
@@ -3120,6 +2910,7 @@ fn run_stale_base_preflight(flag_value: Option<&str>) {
}
}
#[allow(clippy::needless_pass_by_value)]
fn run_repl(
model: String,
allowed_tools: Option<AllowedToolSet>,
@@ -4474,6 +4265,7 @@ impl LiveCli {
Ok(())
}
#[allow(clippy::too_many_lines)]
fn handle_session_command(
&mut self,
action: Option<&str>,
@@ -4765,8 +4557,7 @@ fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let handle = current_session_store()?
.create_handle(session_id);
let handle = current_session_store()?.create_handle(session_id);
Ok(SessionHandle {
id: handle.id,
path: handle.path,
@@ -5366,14 +5157,14 @@ fn render_config_json(
ConfigSource::Project => "project",
ConfigSource::Local => "local",
};
let loaded = runtime_config
let is_loaded = runtime_config
.loaded_entries()
.iter()
.any(|le| le.path == e.path);
serde_json::json!({
"path": e.path.display().to_string(),
"source": source,
"loaded": loaded,
"loaded": is_loaded,
})
})
.collect();
@@ -5798,6 +5589,11 @@ fn format_history_timestamp(timestamp_ms: u64) -> String {
// Computes civil (Gregorian) year/month/day from days since the Unix epoch
// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm.
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::cast_possible_truncation
)]
fn civil_from_days(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 {
@@ -6852,29 +6648,11 @@ impl AnthropicRuntimeClient {
}
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(resolve_cli_auth_source_for_cwd(&cwd, default_oauth_config)?)
Ok(resolve_cli_auth_source_for_cwd()?)
}
fn resolve_cli_auth_source_for_cwd<F>(
cwd: &Path,
default_oauth: F,
) -> Result<AuthSource, api::ApiError>
where
F: FnOnce() -> OAuthConfig,
{
resolve_startup_auth_source(|| {
Ok(Some(
load_runtime_oauth_config_for(cwd)?.unwrap_or_else(default_oauth),
))
})
}
fn load_runtime_oauth_config_for(cwd: &Path) -> Result<Option<OAuthConfig>, api::ApiError> {
let config = ConfigLoader::default_for(cwd).load().map_err(|error| {
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
})?;
Ok(config.oauth().cloned())
fn resolve_cli_auth_source_for_cwd() -> Result<AuthSource, api::ApiError> {
resolve_startup_auth_source(|| Ok(None))
}
impl ApiClient for AnthropicRuntimeClient {
@@ -6917,7 +6695,6 @@ impl ApiClient for AnthropicRuntimeClient {
{
// Stalled after tool completion — nudge the model by
// re-sending the same request.
continue;
}
Err(error) => return Err(error),
}
@@ -8251,8 +8028,6 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, " claw mcp")?;
writeln!(out, " claw skills")?;
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
writeln!(out, " claw login")?;
writeln!(out, " claw logout")?;
writeln!(out, " claw init")?;
writeln!(
out,
@@ -8336,7 +8111,6 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> {
writeln!(out, " claw mcp show my-server")?;
writeln!(out, " claw /skills")?;
writeln!(out, " claw doctor")?;
writeln!(out, " claw login")?;
writeln!(out, " claw init")?;
writeln!(out, " claw export")?;
writeln!(out, " claw export conversation.md")?;
@@ -8612,36 +8386,6 @@ mod tests {
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn sample_oauth_config(token_url: String) -> OAuthConfig {
OAuthConfig {
client_id: "runtime-client".to_string(),
authorize_url: "https://console.test/oauth/authorize".to_string(),
token_url,
callback_port: Some(4545),
manual_redirect_url: Some("https://console.test/oauth/callback".to_string()),
scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()],
}
}
fn spawn_token_server(response_body: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
let address = listener.local_addr().expect("local addr");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept connection");
let mut buffer = [0_u8; 4096];
let _ = stream.read(&mut buffer).expect("read request");
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
response_body.len(),
response_body
);
stream
.write_all(response.as_bytes())
.expect("write response");
});
format!("http://{address}/oauth/token")
}
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
let _guard = cwd_lock()
.lock()
@@ -8784,25 +8528,9 @@ mod tests {
}
#[test]
fn load_runtime_oauth_config_for_returns_none_without_project_config() {
fn resolve_cli_auth_source_ignores_saved_oauth_credentials() {
let _guard = env_lock();
let root = temp_dir();
std::fs::create_dir_all(&root).expect("workspace should exist");
let oauth = super::load_runtime_oauth_config_for(&root)
.expect("loading config should succeed when files are absent");
std::fs::remove_dir_all(root).expect("temp workspace should clean up");
assert_eq!(oauth, None);
}
#[test]
fn resolve_cli_auth_source_uses_default_oauth_when_runtime_config_is_missing() {
let _guard = env_lock();
let workspace = temp_dir();
let config_home = temp_dir();
std::fs::create_dir_all(&workspace).expect("workspace should exist");
std::fs::create_dir_all(&config_home).expect("config home should exist");
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
@@ -8820,17 +8548,8 @@ mod tests {
})
.expect("save expired oauth credentials");
let token_url = spawn_token_server(
r#"{"access_token":"refreshed-access-token","refresh_token":"refreshed-refresh-token","expires_at":4102444800,"scopes":["org:create_api_key","user:profile"]}"#,
);
let auth =
super::resolve_cli_auth_source_for_cwd(&workspace, || sample_oauth_config(token_url))
.expect("expired saved oauth should refresh via default config");
let stored = load_oauth_credentials()
.expect("load stored credentials")
.expect("stored credentials should exist");
let error = super::resolve_cli_auth_source_for_cwd()
.expect_err("saved oauth should be ignored without env auth");
match original_config_home {
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
@@ -8844,15 +8563,9 @@ mod tests {
Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
}
std::fs::remove_dir_all(workspace).expect("temp workspace should clean up");
std::fs::remove_dir_all(config_home).expect("temp config home should clean up");
assert_eq!(auth.bearer_token(), Some("refreshed-access-token"));
assert_eq!(stored.access_token, "refreshed-access-token");
assert_eq!(
stored.refresh_token.as_deref(),
Some("refreshed-refresh-token")
);
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
}
#[test]
@@ -9229,19 +8942,11 @@ mod tests {
}
#[test]
fn parses_login_and_logout_subcommands() {
assert_eq!(
parse_args(&["login".to_string()]).expect("login should parse"),
CliAction::Login {
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["logout".to_string()]).expect("logout should parse"),
CliAction::Logout {
output_format: CliOutputFormat::Text,
}
);
fn removed_login_and_logout_subcommands_error_helpfully() {
let login = parse_args(&["login".to_string()]).expect_err("login should be removed");
assert!(login.contains("ANTHROPIC_API_KEY"));
let logout = parse_args(&["logout".to_string()]).expect_err("logout should be removed");
assert!(logout.contains("ANTHROPIC_AUTH_TOKEN"));
assert_eq!(
parse_args(&["doctor".to_string()]).expect("doctor should parse"),
CliAction::Doctor {
@@ -10218,6 +9923,8 @@ mod tests {
assert!(help.contains("claw mcp"));
assert!(help.contains("claw skills"));
assert!(help.contains("claw /skills"));
assert!(!help.contains("claw login"));
assert!(!help.contains("claw logout"));
}
#[test]
@@ -11308,31 +11015,6 @@ UU conflicted.rs",
assert!(!rendered.contains("step 1"));
}
#[test]
fn login_browser_failure_keeps_json_stdout_clean() {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let error = std::io::Error::new(
std::io::ErrorKind::NotFound,
"no supported browser opener command found",
);
super::emit_login_browser_open_failure(
CliOutputFormat::Json,
"https://example.test/oauth/authorize",
&error,
&mut stdout,
&mut stderr,
)
.expect("browser warning should render");
assert!(stdout.is_empty());
let stderr = String::from_utf8(stderr).expect("utf8");
assert!(stderr.contains("failed to open browser automatically"));
assert!(stderr.contains("Open this URL manually:"));
assert!(stderr.contains("https://example.test/oauth/authorize"));
}
#[test]
fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() {
let config_home = temp_dir();
@@ -11620,8 +11302,7 @@ UU conflicted.rs",
]);
assert!(
result.is_ok(),
"--reasoning-effort {value} should be accepted, got: {:?}",
result
"--reasoning-effort {value} should be accepted, got: {result:?}"
);
if let Ok(CliAction::Prompt {
reasoning_effort, ..

View File

@@ -639,10 +639,16 @@ fn apply_code_block_background(line: &str) -> String {
/// fence markers of equal or greater length are wrapped with a longer fence.
///
/// LLMs frequently emit triple-backtick code blocks that contain triple-backtick
/// examples. CommonMark (and pulldown-cmark) treats the inner marker as the
/// examples. `CommonMark` (and pulldown-cmark) treats the inner marker as the
/// closing fence, breaking the render. This function detects the situation and
/// upgrades the outer fence to use enough backticks (or tildes) that the inner
/// markers become ordinary content.
#[allow(
clippy::too_many_lines,
clippy::items_after_statements,
clippy::manual_repeat_n,
clippy::manual_str_repeat
)]
fn normalize_nested_fences(markdown: &str) -> String {
// A fence line is either "labeled" (has an info string ⇒ always an opener)
// or "bare" (no info string ⇒ could be opener or closer).

View File

@@ -3771,10 +3771,7 @@ impl ProviderRuntimeClient {
allowed_tools: BTreeSet<String>,
fallback_config: &ProviderFallbackConfig,
) -> Result<Self, String> {
let primary_model = fallback_config
.primary()
.map(str::to_string)
.unwrap_or(model);
let primary_model = fallback_config.primary().map_or(model, str::to_string);
let primary = build_provider_entry(&primary_model)?;
let mut chain = vec![primary];
for fallback_model in fallback_config.fallbacks() {
@@ -3852,17 +3849,15 @@ impl ApiClient for ProviderRuntimeClient {
entry.model
);
last_error = Some(error);
continue;
}
Err(error) => return Err(RuntimeError::new(error.to_string())),
}
}
Err(RuntimeError::new(
last_error
.map(|error| error.to_string())
.unwrap_or_else(|| String::from("provider chain exhausted with no attempts")),
))
Err(RuntimeError::new(last_error.map_or_else(
|| String::from("provider chain exhausted with no attempts"),
|error| error.to_string(),
)))
}
}