@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,305 @@
|
|
|
1
|
+
//! Native vendor login ceremonies behind one uniform trait.
|
|
2
|
+
//!
|
|
3
|
+
//! Drivers run the vendor handshake natively only; they are never compiled
|
|
4
|
+
//! for Wasm. A driver spawns the vendor helper, relays the user-visible
|
|
5
|
+
//! challenge, and projects the vendor reply into plain state. Drivers never
|
|
6
|
+
//! extract secret bytes, never persist anything, and never log credentials.
|
|
7
|
+
//! The application owns storage through [`crate::CredentialsProvider`]: it
|
|
8
|
+
//! keeps any secret, decides where the secret lives, and clears it on
|
|
9
|
+
//! sign-out. Drivers only report whether the vendor considers the account
|
|
10
|
+
//! connected and, when the vendor offers one, which display label to show.
|
|
11
|
+
//!
|
|
12
|
+
//! All failures use fixed messages so URLs, paths, tokens, and keys cannot
|
|
13
|
+
//! leak through [`DriverError`].
|
|
14
|
+
|
|
15
|
+
use std::path::PathBuf;
|
|
16
|
+
|
|
17
|
+
mod codex;
|
|
18
|
+
mod opencode;
|
|
19
|
+
|
|
20
|
+
pub use codex::CodexDeviceDriver;
|
|
21
|
+
pub use opencode::OpenCodeKeyDriver;
|
|
22
|
+
|
|
23
|
+
/// User-visible device challenge for a browser approval step.
|
|
24
|
+
///
|
|
25
|
+
/// The URL is opened by the person, the code is typed or pasted there.
|
|
26
|
+
/// Neither field is a secret, but both are validated before display so a
|
|
27
|
+
/// compromised helper cannot turn the application into an open redirect.
|
|
28
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
29
|
+
pub struct Challenge {
|
|
30
|
+
/// Secure page where the person approves the login.
|
|
31
|
+
pub verification_url: String,
|
|
32
|
+
/// Short code the person confirms on that page.
|
|
33
|
+
pub user_code: String,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
impl Challenge {
|
|
37
|
+
/// Create a challenge without validation for transport purposes.
|
|
38
|
+
///
|
|
39
|
+
/// Call [`Challenge::validate`] before display.
|
|
40
|
+
pub fn new(verification_url: String, user_code: String) -> Self {
|
|
41
|
+
Self {
|
|
42
|
+
verification_url,
|
|
43
|
+
user_code,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Check shape without network access or persistence.
|
|
48
|
+
///
|
|
49
|
+
/// The URL must use `https` and include a host; the code must be 4 to
|
|
50
|
+
/// 32 characters of ASCII letters, digits, or `-`.
|
|
51
|
+
///
|
|
52
|
+
/// # Errors
|
|
53
|
+
///
|
|
54
|
+
/// Returns [`DriverError::InvalidOptions`] when either field has an
|
|
55
|
+
/// unsupported shape.
|
|
56
|
+
pub fn validate(&self) -> Result<(), DriverError> {
|
|
57
|
+
if self.verification_url.is_empty() || self.user_code.is_empty() {
|
|
58
|
+
return Err(DriverError::InvalidOptions(
|
|
59
|
+
"challenge must include a URL and a code",
|
|
60
|
+
));
|
|
61
|
+
}
|
|
62
|
+
if self.verification_url.chars().any(char::is_whitespace) {
|
|
63
|
+
return Err(DriverError::InvalidOptions(
|
|
64
|
+
"challenge URL must not contain whitespace",
|
|
65
|
+
));
|
|
66
|
+
}
|
|
67
|
+
let Some(rest) = self.verification_url.strip_prefix("https://") else {
|
|
68
|
+
return Err(DriverError::InvalidOptions("challenge URL must use https"));
|
|
69
|
+
};
|
|
70
|
+
let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
|
71
|
+
if host.is_empty() {
|
|
72
|
+
return Err(DriverError::InvalidOptions(
|
|
73
|
+
"challenge URL must include a host",
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
let host_ok = host
|
|
77
|
+
.bytes()
|
|
78
|
+
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b':'));
|
|
79
|
+
if !host_ok {
|
|
80
|
+
return Err(DriverError::InvalidOptions(
|
|
81
|
+
"challenge URL host has an unsupported shape",
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
let code = self.user_code.as_str();
|
|
85
|
+
if !(4..=32).contains(&code.len()) {
|
|
86
|
+
return Err(DriverError::InvalidOptions(
|
|
87
|
+
"challenge code has an unsupported shape",
|
|
88
|
+
));
|
|
89
|
+
}
|
|
90
|
+
if !code
|
|
91
|
+
.bytes()
|
|
92
|
+
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
|
93
|
+
{
|
|
94
|
+
return Err(DriverError::InvalidOptions(
|
|
95
|
+
"challenge code has an unsupported shape",
|
|
96
|
+
));
|
|
97
|
+
}
|
|
98
|
+
Ok(())
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Projected account presence after a vendor `read` call.
|
|
103
|
+
///
|
|
104
|
+
/// The label is display-only provenance (for example an email). It is never
|
|
105
|
+
/// an authentication proof by itself and never carries secret bytes.
|
|
106
|
+
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
107
|
+
pub struct AccountInfo {
|
|
108
|
+
/// Display-only label when the vendor reports a connected account.
|
|
109
|
+
pub account: Option<String>,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
impl AccountInfo {
|
|
113
|
+
/// Project a connected account with an optional display label.
|
|
114
|
+
pub fn new(account: Option<String>) -> Self {
|
|
115
|
+
Self { account }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// Project a signed-out vendor state.
|
|
119
|
+
pub fn signed_out() -> Self {
|
|
120
|
+
Self { account: None }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Whether the vendor reports a connected account label.
|
|
124
|
+
#[must_use]
|
|
125
|
+
pub fn connected(&self) -> bool {
|
|
126
|
+
self.account
|
|
127
|
+
.as_deref()
|
|
128
|
+
.is_some_and(|label| !label.is_empty())
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Login driver failure without secrets, paths, or peer text.
|
|
133
|
+
///
|
|
134
|
+
/// Every message is fixed at compile time except for the numeric protocol
|
|
135
|
+
/// code, which carries no text or data from the vendor.
|
|
136
|
+
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
|
|
137
|
+
pub enum DriverError {
|
|
138
|
+
/// Driver configuration or a vendor challenge has an unsupported shape.
|
|
139
|
+
#[error("invalid login driver configuration: {0}")]
|
|
140
|
+
InvalidOptions(&'static str),
|
|
141
|
+
/// The vendor did not finish within the configured deadline.
|
|
142
|
+
#[error("login driver operation timed out")]
|
|
143
|
+
Timeout,
|
|
144
|
+
/// The helper exited or the driver was shut down before completion.
|
|
145
|
+
#[error("login driver connection is closed")]
|
|
146
|
+
Closed,
|
|
147
|
+
/// The helper process could not start or exited early.
|
|
148
|
+
#[error("login driver process could not start: {0}")]
|
|
149
|
+
Spawn(&'static str),
|
|
150
|
+
/// A numeric vendor protocol code without peer text or data.
|
|
151
|
+
#[error("vendor returned protocol error {0}")]
|
|
152
|
+
Protocol(i32),
|
|
153
|
+
/// The application cancelled the attempt.
|
|
154
|
+
#[error("login attempt was cancelled")]
|
|
155
|
+
Cancelled,
|
|
156
|
+
/// The vendor or driver does not implement the requested step.
|
|
157
|
+
#[error("login driver does not support {0}")]
|
|
158
|
+
Unsupported(&'static str),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
impl DriverError {
|
|
162
|
+
/// Stable category suitable for structured application events.
|
|
163
|
+
pub fn code(&self) -> &'static str {
|
|
164
|
+
match self {
|
|
165
|
+
Self::InvalidOptions(_) => "invalid_options",
|
|
166
|
+
Self::Timeout => "timeout",
|
|
167
|
+
Self::Closed => "closed",
|
|
168
|
+
Self::Spawn(_) => "spawn_failed",
|
|
169
|
+
Self::Protocol(_) => "protocol",
|
|
170
|
+
Self::Cancelled => "cancelled",
|
|
171
|
+
Self::Unsupported(_) => "unsupported",
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/// Observable outcome of one driver step.
|
|
177
|
+
///
|
|
178
|
+
/// Drivers return state; they never return secret bytes. The application
|
|
179
|
+
/// decides what to persist through its own credential store.
|
|
180
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
181
|
+
pub enum LoginState {
|
|
182
|
+
/// Show the URL and code, then call `poll` again after approval.
|
|
183
|
+
ChallengeRequired(Challenge),
|
|
184
|
+
/// The vendor reports a connected account.
|
|
185
|
+
Authenticated(AccountInfo),
|
|
186
|
+
/// The vendor declined or reports no connected account.
|
|
187
|
+
Failed,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// One vendor login ceremony.
|
|
191
|
+
///
|
|
192
|
+
/// Implementations are `Send` so applications can hold them across await
|
|
193
|
+
/// points on a Tokio runtime. The ceremony is native only and performs no
|
|
194
|
+
/// credential storage: `start` begins the handshake, `poll` waits for the
|
|
195
|
+
/// person to approve it, `account` re-reads the vendor presence, and
|
|
196
|
+
/// `cancel` stops the helper.
|
|
197
|
+
#[allow(async_fn_in_trait)]
|
|
198
|
+
pub trait LoginDriver: Send {
|
|
199
|
+
/// Begin the handshake and report the first visible state.
|
|
200
|
+
async fn start(&mut self) -> Result<LoginState, DriverError>;
|
|
201
|
+
/// Wait for browser approval and project the vendor presence.
|
|
202
|
+
async fn poll(&mut self) -> Result<LoginState, DriverError>;
|
|
203
|
+
/// Re-read the vendor presence without starting a new handshake.
|
|
204
|
+
async fn account(&mut self) -> Result<AccountInfo, DriverError>;
|
|
205
|
+
/// Stop the helper; later steps report cancellation.
|
|
206
|
+
async fn cancel(&mut self) -> Result<(), DriverError>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Result returned by login driver operations.
|
|
210
|
+
pub type DriverResult<T> = Result<T, DriverError>;
|
|
211
|
+
|
|
212
|
+
/// Resolve a bare program name against the parent process `PATH`.
|
|
213
|
+
///
|
|
214
|
+
/// Drivers spawn helpers with a cleared minimal environment, so a relative
|
|
215
|
+
/// name like `python3` would otherwise resolve only against
|
|
216
|
+
/// `/usr/local/bin:/usr/bin:/bin`. Test fixtures and caller-supplied bare
|
|
217
|
+
/// names (for example a Nix-profile `python3` on CI workers) live outside
|
|
218
|
+
/// that minimal set. Absolute or slash-containing paths pass through
|
|
219
|
+
/// unchanged; bare names resolve to the first `PATH` match, falling back to
|
|
220
|
+
/// the original name so spawn still fails with the fixed `Spawn` message.
|
|
221
|
+
pub(crate) fn resolve_program(program: &PathBuf) -> PathBuf {
|
|
222
|
+
let text = program.to_string_lossy();
|
|
223
|
+
if text.is_empty() || text.contains('/') {
|
|
224
|
+
return program.clone();
|
|
225
|
+
}
|
|
226
|
+
if let Some(paths) = std::env::var_os("PATH") {
|
|
227
|
+
for dir in std::env::split_paths(&paths) {
|
|
228
|
+
let candidate = dir.join(program);
|
|
229
|
+
if candidate.is_file() {
|
|
230
|
+
return candidate;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
program.clone()
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#[cfg(test)]
|
|
238
|
+
fn assert_send<T: Send>() {
|
|
239
|
+
let _ = core::marker::PhantomData::<T>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[cfg(test)]
|
|
243
|
+
mod tests {
|
|
244
|
+
use super::*;
|
|
245
|
+
|
|
246
|
+
#[test]
|
|
247
|
+
fn driver_types_are_send() {
|
|
248
|
+
assert_send::<Challenge>();
|
|
249
|
+
assert_send::<AccountInfo>();
|
|
250
|
+
assert_send::<DriverError>();
|
|
251
|
+
assert_send::<LoginState>();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[test]
|
|
255
|
+
fn challenge_accepts_well_formed_input() {
|
|
256
|
+
let challenge = Challenge::new(
|
|
257
|
+
"https://auth.openai.com/codex/device".to_owned(),
|
|
258
|
+
"ABCD-1234".to_owned(),
|
|
259
|
+
);
|
|
260
|
+
challenge.validate().unwrap();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#[test]
|
|
264
|
+
fn challenge_rejects_bad_shapes() {
|
|
265
|
+
for (url, code) in [
|
|
266
|
+
("", "ABCD-1234"),
|
|
267
|
+
("https://auth.openai.com/codex/device", ""),
|
|
268
|
+
("http://auth.openai.com/codex/device", "ABCD-1234"),
|
|
269
|
+
("https://", "ABCD-1234"),
|
|
270
|
+
("https:///path", "ABCD-1234"),
|
|
271
|
+
("https://auth.openai.com/codex/device", "abc"),
|
|
272
|
+
(
|
|
273
|
+
"https://auth.openai.com/codex/device",
|
|
274
|
+
"this-code-is-far-too-long-for-a-device-step",
|
|
275
|
+
),
|
|
276
|
+
("https://auth.openai.com/codex/device", "bad code!"),
|
|
277
|
+
("https://auth.openai.com/code x/device", "ABCD-1234"),
|
|
278
|
+
] {
|
|
279
|
+
let challenge = Challenge::new(url.to_owned(), code.to_owned());
|
|
280
|
+
assert!(
|
|
281
|
+
matches!(challenge.validate(), Err(DriverError::InvalidOptions(_))),
|
|
282
|
+
"expected rejection for {url:?} {code:?}"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#[test]
|
|
288
|
+
fn error_codes_stay_stable() {
|
|
289
|
+
assert_eq!(DriverError::InvalidOptions("x").code(), "invalid_options");
|
|
290
|
+
assert_eq!(DriverError::Timeout.code(), "timeout");
|
|
291
|
+
assert_eq!(DriverError::Closed.code(), "closed");
|
|
292
|
+
assert_eq!(DriverError::Spawn("x").code(), "spawn_failed");
|
|
293
|
+
assert_eq!(DriverError::Protocol(-32603).code(), "protocol");
|
|
294
|
+
assert_eq!(DriverError::Cancelled.code(), "cancelled");
|
|
295
|
+
assert_eq!(DriverError::Unsupported("x").code(), "unsupported");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#[test]
|
|
299
|
+
fn account_presence_requires_a_non_empty_label() {
|
|
300
|
+
assert!(!AccountInfo::signed_out().connected());
|
|
301
|
+
assert!(!AccountInfo::new(None).connected());
|
|
302
|
+
assert!(!AccountInfo::new(Some(String::new())).connected());
|
|
303
|
+
assert!(AccountInfo::new(Some("user@example.com".to_owned())).connected());
|
|
304
|
+
}
|
|
305
|
+
}
|