sigit-code 1.7.4__tar.gz → 1.7.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {sigit_code-1.7.4 → sigit_code-1.7.5}/Cargo.lock +2 -2
  2. {sigit_code-1.7.4 → sigit_code-1.7.5}/Cargo.toml +2 -2
  3. {sigit_code-1.7.4 → sigit_code-1.7.5}/PKG-INFO +1 -1
  4. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/account.rs +4 -39
  5. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/backend.rs +27 -3
  6. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/chat.rs +9 -6
  7. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/headless.rs +9 -6
  8. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/main.rs +49 -41
  9. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/provider.rs +11 -1
  10. sigit_code-1.7.5/crates/cli/tests/acp_cloud_reauth.rs +208 -0
  11. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_logout.rs +71 -0
  12. {sigit_code-1.7.4 → sigit_code-1.7.5}/README.md +0 -0
  13. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/Cargo.toml +0 -0
  14. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/README.md +0 -0
  15. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/client_fs.rs +0 -0
  16. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/client_terminal.rs +0 -0
  17. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/commands.rs +0 -0
  18. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/frontmatter.rs +0 -0
  19. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/harness_markup.rs +0 -0
  20. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/hooks.rs +0 -0
  21. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/inline_tool_calls.rs +0 -0
  22. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/instructions.rs +0 -0
  23. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/mcp.rs +0 -0
  24. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/models.rs +0 -0
  25. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/permissions.rs +0 -0
  26. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/program_status.rs +0 -0
  27. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/repetition.rs +0 -0
  28. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/session_store.rs +0 -0
  29. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/settings.rs +0 -0
  30. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/setup.rs +0 -0
  31. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/skills.rs +0 -0
  32. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/subagents.rs +0 -0
  33. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/tools.rs +0 -0
  34. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/src/workspace.rs +0 -0
  35. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_audio.rs +0 -0
  36. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_client_fs.rs +0 -0
  37. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_client_terminal.rs +0 -0
  38. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_concurrent_sessions.rs +0 -0
  39. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_elicitation.rs +0 -0
  40. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_embedded_context.rs +0 -0
  41. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_endpoint_errors.rs +0 -0
  42. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_error_codes.rs +0 -0
  43. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_harness_markup.rs +0 -0
  44. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_images.rs +0 -0
  45. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_multi_root.rs +0 -0
  46. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_permissions.rs +0 -0
  47. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_session_list.rs +0 -0
  48. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_session_load.rs +0 -0
  49. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_stop_reasons.rs +0 -0
  50. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_tool_call_name.rs +0 -0
  51. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/acp_tool_stdin.rs +0 -0
  52. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/headless_mode.rs +0 -0
  53. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/cli/tests/version_flag.rs +0 -0
  54. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/Cargo.toml +0 -0
  55. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/README.md +0 -0
  56. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/assets/sign-in-logo.png +0 -0
  57. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/src/auth.rs +0 -0
  58. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/src/browser_auth.rs +0 -0
  59. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/src/credentials.rs +0 -0
  60. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/src/lib.rs +0 -0
  61. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/src/profile.rs +0 -0
  62. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/src/prompt.rs +0 -0
  63. {sigit_code-1.7.4 → sigit_code-1.7.5}/crates/sigit-agent/tests/embedded.rs +0 -0
  64. {sigit_code-1.7.4 → sigit_code-1.7.5}/pyproject.toml +0 -0
@@ -5945,7 +5945,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5945
5945
 
5946
5946
  [[package]]
5947
5947
  name = "sigit"
