@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,506 @@
1
+ //! ChatGPT device-code login through a native Codex helper.
2
+ //!
3
+ //! The driver spawns the vendor `app-server` over stdio, requests a device
4
+ //! challenge, waits for browser approval, and projects the vendor presence.
5
+ //! No secret bytes are read or stored; only the user-visible URL, code, and
6
+ //! display label leave the helper.
7
+
8
+ use std::collections::VecDeque;
9
+ use std::path::PathBuf;
10
+ use std::process::Stdio;
11
+ use std::time::Duration;
12
+
13
+ use serde_json::Value;
14
+ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
15
+ use tokio::process::{Child, ChildStdin, ChildStdout, Command};
16
+ use tokio::time::{Instant, timeout};
17
+
18
+ use super::{AccountInfo, Challenge, DriverError, LoginDriver, LoginState};
19
+
20
+ /// Maximum accepted line length for helper frames.
21
+ const MAX_FRAME: u64 = 1_048_576;
22
+
23
+ /// Maximum buffered vendor notifications while waiting for a reply.
24
+ const MAX_QUEUED_NOTES: usize = 64;
25
+
26
+ /// Default approval host for the ChatGPT device step.
27
+ const DEFAULT_HOST: &str = "auth.openai.com";
28
+
29
+ /// Native Codex device-code handshake.
30
+ ///
31
+ /// Spawns `program app-server` with a cleared environment containing only a
32
+ /// minimal `PATH` and `HOME`. The helper owns its credential files; this
33
+ /// driver only relays the challenge and the projected presence.
34
+ pub struct CodexDeviceDriver {
35
+ program: PathBuf,
36
+ lead_args: Vec<String>,
37
+ allowed_hosts: Vec<String>,
38
+ call_timeout: Duration,
39
+ approval_deadline: Duration,
40
+ handshake: Option<ActiveHandshake>,
41
+ cancelled: bool,
42
+ }
43
+
44
+ /// Live helper state for one login attempt.
45
+ struct ActiveHandshake {
46
+ child: Child,
47
+ intake: ChildStdin,
48
+ outtake: BufReader<ChildStdout>,
49
+ next_id: u64,
50
+ queued: VecDeque<Value>,
51
+ login_id: String,
52
+ challenge: Challenge,
53
+ }
54
+
55
+ impl CodexDeviceDriver {
56
+ /// Create a driver with the default approval host.
57
+ ///
58
+ /// The program is the Codex executable; `app-server` is appended at
59
+ /// spawn time. Extra leading arguments for fixtures can be added with
60
+ /// [`CodexDeviceDriver::with_lead_args`].
61
+ pub fn new(program: impl Into<PathBuf>) -> Self {
62
+ Self {
63
+ program: program.into(),
64
+ lead_args: Vec::new(),
65
+ allowed_hosts: vec![DEFAULT_HOST.to_owned()],
66
+ call_timeout: Duration::from_secs(15),
67
+ approval_deadline: Duration::from_secs(300),
68
+ handshake: None,
69
+ cancelled: false,
70
+ }
71
+ }
72
+
73
+ /// Create a driver with an explicit approval-host allowlist.
74
+ ///
75
+ /// Every host must be a bare DNS name without scheme, path, or
76
+ /// whitespace. An empty list rejects every challenge. Matching is
77
+ /// case-insensitive and exact; subdomains are not implied.
78
+ ///
79
+ /// # Errors
80
+ ///
81
+ /// Returns [`DriverError::InvalidOptions`] when the program is empty,
82
+ /// the list is empty, or any host has an unsupported shape.
83
+ pub fn with_allowed_hosts(
84
+ program: impl Into<PathBuf>,
85
+ allowed: Vec<String>,
86
+ ) -> Result<Self, DriverError> {
87
+ if allowed.is_empty() {
88
+ return Err(DriverError::InvalidOptions(
89
+ "approval host allowlist must not be empty",
90
+ ));
91
+ }
92
+ for host in &allowed {
93
+ if !is_bare_host(host) {
94
+ return Err(DriverError::InvalidOptions(
95
+ "approval host has an unsupported shape",
96
+ ));
97
+ }
98
+ }
99
+ let program = program.into();
100
+ if program.as_os_str().is_empty() {
101
+ return Err(DriverError::InvalidOptions(
102
+ "codex program must not be empty",
103
+ ));
104
+ }
105
+ Ok(Self {
106
+ program,
107
+ lead_args: Vec::new(),
108
+ allowed_hosts: allowed,
109
+ call_timeout: Duration::from_secs(15),
110
+ approval_deadline: Duration::from_secs(300),
111
+ handshake: None,
112
+ cancelled: false,
113
+ })
114
+ }
115
+
116
+ /// Add leading arguments before the appended `app-server` token.
117
+ ///
118
+ /// Production use leaves this empty; fixture tests pass an interpreter
119
+ /// preamble such as `-u -c <code> <mode>` here.
120
+ pub fn with_lead_args(mut self, args: Vec<String>) -> Self {
121
+ self.lead_args = args;
122
+ self
123
+ }
124
+
125
+ /// Set the per-call deadline for helper requests.
126
+ ///
127
+ /// Applies to the challenge request and the presence read. Polling for
128
+ /// browser approval uses [`CodexDeviceDriver::with_approval_deadline`].
129
+ pub fn with_call_timeout(mut self, limit: Duration) -> Self {
130
+ self.call_timeout = limit;
131
+ self
132
+ }
133
+
134
+ /// Set how long [`LoginDriver::poll`] waits for browser approval.
135
+ pub fn with_approval_deadline(mut self, limit: Duration) -> Self {
136
+ self.approval_deadline = limit;
137
+ self
138
+ }
139
+
140
+ /// Current approval-host allowlist.
141
+ pub fn allowed_hosts(&self) -> &[String] {
142
+ &self.allowed_hosts
143
+ }
144
+
145
+ /// Whether a helper is currently running.
146
+ pub fn has_helper(&self) -> bool {
147
+ self.handshake.is_some()
148
+ }
149
+
150
+ /// Spawn the helper with a cleared minimal environment.
151
+ fn spawn_helper(&self) -> Result<ActiveHandshake, DriverError> {
152
+ if self.program.as_os_str().is_empty() {
153
+ return Err(DriverError::InvalidOptions(
154
+ "codex program must not be empty",
155
+ ));
156
+ }
157
+ if self.call_timeout.is_zero() || self.approval_deadline.is_zero() {
158
+ return Err(DriverError::InvalidOptions(
159
+ "driver deadlines must be positive",
160
+ ));
161
+ }
162
+ let home = std::env::var("HOME")
163
+ .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned());
164
+ // Resolve bare names against the parent PATH before clearing it, so
165
+ // fixtures work where the interpreter lives outside the minimal set.
166
+ let resolved = super::resolve_program(&self.program);
167
+ let mut spawn = Command::new(&resolved);
168
+ spawn
169
+ .args(&self.lead_args)
170
+ .arg("app-server")
171
+ .env_clear()
172
+ .env("PATH", "/usr/local/bin:/usr/bin:/bin")
173
+ .env("HOME", home)
174
+ .stdin(Stdio::piped())
175
+ .stdout(Stdio::piped())
176
+ .stderr(Stdio::null())
177
+ .kill_on_drop(true);
178
+ let mut child = spawn
179
+ .spawn()
180
+ .map_err(|_| DriverError::Spawn("codex helper could not be launched"))?;
181
+ let intake = child
182
+ .stdin
183
+ .take()
184
+ .ok_or(DriverError::Spawn("codex helper input was unavailable"))?;
185
+ let output = child
186
+ .stdout
187
+ .take()
188
+ .ok_or(DriverError::Spawn("codex helper output was unavailable"))?;
189
+ Ok(ActiveHandshake {
190
+ child,
191
+ intake,
192
+ outtake: BufReader::new(output),
193
+ next_id: 0,
194
+ queued: VecDeque::new(),
195
+ login_id: String::new(),
196
+ challenge: Challenge::new(String::new(), String::new()),
197
+ })
198
+ }
199
+
200
+ /// Stop the helper without reporting secrets.
201
+ fn shutdown(&mut self) {
202
+ if let Some(mut live) = self.handshake.take() {
203
+ let _ = live.child.start_kill();
204
+ }
205
+ }
206
+
207
+ /// Short label for redacted debug output.
208
+ fn phase_label(&self) -> &'static str {
209
+ if self.cancelled {
210
+ "cancelled"
211
+ } else if let Some(live) = &self.handshake {
212
+ if live.login_id.is_empty() {
213
+ "starting"
214
+ } else {
215
+ "awaiting-approval"
216
+ }
217
+ } else {
218
+ "idle"
219
+ }
220
+ }
221
+ }
222
+
223
+ impl std::fmt::Debug for CodexDeviceDriver {
224
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225
+ f.debug_struct("CodexDeviceDriver")
226
+ .field("phase", &self.phase_label())
227
+ .field("has_helper", &self.handshake.is_some())
228
+ .finish()
229
+ }
230
+ }
231
+
232
+ impl Drop for CodexDeviceDriver {
233
+ fn drop(&mut self) {
234
+ self.shutdown();
235
+ }
236
+ }
237
+
238
+ /// Whether a string is a bare host without scheme or path.
239
+ fn is_bare_host(candidate: &str) -> bool {
240
+ if candidate.is_empty() || candidate.chars().any(char::is_whitespace) {
241
+ return false;
242
+ }
243
+ if candidate.contains("://") || candidate.contains('/') {
244
+ return false;
245
+ }
246
+ candidate
247
+ .bytes()
248
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.'))
249
+ && candidate.bytes().any(|byte| byte.is_ascii_alphanumeric())
250
+ }
251
+
252
+ /// Extract the lowercased host from an `https` URL.
253
+ fn host_of(url: &str) -> Option<String> {
254
+ let rest = url.strip_prefix("https://")?;
255
+ let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
256
+ let host = host.split('@').next_back().unwrap_or_default();
257
+ let host = host.split(':').next().unwrap_or_default();
258
+ if host.is_empty() {
259
+ return None;
260
+ }
261
+ Some(host.to_ascii_lowercase())
262
+ }
263
+
264
+ /// Read one capped line-delimited frame from the helper.
265
+ async fn read_frame(outtake: &mut BufReader<ChildStdout>) -> Result<Value, DriverError> {
266
+ let mut buf = Vec::new();
267
+ let taken = outtake
268
+ .take(MAX_FRAME)
269
+ .read_until(b'\n', &mut buf)
270
+ .await
271
+ .map_err(|_| DriverError::Closed)?;
272
+ let _ = taken;
273
+ if buf.is_empty() {
274
+ return Err(DriverError::Closed);
275
+ }
276
+ if buf.last() != Some(&b'\n') {
277
+ return Err(DriverError::Protocol(-32700));
278
+ }
279
+ serde_json::from_slice(&buf).map_err(|_| DriverError::Protocol(-32700))
280
+ }
281
+
282
+ /// Send one frame to the helper.
283
+ async fn write_frame(intake: &mut ChildStdin, frame: &Value) -> Result<(), DriverError> {
284
+ let mut bytes = serde_json::to_vec(frame).map_err(|_| DriverError::Protocol(-32700))?;
285
+ bytes.push(b'\n');
286
+ intake
287
+ .write_all(&bytes)
288
+ .await
289
+ .map_err(|_| DriverError::Closed)?;
290
+ intake.flush().await.map_err(|_| DriverError::Closed)?;
291
+ Ok(())
292
+ }
293
+
294
+ /// Call one helper method and wait for the matching reply.
295
+ async fn round_trip(
296
+ live: &mut ActiveHandshake,
297
+ method: &str,
298
+ params: Value,
299
+ limit: Duration,
300
+ ) -> Result<Value, DriverError> {
301
+ live.next_id = live.next_id.wrapping_add(1);
302
+ let id = live.next_id;
303
+ write_frame(
304
+ &mut live.intake,
305
+ &serde_json::json!({"id": id, "method": method, "params": params}),
306
+ )
307
+ .await?;
308
+ timeout(limit, async {
309
+ loop {
310
+ let frame = read_frame(&mut live.outtake).await?;
311
+ if frame.get("id") == Some(&Value::from(id)) {
312
+ if let Some(err) = frame.get("error") {
313
+ let code = err.get("code").and_then(Value::as_i64).unwrap_or(-32603) as i32;
314
+ return Err(DriverError::Protocol(code));
315
+ }
316
+ if let Some(result) = frame.get("result") {
317
+ return Ok(result.clone());
318
+ }
319
+ return Err(DriverError::Protocol(-32603));
320
+ }
321
+ if frame.get("method").is_some() {
322
+ if live.queued.len() >= MAX_QUEUED_NOTES {
323
+ return Err(DriverError::Protocol(-32603));
324
+ }
325
+ live.queued.push_back(frame);
326
+ }
327
+ }
328
+ })
329
+ .await
330
+ .map_err(|_| DriverError::Timeout)?
331
+ }
332
+
333
+ /// Fetch one queued or fresh helper notification.
334
+ async fn next_note(live: &mut ActiveHandshake, limit: Duration) -> Result<Value, DriverError> {
335
+ if let Some(note) = live.queued.pop_front() {
336
+ return Ok(note);
337
+ }
338
+ timeout(limit, read_frame(&mut live.outtake))
339
+ .await
340
+ .map_err(|_| DriverError::Timeout)?
341
+ }
342
+
343
+ /// Pull one string field, accepting camelCase or snake_case keys.
344
+ fn pick_text(source: &Value, camel: &str, snake: &str) -> Option<String> {
345
+ for key in [camel, snake] {
346
+ if let Some(text) = source.get(key).and_then(Value::as_str) {
347
+ return Some(text.to_owned());
348
+ }
349
+ }
350
+ None
351
+ }
352
+
353
+ /// Project the login-start reply into an id plus a validated challenge.
354
+ fn project_start(reply: &Value, allowed: &[String]) -> Result<(String, Challenge), DriverError> {
355
+ let login_id = pick_text(reply, "loginId", "login_id").ok_or(DriverError::InvalidOptions(
356
+ "helper challenge missed its login id",
357
+ ))?;
358
+ let url = pick_text(reply, "verificationUrl", "verification_url").ok_or(
359
+ DriverError::InvalidOptions("helper challenge missed its URL"),
360
+ )?;
361
+ let code = pick_text(reply, "userCode", "user_code").ok_or(DriverError::InvalidOptions(
362
+ "helper challenge missed its code",
363
+ ))?;
364
+ if login_id.is_empty() || login_id.len() > 128 {
365
+ return Err(DriverError::InvalidOptions(
366
+ "helper challenge missed its login id",
367
+ ));
368
+ }
369
+ let challenge = Challenge::new(url, code);
370
+ challenge.validate()?;
371
+ let host = host_of(&challenge.verification_url).ok_or(DriverError::InvalidOptions(
372
+ "challenge URL must include a host",
373
+ ))?;
374
+ let permitted = allowed
375
+ .iter()
376
+ .any(|entry| entry.to_ascii_lowercase() == host);
377
+ if !permitted {
378
+ return Err(DriverError::InvalidOptions("challenge host is not allowed"));
379
+ }
380
+ Ok((login_id, challenge))
381
+ }
382
+
383
+ /// Project the account-read reply into display-only presence.
384
+ fn project_presence(reply: &Value) -> AccountInfo {
385
+ let node = reply.get("account").unwrap_or(&Value::Null);
386
+ let kind = node.get("type").and_then(Value::as_str).unwrap_or_default();
387
+ if kind != "chatgpt" {
388
+ return AccountInfo::signed_out();
389
+ }
390
+ let label = node
391
+ .get("email")
392
+ .and_then(Value::as_str)
393
+ .or_else(|| node.get("label").and_then(Value::as_str))
394
+ .unwrap_or_default();
395
+ if label.is_empty() || label.len() > 320 {
396
+ return AccountInfo::new(None);
397
+ }
398
+ AccountInfo::new(Some(label.to_owned()))
399
+ }
400
+
401
+ impl LoginDriver for CodexDeviceDriver {
402
+ /// Spawn the helper and request a ChatGPT device challenge.
403
+ async fn start(&mut self) -> Result<LoginState, DriverError> {
404
+ if self.cancelled {
405
+ return Err(DriverError::Cancelled);
406
+ }
407
+ self.shutdown();
408
+ let mut live = self.spawn_helper()?;
409
+ let reply = round_trip(
410
+ &mut live,
411
+ "account/login/start",
412
+ serde_json::json!({"type": "chatgptDeviceCode"}),
413
+ self.call_timeout,
414
+ )
415
+ .await
416
+ .inspect_err(|_| {
417
+ let _ = live.child.start_kill();
418
+ })?;
419
+ let (login_id, challenge) =
420
+ project_start(&reply, &self.allowed_hosts).inspect_err(|_| {
421
+ let _ = live.child.start_kill();
422
+ })?;
423
+ live.login_id = login_id;
424
+ live.challenge = challenge.clone();
425
+ self.handshake = Some(live);
426
+ Ok(LoginState::ChallengeRequired(challenge))
427
+ }
428
+
429
+ /// Wait for browser approval, then project the connected account.
430
+ async fn poll(&mut self) -> Result<LoginState, DriverError> {
431
+ if self.cancelled {
432
+ return Err(DriverError::Cancelled);
433
+ }
434
+ let deadline = Instant::now() + self.approval_deadline;
435
+ if self.approval_deadline.is_zero() {
436
+ return Err(DriverError::InvalidOptions(
437
+ "driver deadlines must be positive",
438
+ ));
439
+ }
440
+ let live = self.handshake.as_mut().ok_or(DriverError::Closed)?;
441
+ let wanted = live.login_id.clone();
442
+ loop {
443
+ let remaining = deadline.saturating_duration_since(Instant::now());
444
+ if remaining.is_zero() {
445
+ return Err(DriverError::Timeout);
446
+ }
447
+ let wait = remaining.min(self.call_timeout);
448
+ let event = next_note(live, wait).await?;
449
+ let method = event.get("method").and_then(Value::as_str).unwrap_or("");
450
+ if method != "account/login/completed" {
451
+ continue;
452
+ }
453
+ let params = event.get("params").unwrap_or(&Value::Null);
454
+ let seen = params
455
+ .get("loginId")
456
+ .and_then(Value::as_str)
457
+ .or_else(|| params.get("login_id").and_then(Value::as_str))
458
+ .unwrap_or_default();
459
+ if seen != wanted {
460
+ continue;
461
+ }
462
+ let ok = params
463
+ .get("success")
464
+ .and_then(Value::as_bool)
465
+ .unwrap_or(false);
466
+ if !ok {
467
+ return Ok(LoginState::Failed);
468
+ }
469
+ let reply = round_trip(
470
+ live,
471
+ "account/read",
472
+ serde_json::json!({"refreshToken": false}),
473
+ self.call_timeout,
474
+ )
475
+ .await?;
476
+ let info = project_presence(&reply);
477
+ if info.account.is_none() {
478
+ return Ok(LoginState::Failed);
479
+ }
480
+ return Ok(LoginState::Authenticated(info));
481
+ }
482
+ }
483
+
484
+ /// Re-read vendor presence without a new device challenge.
485
+ async fn account(&mut self) -> Result<AccountInfo, DriverError> {
486
+ if self.cancelled {
487
+ return Err(DriverError::Cancelled);
488
+ }
489
+ let live = self.handshake.as_mut().ok_or(DriverError::Closed)?;
490
+ let reply = round_trip(
491
+ live,
492
+ "account/read",
493
+ serde_json::json!({"refreshToken": false}),
494
+ self.call_timeout,
495
+ )
496
+ .await?;
497
+ Ok(project_presence(&reply))
498
+ }
499
+
500
+ /// Stop the helper; later steps report cancellation.
501
+ async fn cancel(&mut self) -> Result<(), DriverError> {
502
+ self.cancelled = true;
503
+ self.shutdown();
504
+ Ok(())
505
+ }
506
+ }