I'm siGit, running on siGit Code & Deploy platform, browsing siGit Code coding agent repo.

1 //! ACP `logout`: the editor's sign out button.
2 //!
3 //! The method is optional in ACP v1 and gated by `agentCapabilities.auth.logout`.
4 //! A client that does not see the capability shows no button, and one that
5 //! does expects the request to end the account session the same way `/logout`
6 //! does: the server is told, and the stored token is gone.
7
8 use std::io::{BufRead, BufReader, Write};
9 use std::net::TcpListener;
10 use std::process::{Child, ChildStdin, Command, Stdio};
11 use std::sync::mpsc::{Receiver, channel};
12 use std::time::{Duration, Instant};
13
14 use serde_json::{Value, json};
15
16 const TIMEOUT: Duration = Duration::from_secs(30);
17
18 /// Stand in for the account API. Every request's head (request line and
19 /// headers, lowercased) is forwarded to the returned receiver.
20 fn start_fake_account_api() -> (u16, Receiver<String>) {
21 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake account api");
22 let port = listener.local_addr().unwrap().port();
23 let (head_tx, heads) = channel();
24
25 std::thread::spawn(move || {
26 for stream in listener.incoming() {
27 let Ok(mut stream) = stream else { continue };
28 let mut reader = BufReader::new(match stream.try_clone() {
29 Ok(clone) => clone,
30 Err(_) => continue,
31 });
32 let mut head = String::new();
33 loop {
34 let mut line = String::new();
35 if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
36 break;
37 }
38 head.push_str(&line.to_ascii_lowercase());
39 }
40 let _ = head_tx.send(head);
41 let _ = stream.write_all(
42 b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
43 content-length: 2\r\nconnection: close\r\n\r\n{}",
44 );
45 let _ = stream.flush();
46 }
47 });
48
49 (port, heads)
50 }
51
52 struct AgentUnderTest {
53 child: Child,
54 stdin: ChildStdin,
55 incoming: Receiver<Value>,
56 next_id: u64,
57 }
58
59 fn spawn_agent(api_port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
60 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
61 // A provider override keeps inference off both the cloud and the
62 // on-device model. No prompt is sent, so nothing ever connects to it.
63 .env("OPENAI_BASE_URL", "http://127.0.0.1:9")
64 .env("OPENAI_API_KEY", "test-key")
65 .env("SIGIT_MODEL", "scripted-model")
66 .env("SIGIT_API_URL", format!("http://127.0.0.1:{api_port}"))
67 .env("SIGIT_CONFIG_DIR", config_dir)
68 .env("SIGIT_MCP", "off")
69 .env_remove("SIGIT_LOCAL_INFERENCE")
70 .stdin(Stdio::piped())
71 .stdout(Stdio::piped())
72 .stderr(Stdio::null())
73 .spawn()
74 .expect("spawn sigit in ACP mode");
75
76 let stdout = child.stdout.take().unwrap();
77 let (message_tx, incoming) = channel();
78 std::thread::spawn(move || {
79 for line in BufReader::new(stdout).lines() {
80 let Ok(line) = line else { break };
81 if let Ok(message) = serde_json::from_str::<Value>(&line)
82 && message_tx.send(message).is_err()
83 {
84 break;
85 }
86 }
87 });
88
89 let stdin = child.stdin.take().unwrap();
90 AgentUnderTest {
91 child,
92 stdin,
93 incoming,
94 next_id: 0,
95 }
96 }
97
98 impl AgentUnderTest {
99 fn request(&mut self, method: &str, params: Value) -> u64 {
100 self.next_id += 1;
101 let id = self.next_id;
102 let mut line =
103 json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
104 line.push('\n');
105 self.stdin.write_all(line.as_bytes()).expect("write stdin");
106 self.stdin.flush().expect("flush stdin");
107 id
108 }
109
110 fn wait_for_response(&mut self, id: u64) -> Value {
111 let deadline = Instant::now() + TIMEOUT;
112 loop {
113 let remaining = deadline.saturating_duration_since(Instant::now());
114 let Ok(message) = self.incoming.recv_timeout(remaining) else {
115 panic!("timed out waiting for the response to request {id}");
116 };
117 if message["id"] == id && message.get("method").is_none() {
118 assert!(
119 message.get("error").is_none(),
120 "request {id} failed: {message}"
121 );
122 return message;
123 }
124 }
125 }
126
127 /// Send `text` as a prompt and return the system notice it produced: the
128 /// completed `think` tool card's title, then its content text if any.
129 fn prompt_notice(&mut self, session_id: &str, text: &str) -> String {
130 let id = self.request(
131 "session/prompt",
132 json!({"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}),
133 );
134 let deadline = Instant::now() + TIMEOUT;
135 let mut notice = String::new();
136 loop {
137 let remaining = deadline.saturating_duration_since(Instant::now());
138 let Ok(message) = self.incoming.recv_timeout(remaining) else {
139 panic!("timed out waiting for the response to prompt {id}");
140 };
141 let update = &message["params"]["update"];
142 if message["method"] == "session/update" && update["sessionUpdate"] == "tool_call" {
143 assert_eq!(update["kind"], "think", "{update}");
144 assert_eq!(update["status"], "completed", "{update}");
145 notice.push_str(update["title"].as_str().unwrap_or_default());
146 for item in update["content"].as_array().into_iter().flatten() {
147 if let Some(body) = item["content"]["text"].as_str() {
148 notice.push('\n');
149 notice.push_str(body);
150 }
151 }
152 }
153 if message["id"] == id && message.get("method").is_none() {
154 return notice;
155 }
156 }
157 }
158 }
159
160 impl Drop for AgentUnderTest {
161 fn drop(&mut self) {
162 let _ = self.child.kill();
163 let _ = self.child.wait();
164 }
165 }
166
167 #[test]
168 fn logout_is_advertised_and_ends_the_account_session() {
169 let scratch = std::env::temp_dir().join(format!("sigit_acp_logout_{}", std::process::id()));
170 let config_dir = scratch.join("config");
171 std::fs::create_dir_all(&config_dir).unwrap();
172 let credentials = config_dir.join("credentials.toml");
173 std::fs::write(
174 &credentials,
175 "access_token = \"token-under-test\"\nemail = \"dev@example.com\"\n",
176 )
177 .unwrap();
178
179 let (api_port, api_requests) = start_fake_account_api();
180 let mut agent = spawn_agent(api_port, &config_dir);
181
182 let id = agent.request(
183 "initialize",
184 json!({"protocolVersion": 1, "clientCapabilities": {}}),
185 );
186 let initialized = agent.wait_for_response(id);
187 assert!(
188 initialized["result"]["agentCapabilities"]["auth"]["logout"].is_object(),
189 "logout capability missing: {initialized}"
190 );
191
192 let id = agent.request("logout", json!({}));
193 let response = agent.wait_for_response(id);
194 assert!(response["result"].is_object(), "{response}");
195
196 assert!(!credentials.exists(), "the stored token should be gone");
197 let head = api_requests
198 .recv_timeout(TIMEOUT)
199 .expect("the account API should be told about the sign-out");
200 assert!(head.starts_with("delete /api/v1/auth/sign_out "), "{head}");
201 assert!(
202 head.contains("authorization: bearer token-under-test"),
203 "{head}"
204 );
205
206 // Signing out twice is not an error: the editor's state may be stale.
207 let id = agent.request("logout", json!({}));
208 agent.wait_for_response(id);
209
210 std::fs::remove_dir_all(&scratch).ok();
211 }
212
213 /// `/whoami` asks `/api/v1/user`, the endpoint that accepts the OAuth tokens
214 /// browser sign-in stores. The deprecated `/api/v1/me` answers them with a 403.
215 #[test]
216 fn whoami_checks_the_session_against_the_user_endpoint() {
217 let scratch = std::env::temp_dir().join(format!("sigit_acp_whoami_{}", std::process::id()));
218 let config_dir = scratch.join("config");
219 std::fs::create_dir_all(&config_dir).unwrap();
220 std::fs::write(
221 config_dir.join("credentials.toml"),
222 "access_token = \"token-under-test\"\nemail = \"dev@example.com\"\n",
223 )
224 .unwrap();
225
226 let (api_port, api_requests) = start_fake_account_api();
227 let mut agent = spawn_agent(api_port, &config_dir);
228
229 let id = agent.request(
230 "initialize",
231 json!({"protocolVersion": 1, "clientCapabilities": {}}),
232 );
233 agent.wait_for_response(id);
234 let id = agent.request("session/new", json!({"cwd": scratch, "mcpServers": []}));
235 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
236 .as_str()
237 .expect("session id")
238 .to_string();
239
240 let reply = agent.prompt_notice(&session_id, "/whoami");
241 assert!(
242 reply.contains("Signed in to siGit Code Cloud as dev@example.com"),
243 "{reply}"
244 );
245
246 let head = api_requests
247 .recv_timeout(TIMEOUT)
248 .expect("/whoami should ask the account API");
249 assert!(head.starts_with("get /api/v1/user "), "{head}");
250 assert!(
251 head.contains("authorization: bearer token-under-test"),
252 "{head}"
253 );
254
255 std::fs::remove_dir_all(&scratch).ok();
256 }