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
128 impl Drop for AgentUnderTest {
129 fn drop(&mut self) {
130 let _ = self.child.kill();
131 let _ = self.child.wait();
132 }
133 }
134
135 #[test]
136 fn logout_is_advertised_and_ends_the_account_session() {
137 let scratch = std::env::temp_dir().join(format!("sigit_acp_logout_{}", std::process::id()));
138 let config_dir = scratch.join("config");
139 std::fs::create_dir_all(&config_dir).unwrap();
140 let credentials = config_dir.join("credentials.toml");
141 std::fs::write(
142 &credentials,
143 "access_token = \"token-under-test\"\nemail = \"dev@example.com\"\n",
144 )
145 .unwrap();
146
147 let (api_port, api_requests) = start_fake_account_api();
148 let mut agent = spawn_agent(api_port, &config_dir);
149
150 let id = agent.request(
151 "initialize",
152 json!({"protocolVersion": 1, "clientCapabilities": {}}),
153 );
154 let initialized = agent.wait_for_response(id);
155 assert!(
156 initialized["result"]["agentCapabilities"]["auth"]["logout"].is_object(),
157 "logout capability missing: {initialized}"
158 );
159
160 let id = agent.request("logout", json!({}));
161 let response = agent.wait_for_response(id);
162 assert!(response["result"].is_object(), "{response}");
163
164 assert!(!credentials.exists(), "the stored token should be gone");
165 let head = api_requests
166 .recv_timeout(TIMEOUT)
167 .expect("the account API should be told about the sign-out");
168 assert!(head.starts_with("delete /api/v1/auth/sign_out "), "{head}");
169 assert!(
170 head.contains("authorization: bearer token-under-test"),
171 "{head}"
172 );
173
174 // Signing out twice is not an error: the editor's state may be stale.
175 let id = agent.request("logout", json!({}));
176 agent.wait_for_response(id);
177
178 std::fs::remove_dir_all(&scratch).ok();
179 }