5948
- version = "1.7.4"
5948
+ version = "1.7.5"
5949
5949
  dependencies = [
5950
5950
  "agent-client-protocol",
5951
5951
  "anyhow",
@@ -5976,7 +5976,7 @@ dependencies = [
5976
5976
 
5977
5977
  [[package]]
5978
5978
  name = "sigit-agent"
5979
- version = "1.7.4"
5979
+ version = "1.7.5"
5980
5980
  dependencies = [
5981
5981
  "agent-client-protocol",
5982
5982
  "anyhow",
@@ -3,7 +3,7 @@ resolver = "3"
3
3
  members = ["crates/cli", "crates/sigit-*"]
4
4
 
5
5
  [workspace.package]
6
- version = "1.7.4"
6
+ version = "1.7.5"
7
7
  edition = "2024"
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/getsigit/sigit"
@@ -16,7 +16,7 @@ authors = ["Seto Elkahfi <seto@sigit.si>", "paydii <193906237+paydii@users.norep
16
16
  # crates.io, so this resolves from the registry rather than the onde-ed checkout next door.
17
17
  ed-acp = { version = "1.3.0", default-features = false }
18
18
  ed-acp-testkit = "1.3.0"
19
- sigit-agent = { version = "1.7.4", default-features = false, path = "crates/sigit-agent" }
19
+ sigit-agent = { version = "1.7.5", default-features = false, path = "crates/sigit-agent" }
20
20
 
21
21
  # ACP protocol SDK
22
22
  agent-client-protocol = { version = "2.2", features = ["unstable_session_fork"] }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sigit-code
3
- Version: 1.7.4
3
+ Version: 1.7.5
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -27,7 +27,7 @@ fn api_base() -> String {
27
27
  // `serde_json::Value` rather than a struct because of the bare-string variant.
28
28
 
29
29
  #[derive(Debug, Deserialize)]
30
- struct MeResponse {
30
+ struct UserResponse {
31
31
  #[serde(default)]
32
32
  email: Option<String>,
33
33
  }
@@ -90,40 +90,6 @@ pub async fn authenticate(email: &str, password: &str) -> Result<String, String>
90
90
  Err(format!("sign-in failed: {message}"))
91
91
  }
92
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
-
127
93
  /// Run an interactive sign-in against the terminal, prompting for email and a
128
94
  /// hidden password. Used by `sigit login`, which the editor launches in an
129
95
  /// embedded terminal for ACP terminal-based authentication. Returns the signed-in
@@ -166,11 +132,10 @@ pub async fn end_session() -> String {
166
132
  /// One-line description of the current session, verifying the token if reachable.
167
133
  pub async fn status_line() -> String {
168
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();
171
136
  };
172
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('/'));
174
139
  match reqwest::Client::new()
175
140
  .get(&url)
176
141
  .bearer_auth(&creds.access_token)
@@ -179,7 +144,7 @@ pub async fn status_line() -> String {
179
144
  {
180
145
  Ok(response) if response.status().is_success() => {
181
146
  let email = response
182
- .json::<MeResponse>()
147
+ .json::<UserResponse>()
183
148
  .await
184
149
  .ok()
185
150
  .and_then(|me| me.email)
@@ -951,6 +951,9 @@ fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
951
951
  pub struct OpenAiBackend {
952
952
  base_url: String,
953
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,
954
957
  model: String,
955
958
  http: reqwest::Client,
956
959
  /// The full message list sent on each request (system + turns + tool results).
@@ -985,6 +988,7 @@ impl OpenAiBackend {
985
988
  Self {
986
989
  base_url: base_url.into(),
987
990
  api_key: api_key.into(),
991
+ session_token: false,
988
992
  accepts_images: crate::provider::model_accepts_images(&model),
989
993
  accepts_audio: crate::provider::model_accepts_audio(&model),
990
994
  model,
@@ -994,6 +998,25 @@ impl OpenAiBackend {
994
998
  }
995
999
  }
996
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
+
997
1020
  fn tools_json(tools: &[ToolSpec]) -> Vec<serde_json::Value> {
998
1021
  tools
999
1022
  .iter()
@@ -1122,7 +1145,7 @@ impl OpenAiBackend {
1122
1145
  let mut response = self
1123
1146
  .http
1124
1147
  .post(&url)
1125
- .bearer_auth(&self.api_key)
1148
+ .bearer_auth(self.bearer())
1126
1149
  .json(&body)
1127
1150
  .send()
1128
1151
  .await
@@ -1144,7 +1167,7 @@ impl OpenAiBackend {
1144
1167
  response = self
1145
1168
  .http
1146
1169
  .post(&url)
1147
- .bearer_auth(&self.api_key)
1170
+ .bearer_auth(self.bearer())
1148
1171
  .json(&body)
1149
1172
  .send()
1150
1173
  .await
@@ -1193,7 +1216,7 @@ impl OpenAiBackend {
1193
1216
  let response = self
1194
1217
  .http
1195
1218
  .post(&url)
1196
- .bearer_auth(&self.api_key)
1219
+ .bearer_auth(self.bearer())
1197
1220
  .json(&body)
1198
1221
  .send()
1199
1222
  .await
@@ -1826,6 +1849,7 @@ impl InferenceBackend for OpenAiBackend {
1826
1849
  Some(Arc::new(Self {
1827
1850
  base_url: self.base_url.clone(),
1828
1851
  api_key: self.api_key.clone(),
1852
+ session_token: self.session_token,
1829
1853
  model: self.model.clone(),
1830
1854
  // The client is a handle on one connection pool; sharing it keeps
1831
1855
  // connections to the endpoint warm across sessions.
@@ -3013,12 +3013,15 @@ mod tui {
3013
3013
  let system_prompt = crate::with_launch_context(
3014
3014
  &crate::system_prompt_for_model(true),
3015
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
+ );
3022
3025
  app.current_model_name = provider.display_name.clone();
3023
3026
  app.tool_calling = true;
3024
3027
  // Selecting a cloud tier puts us in cloud mode.
@@ -349,12 +349,15 @@ pub async fn run(config: HeadlessConfig) -> i32 {
349
349
  &crate::workspace::additional_roots(),
350
350
  ));
351
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
+ );
358
361
 
359
362
  if config.resume {
360
363
  let Some(history) = session_store::load(&config.session_id) else {
@@ -907,12 +907,15 @@ fn agent_tools_as_specs() -> Vec<ToolSpec> {
907
907
  fn register_subagent_factory_for(cfg: &provider::ProviderConfig) {
908
908
  let cfg = cfg.clone();
909
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>)
916
919
  }));
917
920
  }
918
921
 
@@ -2332,17 +2335,12 @@ impl SiGitAgent {
2332
2335
  ));
2333
2336
  }
2334
2337
 
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
2338
+ // `authenticate` is the user asking to sign in, so it always runs the
2339
+ // browser flow, even with a valid session stored: that is how they
2340
+ // switch accounts. There is no way to collect a password here (the
2341
+ // spec forbids asking for credentials in a form) and no way to take a
2342
+ // pasted code back, so this is the loopback variant or nothing: the
2343
+ // editor gets an error naming the terminal command that
2346
2344
  // does work without a listener.
2347
2345
  let flow = browser_auth::begin();
2348
2346
  if !flow.is_loopback() {
@@ -4390,12 +4388,10 @@ impl SiGitAgent {
4390
4388
  )),
4391
4389
  None => system_prompt_for_model(true),
4392
4390
  };
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
- ));
4391
+ let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(
4392
+ OpenAiBackend::new(cfg.base_url, cfg.api_key, cfg.model, Some(system_prompt))
4393
+ .with_session_token(cfg.session_token),
4394
+ );
4399
4395
  // Continue the thread on the new backend instead of starting over.
4400
4396
  let carried =
4401
4397
  backend::carryover_history(self.backend.lock().await.history_snapshot().await);
@@ -6015,12 +6011,15 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
6015
6011
  let _ = load_tx.send(Ok(()));
6016
6012
  let label = provider.display_name.clone();
6017
6013
  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>;
6014
+ let backend = Arc::new(
6015
+ OpenAiBackend::new(
6016
+ provider.base_url,
6017
+ provider.api_key,
6018
+ provider.model,
6019
+ Some(with_instructions(system_prompt_for_model(true))),
6020
+ )
6021
+ .with_session_token(provider.session_token),
6022
+ ) as Arc<dyn InferenceBackend>;
6024
6023
  (backend, label)
6025
6024
  }
6026
6025
  None => {
@@ -6045,12 +6044,15 @@ async fn run_interactive(tty: std::fs::File, mut cleanup_tty: std::fs::File) ->
6045
6044
  let _ = load_tx.send(Ok(()));
6046
6045
  let label = provider.display_name.clone();
6047
6046
  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>;
6047
+ let backend = Arc::new(
6048
+ OpenAiBackend::new(
6049
+ provider.base_url,
6050
+ provider.api_key,
6051
+ provider.model,
6052
+ Some(with_instructions(system_prompt_for_model(true))),
6053
+ )
6054
+ .with_session_token(provider.session_token),
6055
+ ) as Arc<dyn InferenceBackend>;
6054
6056
  (backend, label)
6055
6057
  }
6056
6058
  None => {
@@ -6217,12 +6219,15 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
6217
6219
  cfg.base_url
6218
6220
  );
6219
6221
  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
- ));
6222
+ let override_backend: Arc<dyn InferenceBackend> = Arc::new(
6223
+ OpenAiBackend::new(
6224
+ cfg.base_url,
6225
+ cfg.api_key,
6226
+ cfg.model,
6227
+ Some(system_prompt_for_model(true)),
6228
+ )
6229
+ .with_session_token(cfg.session_token),
6230
+ );
6226
6231
  *state.backend.lock().await = override_backend;
6227
6232
  } else {
6228
6233
  // On-device inference has a single shared conversation history, so a
@@ -7133,6 +7138,7 @@ mod tests {
7133
7138
  base_url: "http://127.0.0.1:9".into(),
7134
7139
  api_key: String::new(),
7135
7140
  model: "test".into(),
7141
+ session_token: false,
7136
7142
  };
7137
7143
 
7138
7144
  // Thread A is parked on a cloud tier, thread B stays on-device, and
@@ -7249,6 +7255,7 @@ mod tests {
7249
7255
  base_url: "http://127.0.0.1:9".into(),
7250
7256
  api_key: String::new(),
7251
7257
  model: "test".into(),
7258
+ session_token: false,
7252
7259
  },
7253
7260
  "sigit-cloud:test".into(),
7254
7261
  )
@@ -7311,6 +7318,7 @@ mod tests {
7311
7318
  base_url: "http://127.0.0.1:9".into(),
7312
7319
  api_key: String::new(),
7313
7320
  model: "test-b".into(),
7321
+ session_token: false,
7314
7322
  },
7315
7323
  "sigit-cloud:test-b".into(),
7316
7324
  )
@@ -40,7 +40,7 @@ const DEFAULT_CLOUD_URL: &str = "https://sigit.si/api/v1";
40
40
  /// to be updated together when a tier is added.
41
41
  pub const CLOUD_TIERS: &[&str] = &[
42
42
  "fast", "balanced", "large", "mini", "air", "pro", "oke", "flux", "apex", "aura", "orbit",
43
- "nova", "flare", "zenith", "prism",
43
+ "nova", "flare", "zenith", "prism", "lumen",
44
44
  ];
45
45
 
46
46
  pub const DEFAULT_CLOUD_TIER: &str = "nova";
@@ -57,6 +57,7 @@ pub const DEFAULT_CLOUD_TIER: &str = "nova";
57
57
  /// client does not know is not offered at all (see [`CLOUD_TIERS`]).
58
58
  const IMAGE_TIERS: &[&str] = &[
59
59
  "fast", "balanced", "large", "mini", "air", "pro", "oke", "aura", "flare", "zenith", "prism",
60
+ "lumen",
60
61
  ];
61
62
 
62
63
  /// Whether the cloud tier `tier` reads images.
@@ -110,6 +111,7 @@ pub fn cloud_tier_provider(tier: &str) -> Option<ProviderConfig> {
110
111
  base_url: cloud_base_url(),
111
112
  api_key: token,
112
113
  model: tier_to_model(tier),
114
+ session_token: true,
113
115
  })
114
116
  }
115
117
 
@@ -132,6 +134,7 @@ fn tier_to_model(tier: &str) -> String {
132
134
  "flare" => "onde-flare",
133
135
  "zenith" => "onde-zenith",
134
136
  "prism" => "onde-prism",
137
+ "lumen" => "onde-lumen",
135
138
  other => other,
136
139
  }
137
140
  .to_string()
@@ -148,6 +151,11 @@ pub struct ProviderConfig {
148
151
  pub api_key: String,
149
152
  /// Model id sent to the endpoint, e.g. `onde-balanced` or `gpt-4o-mini`.
150
153
  pub model: String,
154
+ /// Whether `api_key` is the stored siGit Code Cloud session token. A
155
+ /// backend built from such a config reads the token again on every
156
+ /// request, so signing in as another account takes effect without a
157
+ /// restart.
158
+ pub session_token: bool,
151
159
  }
152
160
 
153
161
  /// Title-case a tier name for display (`balanced` → `Balanced`).
@@ -201,6 +209,7 @@ fn from_env() -> Option<ProviderConfig> {
201
209
  base_url,
202
210
  api_key,
203
211
  model,
212
+ session_token: false,
204
213
  })
205
214
  }
206
215
 
@@ -260,6 +269,7 @@ fn from_config_file() -> Result<Option<ProviderConfig>, String> {
260
269
  base_url: entry.base_url,
261
270
  api_key: entry.api_key,
262
271
  model: entry.model,
272
+ session_token: false,
263
273
  }))
264
274
  }
265
275
 
@@ -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
+ }
@@ -123,6 +123,32 @@ impl AgentUnderTest {
123
123
  }
124
124
  }
125
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
+ }
126
152
  }
127
153
 
128
154
  impl Drop for AgentUnderTest {
@@ -177,3 +203,48 @@ fn logout_is_advertised_and_ends_the_account_session() {
177
203
 
178
204
  std::fs::remove_dir_all(&scratch).ok();
179
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
+ }
File without changes
File without changes