@sigitcode-old / sigit / commits / 442c99d
Fork 1

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

Read the cloud session token on every request

An OpenAiBackend kept the token it was built with. After Reauthenticate stored a new one, the live backend, each parked thread's backend and the subagent factory all went on sending the old account's bearer, so a capped account kept failing until Reload Agent restarted the process. ProviderConfig now says whether its api_key is the stored session token (true only for the cloud tiers). A backend built with with_session_token(true) reads the stored token for each request and falls back to api_key when none is stored, so a signed-out request fails the way it did before. fresh() carries the flag to per-thread backends. The TUI tier switch and the headless run build their backends the same way. The new ACP test stores token-a, prompts, rewrites the credentials with token-b and prompts again; the fake cloud endpoint must see each bearer in turn. 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:07 UTC 442c99db7c97e8a4c46b7f20b2fc61c597e036b9
6 files changed +304 -45
crates/cli/src/backend.rs
+27 -3
@@ -951,6 +951,9 @@ fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
951 pub struct OpenAiBackend {
952 base_url: String,
953 api_key: String,
954 + /// Read the bearer from the stored session on each request instead of
955 + /// using `api_key`; see `bearer`.
956 + session_token: bool,
957 model: String,
958 http: reqwest::Client,
959 /// The full message list sent on each request (system + turns + tool results).
@@ -985,6 +988,7 @@ impl OpenAiBackend {
988 Self {
989 base_url: base_url.into(),
990 api_key: api_key.into(),
991 + session_token: false,
992 accepts_images: crate::provider::model_accepts_images(&model),
993 accepts_audio: crate::provider::model_accepts_audio(&model),
994 model,
@@ -994,6 +998,25 @@ impl OpenAiBackend {
998 }
999 }
1000
1001 + /// Have the backend send the stored siGit Code Cloud session token as its
1002 + /// bearer, read on every request. A token captured at build time goes
1003 + /// stale when the user signs in as someone else; `api_key` is then only
1004 + /// the fallback for a signed-out request.
1005 + pub fn with_session_token(mut self, on: bool) -> Self {
1006 + self.session_token = on;
1007 + self
1008 + }
1009 +
1010 + /// The bearer for the next request.
1011 + fn bearer(&self) -> String {
1012 + if self.session_token
1013 + && let Some(token) = crate::credentials::load_token()
1014 + {
1015 + return token;
1016 + }
1017 + self.api_key.clone()
1018 + }
1019 +
1020 fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
1021 tools
1022 .iter()
@@ -1122,7 +1145,7 @@ impl OpenAiBackend {
1145 let mut response = self
1146 .http
1147 .post(&url)
1125 - .bearer_auth(&self.api_key)
1148 + .bearer_auth(self.bearer())
1149 .json(&body)
1150 .send()
1151 .await
@@ -1144,7 +1167,7 @@ impl OpenAiBackend {
1167 response = self
1168 .http
1169 .post(&url)
1147 - .bearer_auth(&self.api_key)
1170 + .bearer_auth(self.bearer())
1171 .json(&body)
1172 .send()
1173 .await
@@ -1193,7 +1216,7 @@ impl OpenAiBackend {
1216 let response = self
1217 .http
1218 .post(&url)
1196 - .bearer_auth(&self.api_key)
1219 + .bearer_auth(self.bearer())
1220 .json(&body)
1221 .send()
1222 .await
@@ -1826,6 +1849,7 @@ impl InferenceBackend for OpenAiBackend {
1849 Some(Arc::new(Self {
1850 base_url: self.base_url.clone(),
1851 api_key: self.api_key.clone(),
1852 + session_token: self.session_token,
1853 model: self.model.clone(),
1854 // The client is a handle on one connection pool; sharing it keeps
1855 // connections to the endpoint warm across sessions.
crates/cli/src/chat.rs
+9 -6
@@ -3013,12 +3013,15 @@ mod tui {
3013 let system_prompt = crate::with_launch_context(
3014 &crate::system_prompt_for_model(true),
3015 );
3016 - app.backend = Arc::new(OpenAiBackend::new(
3017 - provider.base_url,
3018 - provider.api_key,
3019 - provider.model,
3020 - Some(system_prompt),
3021 - ));
3016 + app.backend = Arc::new(
3017 + OpenAiBackend::new(
3018 + provider.base_url,
3019 + provider.api_key,
3020 + provider.model,
3021 + Some(system_prompt),
3022 + )
3023 + .with_session_token(provider.session_token),
3024 + );
3025 app.current_model_name = provider.display_name.clone();
3026 app.tool_calling = true;
3027 // Selecting a cloud tier puts us in cloud mode.
crates/cli/src/headless.rs
+9 -6
@@ -349,12 +349,15 @@ pub async fn run(config: HeadlessConfig) -> i32 {
349 &crate::workspace::additional_roots(),
350 ));
351
352 - let backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
353 - cfg.base_url,
354 - cfg.api_key,
355 - cfg.model.clone(),
356 - Some(system_prompt),
357 - ));
352 + let backend: Arc<dyn InferenceBackend> = Arc::new(
353 + OpenAiBackend::new(
354 + cfg.base_url,
355 + cfg.api_key,
356 + cfg.model.clone(),
357 + Some(system_prompt),
358 + )
359 + .with_session_token(cfg.session_token),
360 + );
361
362 if config.resume {
363 let Some(history) = session_store::load(&config.session_id) else {
crates/cli/src/main.rs
+43 -30
@@ -907,12 +907,15 @@ fn agent_tools_as_specs() -> Vec<ToolSpec> {
907 fn register_subagent_factory_for(cfg: &provider::ProviderConfig) {
908 let cfg = cfg.clone();
909 tools::set_subagent_factory(Box::new(move |system_prompt: &str| {
910 - Some(Arc::new(OpenAiBackend::new(
911 - cfg.base_url.clone(),
912 - cfg.api_key.clone(),
913 - cfg.model.clone(),
914 - Some(system_prompt.to_string()),
915 - )) as Arc<dyn InferenceBackend>)
910 + Some(Arc::new(
911 + OpenAiBackend::new(
912 + cfg.base_url.clone(),
913 + cfg.api_key.clone(),
914 + cfg.model.clone(),
915 + Some(system_prompt.to_string()),
916 + )
917 + .with_session_token(cfg.session_token),
918 + ) as Arc<dyn InferenceBackend>)
919 }));
920 }
921
@@ -4390,12 +4393,10 @@ impl SiGitAgent {
4393 )),
4394 None => system_prompt_for_model(true),
4395 };
4393 - let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
4394 - cfg.base_url,
4395 - cfg.api_key,
4396 - cfg.model,
4397 - Some(system_prompt),
4398 - ));
4396 + let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(
4397 + OpenAiBackend::new(cfg.base_url, cfg.api_key, cfg.model, Some(system_prompt))
4398 + .with_session_token(cfg.session_token),
4399 + );
4400 // Continue the thread on the new backend instead of starting over.
4401 let carried =
4402 backend::carryover_history(self.backend.lock().await.history_snapshot().await);
@@ -6015,12 +6016,15 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
6016 let _ = load_tx.send(Ok(()));
6017 let label = provider.display_name.clone();
6018 register_subagent_factory_for(&provider);
6018 - let backend = Arc::new(OpenAiBackend::new(
6019 - provider.base_url,
6020 - provider.api_key,
6021 - provider.model,
6022 - Some(with_instructions(system_prompt_for_model(true))),
6023 - )) as Arc<dyn InferenceBackend>;
6019 + let backend = Arc::new(
6020 + OpenAiBackend::new(
6021 + provider.base_url,
6022 + provider.api_key,
6023 + provider.model,
6024 + Some(with_instructions(system_prompt_for_model(true))),
6025 + )
6026 + .with_session_token(provider.session_token),
6027 + ) as Arc<dyn InferenceBackend>;
6028 (backend, label)
6029 }
6030 None => {
@@ -6045,12 +6049,15 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
6049 let _ = load_tx.send(Ok(()));
6050 let label = provider.display_name.clone();
6051 register_subagent_factory_for(&provider);
6048 - let backend = Arc::new(OpenAiBackend::new(
6049 - provider.base_url,
6050 - provider.api_key,
6051 - provider.model,
6052 - Some(with_instructions(system_prompt_for_model(true))),
6053 - )) as Arc<dyn InferenceBackend>;
6052 + let backend = Arc::new(
6053 + OpenAiBackend::new(
6054 + provider.base_url,
6055 + provider.api_key,
6056 + provider.model,
6057 + Some(with_instructions(system_prompt_for_model(true))),
6058 + )
6059 + .with_session_token(provider.session_token),
6060 + ) as Arc<dyn InferenceBackend>;
6061 (backend, label)
6062 }
6063 None => {
@@ -6217,12 +6224,15 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
6224 cfg.base_url
6225 );
6226 register_subagent_factory_for(&cfg);
6220 - let override_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
6221 - cfg.base_url,
6222 - cfg.api_key,
6223 - cfg.model,
6224 - Some(system_prompt_for_model(true)),
6225 - ));
6227 + let override_backend: Arc<dyn InferenceBackend> = Arc::new(
6228 + OpenAiBackend::new(
6229 + cfg.base_url,
6230 + cfg.api_key,
6231 + cfg.model,
6232 + Some(system_prompt_for_model(true)),
6233 + )
6234 + .with_session_token(cfg.session_token),
6235 + );
6236 *state.backend.lock().await = override_backend;
6237 } else {
6238 // On-device inference has a single shared conversation history, so a
@@ -7133,6 +7143,7 @@ mod tests {
7143 base_url: "http://127.0.0.1:9".into(),
7144 api_key: String::new(),
7145 model: "test".into(),
7146 + session_token: false,
7147 };
7148
7149 // Thread A is parked on a cloud tier, thread B stays on-device, and
@@ -7249,6 +7260,7 @@ mod tests {
7260 base_url: "http://127.0.0.1:9".into(),
7261 api_key: String::new(),
7262 model: "test".into(),
7263 + session_token: false,
7264 },
7265 "sigit-cloud:test".into(),
7266 )
@@ -7311,6 +7323,7 @@ mod tests {
7323 base_url: "http://127.0.0.1:9".into(),
7324 api_key: String::new(),
7325 model: "test-b".into(),
7326 + session_token: false,
7327 },
7328 "sigit-cloud:test-b".into(),
7329 )
crates/cli/src/provider.rs
+8
@@ -110,6 +110,7 @@ pub fn cloud_tier_provider(tier: &str) -> Option<ProviderConfig> {
110 base_url: cloud_base_url(),
111 api_key: token,
112 model: tier_to_model(tier),
113 + session_token: true,
114 })
115 }
116
@@ -148,6 +149,11 @@ pub struct ProviderConfig {
149 pub api_key: String,
150 /// Model id sent to the endpoint, e.g. `onde-balanced` or `gpt-4o-mini`.
151 pub model: String,
152 + /// Whether `api_key` is the stored siGit Code Cloud session token. A
153 + /// backend built from such a config reads the token again on every
154 + /// request, so signing in as another account takes effect without a
155 + /// restart.
156 + pub session_token: bool,
157 }
158
159 /// Title-case a tier name for display (`balanced` → `Balanced`).
@@ -201,6 +207,7 @@ fn from_env() -> Option<ProviderConfig> {
207 base_url,
208 api_key,
209 model,
210 + session_token: false,
211 })
212 }
213
@@ -260,6 +267,7 @@ fn from_config_file() -> Result<Option<ProviderConfig>, String> {
267 base_url: entry.base_url,
268 api_key: entry.api_key,
269 model: entry.model,
270 + session_token: false,
271 }))
272 }
273
crates/cli/tests/acp_cloud_reauth.rs new
+208
@@ -0,0 +1,208 @@
1 +//! Signing in as another account takes effect on the next message.
2 +//!
3 +//! A cloud backend used to keep the session token it was built with, so after
4 +//! Reauthenticate wrote a new one the thread went on sending the old account's
5 +//! bearer until the editor restarted the agent. The token is now read from the
6 +//! stored session on every request.
7 +
8 +use std::io::{BufRead, BufReader, Read, 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(60);
17 +
18 +/// Stand in for `<cloud>/chat/completions`. The `authorization` header of each
19 +/// request is forwarded to the returned receiver.
20 +fn start_fake_cloud() -> (u16, Receiver<String>) {
21 + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake cloud");
22 + let port = listener.local_addr().unwrap().port();
23 + let (auth_tx, auths) = 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 content_length = 0usize;
33 + let mut authorization = String::new();
34 + loop {
35 + let mut line = String::new();
36 + if reader.read_line(&mut line).unwrap_or(0) == 0 {
37 + break;
38 + }
39 + let line = line.trim().to_string();
40 + if line.is_empty() {
41 + break;
42 + }
43 + let lower = line.to_ascii_lowercase();
44 + if let Some(length) = lower.strip_prefix("content-length:") {
45 + content_length = length.trim().parse().unwrap_or(0);
46 + }
47 + if lower.starts_with("authorization:") {
48 + authorization = lower;
49 + }
50 + }
51 + let mut body = vec![0u8; content_length];
52 + if reader.read_exact(&mut body).is_err() {
53 + continue;
54 + }
55 + let _ = auth_tx.send(authorization);
56 +
57 + let body = format!(
58 + "data: {}\n\ndata: [DONE]\n\n",
59 + json!({"choices": [{"delta": {"content": "ok"}}]})
60 + );
61 + let response = format!(
62 + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
63 + content-length: {}\r\nconnection: close\r\n\r\n{body}",
64 + body.len()
65 + );
66 + let _ = stream.write_all(response.as_bytes());
67 + let _ = stream.flush();
68 + }
69 + });
70 +
71 + (port, auths)
72 +}
73 +
74 +struct AgentUnderTest {
75 + child: Child,
76 + stdin: ChildStdin,
77 + incoming: Receiver<Value>,
78 + next_id: u64,
79 +}
80 +
81 +fn spawn_agent(cloud_port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
82 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
83 + // No provider override and no on-device model: the thread starts on a
84 + // cloud tier because a session is stored.
85 + .env_remove("OPENAI_BASE_URL")
86 + .env_remove("OPENAI_API_KEY")
87 + .env_remove("SIGIT_MODEL")
88 + .env("SIGIT_LOCAL_INFERENCE", "off")
89 + .env("SIGIT_CLOUD_URL", format!("http://127.0.0.1:{cloud_port}"))
90 + .env("SIGIT_CONFIG_DIR", config_dir)
91 + .env("SIGIT_MCP", "off")
92 + .stdin(Stdio::piped())
93 + .stdout(Stdio::piped())
94 + .stderr(Stdio::null())
95 + .spawn()
96 + .expect("spawn sigit in ACP mode");
97 +
98 + let stdout = child.stdout.take().unwrap();
99 + let (message_tx, incoming) = channel();
100 + std::thread::spawn(move || {
101 + for line in BufReader::new(stdout).lines() {
102 + let Ok(line) = line else { break };
103 + if let Ok(message) = serde_json::from_str::<Value>(&line)
104 + && message_tx.send(message).is_err()
105 + {
106 + break;
107 + }
108 + }
109 + });
110 +
111 + let stdin = child.stdin.take().unwrap();
112 + AgentUnderTest {
113 + child,
114 + stdin,
115 + incoming,
116 + next_id: 0,
117 + }
118 +}
119 +
120 +impl AgentUnderTest {
121 + fn request(&mut self, method: &str, params: Value) -> u64 {
122 + self.next_id += 1;
123 + let id = self.next_id;
124 + let mut line =
125 + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
126 + line.push('\n');
127 + self.stdin.write_all(line.as_bytes()).expect("write stdin");
128 + self.stdin.flush().expect("flush stdin");
129 + id
130 + }
131 +
132 + fn wait_for_response(&mut self, id: u64) -> Value {
133 + let deadline = Instant::now() + TIMEOUT;
134 + loop {
135 + let remaining = deadline.saturating_duration_since(Instant::now());
136 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
137 + panic!("timed out waiting for the response to request {id}");
138 + };
139 + if message["id"] == id && message.get("method").is_none() {
140 + assert!(
141 + message.get("error").is_none(),
142 + "request {id} failed: {message}"
143 + );
144 + return message;
145 + }
146 + }
147 + }
148 +
149 + fn prompt(&mut self, session_id: &str, text: &str) {
150 + let id = self.request(
151 + "session/prompt",
152 + json!({"sessionId": session_id, "prompt": [{"type": "text", "text": text}]}),
153 + );
154 + self.wait_for_response(id);
155 + }
156 +}
157 +
158 +impl Drop for AgentUnderTest {
159 + fn drop(&mut self) {
160 + let _ = self.child.kill();
161 + let _ = self.child.wait();
162 + }
163 +}
164 +
165 +fn store_token(config_dir: &std::path::Path, token: &str) {
166 + std::fs::write(
167 + config_dir.join("credentials.toml"),
168 + format!("access_token = \"{token}\"\nemail = \"dev@example.com\"\n"),
169 + )
170 + .unwrap();
171 +}
172 +
173 +#[test]
174 +fn a_new_session_token_is_used_by_the_next_turn() {
175 + let scratch = std::env::temp_dir().join(format!("sigit_acp_reauth_{}", std::process::id()));
176 + let config_dir = scratch.join("config");
177 + let work_dir = scratch.join("work");
178 + std::fs::create_dir_all(&config_dir).unwrap();
179 + std::fs::create_dir_all(&work_dir).unwrap();
180 + store_token(&config_dir, "token-a");
181 +
182 + let (port, auths) = start_fake_cloud();
183 + let mut agent = spawn_agent(port, &config_dir);
184 +
185 + let id = agent.request(
186 + "initialize",
187 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
188 + );
189 + agent.wait_for_response(id);
190 + let id = agent.request("session/new", json!({"cwd": work_dir, "mcpServers": []}));
191 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
192 + .as_str()
193 + .expect("session id")
194 + .to_string();
195 +
196 + agent.prompt(&session_id, "first");
197 + let first = auths.recv_timeout(TIMEOUT).expect("first request");
198 + assert_eq!(first, "authorization: bearer token-a");
199 +
200 + // What Reauthenticate does once the browser flow hands back a code.
201 + store_token(&config_dir, "token-b");
202 +
203 + agent.prompt(&session_id, "second");
204 + let second = auths.recv_timeout(TIMEOUT).expect("second request");
205 + assert_eq!(second, "authorization: bearer token-b");
206 +
207 + std::fs::remove_dir_all(&scratch).ok();
208 +}