@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.
Files changed (40) hide show
  1. package/LICENSE.md +11 -6
  2. package/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
  3. package/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
  4. package/LICENSES/dependencies/bytes-1.12.1/LICENSE +25 -0
  5. package/README.md +194 -212
  6. package/THIRD-PARTY.md +17 -0
  7. package/index.d.ts +6 -1
  8. package/index.js +8 -1
  9. package/package.json +17 -3
  10. package/source/.ci/wasm-bundle/Cargo.toml +1 -1
  11. package/source/CHANGELOG.md +59 -2
  12. package/source/Cargo.lock +9 -1
  13. package/source/Cargo.toml +4 -4
  14. package/source/LICENSE.md +11 -6
  15. package/source/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
  16. package/source/LICENSES/LGPL-3.0-only WITH LGPL-3.0-linking-exception.txt +16 -0
  17. package/source/README.md +67 -12
  18. package/source/THIRD-PARTY.md +17 -0
  19. package/source/dependencies.tar.gz +0 -0
  20. package/source/src/auth.rs +435 -0
  21. package/source/src/configuration.rs +51 -0
  22. package/source/src/conversation.rs +25 -0
  23. package/source/src/dock.rs +564 -0
  24. package/source/src/lib.rs +9 -0
  25. package/source/src/native/client.rs +6 -0
  26. package/source/src/native/drivers/codex.rs +506 -0
  27. package/source/src/native/drivers/mod.rs +305 -0
  28. package/source/src/native/drivers/opencode.rs +531 -0
  29. package/source/src/native/env.rs +264 -0
  30. package/source/src/native/fixture.py +78 -1
  31. package/source/src/native/mod.rs +5 -0
  32. package/source/src/native/pool.rs +440 -0
  33. package/source/src/native/session.rs +6 -0
  34. package/source/src/native/tests.rs +204 -0
  35. package/source/src/transport.rs +330 -0
  36. package/src/auth.ts +149 -0
  37. package/src/components/AccountConnection.svelte +201 -0
  38. package/src/components/Dock.svelte +172 -0
  39. package/src/dock.ts +244 -0
  40. 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
  }