mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-13 03:24:49 +08:00
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:
@@ -438,7 +438,7 @@ Model name prefix now wins unconditionally over env-var presence. Regression tes
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -232,10 +232,7 @@ mod tests {
|
|||||||
openai_client.base_url()
|
openai_client.base_url()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
other => panic!(
|
other => panic!("Expected ProviderClient::OpenAi for qwen-plus, got: {other:?}"),
|
||||||
"Expected ProviderClient::OpenAi for qwen-plus, got: {:?}",
|
|
||||||
other
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub enum ApiError {
|
|||||||
env_vars: &'static [&'static str],
|
env_vars: &'static [&'static str],
|
||||||
/// Optional, runtime-computed hint appended to the error Display
|
/// Optional, runtime-computed hint appended to the error Display
|
||||||
/// output. Populated when the provider resolver can infer what the
|
/// 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).
|
/// was selected because no Anthropic credentials exist).
|
||||||
hint: Option<String>,
|
hint: Option<String>,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, A
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(reqwest::NoProxy::from_string);
|
.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)),
|
Some(unified) => (Some(unified), Some(unified)),
|
||||||
None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()),
|
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)?;
|
let mut proxy = reqwest::Proxy::https(url)?;
|
||||||
if let Some(filter) = no_proxy.clone() {
|
if let Some(filter) = no_proxy.clone() {
|
||||||
proxy = proxy.no_proxy(Some(filter));
|
proxy = proxy.no_proxy(Some(filter));
|
||||||
|
|||||||
@@ -502,9 +502,8 @@ impl AnthropicClient {
|
|||||||
// Best-effort refinement using the Anthropic count_tokens endpoint.
|
// Best-effort refinement using the Anthropic count_tokens endpoint.
|
||||||
// On any failure (network, parse, auth), fall back to the local
|
// On any failure (network, parse, auth), fall back to the local
|
||||||
// byte-estimate result which already passed above.
|
// byte-estimate result which already passed above.
|
||||||
let counted_input_tokens = match self.count_tokens(request).await {
|
let Ok(counted_input_tokens) = self.count_tokens(request).await else {
|
||||||
Ok(count) => count,
|
return Ok(());
|
||||||
Err(_) => return Ok(()),
|
|
||||||
};
|
};
|
||||||
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
|
let estimated_total_tokens = counted_input_tokens.saturating_add(request.max_tokens);
|
||||||
if estimated_total_tokens > limit.context_window_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")? {
|
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
return Ok(Self::BearerToken(bearer_token));
|
return Ok(Self::BearerToken(bearer_token));
|
||||||
}
|
}
|
||||||
match load_saved_oauth_token() {
|
Err(anthropic_missing_credentials())
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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> {
|
pub fn has_auth_from_env_or_saved() -> Result<bool, ApiError> {
|
||||||
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
|
Ok(read_env_non_empty("ANTHROPIC_API_KEY")?.is_some()
|
||||||
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some()
|
|| read_env_non_empty("ANTHROPIC_AUTH_TOKEN")?.is_some())
|
||||||
|| load_saved_oauth_token()?.is_some())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
|
pub fn resolve_startup_auth_source<F>(load_oauth_config: F) -> Result<AuthSource, ApiError>
|
||||||
where
|
where
|
||||||
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
|
F: FnOnce() -> Result<Option<OAuthConfig>, ApiError>,
|
||||||
{
|
{
|
||||||
|
let _ = load_oauth_config;
|
||||||
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
|
if let Some(api_key) = read_env_non_empty("ANTHROPIC_API_KEY")? {
|
||||||
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
return match read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
|
Some(bearer_token) => Ok(AuthSource::ApiKeyAndBearer {
|
||||||
@@ -685,25 +670,7 @@ where
|
|||||||
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
if let Some(bearer_token) = read_env_non_empty("ANTHROPIC_AUTH_TOKEN")? {
|
||||||
return Ok(AuthSource::BearerToken(bearer_token));
|
return Ok(AuthSource::BearerToken(bearer_token));
|
||||||
}
|
}
|
||||||
|
Err(anthropic_missing_credentials())
|
||||||
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,
|
|
||||||
)?))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_saved_oauth_token_set(
|
fn resolve_saved_oauth_token_set(
|
||||||
@@ -1016,7 +983,7 @@ fn strip_unsupported_beta_body_fields(body: &mut Value) {
|
|||||||
object.remove("presence_penalty");
|
object.remove("presence_penalty");
|
||||||
// Anthropic uses "stop_sequences" not "stop". Convert if present.
|
// Anthropic uses "stop_sequences" not "stop". Convert if present.
|
||||||
if let Some(stop_val) = object.remove("stop") {
|
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);
|
object.insert("stop_sequences".to_string(), stop_val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1180,7 +1147,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 _guard = env_lock();
|
||||||
let config_home = temp_config_home();
|
let config_home = temp_config_home();
|
||||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
@@ -1194,8 +1161,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("save oauth credentials");
|
.expect("save oauth credentials");
|
||||||
|
|
||||||
let auth = AuthSource::from_env_or_saved().expect("saved auth");
|
let error = AuthSource::from_env_or_saved().expect_err("saved oauth should be ignored");
|
||||||
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
|
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
|
||||||
|
|
||||||
clear_oauth_credentials().expect("clear credentials");
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
@@ -1251,7 +1218,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 _guard = env_lock();
|
||||||
let config_home = temp_config_home();
|
let config_home = temp_config_home();
|
||||||
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
@@ -1265,41 +1232,9 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("save oauth credentials");
|
.expect("save oauth credentials");
|
||||||
|
|
||||||
let auth = resolve_startup_auth_source(|| panic!("config should not be loaded"))
|
let error = resolve_startup_auth_source(|| panic!("config should not be loaded"))
|
||||||
.expect("startup auth");
|
.expect_err("saved oauth should be ignored");
|
||||||
assert_eq!(auth.bearer_token(), Some("saved-access-token"));
|
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
|
||||||
|
|
||||||
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"));
|
|
||||||
|
|
||||||
clear_oauth_credentials().expect("clear credentials");
|
clear_oauth_credentials().expect("clear credentials");
|
||||||
std::env::remove_var("CLAW_CONFIG_HOME");
|
std::env::remove_var("CLAW_CONFIG_HOME");
|
||||||
|
|||||||
@@ -508,9 +508,10 @@ mod tests {
|
|||||||
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
|
// ANTHROPIC_API_KEY was set because metadata_for_model returned None
|
||||||
// and detect_provider_kind fell through to auth-sniffer order.
|
// and detect_provider_kind fell through to auth-sniffer order.
|
||||||
// The model prefix must win over env-var presence.
|
// The model prefix must win over env-var presence.
|
||||||
let kind = super::metadata_for_model("openai/gpt-4.1-mini")
|
let kind = super::metadata_for_model("openai/gpt-4.1-mini").map_or_else(
|
||||||
.map(|m| m.provider)
|
|| detect_provider_kind("openai/gpt-4.1-mini"),
|
||||||
.unwrap_or_else(|| detect_provider_kind("openai/gpt-4.1-mini"));
|
|m| m.provider,
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
kind,
|
kind,
|
||||||
ProviderKind::OpenAi,
|
ProviderKind::OpenAi,
|
||||||
@@ -519,8 +520,7 @@ mod tests {
|
|||||||
|
|
||||||
// Also cover bare gpt- prefix
|
// Also cover bare gpt- prefix
|
||||||
let kind2 = super::metadata_for_model("gpt-4o")
|
let kind2 = super::metadata_for_model("gpt-4o")
|
||||||
.map(|m| m.provider)
|
.map_or_else(|| detect_provider_kind("gpt-4o"), |m| m.provider);
|
||||||
.unwrap_or_else(|| detect_provider_kind("gpt-4o"));
|
|
||||||
assert_eq!(kind2, ProviderKind::OpenAi);
|
assert_eq!(kind2, ProviderKind::OpenAi);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
|
||||||
/// Requested via Discord #clawcode-get-help: native Alibaba API for
|
/// 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]
|
#[must_use]
|
||||||
pub const fn dashscope() -> Self {
|
pub const fn dashscope() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -170,7 +170,7 @@ impl OpenAiCompatClient {
|
|||||||
.to_string();
|
.to_string();
|
||||||
let code = err_obj
|
let code = err_obj
|
||||||
.get("code")
|
.get("code")
|
||||||
.and_then(|c| c.as_u64())
|
.and_then(serde_json::Value::as_u64)
|
||||||
.map(|c| c as u16);
|
.map(|c| c as u16);
|
||||||
return Err(ApiError::Api {
|
return Err(ApiError::Api {
|
||||||
status: reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
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,
|
/// 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.
|
/// reasoning/chain-of-thought models with fixed sampling.
|
||||||
fn is_reasoning_model(model: &str) -> bool {
|
fn is_reasoning_model(model: &str) -> bool {
|
||||||
let lowered = model.to_ascii_lowercase();
|
let lowered = model.to_ascii_lowercase();
|
||||||
@@ -974,12 +974,11 @@ fn sanitize_tool_message_pairing(messages: Vec<Value>) -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
let paired = preceding
|
let paired = preceding
|
||||||
.and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
|
.and_then(|m| m.get("tool_calls").and_then(|tc| tc.as_array()))
|
||||||
.map(|tool_calls| {
|
.is_some_and(|tool_calls| {
|
||||||
tool_calls
|
tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
|
.any(|tc| tc.get("id").and_then(|v| v.as_str()) == Some(tool_call_id))
|
||||||
})
|
});
|
||||||
.unwrap_or(false);
|
|
||||||
if !paired {
|
if !paired {
|
||||||
drop_indices.insert(i);
|
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
|
/// Recursively ensure every object-type node in a JSON Schema has
|
||||||
/// `"properties"` (at least `{}`) and `"additionalProperties": false`.
|
/// `"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
|
/// objects that omit these fields; `/chat/completions` is lenient but also
|
||||||
/// accepts them, so we normalise unconditionally.
|
/// accepts them, so we normalise unconditionally.
|
||||||
fn normalize_object_schema(schema: &mut Value) {
|
fn normalize_object_schema(schema: &mut Value) {
|
||||||
@@ -1173,7 +1172,7 @@ fn parse_sse_frame(
|
|||||||
.to_string();
|
.to_string();
|
||||||
let code = err_obj
|
let code = err_obj
|
||||||
.get("code")
|
.get("code")
|
||||||
.and_then(|c| c.as_u64())
|
.and_then(serde_json::Value::as_u64)
|
||||||
.map(|c| c as u16);
|
.map(|c| c as u16);
|
||||||
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
let status = reqwest::StatusCode::from_u16(code.unwrap_or(400))
|
||||||
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
|
.unwrap_or(reqwest::StatusCode::BAD_REQUEST);
|
||||||
@@ -1185,7 +1184,7 @@ fn parse_sse_frame(
|
|||||||
.map(str::to_owned),
|
.map(str::to_owned),
|
||||||
message: Some(msg),
|
message: Some(msg),
|
||||||
request_id: None,
|
request_id: None,
|
||||||
body: payload.to_string(),
|
body: payload.clone(),
|
||||||
retryable: false,
|
retryable: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1642,6 +1641,16 @@ mod tests {
|
|||||||
/// Before the fix this produced: `invalid type: null, expected a sequence`.
|
/// Before the fix this produced: `invalid type: null, expected a sequence`.
|
||||||
#[test]
|
#[test]
|
||||||
fn delta_with_null_tool_calls_deserializes_as_empty_vec() {
|
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)
|
// Simulate the exact shape observed in the wild (gaebal-gajae repro 2026-04-09)
|
||||||
let json = r#"{
|
let json = r#"{
|
||||||
"content": "",
|
"content": "",
|
||||||
@@ -1650,15 +1659,6 @@ mod tests {
|
|||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"tool_calls": null
|
"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)
|
let delta: Delta = serde_json::from_str(json)
|
||||||
.expect("delta with tool_calls:null must deserialize without error");
|
.expect("delta with tool_calls:null must deserialize without error");
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1670,7 +1670,7 @@ mod tests {
|
|||||||
/// Regression: when building a multi-turn request where a prior assistant
|
/// Regression: when building a multi-turn request where a prior assistant
|
||||||
/// turn has no tool calls, the serialized assistant message must NOT include
|
/// turn has no tool calls, the serialized assistant message must NOT include
|
||||||
/// `tool_calls: []`. Some providers reject requests that carry an empty
|
/// `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]
|
#[test]
|
||||||
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
|
fn assistant_message_without_tool_calls_omits_tool_calls_field() {
|
||||||
use crate::types::{InputContentBlock, InputMessage};
|
use crate::types::{InputContentBlock, InputMessage};
|
||||||
@@ -1695,13 +1695,12 @@ mod tests {
|
|||||||
.expect("assistant message must be present");
|
.expect("assistant message must be present");
|
||||||
assert!(
|
assert!(
|
||||||
assistant_msg.get("tool_calls").is_none(),
|
assistant_msg.get("tool_calls").is_none(),
|
||||||
"assistant message without tool calls must omit tool_calls field: {:?}",
|
"assistant message without tool calls must omit tool_calls field: {assistant_msg:?}"
|
||||||
assistant_msg
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: assistant messages WITH tool calls must still include
|
/// 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]
|
#[test]
|
||||||
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
|
fn assistant_message_with_tool_calls_includes_tool_calls_field() {
|
||||||
use crate::types::{InputContentBlock, InputMessage};
|
use crate::types::{InputContentBlock, InputMessage};
|
||||||
@@ -1733,7 +1732,7 @@ mod tests {
|
|||||||
assert_eq!(tool_calls.as_array().unwrap().len(), 1);
|
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
|
/// dropped by the request-builder sanitizer. Regression for the second
|
||||||
/// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10).
|
/// layer of the tool-pairing invariant fix (gaebal-gajae 2026-04-10).
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -257,20 +257,6 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||||||
argument_hint: None,
|
argument_hint: None,
|
||||||
resume_supported: true,
|
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 {
|
SlashCommandSpec {
|
||||||
name: "plan",
|
name: "plan",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
@@ -1291,7 +1277,6 @@ impl SlashCommand {
|
|||||||
Self::Tag { .. } => "/tag",
|
Self::Tag { .. } => "/tag",
|
||||||
Self::OutputStyle { .. } => "/output-style",
|
Self::OutputStyle { .. } => "/output-style",
|
||||||
Self::AddDir { .. } => "/add-dir",
|
Self::AddDir { .. } => "/add-dir",
|
||||||
Self::Unknown(_) => "/unknown",
|
|
||||||
Self::Sandbox => "/sandbox",
|
Self::Sandbox => "/sandbox",
|
||||||
Self::Mcp { .. } => "/mcp",
|
Self::Mcp { .. } => "/mcp",
|
||||||
Self::Export { .. } => "/export",
|
Self::Export { .. } => "/export",
|
||||||
@@ -1402,13 +1387,12 @@ pub fn validate_slash_command_input(
|
|||||||
validate_no_args(command, &args)?;
|
validate_no_args(command, &args)?;
|
||||||
SlashCommand::Doctor
|
SlashCommand::Doctor
|
||||||
}
|
}
|
||||||
"login" => {
|
"login" | "logout" => {
|
||||||
validate_no_args(command, &args)?;
|
return Err(command_error(
|
||||||
SlashCommand::Login
|
"This auth flow was removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.",
|
||||||
}
|
command,
|
||||||
"logout" => {
|
"",
|
||||||
validate_no_args(command, &args)?;
|
));
|
||||||
SlashCommand::Logout
|
|
||||||
}
|
}
|
||||||
"vim" => {
|
"vim" => {
|
||||||
validate_no_args(command, &args)?;
|
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 {
|
fn slash_command_category(name: &str) -> &'static str {
|
||||||
match name {
|
match name {
|
||||||
"help" | "status" | "cost" | "resume" | "session" | "version" | "login" | "logout"
|
"help" | "status" | "cost" | "resume" | "session" | "version" | "usage" | "stats"
|
||||||
| "usage" | "stats" | "rename" | "clear" | "compact" | "history" | "tokens" | "cache"
|
| "rename" | "clear" | "compact" | "history" | "tokens" | "cache" | "exit" | "summary"
|
||||||
| "exit" | "summary" | "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind"
|
| "tag" | "thinkback" | "copy" | "share" | "feedback" | "rewind" | "pin" | "unpin"
|
||||||
| "pin" | "unpin" | "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry"
|
| "bookmarks" | "context" | "files" | "focus" | "unfocus" | "retry" | "stop" | "undo" => {
|
||||||
| "stop" | "undo" => "Session",
|
"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",
|
|
||||||
"model" | "permissions" | "config" | "memory" | "theme" | "vim" | "voice" | "color"
|
"model" | "permissions" | "config" | "memory" | "theme" | "vim" | "voice" | "color"
|
||||||
| "effort" | "fast" | "brief" | "output-style" | "keybindings" | "privacy-settings"
|
| "effort" | "fast" | "brief" | "output-style" | "keybindings" | "privacy-settings"
|
||||||
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
|
| "stickers" | "language" | "profile" | "max-tokens" | "temperature" | "system-prompt"
|
||||||
@@ -2477,7 +2453,8 @@ pub fn resolve_skill_invocation(
|
|||||||
.map(|s| s.name.clone())
|
.map(|s| s.name.clone())
|
||||||
.collect();
|
.collect();
|
||||||
if !names.is_empty() {
|
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]]");
|
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
|
// Show warnings for broken plugins
|
||||||
if !failures.is_empty() {
|
if !failures.is_empty() {
|
||||||
lines.push("".to_string());
|
lines.push(String::new());
|
||||||
lines.push("Warnings:".to_string());
|
lines.push("Warnings:".to_string());
|
||||||
for failure in failures {
|
for failure in failures {
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
@@ -4598,6 +4575,14 @@ mod tests {
|
|||||||
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
|
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]
|
#[test]
|
||||||
fn renders_help_from_shared_specs() {
|
fn renders_help_from_shared_specs() {
|
||||||
let help = render_slash_command_help();
|
let help = render_slash_command_help();
|
||||||
@@ -4639,7 +4624,9 @@ mod tests {
|
|||||||
assert!(help.contains("/agents [list|help]"));
|
assert!(help.contains("/agents [list|help]"));
|
||||||
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
|
assert!(help.contains("/skills [list|install <path>|help|<skill> [args]]"));
|
||||||
assert!(help.contains("aliases: /skill"));
|
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);
|
assert!(resume_supported_slash_commands().len() >= 39);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,8 +135,7 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
|
|||||||
let starts_with_tool_result = first_preserved
|
let starts_with_tool_result = first_preserved
|
||||||
.blocks
|
.blocks
|
||||||
.first()
|
.first()
|
||||||
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
|
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
|
||||||
.unwrap_or(false);
|
|
||||||
if !starts_with_tool_result {
|
if !starts_with_tool_result {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -741,7 +740,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Regression: compaction must not split an assistant(ToolUse) /
|
/// Regression: compaction must not split an assistant(ToolUse) /
|
||||||
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
|
/// 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).
|
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
|
||||||
#[test]
|
#[test]
|
||||||
fn compaction_does_not_split_tool_use_tool_result_pair() {
|
fn compaction_does_not_split_tool_use_tool_result_pair() {
|
||||||
@@ -795,8 +794,7 @@ mod tests {
|
|||||||
let curr_is_tool_result = messages[i]
|
let curr_is_tool_result = messages[i]
|
||||||
.blocks
|
.blocks
|
||||||
.first()
|
.first()
|
||||||
.map(|b| matches!(b, ContentBlock::ToolResult { .. }))
|
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
|
||||||
.unwrap_or(false);
|
|
||||||
if curr_is_tool_result {
|
if curr_is_tool_result {
|
||||||
let prev_has_tool_use = messages[i - 1]
|
let prev_has_tool_use = messages[i - 1]
|
||||||
.blocks
|
.blocks
|
||||||
|
|||||||
@@ -1467,12 +1467,8 @@ mod tests {
|
|||||||
/// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD.
|
/// Called by external consumers (e.g. clawhip) to enumerate sessions for a CWD.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn workspace_sessions_dir(cwd: &std::path::Path) -> Result<std::path::PathBuf, SessionError> {
|
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| {
|
let store = crate::session_control::SessionStore::from_cwd(cwd)
|
||||||
SessionError::Io(std::io::Error::new(
|
.map_err(|e| SessionError::Io(std::io::Error::other(e.to_string())))?;
|
||||||
std::io::ErrorKind::Other,
|
|
||||||
e.to_string(),
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
Ok(store.sessions_dir().to_path_buf())
|
Ok(store.sessions_dir().to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1489,8 +1485,7 @@ mod workspace_sessions_dir_tests {
|
|||||||
let result = workspace_sessions_dir(&tmp);
|
let result = workspace_sessions_dir(&tmp);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"workspace_sessions_dir should succeed for a valid CWD, got: {:?}",
|
"workspace_sessions_dir should succeed for a valid CWD, got: {result:?}"
|
||||||
result
|
|
||||||
);
|
);
|
||||||
let dir = result.unwrap();
|
let dir = result.unwrap();
|
||||||
// The returned path should be non-empty and end with a hash component
|
// The returned path should be non-empty and end with a hash component
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ impl SessionStore {
|
|||||||
&self.workspace_root
|
&self.workspace_root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn create_handle(&self, session_id: &str) -> SessionHandle {
|
pub fn create_handle(&self, session_id: &str) -> SessionHandle {
|
||||||
let id = session_id.to_string();
|
let id = session_id.to_string();
|
||||||
let path = self
|
let path = self
|
||||||
|
|||||||
@@ -575,16 +575,8 @@ fn push_event(
|
|||||||
/// Write current worker state to `.claw/worker-state.json` under the worker's cwd.
|
/// 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)
|
/// This is the file-based observability surface: external observers (clawhip, orchestrators)
|
||||||
/// poll this file instead of requiring an HTTP route on the opencode binary.
|
/// poll this file instead of requiring an HTTP route on the opencode binary.
|
||||||
fn emit_state_file(worker: &Worker) {
|
#[derive(serde::Serialize)]
|
||||||
let state_dir = std::path::Path::new(&worker.cwd).join(".claw");
|
struct StateSnapshot<'a> {
|
||||||
if let Err(_) = std::fs::create_dir_all(&state_dir) {
|
|
||||||
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,
|
worker_id: &'a str,
|
||||||
status: WorkerStatus,
|
status: WorkerStatus,
|
||||||
is_ready: bool,
|
is_ready: bool,
|
||||||
@@ -595,7 +587,15 @@ fn emit_state_file(worker: &Worker) {
|
|||||||
/// Seconds since last state transition. Clawhip uses this to detect
|
/// Seconds since last state transition. Clawhip uses this to detect
|
||||||
/// stalled workers without computing epoch deltas.
|
/// stalled workers without computing epoch deltas.
|
||||||
seconds_since_update: u64,
|
seconds_since_update: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_state_file(worker: &Worker) {
|
||||||
|
let state_dir = std::path::Path::new(&worker.cwd).join(".claw");
|
||||||
|
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");
|
||||||
|
|
||||||
let now = now_secs();
|
let now = now_secs();
|
||||||
let snapshot = StateSnapshot {
|
let snapshot = StateSnapshot {
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ fn main() {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(|s| s.trim().to_string())
|
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
|
||||||
.unwrap_or_else(|| "unknown".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
|
// TARGET is always set by Cargo during build
|
||||||
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
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.
|
// Build date from SOURCE_DATE_EPOCH (reproducible builds) or current UTC date.
|
||||||
// Intentionally ignoring time component to keep output deterministic within a day.
|
// Intentionally ignoring time component to keep output deterministic within a day.
|
||||||
@@ -48,8 +47,7 @@ fn main() {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.map(|s| s.trim().to_string())
|
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
|
||||||
.unwrap_or_else(|| "unknown".to_string())
|
|
||||||
});
|
});
|
||||||
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,10 @@ use std::thread::{self, JoinHandle};
|
|||||||
use std::time::{Duration, Instant, UNIX_EPOCH};
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||||
|
|
||||||
use api::{
|
use api::{
|
||||||
detect_provider_kind, oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient,
|
detect_provider_kind, resolve_startup_auth_source, AnthropicClient, AuthSource,
|
||||||
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
|
ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse,
|
||||||
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
|
OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient, ProviderKind,
|
||||||
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
|
StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
|
||||||
ToolResultContentBlock,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
@@ -43,15 +42,13 @@ use init::initialize_repo;
|
|||||||
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry};
|
||||||
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
use render::{MarkdownStreamState, Spinner, TerminalRenderer};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
check_base_commit, clear_oauth_credentials, format_stale_base_warning, format_usd,
|
check_base_commit, format_stale_base_warning, format_usd, load_oauth_credentials,
|
||||||
generate_pkce_pair, generate_state, load_oauth_credentials, load_system_prompt,
|
load_system_prompt, pricing_for_model, resolve_expected_base, resolve_sandbox_status,
|
||||||
parse_oauth_callback_request_target, pricing_for_model, resolve_expected_base,
|
ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource,
|
||||||
resolve_sandbox_status, save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent,
|
ContentBlock, ConversationMessage, ConversationRuntime, McpServer, McpServerManager,
|
||||||
CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, ConversationMessage,
|
McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, PermissionPolicy,
|
||||||
ConversationRuntime, McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole,
|
ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, Session, TokenUsage,
|
||||||
ModelPricing, OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest,
|
ToolError, ToolExecutor, UsageTracker,
|
||||||
PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode,
|
|
||||||
RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
|
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Map, Value};
|
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.set_reasoning_effort(reasoning_effort);
|
||||||
cli.run_turn_with_output(&effective_prompt, output_format, compact)?;
|
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::Doctor { output_format } => run_doctor(output_format)?,
|
||||||
CliAction::State { output_format } => run_worker_state(output_format)?,
|
CliAction::State { output_format } => run_worker_state(output_format)?,
|
||||||
CliAction::Init { output_format } => run_init(output_format)?,
|
CliAction::Init { output_format } => run_init(output_format)?,
|
||||||
@@ -332,12 +327,6 @@ enum CliAction {
|
|||||||
reasoning_effort: Option<String>,
|
reasoning_effort: Option<String>,
|
||||||
allow_broad_cwd: bool,
|
allow_broad_cwd: bool,
|
||||||
},
|
},
|
||||||
Login {
|
|
||||||
output_format: CliOutputFormat,
|
|
||||||
},
|
|
||||||
Logout {
|
|
||||||
output_format: CliOutputFormat,
|
|
||||||
},
|
|
||||||
Doctor {
|
Doctor {
|
||||||
output_format: CliOutputFormat,
|
output_format: CliOutputFormat,
|
||||||
},
|
},
|
||||||
@@ -418,8 +407,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
&& matches!(
|
&& matches!(
|
||||||
rest[0].as_str(),
|
rest[0].as_str(),
|
||||||
"prompt"
|
"prompt"
|
||||||
| "login"
|
|
||||||
| "logout"
|
|
||||||
| "version"
|
| "version"
|
||||||
| "state"
|
| "state"
|
||||||
| "init"
|
| "init"
|
||||||
@@ -667,8 +654,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
|
"system-prompt" => parse_system_prompt_args(&rest[1..], output_format),
|
||||||
"login" => Ok(CliAction::Login { output_format }),
|
"login" | "logout" => Err(removed_auth_surface_error(rest[0].as_str())),
|
||||||
"logout" => Ok(CliAction::Logout { output_format }),
|
|
||||||
"init" => Ok(CliAction::Init { output_format }),
|
"init" => Ok(CliAction::Init { output_format }),
|
||||||
"export" => parse_export_args(&rest[1..], output_format),
|
"export" => parse_export_args(&rest[1..], output_format),
|
||||||
"prompt" => {
|
"prompt" => {
|
||||||
@@ -765,8 +751,6 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
|||||||
| "mcp"
|
| "mcp"
|
||||||
| "skills"
|
| "skills"
|
||||||
| "system-prompt"
|
| "system-prompt"
|
||||||
| "login"
|
|
||||||
| "logout"
|
|
||||||
| "init"
|
| "init"
|
||||||
| "prompt"
|
| "prompt"
|
||||||
| "export"
|
| "export"
|
||||||
@@ -788,12 +772,19 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
|||||||
Some(guidance)
|
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> {
|
fn join_optional_args(args: &[String]) -> Option<String> {
|
||||||
let joined = args.join(" ");
|
let joined = args.join(" ");
|
||||||
let trimmed = joined.trim();
|
let trimmed = joined.trim();
|
||||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
|
||||||
fn parse_direct_slash_cli_action(
|
fn parse_direct_slash_cli_action(
|
||||||
rest: &[String],
|
rest: &[String],
|
||||||
model: String,
|
model: String,
|
||||||
@@ -1168,7 +1159,7 @@ fn parse_export_args(args: &[String], output_format: CliOutputFormat) -> Result<
|
|||||||
let value = args
|
let value = args
|
||||||
.get(index + 1)
|
.get(index + 1)
|
||||||
.ok_or_else(|| "missing value for --session".to_string())?;
|
.ok_or_else(|| "missing value for --session".to_string())?;
|
||||||
session_reference = value.clone();
|
session_reference.clone_from(value);
|
||||||
index += 2;
|
index += 2;
|
||||||
}
|
}
|
||||||
flag if flag.starts_with("--session=") => {
|
flag if flag.starts_with("--session=") => {
|
||||||
@@ -1529,17 +1520,7 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||||||
let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN")
|
let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN")
|
||||||
.ok()
|
.ok()
|
||||||
.is_some_and(|value| !value.trim().is_empty());
|
.is_some_and(|value| !value.trim().is_empty());
|
||||||
|
let env_details = format!(
|
||||||
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={}",
|
"Environment api_key={} auth_token={}",
|
||||||
if api_key_present { "present" } else { "absent" },
|
if api_key_present { "present" } else { "absent" },
|
||||||
if auth_token_present {
|
if auth_token_present {
|
||||||
@@ -1547,9 +1528,26 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||||||
} else {
|
} else {
|
||||||
"absent"
|
"absent"
|
||||||
}
|
}
|
||||||
),
|
);
|
||||||
|
|
||||||
|
match load_oauth_credentials() {
|
||||||
|
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!(
|
format!(
|
||||||
"Saved OAuth expires_at={} refresh_token={} scopes={}",
|
"Legacy OAuth expires_at={} refresh_token={} scopes={}",
|
||||||
token_set
|
token_set
|
||||||
.expires_at
|
.expires_at
|
||||||
.map_or_else(|| "<none>".to_string(), |value| value.to_string()),
|
.map_or_else(|| "<none>".to_string(), |value| value.to_string()),
|
||||||
@@ -1564,44 +1562,23 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||||||
token_set.scopes.join(",")
|
token_set.scopes.join(",")
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
];
|
"Suggested action set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN; `claw login` is removed"
|
||||||
if expired {
|
.to_string(),
|
||||||
details.push(
|
])
|
||||||
"Suggested action claw login to refresh local OAuth credentials".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
DiagnosticCheck::new(
|
|
||||||
"Auth",
|
|
||||||
if expired {
|
|
||||||
DiagnosticLevel::Warn
|
|
||||||
} else {
|
|
||||||
DiagnosticLevel::Ok
|
|
||||||
},
|
|
||||||
if expired {
|
|
||||||
"saved OAuth credentials are present but expired"
|
|
||||||
} else if api_key_present || auth_token_present {
|
|
||||||
"environment and saved credentials are available"
|
|
||||||
} else {
|
|
||||||
"saved OAuth credentials are available"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.with_details(details)
|
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("api_key_present".to_string(), json!(api_key_present)),
|
("api_key_present".to_string(), json!(api_key_present)),
|
||||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||||
("saved_oauth_present".to_string(), json!(true)),
|
("legacy_saved_oauth_present".to_string(), json!(true)),
|
||||||
("saved_oauth_expired".to_string(), json!(expired)),
|
|
||||||
(
|
(
|
||||||
"saved_oauth_expires_at".to_string(),
|
"legacy_saved_oauth_expires_at".to_string(),
|
||||||
json!(token_set.expires_at),
|
json!(token_set.expires_at),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"refresh_token_present".to_string(),
|
"legacy_refresh_token_present".to_string(),
|
||||||
json!(token_set.refresh_token.is_some()),
|
json!(token_set.refresh_token.is_some()),
|
||||||
),
|
),
|
||||||
("scopes".to_string(), json!(token_set.scopes)),
|
("legacy_scopes".to_string(), json!(token_set.scopes)),
|
||||||
]))
|
])),
|
||||||
}
|
|
||||||
Ok(None) => DiagnosticCheck::new(
|
Ok(None) => DiagnosticCheck::new(
|
||||||
"Auth",
|
"Auth",
|
||||||
if api_key_present || auth_token_present {
|
if api_key_present || auth_token_present {
|
||||||
@@ -1610,43 +1587,33 @@ fn check_auth_health() -> DiagnosticCheck {
|
|||||||
DiagnosticLevel::Warn
|
DiagnosticLevel::Warn
|
||||||
},
|
},
|
||||||
if api_key_present || auth_token_present {
|
if api_key_present || auth_token_present {
|
||||||
"environment credentials are configured"
|
"supported auth env vars are configured"
|
||||||
} else {
|
} else {
|
||||||
"no API key or saved OAuth credentials were found"
|
"no supported auth env vars were found"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.with_details(vec![format!(
|
.with_details(vec![env_details])
|
||||||
"Environment api_key={} auth_token={}",
|
|
||||||
if api_key_present { "present" } else { "absent" },
|
|
||||||
if auth_token_present {
|
|
||||||
"present"
|
|
||||||
} else {
|
|
||||||
"absent"
|
|
||||||
}
|
|
||||||
)])
|
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("api_key_present".to_string(), json!(api_key_present)),
|
("api_key_present".to_string(), json!(api_key_present)),
|
||||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||||
("saved_oauth_present".to_string(), json!(false)),
|
("legacy_saved_oauth_present".to_string(), json!(false)),
|
||||||
("saved_oauth_expired".to_string(), json!(false)),
|
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
||||||
("saved_oauth_expires_at".to_string(), Value::Null),
|
("legacy_refresh_token_present".to_string(), json!(false)),
|
||||||
("refresh_token_present".to_string(), json!(false)),
|
("legacy_scopes".to_string(), json!(Vec::<String>::new())),
|
||||||
("scopes".to_string(), json!(Vec::<String>::new())),
|
|
||||||
])),
|
])),
|
||||||
Err(error) => DiagnosticCheck::new(
|
Err(error) => DiagnosticCheck::new(
|
||||||
"Auth",
|
"Auth",
|
||||||
DiagnosticLevel::Fail,
|
DiagnosticLevel::Fail,
|
||||||
format!("failed to inspect saved credentials: {error}"),
|
format!("failed to inspect legacy saved credentials: {error}"),
|
||||||
)
|
)
|
||||||
.with_data(Map::from_iter([
|
.with_data(Map::from_iter([
|
||||||
("api_key_present".to_string(), json!(api_key_present)),
|
("api_key_present".to_string(), json!(api_key_present)),
|
||||||
("auth_token_present".to_string(), json!(auth_token_present)),
|
("auth_token_present".to_string(), json!(auth_token_present)),
|
||||||
("saved_oauth_present".to_string(), Value::Null),
|
("legacy_saved_oauth_present".to_string(), Value::Null),
|
||||||
("saved_oauth_expired".to_string(), Value::Null),
|
("legacy_saved_oauth_expires_at".to_string(), Value::Null),
|
||||||
("saved_oauth_expires_at".to_string(), Value::Null),
|
("legacy_refresh_token_present".to_string(), Value::Null),
|
||||||
("refresh_token_present".to_string(), Value::Null),
|
("legacy_scopes".to_string(), Value::Null),
|
||||||
("scopes".to_string(), Value::Null),
|
("legacy_saved_oauth_error".to_string(), json!(error.to_string())),
|
||||||
("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(())
|
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(
|
fn print_system_prompt(
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
date: String,
|
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) {
|
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
|
||||||
let session_reference = session_path.display().to_string();
|
let session_reference = session_path.display().to_string();
|
||||||
let (handle, session) = match load_session_reference(&session_reference) {
|
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")
|
let is_home = env::var_os("HOME")
|
||||||
.or_else(|| env::var_os("USERPROFILE"))
|
.or_else(|| env::var_os("USERPROFILE"))
|
||||||
.map(|h| PathBuf::from(h) == cwd)
|
.is_some_and(|h| Path::new(&h) == cwd);
|
||||||
.unwrap_or(false);
|
|
||||||
let is_root = cwd.parent().is_none();
|
let is_root = cwd.parent().is_none();
|
||||||
if is_home || is_root {
|
if is_home || is_root {
|
||||||
Some(cwd)
|
Some(cwd)
|
||||||
@@ -3109,9 +2900,8 @@ fn enforce_broad_cwd_policy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_stale_base_preflight(flag_value: Option<&str>) {
|
fn run_stale_base_preflight(flag_value: Option<&str>) {
|
||||||
let cwd = match env::current_dir() {
|
let Ok(cwd) = env::current_dir() else {
|
||||||
Ok(cwd) => cwd,
|
return;
|
||||||
Err(_) => return,
|
|
||||||
};
|
};
|
||||||
let source = resolve_expected_base(flag_value, &cwd);
|
let source = resolve_expected_base(flag_value, &cwd);
|
||||||
let state = check_base_commit(&cwd, source.as_ref());
|
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(
|
fn run_repl(
|
||||||
model: String,
|
model: String,
|
||||||
allowed_tools: Option<AllowedToolSet>,
|
allowed_tools: Option<AllowedToolSet>,
|
||||||
@@ -4474,6 +4265,7 @@ impl LiveCli {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
fn handle_session_command(
|
fn handle_session_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
action: Option<&str>,
|
action: Option<&str>,
|
||||||
@@ -4765,8 +4557,7 @@ fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
|
|||||||
fn create_managed_session_handle(
|
fn create_managed_session_handle(
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||||
let handle = current_session_store()?
|
let handle = current_session_store()?.create_handle(session_id);
|
||||||
.create_handle(session_id);
|
|
||||||
Ok(SessionHandle {
|
Ok(SessionHandle {
|
||||||
id: handle.id,
|
id: handle.id,
|
||||||
path: handle.path,
|
path: handle.path,
|
||||||
@@ -5366,14 +5157,14 @@ fn render_config_json(
|
|||||||
ConfigSource::Project => "project",
|
ConfigSource::Project => "project",
|
||||||
ConfigSource::Local => "local",
|
ConfigSource::Local => "local",
|
||||||
};
|
};
|
||||||
let loaded = runtime_config
|
let is_loaded = runtime_config
|
||||||
.loaded_entries()
|
.loaded_entries()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|le| le.path == e.path);
|
.any(|le| le.path == e.path);
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"path": e.path.display().to_string(),
|
"path": e.path.display().to_string(),
|
||||||
"source": source,
|
"source": source,
|
||||||
"loaded": loaded,
|
"loaded": is_loaded,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.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
|
// Computes civil (Gregorian) year/month/day from days since the Unix epoch
|
||||||
// (1970-01-01) using Howard Hinnant's `civil_from_days` algorithm.
|
// (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) {
|
fn civil_from_days(days: i64) -> (i32, u32, u32) {
|
||||||
let z = days + 719_468;
|
let z = days + 719_468;
|
||||||
let era = if z >= 0 {
|
let era = if z >= 0 {
|
||||||
@@ -6852,29 +6648,11 @@ impl AnthropicRuntimeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
Ok(resolve_cli_auth_source_for_cwd()?)
|
||||||
Ok(resolve_cli_auth_source_for_cwd(&cwd, default_oauth_config)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_cli_auth_source_for_cwd<F>(
|
fn resolve_cli_auth_source_for_cwd() -> Result<AuthSource, api::ApiError> {
|
||||||
cwd: &Path,
|
resolve_startup_auth_source(|| Ok(None))
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiClient for AnthropicRuntimeClient {
|
impl ApiClient for AnthropicRuntimeClient {
|
||||||
@@ -6917,7 +6695,6 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
{
|
{
|
||||||
// Stalled after tool completion — nudge the model by
|
// Stalled after tool completion — nudge the model by
|
||||||
// re-sending the same request.
|
// re-sending the same request.
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
Err(error) => return Err(error),
|
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 mcp")?;
|
||||||
writeln!(out, " claw skills")?;
|
writeln!(out, " claw skills")?;
|
||||||
writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?;
|
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, " claw init")?;
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
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 mcp show my-server")?;
|
||||||
writeln!(out, " claw /skills")?;
|
writeln!(out, " claw /skills")?;
|
||||||
writeln!(out, " claw doctor")?;
|
writeln!(out, " claw doctor")?;
|
||||||
writeln!(out, " claw login")?;
|
|
||||||
writeln!(out, " claw init")?;
|
writeln!(out, " claw init")?;
|
||||||
writeln!(out, " claw export")?;
|
writeln!(out, " claw export")?;
|
||||||
writeln!(out, " claw export conversation.md")?;
|
writeln!(out, " claw export conversation.md")?;
|
||||||
@@ -8612,36 +8386,6 @@ mod tests {
|
|||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
.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 {
|
fn with_current_dir<T>(cwd: &Path, f: impl FnOnce() -> T) -> T {
|
||||||
let _guard = cwd_lock()
|
let _guard = cwd_lock()
|
||||||
.lock()
|
.lock()
|
||||||
@@ -8784,25 +8528,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 _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();
|
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");
|
std::fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
|
||||||
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok();
|
||||||
@@ -8820,17 +8548,8 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.expect("save expired oauth credentials");
|
.expect("save expired oauth credentials");
|
||||||
|
|
||||||
let token_url = spawn_token_server(
|
let error = super::resolve_cli_auth_source_for_cwd()
|
||||||
r#"{"access_token":"refreshed-access-token","refresh_token":"refreshed-refresh-token","expires_at":4102444800,"scopes":["org:create_api_key","user:profile"]}"#,
|
.expect_err("saved oauth should be ignored without env auth");
|
||||||
);
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
match original_config_home {
|
match original_config_home {
|
||||||
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
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),
|
Some(value) => std::env::set_var("ANTHROPIC_AUTH_TOKEN", value),
|
||||||
None => std::env::remove_var("ANTHROPIC_AUTH_TOKEN"),
|
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");
|
std::fs::remove_dir_all(config_home).expect("temp config home should clean up");
|
||||||
|
|
||||||
assert_eq!(auth.bearer_token(), Some("refreshed-access-token"));
|
assert!(error.to_string().contains("ANTHROPIC_API_KEY"));
|
||||||
assert_eq!(stored.access_token, "refreshed-access-token");
|
|
||||||
assert_eq!(
|
|
||||||
stored.refresh_token.as_deref(),
|
|
||||||
Some("refreshed-refresh-token")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -9229,19 +8942,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_login_and_logout_subcommands() {
|
fn removed_login_and_logout_subcommands_error_helpfully() {
|
||||||
assert_eq!(
|
let login = parse_args(&["login".to_string()]).expect_err("login should be removed");
|
||||||
parse_args(&["login".to_string()]).expect("login should parse"),
|
assert!(login.contains("ANTHROPIC_API_KEY"));
|
||||||
CliAction::Login {
|
let logout = parse_args(&["logout".to_string()]).expect_err("logout should be removed");
|
||||||
output_format: CliOutputFormat::Text,
|
assert!(logout.contains("ANTHROPIC_AUTH_TOKEN"));
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_args(&["logout".to_string()]).expect("logout should parse"),
|
|
||||||
CliAction::Logout {
|
|
||||||
output_format: CliOutputFormat::Text,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_args(&["doctor".to_string()]).expect("doctor should parse"),
|
parse_args(&["doctor".to_string()]).expect("doctor should parse"),
|
||||||
CliAction::Doctor {
|
CliAction::Doctor {
|
||||||
@@ -10218,6 +9923,8 @@ mod tests {
|
|||||||
assert!(help.contains("claw mcp"));
|
assert!(help.contains("claw mcp"));
|
||||||
assert!(help.contains("claw skills"));
|
assert!(help.contains("claw skills"));
|
||||||
assert!(help.contains("claw /skills"));
|
assert!(help.contains("claw /skills"));
|
||||||
|
assert!(!help.contains("claw login"));
|
||||||
|
assert!(!help.contains("claw logout"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -11308,31 +11015,6 @@ UU conflicted.rs",
|
|||||||
assert!(!rendered.contains("step 1"));
|
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]
|
#[test]
|
||||||
fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() {
|
fn build_runtime_plugin_state_merges_plugin_hooks_into_runtime_features() {
|
||||||
let config_home = temp_dir();
|
let config_home = temp_dir();
|
||||||
@@ -11620,8 +11302,7 @@ UU conflicted.rs",
|
|||||||
]);
|
]);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"--reasoning-effort {value} should be accepted, got: {:?}",
|
"--reasoning-effort {value} should be accepted, got: {result:?}"
|
||||||
result
|
|
||||||
);
|
);
|
||||||
if let Ok(CliAction::Prompt {
|
if let Ok(CliAction::Prompt {
|
||||||
reasoning_effort, ..
|
reasoning_effort, ..
|
||||||
|
|||||||
@@ -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.
|
/// fence markers of equal or greater length are wrapped with a longer fence.
|
||||||
///
|
///
|
||||||
/// LLMs frequently emit triple-backtick code blocks that contain triple-backtick
|
/// 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
|
/// closing fence, breaking the render. This function detects the situation and
|
||||||
/// upgrades the outer fence to use enough backticks (or tildes) that the inner
|
/// upgrades the outer fence to use enough backticks (or tildes) that the inner
|
||||||
/// markers become ordinary content.
|
/// 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 {
|
fn normalize_nested_fences(markdown: &str) -> String {
|
||||||
// A fence line is either "labeled" (has an info string ⇒ always an opener)
|
// A fence line is either "labeled" (has an info string ⇒ always an opener)
|
||||||
// or "bare" (no info string ⇒ could be opener or closer).
|
// or "bare" (no info string ⇒ could be opener or closer).
|
||||||
|
|||||||
@@ -3771,10 +3771,7 @@ impl ProviderRuntimeClient {
|
|||||||
allowed_tools: BTreeSet<String>,
|
allowed_tools: BTreeSet<String>,
|
||||||
fallback_config: &ProviderFallbackConfig,
|
fallback_config: &ProviderFallbackConfig,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
let primary_model = fallback_config
|
let primary_model = fallback_config.primary().map_or(model, str::to_string);
|
||||||
.primary()
|
|
||||||
.map(str::to_string)
|
|
||||||
.unwrap_or(model);
|
|
||||||
let primary = build_provider_entry(&primary_model)?;
|
let primary = build_provider_entry(&primary_model)?;
|
||||||
let mut chain = vec![primary];
|
let mut chain = vec![primary];
|
||||||
for fallback_model in fallback_config.fallbacks() {
|
for fallback_model in fallback_config.fallbacks() {
|
||||||
@@ -3852,17 +3849,15 @@ impl ApiClient for ProviderRuntimeClient {
|
|||||||
entry.model
|
entry.model
|
||||||
);
|
);
|
||||||
last_error = Some(error);
|
last_error = Some(error);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
Err(error) => return Err(RuntimeError::new(error.to_string())),
|
Err(error) => return Err(RuntimeError::new(error.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(RuntimeError::new(
|
Err(RuntimeError::new(last_error.map_or_else(
|
||||||
last_error
|
|| String::from("provider chain exhausted with no attempts"),
|
||||||
.map(|error| error.to_string())
|
|error| error.to_string(),
|
||||||
.unwrap_or_else(|| String::from("provider chain exhausted with no attempts")),
|
)))
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user