feature/acp-cancel-request
claude/github-issue-40-0au5ua
claude/linked-issue-acp-tui-u4vvpa
claude/minor-release-cut-vkugow
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/acp-cancel-request
feature/acp-session-list-cursor
feature/acp-v1-conformance
feature/agent-tools-multiedit-glob-todos-remember
feature/cargo-workspace
feature/hooks
feature/load-local-model-explicitly
feature/sigit-agent-profile
feature/sigit-code-cloud
feature/xcode-mcp-server
fix/acp-replay-loaded-session-history
fix/acp-replay-reasoning-and-attachments
fix/dsml-doubled-bar-markers
main
release/v1.5.10
release/v1.5.6
release/v1.6.1
| 1 | //! `authenticate` through a URL-mode elicitation (issue #197). |
| 2 | //! |
| 3 | //! A client that advertises `elicitation.url` is handed the sign-in page |
| 4 | //! instead of sigit launching a browser itself. The test plays the client and |
| 5 | //! the browser: it answers the `elicitation/create`, follows the authorize URL |
| 6 | //! back to sigit's loopback listener, and serves the token and profile |
| 7 | //! endpoints from a fake siGit Code Cloud. |
| 8 | |
| 9 | use std::io::{BufRead, BufReader, Read, Write}; |
| 10 | use std::net::{TcpListener, TcpStream}; |
| 11 | use std::process::{Child, ChildStdin, Command, Stdio}; |
| 12 | use std::sync::mpsc::{Receiver, channel}; |
| 13 | use std::time::{Duration, Instant}; |
| 14 | |
| 15 | use serde_json::{Value, json}; |
| 16 | |
| 17 | const TIMEOUT: Duration = Duration::from_secs(60); |
| 18 | |
| 19 | /// A fake siGit Code Cloud: answers `POST /oauth/token` with a token and |
| 20 | /// `GET /api/v1/user` with an email. Returns its base URL. |
| 21 | fn start_fake_cloud() -> String { |
| 22 | let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake cloud"); |
| 23 | let port = listener.local_addr().unwrap().port(); |
| 24 | std::thread::spawn(move || { |
| 25 | for stream in listener.incoming() { |
| 26 | let Ok(mut stream) = stream else { continue }; |
| 27 | let mut reader = BufReader::new(stream.try_clone().unwrap()); |
| 28 | let mut request_line = String::new(); |
| 29 | reader.read_line(&mut request_line).ok(); |
| 30 | let mut content_length = 0usize; |
| 31 | loop { |
| 32 | let mut line = String::new(); |
| 33 | if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { |
| 34 | break; |
| 35 | } |
| 36 | if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") { |
| 37 | content_length = length.trim().parse().unwrap_or(0); |
| 38 | } |
| 39 | } |
| 40 | let mut body = vec![0u8; content_length]; |
| 41 | reader.read_exact(&mut body).ok(); |
| 42 | let payload = if request_line.contains("/oauth/token") { |
| 43 | json!({"access_token": "tok_from_browser", "token_type": "Bearer"}) |
| 44 | } else { |
| 45 | json!({"email": "dev@sigit.si"}) |
| 46 | } |
| 47 | .to_string(); |
| 48 | let response = format!( |
| 49 | "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ |
| 50 | content-length: {}\r\nconnection: close\r\n\r\n{}", |
| 51 | payload.len(), |
| 52 | payload |
| 53 | ); |
| 54 | stream.write_all(response.as_bytes()).ok(); |
| 55 | } |
| 56 | }); |
| 57 | format!("http://127.0.0.1:{port}") |
| 58 | } |
| 59 | |
| 60 | struct AgentUnderTest { |
| 61 | child: Child, |
| 62 | stdin: ChildStdin, |
| 63 | incoming: Receiver<Value>, |
| 64 | } |
| 65 | |
| 66 | fn spawn_agent(cloud: &str, config_dir: &std::path::Path) -> AgentUnderTest { |
| 67 | let mut child = Command::new(env!("CARGO_BIN_EXE_sigit")) |
| 68 | .env("SIGIT_API_URL", cloud) |
| 69 | .env("SIGIT_CLOUD_URL", cloud) |
| 70 | .env("SIGIT_CONFIG_DIR", config_dir) |
| 71 | .env("SIGIT_MCP", "off") |
| 72 | // Nothing here talks to a model; any endpoint will do. |
| 73 | .env("OPENAI_BASE_URL", "http://127.0.0.1:9") |
| 74 | .env("OPENAI_API_KEY", "test-key") |
| 75 | .env("SIGIT_MODEL", "scripted-model") |
| 76 | .env_remove("SIGIT_LOCAL_INFERENCE") |
| 77 | .stdin(Stdio::piped()) |
| 78 | .stdout(Stdio::piped()) |
| 79 | .stderr(Stdio::null()) |
| 80 | .spawn() |
| 81 | .expect("spawn sigit in ACP mode"); |
| 82 | |
| 83 | let stdout = child.stdout.take().unwrap(); |
| 84 | let (message_tx, incoming) = channel(); |
| 85 | std::thread::spawn(move || { |
| 86 | for line in BufReader::new(stdout).lines() { |
| 87 | let Ok(line) = line else { break }; |
| 88 | if let Ok(message) = serde_json::from_str::<Value>(&line) |
| 89 | && message_tx.send(message).is_err() |
| 90 | { |
| 91 | break; |
| 92 | } |
| 93 | } |
| 94 | }); |
| 95 | let stdin = child.stdin.take().unwrap(); |
| 96 | AgentUnderTest { |
| 97 | child, |
| 98 | stdin, |
| 99 | incoming, |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | impl AgentUnderTest { |
| 104 | fn send(&mut self, message: Value) { |
| 105 | let mut line = message.to_string(); |
| 106 | line.push('\n'); |
| 107 | self.stdin.write_all(line.as_bytes()).unwrap(); |
| 108 | self.stdin.flush().unwrap(); |
| 109 | } |
| 110 | |
| 111 | fn next(&mut self, what: &str, matches: impl Fn(&Value) -> bool) -> Value { |
| 112 | let deadline = Instant::now() + TIMEOUT; |
| 113 | loop { |
| 114 | let remaining = deadline.saturating_duration_since(Instant::now()); |
| 115 | match self.incoming.recv_timeout(remaining) { |
| 116 | Ok(message) if matches(&message) => return message, |
| 117 | Ok(_) => continue, |
| 118 | Err(_) => panic!("timed out waiting for {what}"), |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | fn response(&mut self, id: u64) -> Value { |
| 124 | self.next(&format!("the response to {id}"), |message| { |
| 125 | message["id"] == id && message.get("method").is_none() |
| 126 | }) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | impl Drop for AgentUnderTest { |
| 131 | fn drop(&mut self) { |
| 132 | let _ = self.child.kill(); |
| 133 | let _ = self.child.wait(); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | /// The value of `key` in the authorize URL's query, percent-decoded. |
| 138 | fn query_value(url: &str, key: &str) -> String { |
| 139 | let query = url.split_once('?').expect("query").1; |
| 140 | let raw = query |
| 141 | .split('&') |
| 142 | .find_map(|pair| pair.strip_prefix(&format!("{key}="))) |
| 143 | .unwrap_or_else(|| panic!("{key} missing from {url}")); |
| 144 | let mut decoded = String::new(); |
| 145 | let mut bytes = raw.bytes(); |
| 146 | while let Some(byte) = bytes.next() { |
| 147 | if byte == b'%' { |
| 148 | let hex: String = [bytes.next().unwrap(), bytes.next().unwrap()] |
| 149 | .iter() |
| 150 | .map(|&b| b as char) |
| 151 | .collect(); |
| 152 | decoded.push(u8::from_str_radix(&hex, 16).unwrap() as char); |
| 153 | } else { |
| 154 | decoded.push(byte as char); |
| 155 | } |
| 156 | } |
| 157 | decoded |
| 158 | } |
| 159 | |
| 160 | /// Start `authenticate` from a client that renders URL elicitations, and |
| 161 | /// return the agent, the request id, and the elicitation it was sent. |
| 162 | fn start_sign_in(tag: &str) -> (AgentUnderTest, std::path::PathBuf, Value) { |
| 163 | let cloud = start_fake_cloud(); |
| 164 | let scratch = |
| 165 | std::env::temp_dir().join(format!("sigit_acp_elicit_{tag}_{}", std::process::id())); |
| 166 | let config_dir = scratch.join("config"); |
| 167 | std::fs::create_dir_all(&config_dir).unwrap(); |
| 168 | |
| 169 | let mut agent = spawn_agent(&cloud, &config_dir); |
| 170 | agent.send(json!({ |
| 171 | "jsonrpc": "2.0", "id": 1, "method": "initialize", |
| 172 | "params": { |
| 173 | "protocolVersion": 1, |
| 174 | "clientCapabilities": {"elicitation": {"url": {}}}, |
| 175 | }, |
| 176 | })); |
| 177 | agent.response(1); |
| 178 | |
| 179 | agent.send(json!({ |
| 180 | "jsonrpc": "2.0", "id": 2, "method": "authenticate", |
| 181 | "params": {"methodId": "sigit"}, |
| 182 | })); |
| 183 | let elicitation = agent.next("the sign-in elicitation", |message| { |
| 184 | message["method"] == "elicitation/create" |
| 185 | }); |
| 186 | let params = &elicitation["params"]; |
| 187 | assert_eq!(params["mode"], "url", "{params}"); |
| 188 | assert_eq!( |
| 189 | params["requestId"], 2, |
| 190 | "scoped to the authenticate request: {params}" |
| 191 | ); |
| 192 | assert!( |
| 193 | params["url"] |
| 194 | .as_str() |
| 195 | .unwrap_or_default() |
| 196 | .starts_with(&format!("{cloud}/oauth/authorize?")), |
| 197 | "{params}" |
| 198 | ); |
| 199 | assert!(params["elicitationId"].is_string(), "{params}"); |
| 200 | (agent, scratch, elicitation) |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn sign_in_hands_the_page_to_a_client_that_renders_urls() { |
| 205 | let (mut agent, scratch, elicitation) = start_sign_in("accept"); |
| 206 | let params = &elicitation["params"]; |
| 207 | let url = params["url"].as_str().unwrap().to_string(); |
| 208 | |
| 209 | agent.send(json!({ |
| 210 | "jsonrpc": "2.0", "id": elicitation["id"], "result": {"action": "accept"}, |
| 211 | })); |
| 212 | |
| 213 | // The browser finishes the authorization and comes back to sigit. |
| 214 | let redirect = query_value(&url, "redirect_uri"); |
| 215 | let state = query_value(&url, "state"); |
| 216 | let address = redirect |
| 217 | .strip_prefix("http://") |
| 218 | .and_then(|rest| rest.split_once('/')) |
| 219 | .expect("loopback redirect") |
| 220 | .0 |
| 221 | .to_string(); |
| 222 | let mut browser = TcpStream::connect(&address).expect("reach the loopback listener"); |
| 223 | write!( |
| 224 | browser, |
| 225 | "GET /callback?code=abc&state={state} HTTP/1.1\r\nHost: {address}\r\n\r\n" |
| 226 | ) |
| 227 | .unwrap(); |
| 228 | let mut page = String::new(); |
| 229 | browser.read_to_string(&mut page).ok(); |
| 230 | assert!(page.contains("Signed in"), "{page}"); |
| 231 | |
| 232 | // The client hears that the page is done with, then gets its answer. |
| 233 | let complete = agent.next("elicitation/complete", |message| { |
| 234 | message["method"] == "elicitation/complete" |
| 235 | }); |
| 236 | assert_eq!(complete["params"]["elicitationId"], params["elicitationId"]); |
| 237 | let response = agent.response(2); |
| 238 | assert!(response.get("error").is_none(), "{response}"); |
| 239 | let saved = std::fs::read_to_string(scratch.join("config/credentials.toml")).unwrap(); |
| 240 | assert!(saved.contains("tok_from_browser"), "{saved}"); |
| 241 | |
| 242 | drop(agent); |
| 243 | let _ = std::fs::remove_dir_all(&scratch); |
| 244 | } |
| 245 | |
| 246 | #[test] |
| 247 | fn declining_the_sign_in_page_ends_the_sign_in() { |
| 248 | let (mut agent, scratch, elicitation) = start_sign_in("decline"); |
| 249 | |
| 250 | agent.send(json!({ |
| 251 | "jsonrpc": "2.0", "id": elicitation["id"], "result": {"action": "decline"}, |
| 252 | })); |
| 253 | |
| 254 | let response = agent.response(2); |
| 255 | assert!( |
| 256 | response["error"]["message"] |
| 257 | .as_str() |
| 258 | .unwrap_or_default() |
| 259 | .contains("Sign-in did not complete"), |
| 260 | "{response}" |
| 261 | ); |
| 262 | assert!(!scratch.join("config/credentials.toml").exists()); |
| 263 | |
| 264 | drop(agent); |
| 265 | let _ = std::fs::remove_dir_all(&scratch); |
| 266 | } |