@sigitcode-old / sigit / commits / 50ade97
Fork 1

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

Check /whoami against /api/v1/user and let authenticate switch accounts

/whoami called the deprecated /api/v1/me, which rejects the OAuth tokens browser sign-in stores, so it reported "Session may be expired (HTTP 403)" for a working session. It now asks /api/v1/user, as browser sign-in does. The not-signed-in hint points at a bare /login instead of the email/password form. handle_authenticate no longer returns early when a stored session verifies. Reauthenticate is the user asking to sign in, and skipping the browser would make it impossible to switch accounts once the check works. verify_session had no other callers and is removed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.7.4-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 25, 2026 at 08:02 UTC 50ade976c02f99a7fa61fba6288a0eff5304c772
3 files changed +81 -50
crates/cli/src/account.rs
+4 -39
@@ -27,7 +27,7 @@ fn api_base() -> String {
27 // `serde_json::Value` rather than a struct because of the bare-string variant.
28
29 #[derive(Debug, Deserialize)]
30 -struct MeResponse {
30 +struct UserResponse {
31 #[serde(default)]
32 email: Option<String>,
33 }
@@ -90,40 +90,6 @@ pub async fn authenticate(email: &str, password: &str) -> Result<String, String>
90 Err(format!("sign-in failed: {message}"))
91 }
92
93 -/// Verify that a usable session exists, confirming the stored token against the
94 -/// account API. Returns the signed-in email on success, or a human-readable
95 -/// reason it is not usable. Used by the ACP `authenticate` handler to answer the
96 -/// editor's auth gate.
97 -pub async fn verify_session() -> Result<String, String> {
98 - let Some(creds) = credentials::load() else {
99 - return Err("not signed in".to_string());
100 - };
101 -
102 - let url = format!("{}/api/v1/me", api_base().trim_end_matches('/'));
103 - let response = reqwest::Client::new()
104 - .get(&url)
105 - .bearer_auth(&creds.access_token)
106 - .send()
107 - .await
108 - .map_err(|error| format!("could not reach siGit Code Cloud: {error}"))?;
109 -
110 - if response.status().is_success() {
111 - let email = response
112 - .json::<MeResponse>()
113 - .await
114 - .ok()
115 - .and_then(|me| me.email)
116 - .or(creds.email)
117 - .unwrap_or_else(|| "(unknown)".to_string());
118 - Ok(email)
119 - } else {
120 - Err(format!(
121 - "session expired (HTTP {})",
122 - response.status().as_u16()
123 - ))
124 - }
125 -}
126 -
93 /// Run an interactive sign-in against the terminal, prompting for email and a
94 /// hidden password. Used by `sigit login`, which the editor launches in an
95 /// embedded terminal for ACP terminal-based authentication. Returns the signed-in
@@ -166,11 +132,10 @@ pub async fn end_session() -> String {
132 /// One-line description of the current session, verifying the token if reachable.
133 pub async fn status_line() -> String {
134 let Some(creds) = credentials::load() else {
169 - return "Not signed in. Use `/login <email> <password>` to use siGit Code Cloud."
170 - .to_string();
135 + return "Not signed in. Use `/login` to use siGit Code Cloud.".to_string();
136 };
137
173 - let url = format!("{}/api/v1/me", api_base().trim_end_matches('/'));
138 + let url = format!("{}/api/v1/user", api_base().trim_end_matches('/'));
139 match reqwest::Client::new()
140 .get(&url)
141 .bearer_auth(&creds.access_token)
@@ -179,7 +144,7 @@ pub async fn status_line() -> String {
144 {
145 Ok(response) if response.status().is_success() => {
146 let email = response
182 - .json::<MeResponse>()
147 + .json::<UserResponse>()
148 .await
149 .ok()
150 .and_then(|me| me.email)
crates/cli/src/main.rs
+6 -11
@@ -2332,17 +2332,12 @@ impl SiGitAgent {
2332 ));
2333 }
2334
2335 - // An existing session clears the gate without sending anyone to a
2336 - // browser they didn't ask for.
2337 - if let Ok(email) = account::verify_session().await {
2338 - log::info!("authenticate: verified existing session for {email}");
2339 - return Ok(AuthenticateResponse::default());
2340 - }
2341 -
2342 - // Otherwise run the browser flow. There is no way to collect a password
2343 - // here (the spec forbids asking for credentials in a form) and no way
2344 - // to take a pasted code back, so this is the loopback variant or
2345 - // nothing: the editor gets an error naming the terminal command that
2335 + // `authenticate` is the user asking to sign in, so it always runs the
2336 + // browser flow, even with a valid session stored: that is how they
2337 + // switch accounts. There is no way to collect a password here (the
2338 + // spec forbids asking for credentials in a form) and no way to take a
2339 + // pasted code back, so this is the loopback variant or nothing: the
2340 + // editor gets an error naming the terminal command that
2341 // does work without a listener.
2342 let flow = browser_auth::begin();
2343 if !flow.is_loopback() {
crates/cli/tests/acp_logout.rs
+71
@@ -123,6 +123,32 @@ impl AgentUnderTest {
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 {
@@ -177,3 +203,48 @@ fn logout_is_advertised_and_ends_the_account_session() {
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 +}