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 agent message text it streamed
128 /// before the turn ended.
129 fn prompt_text(&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 reply = 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 if message["method"] == "session/update"
142 && message["params"]["update"]["sessionUpdate"] == "agent_message_chunk"
143 && let Some(chunk) = message["params"]["update"]["content"]["text"].as_str()
144 {
145 reply.push_str(chunk);
146 }
147 if message["id"] == id && message.get("method").is_none() {
148 return reply;
149 }
150 }
151 }
152 }
153
154 impl Drop for AgentUnderTest {
155 fn drop(&mut self) {
156 let _ = self.child.kill();
157 let _ = self.child.wait();
158 }
159 }
160
161 #[test]
162 fn logout_is_advertised_and_ends_the_account_session() {
163 let scratch = std::env::temp_dir().join(format!("sigit_acp_logout_{}", std::process::id()));
164 let config_dir = scratch.join("config");
165 std::fs::create_dir_all(&config_dir).unwrap();
166 let credentials = config_dir.join("credentials.toml");
167 std::fs::write(
168 &credentials,
169 "access_token = \"token-under-test\"\nemail = \"dev@example.com\"\n",
170 )
171 .unwrap();
172
173 let (api_port, api_requests) = start_fake_account_api();
174 let mut agent = spawn_agent(api_port, &config_dir);
175
176 let id = agent.request(
177 "initialize",
178 json!({"protocolVersion": 1, "clientCapabilities": {}}),
179 );
180 let initialized = agent.wait_for_response(id);
181 assert!(
182 initialized["result"]["agentCapabilities"]["auth"]["logout"].is_object(),
183 "logout capability missing: {initialized}"
184 );
185
186 let id = agent.request("logout", json!({}));
187 let response = agent.wait_for_response(id);
188 assert!(response["result"].is_object(), "{response}");
189
190 assert!(!credentials.exists(), "the stored token should be gone");
191 let head = api_requests
192 .recv_timeout(TIMEOUT)
193 .expect("the account API should be told about the sign-out");
194 assert!(head.starts_with("delete /api/v1/auth/sign_out "), "{head}");
195 assert!(
196 head.contains("authorization: bearer token-under-test"),
197 "{head}"
198 );
199
200 // Signing out twice is not an error: the editor's state may be stale.
201 let id = agent.request("logout", json!({}));
202 agent.wait_for_response(id);
203
204 std::fs::remove_dir_all(&scratch).ok();
205 }
206
207 /// `/whoami` asks `/api/v1/user`, the endpoint that accepts the OAuth tokens
208 /// browser sign-in stores. The deprecated `/api/v1/me` answers them with a 403.
209 #[test]
210 fn whoami_checks_the_session_against_the_user_endpoint() {
211 let scratch = std::env::temp_dir().join(format!("sigit_acp_whoami_{}", std::process::id()));
212 let config_dir = scratch.join("config");
213 std::fs::create_dir_all(&config_dir).unwrap();
214 std::fs::write(
215 config_dir.join("credentials.toml"),
216 "access_token = \"token-under-test\"\nemail = \"dev@example.com\"\n",
217 )
218 .unwrap();
219
220 let (api_port, api_requests) = start_fake_account_api();
221 let mut agent = spawn_agent(api_port, &config_dir);
222
223 let id = agent.request(
224 "initialize",
225 json!({"protocolVersion": 1, "clientCapabilities": {}}),
226 );
227 agent.wait_for_response(id);
228 let id = agent.request("session/new", json!({"cwd": scratch, "mcpServers": []}));
229 let session_id = agent.wait_for_response(id)["result"]["sessionId"]
230 .as_str()
231 .expect("session id")
232 .to_string();
233
234 let reply = agent.prompt_text(&session_id, "/whoami");
235 assert!(
236 reply.contains("Signed in to siGit Code Cloud as dev@example.com"),
237 "{reply}"
238 );
239
240 let head = api_requests
241 .recv_timeout(TIMEOUT)
242 .expect("/whoami should ask the account API");
243 assert!(head.starts_with("get /api/v1/user "), "{head}");
244 assert!(
245 head.contains("authorization: bearer token-under-test"),
246 "{head}"
247 );
248
249 std::fs::remove_dir_all(&scratch).ok();
250 }