feat(acp): add the logout method (#173)

The initialize reply had an empty auth object, so an editor showed no sign out button and the only way out of an account was typing /logout. initialize now advertises agentCapabilities.auth.logout, and a logout request ends the account session. /logout and the new request share one sign_out method. The pinned SDK (2.2.0, schema 1.9.1) has the request type without an unstable feature. The request names no session, so every open thread is signed out. A thread parked on a cloud tier is moved on-device along with the live one. Before, /logout only moved the live thread, and a parked one would fail to restore its tier on its next prompt and run on whatever model the previous thread left live. Each thread that moved gets a fresh model picker, which /logout did not send either. Local inference is switched back on if it was off, so a thread opened after signing out does not ask for the default cloud tier. Fixes #150 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: siGit Code v1.6.1-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 6, 2026 at 20:55 UTC 0803e45a96edf091f06b85ca4e54129842a6d0de
3 files changed +365 -16
.agents/AGENTS.md
+4
@@ -350,6 +350,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
350 those read env vars once at init.
351 - **`src/account.rs`** — siGit Code Cloud auth (`/login`, `/logout`, `/whoami`); authenticates
352 against the account API and stores a session token. Performs no console I/O.
353 + ACP `logout` (the editor's sign out button, shown only because `handle_initialize` advertises
354 + `agentCapabilities.auth.logout`) and `/logout` both go through `SiGitAgent::sign_out` in
355 + `main.rs`. The request names no session, so `leave_cloud` moves the parked threads off their
356 + cloud tier along with the live one and each of them gets a fresh model picker.
357 - **`src/browser_auth.rs`** — browser sign-in, the path the editor's "Sign in to siGit Code"
358 button takes. sigit is a public OAuth client (client id `sigit-code-cli`, no secret, PKCE
359 S256) against the authorization server at `$SIGIT_API_URL/oauth`. Two ways the code gets
src/main.rs
+182 -16
@@ -71,11 +71,12 @@ use onde::inference::SamplingConfig;
71 // schema types moved under `schema::v1` in agent-client-protocol 1.0.
72 use agent_client_protocol::schema::ProtocolVersion;
73 use agent_client_protocol::schema::v1::{
74 - AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
75 - AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
76 - ConfigOptionUpdate, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResourceResource,
77 - ForkSessionRequest, ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
78 - ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
74 + AgentAuthCapabilities, AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest,
75 + AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate,
76 + CancelNotification, ConfigOptionUpdate, ContentBlock, ContentChunk, CurrentModeUpdate,
77 + EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse, Implementation,
78 + InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse,
79 + LoadSessionRequest, LoadSessionResponse, LogoutCapabilities, LogoutRequest, LogoutResponse,
80 McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
81 PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptCapabilities,
82 PromptRequest, PromptResponse, ReadTextFileRequest, RequestPermissionOutcome,
@@ -1824,6 +1825,9 @@ impl SiGitAgent {
1825 .agent_capabilities(
1826 AgentCapabilities::default()
1827 .load_session(true)
1828 + // Gates the editor's sign out button. Without it the
1829 + // only way out of an account is typing `/logout`.
1830 + .auth(AgentAuthCapabilities::new().logout(LogoutCapabilities::new()))
1831 // Lets the editor attach images to a prompt. The capability is fixed
1832 // for the connection while the model is not, so a prompt that
1833 // brings an image to a model that cannot read one is answered with
@@ -1901,6 +1905,69 @@ impl SiGitAgent {
1905 }
1906 }
1907
1908 + /// ACP `logout`, the editor's sign out button. It names no session: the
1909 + /// account belongs to the process, so every open thread is signed out.
1910 + async fn handle_logout(
1911 + &self,
1912 + cx: &ConnectionTo<Client>,
1913 + _req: LogoutRequest,
1914 + ) -> agent_client_protocol::Result<LogoutResponse> {
1915 + let message = self.sign_out(cx).await;
1916 + log::info!("logout: {message}");
1917 + Ok(LogoutResponse::new())
1918 + }
1919 +
1920 + /// Clear the account session, move every thread off the cloud tiers, and
1921 + /// push a fresh picker to each thread whose model changed. Returns the
1922 + /// message to show. Shared by ACP `logout` and the `/logout` command.
1923 + async fn sign_out(&self, cx: &ConnectionTo<Client>) -> String {
1924 + let message = account::end_session().await;
1925 + let local = default_local_model_config();
1926 + for key in self.leave_cloud(&local).await {
1927 + let config_options = build_config_options(&local, &key);
1928 + self.send_tool_call_update(
1929 + cx,
1930 + SessionId::new(key),
1931 + SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(config_options)),
1932 + )
1933 + .ok();
1934 + }
1935 + message
1936 + }
1937 +
1938 + /// Put every session that was on a siGit Code Cloud tier on `local`
1939 + /// instead, and return their ids. The token is gone, so the tiers cannot
1940 + /// serve them.
1941 + ///
1942 + /// Parked sessions are moved along with the live one. Left on a tier, a
1943 + /// parked thread would fail to restore it on its next prompt and run on
1944 + /// whatever model the previous thread left live.
1945 + async fn leave_cloud(&self, local: &GgufModelConfig) -> Vec<String> {
1946 + let on_cloud = |model_id: &str| model_id.starts_with("sigit-cloud:");
1947 +
1948 + // Collected before the live backend moves: `reset_to_local_backend`
1949 + // records the live session's new model, which would hide it here.
1950 + let mut moved = Vec::new();
1951 + for (key, state) in self.sessions.lock().unwrap().iter_mut() {
1952 + if on_cloud(&state.model_id) {
1953 + state.model_id = local.model_id.clone();
1954 + moved.push(key.clone());
1955 + }
1956 + }
1957 +
1958 + let live_on_cloud = on_cloud(&self.current_model.lock().unwrap().model_id);
1959 + if live_on_cloud {
1960 + *self.current_model.lock().unwrap() = local.clone();
1961 + self.reset_to_local_backend().await;
1962 + }
1963 +
1964 + // A new thread opened in cloud mode would ask for the default tier.
1965 + if live_on_cloud || !moved.is_empty() || !settings::local_inference_enabled() {
1966 + let _ = settings::set_local_inference(true);
1967 + }
1968 + moved
1969 + }
1970 +
1971 /// Build the state for a session request: its primary `cwd` plus the extra
1972 /// roots worth keeping (see `workspace::set_additional_roots`).
1973 fn session_state(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> SessionState {
@@ -4708,17 +4775,7 @@ async fn exec_slash_acp(
4775 agent.send_assistant_message(cx, session_id, message).ok();
4776 }
4777 SlashCommand::Logout => {
4711 - // If we're on a cloud tier, drop back to local — the token is gone.
4712 - let on_cloud = {
4713 - let guard = agent.current_model.lock().unwrap();
4714 - guard.model_id.starts_with("sigit-cloud:")
4715 - };
4716 - let message = account::end_session().await;
4717 - if on_cloud {
4718 - *agent.current_model.lock().unwrap() = default_local_model_config();
4719 - agent.reset_to_local_backend().await;
4720 - let _ = settings::set_local_inference(true);
4721 - }
4778 + let message = agent.sign_out(cx).await;
4779 agent.send_assistant_message(cx, session_id, message).ok();
4780 }
4781 SlashCommand::Whoami => {
@@ -5139,6 +5196,20 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
5196 // while the handler blocks it. Requests on one session keep their
5197 // order through that session's lock; whatever touches process-wide
5198 // state takes `workspace_lock` (a prompt does so itself, in pieces).
5199 + .on_receive_request(
5200 + {
5201 + let state = Arc::clone(&state);
5202 + async move |req: LogoutRequest, responder, cx: ConnectionTo<Client>| {
5203 + let state = Arc::clone(&state);
5204 + let task_cx = cx.clone();
5205 + cx.spawn(async move {
5206 + let _workspace = state.workspace_lock.lock().await;
5207 + handle_response(responder, state.handle_logout(&task_cx, req).await)
5208 + })
5209 + }
5210 + },
5211 + agent_client_protocol::on_receive_request!(),
5212 + )
5213 .on_receive_request(
5214 {
5215 let state = Arc::clone(&state);
@@ -5674,6 +5745,101 @@ mod tests {
5745 .expect("cancel notification should remain available to the prompt task");
5746 }
5747
5748 + #[tokio::test]
5749 + #[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
5750 + async fn signing_out_moves_every_cloud_session_on_device() {
5751 + let _guard = crate::ENV_TEST_LOCK
5752 + .lock()
5753 + .unwrap_or_else(|poisoned| poisoned.into_inner());
5754 + let temp = std::env::temp_dir().join(format!("sigit-logout-{}", uuid::Uuid::new_v4()));
5755 + let (repo_a, repo_b, repo_c) = (
5756 + temp.join("repo-a"),
5757 + temp.join("repo-b"),
5758 + temp.join("repo-c"),
5759 + );
5760 + for dir in [&repo_a, &repo_b, &repo_c] {
5761 + std::fs::create_dir_all(dir).unwrap();
5762 + }
5763 + let prev_cwd = std::env::current_dir().unwrap();
5764 + // SAFETY: serialized by ENV_TEST_LOCK and restored below.
5765 + unsafe {
5766 + std::env::set_var("SIGIT_CONFIG_DIR", temp.join("config"));
5767 + std::env::set_var("SIGIT_LOCAL_INFERENCE", "on");
5768 + }
5769 +
5770 + let local = default_local_model_config();
5771 + let agent = SiGitAgent::new(
5772 + Arc::new(ChatEngine::new()),
5773 + local.clone(),
5774 + Arc::new(AtomicBool::new(true)),
5775 + Arc::new(AtomicBool::new(false)),
5776 + Arc::new(std::sync::Mutex::new(None)),
5777 + false,
5778 + false,
5779 + );
5780 + let cloud = || provider::ProviderConfig {
5781 + display_name: "test cloud".into(),
5782 + base_url: "http://127.0.0.1:9".into(),
5783 + api_key: String::new(),
5784 + model: "test".into(),
5785 + };
5786 +
5787 + // Thread A is parked on a cloud tier, thread B stays on-device, and
5788 + // thread C is live on a cloud tier with a turn in its history.
5789 + for (id, repo) in [
5790 + ("thread-a", &repo_a),
5791 + ("thread-b", &repo_b),
5792 + ("thread-c", &repo_c),
5793 + ] {
5794 + agent
5795 + .open_session(&SessionId::new(id), agent.session_state(repo, &[]))
5796 + .await;
5797 + }
5798 + agent
5799 + .sessions
5800 + .lock()
5801 + .unwrap()
5802 + .get_mut("thread-a")
5803 + .unwrap()
5804 + .model_id = "sigit-cloud:a".into();
5805 + agent
5806 + .install_remote_backend(cloud(), "sigit-cloud:c".into())
5807 + .await;
5808 + say(&agent, "working on repo c").await;
5809 + // The cloud tiers are in use, so the stored toggle reads "cloud".
5810 + // SAFETY: serialized by ENV_TEST_LOCK.
5811 + unsafe {
5812 + std::env::remove_var("SIGIT_LOCAL_INFERENCE");
5813 + }
5814 + settings::set_local_inference(false).unwrap();
5815 +
5816 + let mut moved = agent.leave_cloud(&local).await;
5817 + moved.sort();
5818 + assert_eq!(moved, ["thread-a", "thread-c"]);
5819 +
5820 + assert!(!agent.backend.lock().await.is_remote());
5821 + assert_eq!(agent.current_model.lock().unwrap().model_id, local.model_id);
5822 + for state in agent.sessions.lock().unwrap().values() {
5823 + assert_eq!(state.model_id, local.model_id);
5824 + }
5825 + assert!(settings::local_inference_enabled());
5826 + // The thread carries on where the cloud tier left it.
5827 + let history = history_text(&live(&agent).await);
5828 + assert!(history.contains("working on repo c"), "{history}");
5829 +
5830 + // Nothing is left to move the second time.
5831 + assert!(agent.leave_cloud(&local).await.is_empty());
5832 +
5833 + std::env::set_current_dir(prev_cwd).unwrap();
5834 + tools::set_active_session(None);
5835 + workspace::replace_additional_roots(Vec::new());
5836 + // SAFETY: serialized by ENV_TEST_LOCK.
5837 + unsafe {
5838 + std::env::remove_var("SIGIT_CONFIG_DIR");
5839 + }
5840 + std::fs::remove_dir_all(&temp).ok();
5841 + }
5842 +
5843 #[tokio::test]
5844 #[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
5845 async fn each_session_keeps_its_own_roots_and_conversation() {
tests/acp_logout.rs new
+179
@@ -0,0 +1,179 @@
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 +}