mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-08 00:54:49 +08:00
fix(auth): harden OAuth fallback and collapse thinking output
This commit is contained in:
@@ -1599,8 +1599,13 @@ fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::E
|
|||||||
println!("Listening for callback on {redirect_uri}");
|
println!("Listening for callback on {redirect_uri}");
|
||||||
}
|
}
|
||||||
if let Err(error) = open_browser(&authorize_url) {
|
if let Err(error) = open_browser(&authorize_url) {
|
||||||
eprintln!("warning: failed to open browser automatically: {error}");
|
emit_login_browser_open_failure(
|
||||||
println!("Open this URL manually:\n{authorize_url}");
|
output_format,
|
||||||
|
&authorize_url,
|
||||||
|
&error,
|
||||||
|
&mut io::stdout(),
|
||||||
|
&mut io::stderr(),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let callback = wait_for_oauth_callback(callback_port)?;
|
let callback = wait_for_oauth_callback(callback_port)?;
|
||||||
@@ -1651,6 +1656,23 @@ fn run_login(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::E
|
|||||||
Ok(())
|
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>> {
|
fn run_logout(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
clear_oauth_credentials()?;
|
clear_oauth_credentials()?;
|
||||||
match output_format {
|
match output_format {
|
||||||
@@ -5586,13 +5608,29 @@ 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>> {
|
||||||
Ok(resolve_startup_auth_source(|| {
|
let cwd = env::current_dir()?;
|
||||||
let cwd = env::current_dir().map_err(api::ApiError::from)?;
|
Ok(resolve_cli_auth_source_for_cwd(&cwd, default_oauth_config)?)
|
||||||
let config = ConfigLoader::default_for(&cwd).load().map_err(|error| {
|
}
|
||||||
api::ApiError::Auth(format!("failed to load runtime OAuth config: {error}"))
|
|
||||||
})?;
|
fn resolve_cli_auth_source_for_cwd<F>(
|
||||||
Ok(config.oauth().cloned())
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiClient for AnthropicRuntimeClient {
|
impl ApiClient for AnthropicRuntimeClient {
|
||||||
@@ -5632,6 +5670,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
let mut markdown_stream = MarkdownStreamState::default();
|
let mut markdown_stream = MarkdownStreamState::default();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut pending_tool: Option<(String, String, String)> = None;
|
let mut pending_tool: Option<(String, String, String)> = None;
|
||||||
|
let mut block_has_thinking_summary = false;
|
||||||
let mut saw_stop = false;
|
let mut saw_stop = false;
|
||||||
|
|
||||||
while let Some(event) = stream.next_event().await.map_err(|error| {
|
while let Some(event) = stream.next_event().await.map_err(|error| {
|
||||||
@@ -5640,7 +5679,14 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
match event {
|
match event {
|
||||||
ApiStreamEvent::MessageStart(start) => {
|
ApiStreamEvent::MessageStart(start) => {
|
||||||
for block in start.message.content {
|
for block in start.message.content {
|
||||||
push_output_block(block, out, &mut events, &mut pending_tool, true)?;
|
push_output_block(
|
||||||
|
block,
|
||||||
|
out,
|
||||||
|
&mut events,
|
||||||
|
&mut pending_tool,
|
||||||
|
true,
|
||||||
|
&mut block_has_thinking_summary,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ApiStreamEvent::ContentBlockStart(start) => {
|
ApiStreamEvent::ContentBlockStart(start) => {
|
||||||
@@ -5650,6 +5696,7 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
&mut events,
|
&mut events,
|
||||||
&mut pending_tool,
|
&mut pending_tool,
|
||||||
true,
|
true,
|
||||||
|
&mut block_has_thinking_summary,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
|
||||||
@@ -5671,10 +5718,16 @@ impl ApiClient for AnthropicRuntimeClient {
|
|||||||
input.push_str(&partial_json);
|
input.push_str(&partial_json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ContentBlockDelta::ThinkingDelta { .. }
|
ContentBlockDelta::ThinkingDelta { .. } => {
|
||||||
| ContentBlockDelta::SignatureDelta { .. } => {}
|
if !block_has_thinking_summary {
|
||||||
|
render_thinking_block_summary(out, None, false)?;
|
||||||
|
block_has_thinking_summary = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContentBlockDelta::SignatureDelta { .. } => {}
|
||||||
},
|
},
|
||||||
ApiStreamEvent::ContentBlockStop(_) => {
|
ApiStreamEvent::ContentBlockStop(_) => {
|
||||||
|
block_has_thinking_summary = false;
|
||||||
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
if let Some(rendered) = markdown_stream.flush(&renderer) {
|
||||||
write!(out, "{rendered}")
|
write!(out, "{rendered}")
|
||||||
.and_then(|()| out.flush())
|
.and_then(|()| out.flush())
|
||||||
@@ -6409,12 +6462,30 @@ fn truncate_output_for_display(content: &str, max_lines: usize, max_chars: usize
|
|||||||
preview
|
preview
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_thinking_block_summary(
|
||||||
|
out: &mut (impl Write + ?Sized),
|
||||||
|
char_count: Option<usize>,
|
||||||
|
redacted: bool,
|
||||||
|
) -> Result<(), RuntimeError> {
|
||||||
|
let summary = if redacted {
|
||||||
|
"\n▶ Thinking block hidden by provider\n".to_string()
|
||||||
|
} else if let Some(char_count) = char_count {
|
||||||
|
format!("\n▶ Thinking ({char_count} chars hidden)\n")
|
||||||
|
} else {
|
||||||
|
"\n▶ Thinking hidden\n".to_string()
|
||||||
|
};
|
||||||
|
write!(out, "{summary}")
|
||||||
|
.and_then(|()| out.flush())
|
||||||
|
.map_err(|error| RuntimeError::new(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
fn push_output_block(
|
fn push_output_block(
|
||||||
block: OutputContentBlock,
|
block: OutputContentBlock,
|
||||||
out: &mut (impl Write + ?Sized),
|
out: &mut (impl Write + ?Sized),
|
||||||
events: &mut Vec<AssistantEvent>,
|
events: &mut Vec<AssistantEvent>,
|
||||||
pending_tool: &mut Option<(String, String, String)>,
|
pending_tool: &mut Option<(String, String, String)>,
|
||||||
streaming_tool_input: bool,
|
streaming_tool_input: bool,
|
||||||
|
block_has_thinking_summary: &mut bool,
|
||||||
) -> Result<(), RuntimeError> {
|
) -> Result<(), RuntimeError> {
|
||||||
match block {
|
match block {
|
||||||
OutputContentBlock::Text { text } => {
|
OutputContentBlock::Text { text } => {
|
||||||
@@ -6440,7 +6511,14 @@ fn push_output_block(
|
|||||||
};
|
};
|
||||||
*pending_tool = Some((id, name, initial_input));
|
*pending_tool = Some((id, name, initial_input));
|
||||||
}
|
}
|
||||||
OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => {}
|
OutputContentBlock::Thinking { thinking, .. } => {
|
||||||
|
render_thinking_block_summary(out, Some(thinking.chars().count()), false)?;
|
||||||
|
*block_has_thinking_summary = true;
|
||||||
|
}
|
||||||
|
OutputContentBlock::RedactedThinking { .. } => {
|
||||||
|
render_thinking_block_summary(out, None, true)?;
|
||||||
|
*block_has_thinking_summary = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -6453,7 +6531,15 @@ fn response_to_events(
|
|||||||
let mut pending_tool = None;
|
let mut pending_tool = None;
|
||||||
|
|
||||||
for block in response.content {
|
for block in response.content {
|
||||||
push_output_block(block, out, &mut events, &mut pending_tool, false)?;
|
let mut block_has_thinking_summary = false;
|
||||||
|
push_output_block(
|
||||||
|
block,
|
||||||
|
out,
|
||||||
|
&mut events,
|
||||||
|
&mut pending_tool,
|
||||||
|
false,
|
||||||
|
&mut block_has_thinking_summary,
|
||||||
|
)?;
|
||||||
if let Some((id, name, input)) = pending_tool.take() {
|
if let Some((id, name, input)) = pending_tool.take() {
|
||||||
events.push(AssistantEvent::ToolUse { id, name, input });
|
events.push(AssistantEvent::ToolUse { id, name, input });
|
||||||
}
|
}
|
||||||
@@ -6841,14 +6927,17 @@ mod tests {
|
|||||||
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
PluginManager, PluginManagerConfig, PluginTool, PluginToolDefinition, PluginToolPermission,
|
||||||
};
|
};
|
||||||
use runtime::{
|
use runtime::{
|
||||||
AssistantEvent, ConfigLoader, ContentBlock, ConversationMessage, MessageRole,
|
load_oauth_credentials, save_oauth_credentials, AssistantEvent, ConfigLoader, ContentBlock,
|
||||||
PermissionMode, Session, ToolExecutor,
|
ConversationMessage, MessageRole, OAuthConfig, PermissionMode, Session, ToolExecutor,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||||
|
use std::thread;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tools::GlobalToolRegistry;
|
use tools::GlobalToolRegistry;
|
||||||
|
|
||||||
@@ -7051,6 +7140,36 @@ 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()
|
||||||
@@ -7189,6 +7308,78 @@ mod tests {
|
|||||||
assert_eq!(resolved, PermissionMode::ReadOnly);
|
assert_eq!(resolved, PermissionMode::ReadOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_runtime_oauth_config_for_returns_none_without_project_config() {
|
||||||
|
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();
|
||||||
|
let original_api_key = std::env::var("ANTHROPIC_API_KEY").ok();
|
||||||
|
let original_auth_token = std::env::var("ANTHROPIC_AUTH_TOKEN").ok();
|
||||||
|
std::env::set_var("CLAW_CONFIG_HOME", &config_home);
|
||||||
|
std::env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
|
||||||
|
save_oauth_credentials(&runtime::OAuthTokenSet {
|
||||||
|
access_token: "expired-access-token".to_string(),
|
||||||
|
refresh_token: Some("refresh-token".to_string()),
|
||||||
|
expires_at: Some(0),
|
||||||
|
scopes: vec!["org:create_api_key".to_string(), "user:profile".to_string()],
|
||||||
|
})
|
||||||
|
.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");
|
||||||
|
|
||||||
|
match original_config_home {
|
||||||
|
Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value),
|
||||||
|
None => std::env::remove_var("CLAW_CONFIG_HOME"),
|
||||||
|
}
|
||||||
|
match original_api_key {
|
||||||
|
Some(value) => std::env::set_var("ANTHROPIC_API_KEY", value),
|
||||||
|
None => std::env::remove_var("ANTHROPIC_API_KEY"),
|
||||||
|
}
|
||||||
|
match original_auth_token {
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_prompt_subcommand() {
|
fn parses_prompt_subcommand() {
|
||||||
let _guard = env_lock();
|
let _guard = env_lock();
|
||||||
@@ -8669,6 +8860,7 @@ UU conflicted.rs",
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut pending_tool = None;
|
let mut pending_tool = None;
|
||||||
|
let mut block_has_thinking_summary = false;
|
||||||
|
|
||||||
push_output_block(
|
push_output_block(
|
||||||
OutputContentBlock::Text {
|
OutputContentBlock::Text {
|
||||||
@@ -8678,6 +8870,7 @@ UU conflicted.rs",
|
|||||||
&mut events,
|
&mut events,
|
||||||
&mut pending_tool,
|
&mut pending_tool,
|
||||||
false,
|
false,
|
||||||
|
&mut block_has_thinking_summary,
|
||||||
)
|
)
|
||||||
.expect("text block should render");
|
.expect("text block should render");
|
||||||
|
|
||||||
@@ -8691,6 +8884,7 @@ UU conflicted.rs",
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
let mut pending_tool = None;
|
let mut pending_tool = None;
|
||||||
|
let mut block_has_thinking_summary = false;
|
||||||
|
|
||||||
push_output_block(
|
push_output_block(
|
||||||
OutputContentBlock::ToolUse {
|
OutputContentBlock::ToolUse {
|
||||||
@@ -8702,6 +8896,7 @@ UU conflicted.rs",
|
|||||||
&mut events,
|
&mut events,
|
||||||
&mut pending_tool,
|
&mut pending_tool,
|
||||||
true,
|
true,
|
||||||
|
&mut block_has_thinking_summary,
|
||||||
)
|
)
|
||||||
.expect("tool block should accumulate");
|
.expect("tool block should accumulate");
|
||||||
|
|
||||||
@@ -8783,7 +8978,7 @@ UU conflicted.rs",
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn response_to_events_ignores_thinking_blocks() {
|
fn response_to_events_renders_collapsed_thinking_summary() {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let events = response_to_events(
|
let events = response_to_events(
|
||||||
MessageResponse {
|
MessageResponse {
|
||||||
@@ -8818,7 +9013,34 @@ UU conflicted.rs",
|
|||||||
&events[0],
|
&events[0],
|
||||||
AssistantEvent::TextDelta(text) if text == "Final answer"
|
AssistantEvent::TextDelta(text) if text == "Final answer"
|
||||||
));
|
));
|
||||||
assert!(!String::from_utf8(out).expect("utf8").contains("step 1"));
|
let rendered = String::from_utf8(out).expect("utf8");
|
||||||
|
assert!(rendered.contains("▶ Thinking (6 chars hidden)"));
|
||||||
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user