@corbet-labs/ccht 0.2.0 → 0.2.3
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.
- package/LICENSE.md +11 -6
- package/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
- package/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
- package/LICENSES/dependencies/bytes-1.12.1/LICENSE +25 -0
- package/README.md +194 -212
- package/THIRD-PARTY.md +17 -0
- package/index.d.ts +6 -1
- package/index.js +8 -1
- package/package.json +17 -3
- package/source/.ci/wasm-bundle/Cargo.toml +1 -1
- package/source/CHANGELOG.md +59 -2
- package/source/Cargo.lock +9 -1
- package/source/Cargo.toml +4 -4
- package/source/LICENSE.md +11 -6
- package/source/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
- package/source/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
- package/source/README.md +67 -12
- package/source/THIRD-PARTY.md +17 -0
- package/source/dependencies.tar.gz +0 -0
- package/source/src/auth.rs +435 -0
- package/source/src/configuration.rs +51 -0
- package/source/src/conversation.rs +25 -0
- package/source/src/dock.rs +564 -0
- package/source/src/lib.rs +9 -0
- package/source/src/native/client.rs +6 -0
- package/source/src/native/drivers/codex.rs +506 -0
- package/source/src/native/drivers/mod.rs +305 -0
- package/source/src/native/drivers/opencode.rs +531 -0
- package/source/src/native/env.rs +264 -0
- package/source/src/native/fixture.py +78 -1
- package/source/src/native/mod.rs +5 -0
- package/source/src/native/pool.rs +440 -0
- package/source/src/native/session.rs +6 -0
- package/source/src/native/tests.rs +204 -0
- package/source/src/transport.rs +330 -0
- package/src/auth.ts +149 -0
- package/src/components/AccountConnection.svelte +201 -0
- package/src/components/Dock.svelte +172 -0
- package/src/dock.ts +244 -0
- package/wasm/ccht_bg.wasm +0 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
//! Provider login state and credential-store abstraction.
|
|
2
|
+
//!
|
|
3
|
+
//! Structural inspiration comes from the provider/auth split in Zed
|
|
4
|
+
//! (GPL-3.0): uniform per-provider auth state, an application-supplied
|
|
5
|
+
//! credential store, and key lifecycle with environment override. This
|
|
6
|
+
//! module is reimplemented from scratch for LGPL use; no Zed code is
|
|
7
|
+
//! contained here.
|
|
8
|
+
//!
|
|
9
|
+
//! Boundary, unchanged: the store sees opaque bytes keyed by service URL
|
|
10
|
+
//! and never learns what they unlock; applications own login ceremonies,
|
|
11
|
+
//! UI, and where the store persists (OS keychain natively, browser storage
|
|
12
|
+
//! on the web). Nothing here performs network I/O.
|
|
13
|
+
|
|
14
|
+
use std::collections::HashMap;
|
|
15
|
+
use std::future::Future;
|
|
16
|
+
use std::pin::Pin;
|
|
17
|
+
use std::sync::Mutex;
|
|
18
|
+
|
|
19
|
+
use serde::{Deserialize, Serialize};
|
|
20
|
+
|
|
21
|
+
/// Login state of one provider as observed by the application.
|
|
22
|
+
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
|
23
|
+
#[serde(rename_all = "snake_case")]
|
|
24
|
+
pub enum AuthState {
|
|
25
|
+
/// Not checked yet (for example, before the store was read).
|
|
26
|
+
#[default]
|
|
27
|
+
Unknown,
|
|
28
|
+
/// Credentials exist. The account label is display-only provenance and
|
|
29
|
+
/// must never be treated as an authentication proof by itself.
|
|
30
|
+
Authenticated {
|
|
31
|
+
/// Display-only account label (username, email, or subscription name).
|
|
32
|
+
account: Option<String>,
|
|
33
|
+
},
|
|
34
|
+
/// No credentials and no other evidence of a login.
|
|
35
|
+
Unauthenticated,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
impl AuthState {
|
|
39
|
+
/// Whether routine work may proceed without prompting for login first.
|
|
40
|
+
#[must_use]
|
|
41
|
+
pub fn authenticated(&self) -> bool {
|
|
42
|
+
matches!(self, Self::Authenticated { .. })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Storage failure of a [`CredentialsProvider`].
|
|
47
|
+
#[derive(Clone, Debug, thiserror::Error, Eq, PartialEq)]
|
|
48
|
+
pub enum AuthError {
|
|
49
|
+
/// The backing store failed (keychain locked, disk full, quota hit).
|
|
50
|
+
#[error("credential store failed: {0}")]
|
|
51
|
+
Store(String),
|
|
52
|
+
/// The operation makes no sense for this store (for example, writing to
|
|
53
|
+
/// a read-only native-agent home owned by the agent itself).
|
|
54
|
+
#[error("operation not supported by this credential store")]
|
|
55
|
+
NotSupported,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Opaque credential: an account label plus secret bytes.
|
|
59
|
+
///
|
|
60
|
+
/// Stores must treat both fields as opaque. The label exists so UIs can say
|
|
61
|
+
/// *who* is signed in without ever unlocking the secret.
|
|
62
|
+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
63
|
+
pub struct Credential {
|
|
64
|
+
/// Display-only account label (username, email, or subscription name).
|
|
65
|
+
pub account: String,
|
|
66
|
+
/// Secret bytes. Never logged, never embedded in errors or fixtures.
|
|
67
|
+
pub secret: Vec<u8>,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Application-supplied credential persistence.
|
|
71
|
+
///
|
|
72
|
+
/// One method triple per service URL. Native applications back this with the
|
|
73
|
+
/// OS keychain; web applications with origin-scoped browser storage; tests
|
|
74
|
+
/// with [`MemoryCredentialsProvider`].
|
|
75
|
+
pub trait CredentialsProvider: Send + Sync {
|
|
76
|
+
/// Read stored credentials, if any, for a service URL.
|
|
77
|
+
fn read_credentials<'a>(
|
|
78
|
+
&'a self,
|
|
79
|
+
service: &'a str,
|
|
80
|
+
) -> Pin<Box<dyn Future<Output = Result<Option<Credential>, AuthError>> + Send + 'a>>;
|
|
81
|
+
|
|
82
|
+
/// Persist credentials for a service URL, replacing any previous entry.
|
|
83
|
+
fn write_credentials<'a>(
|
|
84
|
+
&'a self,
|
|
85
|
+
service: &'a str,
|
|
86
|
+
credential: &'a Credential,
|
|
87
|
+
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>>;
|
|
88
|
+
|
|
89
|
+
/// Remove stored credentials for a service URL. Missing entries are not
|
|
90
|
+
/// an error.
|
|
91
|
+
fn delete_credentials<'a>(
|
|
92
|
+
&'a self,
|
|
93
|
+
service: &'a str,
|
|
94
|
+
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// In-memory [`CredentialsProvider`] for tests and local development.
|
|
98
|
+
///
|
|
99
|
+
/// Never ships credentials anywhere; drop it when the test ends.
|
|
100
|
+
#[derive(Debug, Default)]
|
|
101
|
+
pub struct MemoryCredentialsProvider {
|
|
102
|
+
entries: Mutex<HashMap<String, Credential>>,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
impl MemoryCredentialsProvider {
|
|
106
|
+
/// Empty store.
|
|
107
|
+
#[must_use]
|
|
108
|
+
pub fn new() -> Self {
|
|
109
|
+
Self::default()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
impl CredentialsProvider for MemoryCredentialsProvider {
|
|
114
|
+
fn read_credentials<'a>(
|
|
115
|
+
&'a self,
|
|
116
|
+
service: &'a str,
|
|
117
|
+
) -> Pin<Box<dyn Future<Output = Result<Option<Credential>, AuthError>> + Send + 'a>> {
|
|
118
|
+
let found = match self.entries.lock() {
|
|
119
|
+
Ok(entries) => entries.get(service).cloned(),
|
|
120
|
+
Err(_) => {
|
|
121
|
+
return Box::pin(async move { Err(AuthError::Store("lock poisoned".into())) });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
Box::pin(async move { Ok(found) })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fn write_credentials<'a>(
|
|
128
|
+
&'a self,
|
|
129
|
+
service: &'a str,
|
|
130
|
+
credential: &'a Credential,
|
|
131
|
+
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
|
|
132
|
+
let result = self
|
|
133
|
+
.entries
|
|
134
|
+
.lock()
|
|
135
|
+
.map(|mut entries| {
|
|
136
|
+
entries.insert(service.to_owned(), credential.clone());
|
|
137
|
+
})
|
|
138
|
+
.map_err(|_| AuthError::Store("lock poisoned".into()));
|
|
139
|
+
Box::pin(async move { result })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fn delete_credentials<'a>(
|
|
143
|
+
&'a self,
|
|
144
|
+
service: &'a str,
|
|
145
|
+
) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
|
|
146
|
+
let result = self
|
|
147
|
+
.entries
|
|
148
|
+
.lock()
|
|
149
|
+
.map(|mut entries| {
|
|
150
|
+
entries.remove(service);
|
|
151
|
+
})
|
|
152
|
+
.map_err(|_| AuthError::Store("lock poisoned".into()));
|
|
153
|
+
Box::pin(async move { result })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Lifecycle of one provider's key: environment override wins, otherwise the
|
|
158
|
+
/// store decides. Mirrors the semantics applications need for a Zed-style
|
|
159
|
+
/// settings surface: `is_authenticated()` for badges, `store()` for the
|
|
160
|
+
/// save action, `reset()` for sign-out, `load_if_needed()` for lazy boot.
|
|
161
|
+
#[derive(Clone, Debug, Default)]
|
|
162
|
+
pub struct ApiKeyState {
|
|
163
|
+
key: Option<String>,
|
|
164
|
+
from_env_var: bool,
|
|
165
|
+
// The environment override is unavailable on Wasm; the field is still set
|
|
166
|
+
// through `new` on every target so construction stays uniform.
|
|
167
|
+
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
|
168
|
+
env_var_name: Option<&'static str>,
|
|
169
|
+
loaded_service: Option<String>,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
impl ApiKeyState {
|
|
173
|
+
/// Unloaded state, optionally honoring an environment variable override.
|
|
174
|
+
#[must_use]
|
|
175
|
+
pub fn new(env_var_name: Option<&'static str>) -> Self {
|
|
176
|
+
Self {
|
|
177
|
+
env_var_name,
|
|
178
|
+
..Self::default()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// Whether routine work may proceed: an in-memory key is present.
|
|
183
|
+
#[must_use]
|
|
184
|
+
pub fn has_key(&self) -> bool {
|
|
185
|
+
self.key.as_deref().is_some_and(|key| !key.is_empty())
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Whether the current key came from the environment (read-only: reset
|
|
189
|
+
/// is disabled and the UI must say so instead of failing silently).
|
|
190
|
+
#[must_use]
|
|
191
|
+
pub fn is_from_env_var(&self) -> bool {
|
|
192
|
+
self.from_env_var
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// The in-memory key, if any. Callers forward it to request signing;
|
|
196
|
+
/// they never persist or display it.
|
|
197
|
+
#[must_use]
|
|
198
|
+
pub fn key(&self) -> Option<&str> {
|
|
199
|
+
self.key.as_deref()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Load from environment or store unless this exact service already was.
|
|
203
|
+
/// Returns the resulting [`AuthState`].
|
|
204
|
+
///
|
|
205
|
+
/// The environment override is unavailable on Wasm (browsers expose no
|
|
206
|
+
/// process environment); there the store alone decides.
|
|
207
|
+
pub async fn load_if_needed(
|
|
208
|
+
&mut self,
|
|
209
|
+
service: &str,
|
|
210
|
+
store: &(dyn CredentialsProvider + Send + Sync),
|
|
211
|
+
) -> Result<AuthState, AuthError> {
|
|
212
|
+
if self.loaded_service.as_deref() == Some(service) {
|
|
213
|
+
return Ok(self.snapshot());
|
|
214
|
+
}
|
|
215
|
+
#[cfg(not(target_family = "wasm"))]
|
|
216
|
+
if let Some(name) = self.env_var_name
|
|
217
|
+
&& let Ok(value) = std::env::var(name)
|
|
218
|
+
&& !value.trim().is_empty()
|
|
219
|
+
{
|
|
220
|
+
self.key = Some(value);
|
|
221
|
+
self.from_env_var = true;
|
|
222
|
+
self.loaded_service = Some(service.to_owned());
|
|
223
|
+
return Ok(self.snapshot());
|
|
224
|
+
}
|
|
225
|
+
self.from_env_var = false;
|
|
226
|
+
self.key = store
|
|
227
|
+
.read_credentials(service)
|
|
228
|
+
.await?
|
|
229
|
+
.and_then(|credential| {
|
|
230
|
+
String::from_utf8(credential.secret)
|
|
231
|
+
.ok()
|
|
232
|
+
.filter(|secret| !secret.trim().is_empty())
|
|
233
|
+
});
|
|
234
|
+
self.loaded_service = Some(service.to_owned());
|
|
235
|
+
Ok(self.snapshot())
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Forget a service change: the next `load_if_needed` reads again.
|
|
239
|
+
pub fn handle_service_change(&mut self, service: &str) {
|
|
240
|
+
if self.loaded_service.as_deref() != Some(service) {
|
|
241
|
+
self.key = None;
|
|
242
|
+
self.from_env_var = false;
|
|
243
|
+
self.loaded_service = None;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// Persist a key (or `None` to sign out) through the store. Refuses while
|
|
248
|
+
/// the key comes from the environment: callers must unset the variable.
|
|
249
|
+
pub async fn store(
|
|
250
|
+
&mut self,
|
|
251
|
+
service: &str,
|
|
252
|
+
key: Option<String>,
|
|
253
|
+
store: &(dyn CredentialsProvider + Send + Sync),
|
|
254
|
+
) -> Result<AuthState, AuthError> {
|
|
255
|
+
if self.from_env_var {
|
|
256
|
+
return Err(AuthError::NotSupported);
|
|
257
|
+
}
|
|
258
|
+
match key
|
|
259
|
+
.map(|key| key.trim().to_owned())
|
|
260
|
+
.filter(|key| !key.is_empty())
|
|
261
|
+
{
|
|
262
|
+
Some(key) => {
|
|
263
|
+
store
|
|
264
|
+
.write_credentials(
|
|
265
|
+
service,
|
|
266
|
+
&Credential {
|
|
267
|
+
account: String::new(),
|
|
268
|
+
secret: key.into_bytes(),
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
.await?;
|
|
272
|
+
self.key = store
|
|
273
|
+
.read_credentials(service)
|
|
274
|
+
.await?
|
|
275
|
+
.and_then(|credential| String::from_utf8(credential.secret).ok());
|
|
276
|
+
}
|
|
277
|
+
None => {
|
|
278
|
+
store.delete_credentials(service).await?;
|
|
279
|
+
self.key = None;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
self.loaded_service = Some(service.to_owned());
|
|
283
|
+
Ok(self.snapshot())
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
fn snapshot(&self) -> AuthState {
|
|
287
|
+
if self.has_key() {
|
|
288
|
+
AuthState::Authenticated { account: None }
|
|
289
|
+
} else {
|
|
290
|
+
AuthState::Unauthenticated
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#[cfg(test)]
|
|
296
|
+
mod tests {
|
|
297
|
+
use super::*;
|
|
298
|
+
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
|
|
299
|
+
|
|
300
|
+
/// Minimal single-threaded executor so core tests need no async runtime.
|
|
301
|
+
fn block_on<F: Future>(mut future: F) -> F::Output {
|
|
302
|
+
// SAFETY: all waker callbacks ignore the null data pointer.
|
|
303
|
+
unsafe fn clone_raw(_: *const ()) -> RawWaker {
|
|
304
|
+
RawWaker::new(std::ptr::null(), &VTABLE)
|
|
305
|
+
}
|
|
306
|
+
unsafe fn noop_raw(_: *const ()) {}
|
|
307
|
+
static VTABLE: RawWakerVTable =
|
|
308
|
+
RawWakerVTable::new(clone_raw, noop_raw, noop_raw, noop_raw);
|
|
309
|
+
// SAFETY: the waker never dereferences its null data pointer.
|
|
310
|
+
let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
|
|
311
|
+
let mut context = Context::from_waker(&waker);
|
|
312
|
+
// SAFETY: the future is never moved after pinning for the poll loop.
|
|
313
|
+
let mut future = unsafe { Pin::new_unchecked(&mut future) };
|
|
314
|
+
loop {
|
|
315
|
+
match future.as_mut().poll(&mut context) {
|
|
316
|
+
Poll::Ready(output) => return output,
|
|
317
|
+
Poll::Pending => std::thread::yield_now(),
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async fn roundtrip(store: &MemoryCredentialsProvider) {
|
|
323
|
+
let credential = Credential {
|
|
324
|
+
account: "Pro".into(),
|
|
325
|
+
secret: b"secret".to_vec(),
|
|
326
|
+
};
|
|
327
|
+
assert_eq!(store.read_credentials("svc").await.unwrap(), None);
|
|
328
|
+
store.write_credentials("svc", &credential).await.unwrap();
|
|
329
|
+
assert_eq!(
|
|
330
|
+
store.read_credentials("svc").await.unwrap(),
|
|
331
|
+
Some(credential)
|
|
332
|
+
);
|
|
333
|
+
store.delete_credentials("svc").await.unwrap();
|
|
334
|
+
assert_eq!(store.read_credentials("svc").await.unwrap(), None);
|
|
335
|
+
// Deleting a missing entry is not an error.
|
|
336
|
+
store.delete_credentials("svc").await.unwrap();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#[test]
|
|
340
|
+
fn memory_store_roundtrips() {
|
|
341
|
+
block_on(roundtrip(&MemoryCredentialsProvider::new()));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#[test]
|
|
345
|
+
fn api_key_state_starts_unauthenticated() {
|
|
346
|
+
let state = ApiKeyState::new(None);
|
|
347
|
+
assert!(!state.has_key());
|
|
348
|
+
assert!(!state.is_from_env_var());
|
|
349
|
+
assert_eq!(state.snapshot(), AuthState::Unauthenticated);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[test]
|
|
353
|
+
fn api_key_state_save_and_sign_out() {
|
|
354
|
+
block_on(async {
|
|
355
|
+
let store = MemoryCredentialsProvider::new();
|
|
356
|
+
let mut state = ApiKeyState::new(None);
|
|
357
|
+
let seen = state.load_if_needed("svc", &store).await.unwrap();
|
|
358
|
+
assert_eq!(seen, AuthState::Unauthenticated);
|
|
359
|
+
let seen = state
|
|
360
|
+
.store("svc", Some(" key ".into()), &store)
|
|
361
|
+
.await
|
|
362
|
+
.unwrap();
|
|
363
|
+
assert_eq!(seen, AuthState::Authenticated { account: None });
|
|
364
|
+
assert!(state.has_key());
|
|
365
|
+
// Blank input signs out instead of storing whitespace.
|
|
366
|
+
let seen = state
|
|
367
|
+
.store("svc", Some(" ".into()), &store)
|
|
368
|
+
.await
|
|
369
|
+
.unwrap();
|
|
370
|
+
assert_eq!(seen, AuthState::Unauthenticated);
|
|
371
|
+
assert!(!state.has_key());
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#[cfg(not(target_family = "wasm"))]
|
|
376
|
+
#[test]
|
|
377
|
+
fn api_key_state_prefers_environment_and_locks_reset() {
|
|
378
|
+
block_on(async {
|
|
379
|
+
// SAFETY: single-threaded test with no other environment readers.
|
|
380
|
+
unsafe { std::env::set_var("CCHT_TEST_API_KEY", "env-key") };
|
|
381
|
+
let store = MemoryCredentialsProvider::new();
|
|
382
|
+
let mut state = ApiKeyState::new(Some("CCHT_TEST_API_KEY"));
|
|
383
|
+
let seen = state.load_if_needed("svc", &store).await.unwrap();
|
|
384
|
+
assert_eq!(seen, AuthState::Authenticated { account: None });
|
|
385
|
+
assert!(state.is_from_env_var());
|
|
386
|
+
assert_eq!(
|
|
387
|
+
state.store("svc", None, &store).await.unwrap_err(),
|
|
388
|
+
AuthError::NotSupported
|
|
389
|
+
);
|
|
390
|
+
// SAFETY: restores the pre-test environment; see above.
|
|
391
|
+
unsafe { std::env::remove_var("CCHT_TEST_API_KEY") };
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#[test]
|
|
396
|
+
fn api_key_state_reloads_on_service_change() {
|
|
397
|
+
block_on(async {
|
|
398
|
+
let store = MemoryCredentialsProvider::new();
|
|
399
|
+
let mut state = ApiKeyState::new(None);
|
|
400
|
+
state
|
|
401
|
+
.store("a", Some("key-a".into()), &store)
|
|
402
|
+
.await
|
|
403
|
+
.unwrap();
|
|
404
|
+
assert!(state.has_key());
|
|
405
|
+
state.handle_service_change("b");
|
|
406
|
+
assert!(!state.has_key());
|
|
407
|
+
let seen = state.load_if_needed("b", &store).await.unwrap();
|
|
408
|
+
assert_eq!(seen, AuthState::Unauthenticated);
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#[test]
|
|
413
|
+
fn api_key_state_keeps_key_for_same_service() {
|
|
414
|
+
block_on(async {
|
|
415
|
+
let store = MemoryCredentialsProvider::new();
|
|
416
|
+
let mut state = ApiKeyState::new(None);
|
|
417
|
+
state
|
|
418
|
+
.store("a", Some("key-a".into()), &store)
|
|
419
|
+
.await
|
|
420
|
+
.unwrap();
|
|
421
|
+
state.handle_service_change("a");
|
|
422
|
+
assert!(state.has_key());
|
|
423
|
+
// A repeated load for the same service returns the cached snapshot.
|
|
424
|
+
let seen = state.load_if_needed("a", &store).await.unwrap();
|
|
425
|
+
assert_eq!(seen, AuthState::Authenticated { account: None });
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#[test]
|
|
430
|
+
fn auth_state_reports_readiness() {
|
|
431
|
+
assert!(!AuthState::Unknown.authenticated());
|
|
432
|
+
assert!(!AuthState::Unauthenticated.authenticated());
|
|
433
|
+
assert!(AuthState::Authenticated { account: None }.authenticated());
|
|
434
|
+
}
|
|
435
|
+
}
|
|
@@ -86,4 +86,55 @@ mod tests {
|
|
|
86
86
|
assert!(!settings.accepts("thinking", &"true".into()));
|
|
87
87
|
assert!(!settings.accepts("missing", &true.into()));
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
#[test]
|
|
91
|
+
fn model_option_is_none_without_advertised_model() {
|
|
92
|
+
assert!(SessionConfiguration::default().model_option().is_none());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#[test]
|
|
96
|
+
fn apply_replaces_options_and_tracks_current_mode() {
|
|
97
|
+
use crate::acp::{
|
|
98
|
+
ConfigOptionUpdate, ContentChunk, CurrentModeUpdate, SessionModeId, SessionModeState,
|
|
99
|
+
};
|
|
100
|
+
let mut settings = SessionConfiguration::default();
|
|
101
|
+
// Other updates leave the configuration unchanged.
|
|
102
|
+
settings.apply(&SessionUpdate::AgentMessageChunk(ContentChunk::new(
|
|
103
|
+
"hi".into(),
|
|
104
|
+
)));
|
|
105
|
+
assert!(settings.options.is_empty());
|
|
106
|
+
|
|
107
|
+
let options: SessionConfiguration = serde_json::from_value(serde_json::json!({
|
|
108
|
+
"options": [{"id":"model","name":"Model","type":"select",
|
|
109
|
+
"currentValue":"one","options":[{"value":"one","name":"One"}]}],
|
|
110
|
+
"modes": null
|
|
111
|
+
}))
|
|
112
|
+
.unwrap();
|
|
113
|
+
settings.apply(&SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(
|
|
114
|
+
options.options.clone(),
|
|
115
|
+
)));
|
|
116
|
+
assert_eq!(settings.options, options.options);
|
|
117
|
+
assert!(settings.model_option().is_some());
|
|
118
|
+
|
|
119
|
+
// Without legacy modes, a mode update is a no-op.
|
|
120
|
+
settings.apply(&SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
|
121
|
+
SessionModeId::new("second"),
|
|
122
|
+
)));
|
|
123
|
+
assert!(settings.modes.is_none());
|
|
124
|
+
|
|
125
|
+
settings.modes = Some(SessionModeState::new(
|
|
126
|
+
SessionModeId::new("first"),
|
|
127
|
+
Vec::new(),
|
|
128
|
+
));
|
|
129
|
+
settings.apply(&SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(
|
|
130
|
+
SessionModeId::new("second"),
|
|
131
|
+
)));
|
|
132
|
+
assert_eq!(
|
|
133
|
+
settings
|
|
134
|
+
.modes
|
|
135
|
+
.as_ref()
|
|
136
|
+
.map(|modes| modes.current_mode_id.0.as_ref()),
|
|
137
|
+
Some("second")
|
|
138
|
+
);
|
|
139
|
+
}
|
|
89
140
|
}
|
|
@@ -566,4 +566,29 @@ mod tests {
|
|
|
566
566
|
);
|
|
567
567
|
assert!(conversation.turns().is_empty());
|
|
568
568
|
}
|
|
569
|
+
|
|
570
|
+
#[test]
|
|
571
|
+
fn prompt_text_and_error_events_shape_turn_status() {
|
|
572
|
+
let prompt = Prompt::text("request-1", "hello");
|
|
573
|
+
assert_eq!(prompt.request_id, "request-1");
|
|
574
|
+
assert_eq!(prompt.content.len(), 1);
|
|
575
|
+
|
|
576
|
+
let mut conversation = Conversation::new("c");
|
|
577
|
+
conversation.apply(delta("c", "r", 1, "partial")).unwrap();
|
|
578
|
+
conversation
|
|
579
|
+
.apply(WireEvent::new(
|
|
580
|
+
"c",
|
|
581
|
+
"r",
|
|
582
|
+
2,
|
|
583
|
+
Event::Error {
|
|
584
|
+
code: "closed".into(),
|
|
585
|
+
message: "connection closed".into(),
|
|
586
|
+
},
|
|
587
|
+
))
|
|
588
|
+
.unwrap();
|
|
589
|
+
let turn = &conversation.turns()[0];
|
|
590
|
+
assert_eq!(turn.status, "failed");
|
|
591
|
+
assert!(turn.permissions.is_empty());
|
|
592
|
+
assert!(turn.error.is_some());
|
|
593
|
+
}
|
|
569
594
|
}
|