sigit-code 1.7.3__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.
- {sigit_code-1.7.3 → sigit_code-1.7.5}/Cargo.lock +2 -2
- {sigit_code-1.7.3 → sigit_code-1.7.5}/Cargo.toml +2 -2
- {sigit_code-1.7.3 → sigit_code-1.7.5}/PKG-INFO +1 -1
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/account.rs +4 -39
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/backend.rs +27 -3
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/chat.rs +9 -6
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/headless.rs +9 -6
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/main.rs +49 -41
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/provider.rs +11 -1
- sigit_code-1.7.5/crates/cli/tests/acp_cloud_reauth.rs +208 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_logout.rs +71 -0
- sigit_code-1.7.5/crates/sigit-agent/assets/sign-in-logo.png +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/src/browser_auth.rs +88 -26
- {sigit_code-1.7.3 → sigit_code-1.7.5}/README.md +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/Cargo.toml +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/README.md +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/client_fs.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/client_terminal.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/commands.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/frontmatter.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/harness_markup.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/hooks.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/inline_tool_calls.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/instructions.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/mcp.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/models.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/permissions.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/program_status.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/repetition.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/session_store.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/settings.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/setup.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/skills.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/subagents.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/tools.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/src/workspace.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_audio.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_client_fs.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_client_terminal.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_concurrent_sessions.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_elicitation.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_embedded_context.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_endpoint_errors.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_error_codes.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_harness_markup.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_images.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_multi_root.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_permissions.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_session_list.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_session_load.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_stop_reasons.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_tool_call_name.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/acp_tool_stdin.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/headless_mode.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/cli/tests/version_flag.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/Cargo.toml +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/README.md +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/src/auth.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/src/credentials.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/src/lib.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/src/profile.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/src/prompt.rs +0 -0
- {sigit_code-1.7.3 → sigit_code-1.7.5}/crates/sigit-agent/tests/embedded.rs +0 -0
- {sigit_code-1.7.3 → 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"] }
|
|
@@ -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
|
|
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
|
|
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/
|
|
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::<
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
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(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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(
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
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
|
-
//
|
|
2336
|
-
// browser
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
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(
|
|
4394
|
-
cfg.base_url,
|
|
4395
|
-
|
|
4396
|
-
|
|
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(
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
6022
|
-
|
|
6023
|
-
|
|
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(
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
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(
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
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
|
+
}
|
|
Binary file
|
|
@@ -180,11 +180,27 @@ pub async fn complete_loopback(flow: Flow) -> Result<String, String> {
|
|
|
180
180
|
let listener = listener.ok_or_else(|| "this sign-in has no local listener".to_string())?;
|
|
181
181
|
|
|
182
182
|
let expected_state = state.clone();
|
|
183
|
-
let code
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
let (code, mut stream) =
|
|
184
|
+
tokio::task::spawn_blocking(move || wait_for_code(listener, &expected_state))
|
|
185
|
+
.await
|
|
186
|
+
.map_err(|error| format!("sign-in was interrupted: {error}"))??;
|
|
187
|
+
|
|
188
|
+
// The browser is still waiting on its request. Answer it only once the
|
|
189
|
+
// exchange has settled, so the page never reports a sign-in that failed.
|
|
190
|
+
let result = exchange(&code, &verifier, &redirect_uri).await;
|
|
191
|
+
match &result {
|
|
192
|
+
Ok(_) => respond(
|
|
193
|
+
&mut stream,
|
|
194
|
+
"Signed in to siGit Code",
|
|
195
|
+
"You can close this tab.",
|
|
196
|
+
),
|
|
197
|
+
Err(_) => respond(
|
|
198
|
+
&mut stream,
|
|
199
|
+
"Sign-in did not complete",
|
|
200
|
+
"Return to siGit Code and try again.",
|
|
201
|
+
),
|
|
202
|
+
}
|
|
203
|
+
result
|
|
188
204
|
}
|
|
189
205
|
|
|
190
206
|
/// Redeem a code the person pasted in. Returns the signed-in email.
|
|
@@ -198,8 +214,12 @@ pub async fn complete_paste(flow: &Flow, code: &str) -> Result<String, String> {
|
|
|
198
214
|
|
|
199
215
|
/// Accept connections until one carries our callback. Anything else on the
|
|
200
216
|
/// port (a browser probe, a stray scan) is answered and ignored rather than
|
|
201
|
-
/// ending the wait.
|
|
202
|
-
|
|
217
|
+
/// ending the wait. On success the matching connection is returned unanswered,
|
|
218
|
+
/// for the caller to respond to once the code has been redeemed.
|
|
219
|
+
fn wait_for_code(
|
|
220
|
+
listener: TcpListener,
|
|
221
|
+
expected_state: &str,
|
|
222
|
+
) -> Result<(String, std::net::TcpStream), String> {
|
|
203
223
|
let deadline = std::time::Instant::now() + CALLBACK_TIMEOUT;
|
|
204
224
|
listener
|
|
205
225
|
.set_nonblocking(true)
|
|
@@ -243,7 +263,7 @@ fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String,
|
|
|
243
263
|
}
|
|
244
264
|
}
|
|
245
265
|
Err(error) => {
|
|
246
|
-
respond(&mut stream, "
|
|
266
|
+
respond(&mut stream, "Nothing to do here", "");
|
|
247
267
|
log::debug!("could not read browser callback: {error}");
|
|
248
268
|
continue 'connections;
|
|
249
269
|
}
|
|
@@ -255,7 +275,7 @@ fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String,
|
|
|
255
275
|
.next()
|
|
256
276
|
.and_then(|line| line.split_whitespace().nth(1))
|
|
257
277
|
else {
|
|
258
|
-
respond(&mut stream, "
|
|
278
|
+
respond(&mut stream, "Nothing to do here", "");
|
|
259
279
|
continue;
|
|
260
280
|
};
|
|
261
281
|
|
|
@@ -273,39 +293,68 @@ fn wait_for_code(listener: TcpListener, expected_state: &str) -> Result<String,
|
|
|
273
293
|
}
|
|
274
294
|
|
|
275
295
|
if let Some(error) = error {
|
|
276
|
-
respond(
|
|
277
|
-
&mut stream,
|
|
278
|
-
"Sign-in was cancelled. You can close this tab.",
|
|
279
|
-
);
|
|
296
|
+
respond(&mut stream, "Sign-in cancelled", "You can close this tab.");
|
|
280
297
|
return Err(format!("sign-in was declined ({error})"));
|
|
281
298
|
}
|
|
282
299
|
|
|
283
300
|
let Some(code) = code else {
|
|
284
|
-
respond(&mut stream, "Nothing to do here
|
|
301
|
+
respond(&mut stream, "Nothing to do here", "");
|
|
285
302
|
continue;
|
|
286
303
|
};
|
|
287
304
|
|
|
288
305
|
// The state proves this callback belongs to the flow we started, not
|
|
289
306
|
// to a link someone else sent to this browser.
|
|
290
307
|
if state.as_deref() != Some(expected_state) {
|
|
291
|
-
respond(
|
|
308
|
+
respond(
|
|
309
|
+
&mut stream,
|
|
310
|
+
"Sign-in didn't match",
|
|
311
|
+
"That sign-in did not come from siGit Code.",
|
|
312
|
+
);
|
|
292
313
|
return Err("the sign-in response did not match this request".to_string());
|
|
293
314
|
}
|
|
294
315
|
|
|
295
|
-
|
|
296
|
-
&mut stream,
|
|
297
|
-
"Signed in to siGit Code. You can close this tab.",
|
|
298
|
-
);
|
|
299
|
-
return Ok(code);
|
|
316
|
+
return Ok((code, stream));
|
|
300
317
|
}
|
|
301
318
|
}
|
|
302
319
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
320
|
+
/// The 64x64 app icon, inlined into the page so it needs no second request.
|
|
321
|
+
const LOGO_PNG: &[u8] = include_bytes!("../assets/sign-in-logo.png");
|
|
322
|
+
|
|
323
|
+
/// Renders the page the browser lands on. Self-contained: inline CSS and an
|
|
324
|
+
/// inline logo, because the page is served from the loopback address and must
|
|
325
|
+
/// not fetch anything. Title and hint are constants, so nothing is escaped.
|
|
326
|
+
fn page(title: &str, hint: &str) -> String {
|
|
327
|
+
let logo = base64::engine::general_purpose::STANDARD.encode(LOGO_PNG);
|
|
328
|
+
let hint = if hint.is_empty() {
|
|
329
|
+
String::new()
|
|
330
|
+
} else {
|
|
331
|
+
format!("<p>{hint}</p>")
|
|
332
|
+
};
|
|
333
|
+
format!(
|
|
334
|
+
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
|
335
|
+
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
|
|
336
|
+
<meta name=\"color-scheme\" content=\"dark\"><title>{title}</title><style>\
|
|
337
|
+
:root{{color-scheme:dark}}\
|
|
338
|
+
html,body{{margin:0;height:100%}}\
|
|
339
|
+
body{{display:flex;align-items:center;justify-content:center;background:#15171a;\
|
|
340
|
+
font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,\"Helvetica Neue\",Arial,sans-serif}}\
|
|
341
|
+
main{{display:flex;flex-direction:column;align-items:center;text-align:center;\
|
|
342
|
+
padding:0 24px;transform:translateY(-6%);animation:fade-in 240ms ease-out}}\
|
|
343
|
+
img{{width:40px;height:40px;border-radius:9px;opacity:.8;\
|
|
344
|
+
box-shadow:0 0 0 1px rgb(255 255 255/.06)}}\
|
|
345
|
+
h1{{margin:16px 0 0;font-size:15px;line-height:20px;font-weight:500;\
|
|
346
|
+
letter-spacing:-.01em;color:#f3f4f6}}\
|
|
347
|
+
p{{margin:4px 0 0;font-size:13px;line-height:18px;color:#6b7280}}\
|
|
348
|
+
@keyframes fade-in{{from{{opacity:0}}to{{opacity:1}}}}\
|
|
349
|
+
@media(prefers-reduced-motion:reduce){{main{{animation:none}}}}\
|
|
350
|
+
</style></head><body><main>\
|
|
351
|
+
<img src=\"data:image/png;base64,{logo}\" alt=\"\" width=\"40\" height=\"40\">\
|
|
352
|
+
<h1>{title}</h1>{hint}</main></body></html>"
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fn respond(stream: &mut std::net::TcpStream, title: &str, hint: &str) {
|
|
357
|
+
let body = page(title, hint);
|
|
309
358
|
let response = format!(
|
|
310
359
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
311
360
|
body.len(),
|
|
@@ -464,6 +513,19 @@ fn percent_decode(value: &str) -> String {
|
|
|
464
513
|
mod tests {
|
|
465
514
|
use super::*;
|
|
466
515
|
|
|
516
|
+
#[test]
|
|
517
|
+
fn page_carries_the_title_hint_and_an_inline_logo() {
|
|
518
|
+
let body = page("Signed in to siGit Code", "You can close this tab.");
|
|
519
|
+
assert!(body.contains("<h1>Signed in to siGit Code</h1>"));
|
|
520
|
+
assert!(body.contains("<p>You can close this tab.</p>"));
|
|
521
|
+
assert!(body.contains("data:image/png;base64,"));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#[test]
|
|
525
|
+
fn page_omits_the_hint_when_empty() {
|
|
526
|
+
assert!(!page("Nothing to do here", "").contains("<p>"));
|
|
527
|
+
}
|
|
528
|
+
|
|
467
529
|
#[test]
|
|
468
530
|
fn code_challenge_matches_rfc_7636_example() {
|
|
469
531
|
// RFC 7636 Appendix B.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|