@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,440 @@
|
|
|
1
|
+
//! Keyed pool of native agent sessions sharing one client.
|
|
2
|
+
//!
|
|
3
|
+
//! Workspaces, projects or roles map to pool keys; each key owns exactly one
|
|
4
|
+
//! session, created lazily on first use. Turns in different keys run in
|
|
5
|
+
//! parallel automatically, while each key serializes its own turns. Events
|
|
6
|
+
//! arrive tagged so late answers still land where they were asked.
|
|
7
|
+
//!
|
|
8
|
+
//! The pool owns session mechanics only: applications own prompts, permission
|
|
9
|
+
//! decisions, display and storage, and read conversation snapshots back for
|
|
10
|
+
//! replay or persistence.
|
|
11
|
+
|
|
12
|
+
use std::collections::HashMap;
|
|
13
|
+
|
|
14
|
+
use tokio::sync::mpsc;
|
|
15
|
+
|
|
16
|
+
use super::{
|
|
17
|
+
AgentCommand, NativeClient, NativeError, NativeOptions, SessionHandle, SessionOptions,
|
|
18
|
+
};
|
|
19
|
+
use crate::{Conversation, SessionEvent, WireEvent};
|
|
20
|
+
|
|
21
|
+
/// Application-chosen session key: a workspace, project or role name.
|
|
22
|
+
pub type SessionKey = String;
|
|
23
|
+
|
|
24
|
+
/// Streamed pool activity. Raw session events keep their full detail; the
|
|
25
|
+
/// application maps them to display, storage and permission decisions.
|
|
26
|
+
#[derive(Debug)]
|
|
27
|
+
pub enum PoolEvent {
|
|
28
|
+
/// A session event, tagged with its key.
|
|
29
|
+
Session {
|
|
30
|
+
/// Application-chosen session key.
|
|
31
|
+
key: SessionKey,
|
|
32
|
+
/// Raw session event with full detail (boxed: the common case
|
|
33
|
+
/// dwarfs the terminal variant).
|
|
34
|
+
event: Box<SessionEvent>,
|
|
35
|
+
},
|
|
36
|
+
/// The forwarder for a removed session drained. No further events follow.
|
|
37
|
+
Ended {
|
|
38
|
+
/// Application-chosen session key.
|
|
39
|
+
key: SessionKey,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
struct Session {
|
|
44
|
+
handle: SessionHandle,
|
|
45
|
+
conv: Conversation,
|
|
46
|
+
busy: bool,
|
|
47
|
+
seq: u64,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
struct Inner {
|
|
51
|
+
client: Option<NativeClient>,
|
|
52
|
+
sessions: HashMap<SessionKey, Session>,
|
|
53
|
+
app_events: mpsc::UnboundedSender<PoolEvent>,
|
|
54
|
+
fwd: Option<mpsc::UnboundedSender<(SessionKey, Option<SessionEvent>)>>,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Keyed native sessions over one shared client connection.
|
|
58
|
+
#[derive(Clone)]
|
|
59
|
+
pub struct SessionPool {
|
|
60
|
+
inner: std::sync::Arc<tokio::sync::Mutex<Inner>>,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
impl SessionPool {
|
|
64
|
+
/// Connect the shared client and return the pool plus its event stream.
|
|
65
|
+
/// A failed connection still returns a pool: every operation then
|
|
66
|
+
/// reports the outage instead of panicking, so applications degrade
|
|
67
|
+
/// gracefully (e.g. keep local tools working while the agent is down).
|
|
68
|
+
pub async fn connect(
|
|
69
|
+
command: AgentCommand,
|
|
70
|
+
options: NativeOptions,
|
|
71
|
+
) -> (Self, mpsc::UnboundedReceiver<PoolEvent>) {
|
|
72
|
+
let (app_tx, app_rx) = mpsc::unbounded_channel();
|
|
73
|
+
let (fwd_tx, fwd_rx) = mpsc::unbounded_channel::<(SessionKey, Option<SessionEvent>)>();
|
|
74
|
+
let client = NativeClient::connect(command, options).await.ok();
|
|
75
|
+
let pool = Self {
|
|
76
|
+
inner: std::sync::Arc::new(tokio::sync::Mutex::new(Inner {
|
|
77
|
+
client,
|
|
78
|
+
sessions: HashMap::new(),
|
|
79
|
+
app_events: app_tx,
|
|
80
|
+
fwd: Some(fwd_tx),
|
|
81
|
+
})),
|
|
82
|
+
};
|
|
83
|
+
let router = pool.clone();
|
|
84
|
+
tokio::spawn(async move {
|
|
85
|
+
Self::route(router, fwd_rx).await;
|
|
86
|
+
});
|
|
87
|
+
(pool, app_rx)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Send a prompt to a key's session, creating it from `new` on first
|
|
91
|
+
/// use. Resolves when the turn ends; stream events (including the
|
|
92
|
+
/// terminal `Completed`) arrive separately, tagged with the key.
|
|
93
|
+
/// A second prompt while the key is busy fails with [`NativeError::Busy`].
|
|
94
|
+
pub async fn prompt(
|
|
95
|
+
&self,
|
|
96
|
+
key: &str,
|
|
97
|
+
new: SessionOptions,
|
|
98
|
+
text: String,
|
|
99
|
+
) -> Result<(), NativeError> {
|
|
100
|
+
let handle = {
|
|
101
|
+
let mut inner = self.inner.lock().await;
|
|
102
|
+
Self::ensure(&mut inner, key, new).await?;
|
|
103
|
+
let session = inner.sessions.get_mut(key).expect("just ensured");
|
|
104
|
+
if session.busy {
|
|
105
|
+
return Err(NativeError::Busy);
|
|
106
|
+
}
|
|
107
|
+
session.busy = true;
|
|
108
|
+
session.handle.clone()
|
|
109
|
+
};
|
|
110
|
+
let request_id = format!("ccht-pool-{key}");
|
|
111
|
+
let result = handle.prompt(crate::Prompt::text(request_id, text)).await;
|
|
112
|
+
// Busy clears on the terminal stream event; a failed call has none,
|
|
113
|
+
// so release the slot here to avoid wedging the key.
|
|
114
|
+
if result.is_err()
|
|
115
|
+
&& let Some(session) = self.inner.lock().await.sessions.get_mut(key)
|
|
116
|
+
{
|
|
117
|
+
session.busy = false;
|
|
118
|
+
}
|
|
119
|
+
result.map(|_| ())
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Cancel the active turn of one key. Missing keys are a no-op success.
|
|
123
|
+
pub async fn cancel(&self, key: &str) -> Result<(), NativeError> {
|
|
124
|
+
let handle = {
|
|
125
|
+
let inner = self.inner.lock().await;
|
|
126
|
+
match inner.sessions.get(key) {
|
|
127
|
+
Some(session) => session.handle.clone(),
|
|
128
|
+
None => return Ok(()),
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
handle.cancel().await
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/// Switch the model of an existing session, reporting the agent's
|
|
135
|
+
/// resulting configuration. Unknown keys fail without creating sessions:
|
|
136
|
+
/// model selection needs no session of its own.
|
|
137
|
+
pub async fn set_model(
|
|
138
|
+
&self,
|
|
139
|
+
key: &str,
|
|
140
|
+
model: &str,
|
|
141
|
+
) -> Result<crate::SessionConfiguration, NativeError> {
|
|
142
|
+
let handle = {
|
|
143
|
+
let inner = self.inner.lock().await;
|
|
144
|
+
match inner.sessions.get(key) {
|
|
145
|
+
Some(session) => session.handle.clone(),
|
|
146
|
+
None => return Err(NativeError::Closed),
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
handle.set_model(model).await
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Answer a pending permission request of one key's session.
|
|
153
|
+
pub async fn respond_permission(
|
|
154
|
+
&self,
|
|
155
|
+
key: &str,
|
|
156
|
+
request_id: &str,
|
|
157
|
+
decision: crate::acp::RequestPermissionOutcome,
|
|
158
|
+
) -> Result<(), NativeError> {
|
|
159
|
+
let handle = {
|
|
160
|
+
let inner = self.inner.lock().await;
|
|
161
|
+
match inner.sessions.get(key) {
|
|
162
|
+
Some(session) => session.handle.clone(),
|
|
163
|
+
None => return Err(NativeError::Closed),
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
handle.respond_permission(request_id, decision).await
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// Drop one key's session and end it agent-side. Unknown keys are a no-op.
|
|
170
|
+
pub async fn close(&self, key: &str) {
|
|
171
|
+
let handle = {
|
|
172
|
+
let mut inner = self.inner.lock().await;
|
|
173
|
+
inner.sessions.remove(key).map(|s| s.handle)
|
|
174
|
+
};
|
|
175
|
+
if let Some(handle) = handle {
|
|
176
|
+
let _ = handle.close().await;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// Whether the key holds a live session with a turn in flight.
|
|
181
|
+
pub async fn is_busy(&self, key: &str) -> bool {
|
|
182
|
+
self.inner
|
|
183
|
+
.lock()
|
|
184
|
+
.await
|
|
185
|
+
.sessions
|
|
186
|
+
.get(key)
|
|
187
|
+
.is_some_and(|s| s.busy)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// Whether the key holds a live session at all.
|
|
191
|
+
pub async fn has_session(&self, key: &str) -> bool {
|
|
192
|
+
self.inner.lock().await.sessions.contains_key(key)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// A snapshot of one key's conversation for replay or persistence.
|
|
196
|
+
/// Applications own storage; the pool only lends the state.
|
|
197
|
+
pub async fn conversation(&self, key: &str) -> Option<Conversation> {
|
|
198
|
+
self.inner
|
|
199
|
+
.lock()
|
|
200
|
+
.await
|
|
201
|
+
.sessions
|
|
202
|
+
.get(key)
|
|
203
|
+
.map(|s| s.conv.clone())
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/// Shut every session down and close the shared client.
|
|
207
|
+
pub async fn shutdown(&self) {
|
|
208
|
+
let mut inner = self.inner.lock().await;
|
|
209
|
+
let keys: Vec<String> = inner.sessions.keys().cloned().collect();
|
|
210
|
+
for key in keys {
|
|
211
|
+
if let Some(session) = inner.sessions.remove(&key) {
|
|
212
|
+
let _ = session.handle.close().await;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if let Some(client) = inner.client.take() {
|
|
216
|
+
let _ = client.close().await;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async fn ensure(inner: &mut Inner, key: &str, new: SessionOptions) -> Result<(), NativeError> {
|
|
221
|
+
if inner.sessions.contains_key(key) {
|
|
222
|
+
return Ok(());
|
|
223
|
+
}
|
|
224
|
+
let Some(client) = &inner.client else {
|
|
225
|
+
return Err(NativeError::Closed);
|
|
226
|
+
};
|
|
227
|
+
let mut session = client.new_session(new).await?;
|
|
228
|
+
let handle = session.handle();
|
|
229
|
+
let mut events = session.take_events().map_err(|_| NativeError::Closed)?;
|
|
230
|
+
drop(session);
|
|
231
|
+
let key_owned = key.to_string();
|
|
232
|
+
let fwd = inner
|
|
233
|
+
.fwd
|
|
234
|
+
.clone()
|
|
235
|
+
.expect("forwarder channel installed at connect");
|
|
236
|
+
tokio::spawn(async move {
|
|
237
|
+
while let Some(ev) = events.recv().await {
|
|
238
|
+
if fwd.send((key_owned.clone(), Some(ev))).is_err() {
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
let _ = fwd.send((key_owned, None));
|
|
243
|
+
});
|
|
244
|
+
inner.sessions.insert(
|
|
245
|
+
key.to_string(),
|
|
246
|
+
Session {
|
|
247
|
+
handle,
|
|
248
|
+
conv: Conversation::new(format!("pool-{key}")),
|
|
249
|
+
busy: false,
|
|
250
|
+
seq: 0,
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
Ok(())
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// Single router: applies every streamed event to its key's conversation
|
|
257
|
+
/// (clearing finished turns) and re-emits it tagged for the application.
|
|
258
|
+
async fn route(
|
|
259
|
+
pool: Self,
|
|
260
|
+
mut fwd: mpsc::UnboundedReceiver<(SessionKey, Option<SessionEvent>)>,
|
|
261
|
+
) {
|
|
262
|
+
while let Some((key, ev)) = fwd.recv().await {
|
|
263
|
+
let mut inner = pool.inner.lock().await;
|
|
264
|
+
let Some(session) = inner.sessions.get_mut(&key) else {
|
|
265
|
+
continue;
|
|
266
|
+
};
|
|
267
|
+
let Some(se) = ev else {
|
|
268
|
+
session.busy = false;
|
|
269
|
+
let _ = inner.app_events.send(PoolEvent::Ended { key });
|
|
270
|
+
continue;
|
|
271
|
+
};
|
|
272
|
+
session.seq += 1;
|
|
273
|
+
let rid = se.request_id.clone().unwrap_or_default();
|
|
274
|
+
let SessionEvent { event, .. } = &se;
|
|
275
|
+
let conv_id = session.conv.id().to_string();
|
|
276
|
+
let _ = session
|
|
277
|
+
.conv
|
|
278
|
+
.apply(WireEvent::new(conv_id, rid, session.seq, event.clone()));
|
|
279
|
+
match &event {
|
|
280
|
+
crate::Event::Completed { .. } | crate::Event::Error { .. } => {
|
|
281
|
+
session.busy = false;
|
|
282
|
+
}
|
|
283
|
+
_ => {}
|
|
284
|
+
}
|
|
285
|
+
let _ = inner.app_events.send(PoolEvent::Session {
|
|
286
|
+
key,
|
|
287
|
+
event: Box::new(se),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#[cfg(test)]
|
|
294
|
+
mod tests {
|
|
295
|
+
use super::*;
|
|
296
|
+
use crate::Event;
|
|
297
|
+
use crate::acp::{ContentBlock, SessionUpdate};
|
|
298
|
+
use std::time::Duration;
|
|
299
|
+
use tokio::time::timeout;
|
|
300
|
+
|
|
301
|
+
fn options() -> NativeOptions {
|
|
302
|
+
NativeOptions {
|
|
303
|
+
operation_timeout: Duration::from_secs(5),
|
|
304
|
+
prompt_timeout: Duration::from_secs(5),
|
|
305
|
+
permission_timeout: Duration::from_secs(2),
|
|
306
|
+
shutdown_timeout: Duration::from_secs(2),
|
|
307
|
+
..NativeOptions::default()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
fn command(mode: &str) -> AgentCommand {
|
|
312
|
+
AgentCommand::new("python3").args(["-u", "-c", include_str!("fixture.py"), mode])
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
fn session_options() -> SessionOptions {
|
|
316
|
+
SessionOptions::new(std::env::temp_dir())
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
fn text(event: &SessionEvent) -> Option<&str> {
|
|
320
|
+
match &event.event {
|
|
321
|
+
Event::Update {
|
|
322
|
+
update: SessionUpdate::AgentMessageChunk(chunk),
|
|
323
|
+
} => {
|
|
324
|
+
if let ContentBlock::Text(content) = &chunk.content {
|
|
325
|
+
Some(&content.text)
|
|
326
|
+
} else {
|
|
327
|
+
None
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
_ => None,
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/// Collect streamed text for one key until its turn terminates.
|
|
335
|
+
/// Other keys' events are parked and replayed to the caller in order.
|
|
336
|
+
/// Collect streamed text for both keys until each turn terminates,
|
|
337
|
+
/// demultiplexing the single shared event stream.
|
|
338
|
+
async fn collect_two(rx: &mut mpsc::UnboundedReceiver<PoolEvent>) -> (String, String) {
|
|
339
|
+
let (mut a, mut b) = (String::new(), String::new());
|
|
340
|
+
let (mut done_a, mut done_b) = (false, false);
|
|
341
|
+
while !done_a || !done_b {
|
|
342
|
+
let ev = timeout(Duration::from_secs(15), rx.recv())
|
|
343
|
+
.await
|
|
344
|
+
.expect("event in time")
|
|
345
|
+
.expect("stream open");
|
|
346
|
+
if let PoolEvent::Session { key, event } = ev {
|
|
347
|
+
let terminal = matches!(event.event, Event::Completed { .. } | Event::Error { .. });
|
|
348
|
+
if let Some(t) = text(&event) {
|
|
349
|
+
match key.as_str() {
|
|
350
|
+
"a" => a.push_str(t),
|
|
351
|
+
"b" => b.push_str(t),
|
|
352
|
+
_ => {}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if terminal {
|
|
356
|
+
match key.as_str() {
|
|
357
|
+
"a" => done_a = true,
|
|
358
|
+
"b" => done_b = true,
|
|
359
|
+
_ => {}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
(a, b)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#[tokio::test]
|
|
368
|
+
async fn parallel_keys_complete_independently() {
|
|
369
|
+
let (pool, mut rx) = SessionPool::connect(command("normal"), options()).await;
|
|
370
|
+
// Both turns run concurrently; a single shared session could not do this.
|
|
371
|
+
let p1 = pool.clone();
|
|
372
|
+
let t1 =
|
|
373
|
+
tokio::spawn(
|
|
374
|
+
async move { p1.prompt("a", session_options(), "first".to_string()).await },
|
|
375
|
+
);
|
|
376
|
+
let p2 = pool.clone();
|
|
377
|
+
let t2 = tokio::spawn(async move {
|
|
378
|
+
p2.prompt("b", session_options(), "second".to_string())
|
|
379
|
+
.await
|
|
380
|
+
});
|
|
381
|
+
let (r1, r2, texts) = tokio::join!(t1, t2, collect_two(&mut rx));
|
|
382
|
+
r1.expect("task a").expect("turn a");
|
|
383
|
+
r2.expect("task b").expect("turn b");
|
|
384
|
+
// Fixture answers every turn with the same stream.
|
|
385
|
+
assert_eq!(
|
|
386
|
+
texts,
|
|
387
|
+
("hello world".to_string(), "hello world".to_string())
|
|
388
|
+
);
|
|
389
|
+
assert!(!pool.is_busy("a").await);
|
|
390
|
+
assert!(!pool.is_busy("b").await);
|
|
391
|
+
// Conversations accumulated per key for replay.
|
|
392
|
+
assert!(pool.conversation("a").await.is_some());
|
|
393
|
+
assert!(pool.conversation("b").await.is_some());
|
|
394
|
+
assert!(pool.conversation("ghost").await.is_none());
|
|
395
|
+
pool.shutdown().await;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#[tokio::test]
|
|
399
|
+
async fn second_prompt_on_busy_key_fails_cleanly() {
|
|
400
|
+
use crate::native::NativeError;
|
|
401
|
+
|
|
402
|
+
let (pool, _rx) = SessionPool::connect(command("hang"), options()).await;
|
|
403
|
+
// The hang fixture holds the turn open without answering, so the
|
|
404
|
+
// overlapping prompt deterministically hits the busy guard.
|
|
405
|
+
let p = pool.clone();
|
|
406
|
+
let held = tokio::spawn(async move {
|
|
407
|
+
// The hang fixture never answers, so the held turn stays open
|
|
408
|
+
// until the prompt timeout fires; the busy transition in between
|
|
409
|
+
// is what this test observes, not the outcome.
|
|
410
|
+
let _ = p.prompt("k", session_options(), "one".to_string()).await;
|
|
411
|
+
});
|
|
412
|
+
let mut busy_seen = false;
|
|
413
|
+
for _ in 0..100 {
|
|
414
|
+
if pool.is_busy("k").await {
|
|
415
|
+
busy_seen = true;
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
419
|
+
}
|
|
420
|
+
assert!(busy_seen, "turn never went busy");
|
|
421
|
+
let second = pool.prompt("k", session_options(), "two".to_string()).await;
|
|
422
|
+
assert!(
|
|
423
|
+
matches!(second, Err(NativeError::Busy)),
|
|
424
|
+
"expected Busy, got {second:?}"
|
|
425
|
+
);
|
|
426
|
+
let _ = held.await;
|
|
427
|
+
assert!(!pool.is_busy("k").await);
|
|
428
|
+
pool.shutdown().await;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#[tokio::test]
|
|
432
|
+
async fn missing_keys_fail_without_creating_sessions() {
|
|
433
|
+
let (pool, _rx) = SessionPool::connect(command("normal"), options()).await;
|
|
434
|
+
assert!(pool.set_model("ghost", "m").await.is_err());
|
|
435
|
+
assert!(pool.cancel("ghost").await.is_ok());
|
|
436
|
+
assert!(!pool.has_session("ghost").await);
|
|
437
|
+
pool.close("ghost").await;
|
|
438
|
+
pool.shutdown().await;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
//! Native conversation session with one ordered event stream.
|
|
2
|
+
//!
|
|
3
|
+
//! The worker owns prompt routing, permission validation, and timeout handling.
|
|
4
|
+
//! Applications consume events through the single receiver and control the turn
|
|
5
|
+
//! through [`SessionHandle`]; dropping an active prompt closes the connection.
|
|
6
|
+
|
|
1
7
|
use std::collections::BTreeMap;
|
|
2
8
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
3
9
|
use std::sync::{Arc, Mutex};
|
|
@@ -461,3 +461,207 @@ async fn advertised_controls_preserve_dependent_changes_and_reject_invalid_value
|
|
|
461
461
|
));
|
|
462
462
|
client.close().await.unwrap();
|
|
463
463
|
}
|
|
464
|
+
|
|
465
|
+
fn codex_fixture(mode: &str) -> super::drivers::CodexDeviceDriver {
|
|
466
|
+
use std::time::Duration;
|
|
467
|
+
super::drivers::CodexDeviceDriver::new("python3")
|
|
468
|
+
.with_lead_args(vec![
|
|
469
|
+
"-u".to_owned(),
|
|
470
|
+
"-c".to_owned(),
|
|
471
|
+
include_str!("fixture.py").to_owned(),
|
|
472
|
+
mode.to_owned(),
|
|
473
|
+
])
|
|
474
|
+
.with_call_timeout(Duration::from_secs(5))
|
|
475
|
+
.with_approval_deadline(Duration::from_secs(5))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
fn opencode_fixture(mode: &str, key: &str) -> super::drivers::OpenCodeKeyDriver {
|
|
479
|
+
use std::time::Duration;
|
|
480
|
+
super::drivers::OpenCodeKeyDriver::new("python3", key.to_owned())
|
|
481
|
+
.unwrap()
|
|
482
|
+
.with_lead_args(vec![
|
|
483
|
+
"-u".to_owned(),
|
|
484
|
+
"-c".to_owned(),
|
|
485
|
+
include_str!("fixture.py").to_owned(),
|
|
486
|
+
mode.to_owned(),
|
|
487
|
+
])
|
|
488
|
+
.with_operation_timeout(Duration::from_secs(5))
|
|
489
|
+
.with_readiness_timeout(Duration::from_secs(5))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#[tokio::test]
|
|
493
|
+
async fn codex_challenge_round_trip_projects_presence() {
|
|
494
|
+
use super::drivers::{LoginDriver, LoginState};
|
|
495
|
+
let mut driver = codex_fixture("codex_ok");
|
|
496
|
+
let state = driver.start().await.unwrap();
|
|
497
|
+
let challenge = match state {
|
|
498
|
+
LoginState::ChallengeRequired(challenge) => challenge,
|
|
499
|
+
other => panic!("challenge expected, got {other:?}"),
|
|
500
|
+
};
|
|
501
|
+
assert_eq!(
|
|
502
|
+
challenge.verification_url,
|
|
503
|
+
"https://auth.openai.com/codex/device"
|
|
504
|
+
);
|
|
505
|
+
assert_eq!(challenge.user_code, "ABCD-1234");
|
|
506
|
+
challenge.validate().unwrap();
|
|
507
|
+
let state = driver.poll().await.unwrap();
|
|
508
|
+
match state {
|
|
509
|
+
LoginState::Authenticated(info) => {
|
|
510
|
+
assert_eq!(info.account.as_deref(), Some("user@example.com"));
|
|
511
|
+
}
|
|
512
|
+
other => panic!("authenticated presence expected, got {other:?}"),
|
|
513
|
+
}
|
|
514
|
+
let info = driver.account().await.unwrap();
|
|
515
|
+
assert_eq!(info.account.as_deref(), Some("user@example.com"));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#[tokio::test]
|
|
519
|
+
async fn codex_rejects_malformed_challenge_without_leak() {
|
|
520
|
+
use super::drivers::{Challenge, DriverError, LoginDriver};
|
|
521
|
+
for mode in ["codex_bad_url", "codex_bad_code"] {
|
|
522
|
+
let mut driver = codex_fixture(mode);
|
|
523
|
+
let error = driver.start().await.unwrap_err();
|
|
524
|
+
assert!(
|
|
525
|
+
matches!(error, DriverError::InvalidOptions(_)),
|
|
526
|
+
"expected invalid options for {mode}, got {error:?}"
|
|
527
|
+
);
|
|
528
|
+
assert_eq!(error.code(), "invalid_options");
|
|
529
|
+
let rendered = format!("{error} {error:?}");
|
|
530
|
+
assert!(!rendered.contains("ABCD-1234"));
|
|
531
|
+
assert!(!rendered.contains("evil.example.com"));
|
|
532
|
+
}
|
|
533
|
+
let bad = Challenge::new(
|
|
534
|
+
"http://evil.example.com/x".to_owned(),
|
|
535
|
+
"ABCD-1234".to_owned(),
|
|
536
|
+
);
|
|
537
|
+
assert!(matches!(
|
|
538
|
+
bad.validate().unwrap_err(),
|
|
539
|
+
DriverError::InvalidOptions(_)
|
|
540
|
+
));
|
|
541
|
+
let short = Challenge::new(
|
|
542
|
+
"https://auth.openai.com/codex/device".to_owned(),
|
|
543
|
+
"x".to_owned(),
|
|
544
|
+
);
|
|
545
|
+
assert!(matches!(
|
|
546
|
+
short.validate().unwrap_err(),
|
|
547
|
+
DriverError::InvalidOptions(_)
|
|
548
|
+
));
|
|
549
|
+
let custom = super::drivers::CodexDeviceDriver::with_allowed_hosts(
|
|
550
|
+
"python3",
|
|
551
|
+
vec!["example.com".to_owned()],
|
|
552
|
+
)
|
|
553
|
+
.unwrap()
|
|
554
|
+
.with_lead_args(vec![
|
|
555
|
+
"-u".to_owned(),
|
|
556
|
+
"-c".to_owned(),
|
|
557
|
+
include_str!("fixture.py").to_owned(),
|
|
558
|
+
"codex_ok".to_owned(),
|
|
559
|
+
]);
|
|
560
|
+
let mut custom = custom;
|
|
561
|
+
let error = custom.start().await.unwrap_err();
|
|
562
|
+
assert_eq!(
|
|
563
|
+
error,
|
|
564
|
+
DriverError::InvalidOptions("challenge host is not allowed")
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
#[tokio::test]
|
|
569
|
+
async fn codex_poll_times_out_without_browser_approval() {
|
|
570
|
+
use super::drivers::{DriverError, LoginDriver, LoginState};
|
|
571
|
+
use std::time::Duration;
|
|
572
|
+
let mut driver = codex_fixture("codex_never");
|
|
573
|
+
driver = driver
|
|
574
|
+
.with_call_timeout(Duration::from_millis(300))
|
|
575
|
+
.with_approval_deadline(Duration::from_millis(400));
|
|
576
|
+
let state = driver.start().await.unwrap();
|
|
577
|
+
assert!(matches!(state, LoginState::ChallengeRequired(_)));
|
|
578
|
+
let error = driver.poll().await.unwrap_err();
|
|
579
|
+
assert_eq!(error, DriverError::Timeout);
|
|
580
|
+
assert_eq!(error.code(), "timeout");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#[tokio::test]
|
|
584
|
+
async fn codex_cancel_stops_helper_and_reports_cancelled() {
|
|
585
|
+
use super::drivers::{DriverError, LoginDriver, LoginState};
|
|
586
|
+
let mut driver = codex_fixture("codex_never");
|
|
587
|
+
let state = driver.start().await.unwrap();
|
|
588
|
+
assert!(matches!(state, LoginState::ChallengeRequired(_)));
|
|
589
|
+
assert!(driver.has_helper());
|
|
590
|
+
driver.cancel().await.unwrap();
|
|
591
|
+
assert!(!driver.has_helper());
|
|
592
|
+
assert_eq!(driver.poll().await.unwrap_err(), DriverError::Cancelled);
|
|
593
|
+
assert_eq!(driver.account().await.unwrap_err(), DriverError::Cancelled);
|
|
594
|
+
assert_eq!(driver.start().await.unwrap_err(), DriverError::Cancelled);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#[tokio::test]
|
|
598
|
+
async fn codex_declined_approval_reports_failed() {
|
|
599
|
+
use super::drivers::{LoginDriver, LoginState};
|
|
600
|
+
let mut driver = codex_fixture("codex_declined");
|
|
601
|
+
let state = driver.start().await.unwrap();
|
|
602
|
+
assert!(matches!(state, LoginState::ChallengeRequired(_)));
|
|
603
|
+
assert_eq!(driver.poll().await.unwrap(), LoginState::Failed);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#[tokio::test]
|
|
607
|
+
async fn opencode_key_login_confirms_connected() {
|
|
608
|
+
use super::drivers::{LoginDriver, LoginState};
|
|
609
|
+
let key = "ccht-fixture-key-9f8e7d6c5b4a";
|
|
610
|
+
let mut driver = opencode_fixture("opencode_ok", key);
|
|
611
|
+
let state = driver.start().await.unwrap();
|
|
612
|
+
match state {
|
|
613
|
+
LoginState::Authenticated(info) => {
|
|
614
|
+
assert_eq!(info.account.as_deref(), Some("opencode-go"));
|
|
615
|
+
}
|
|
616
|
+
other => panic!("authenticated presence expected, got {other:?}"),
|
|
617
|
+
}
|
|
618
|
+
let state = driver.poll().await.unwrap();
|
|
619
|
+
assert!(matches!(state, LoginState::Authenticated(_)));
|
|
620
|
+
let info = driver.account().await.unwrap();
|
|
621
|
+
assert_eq!(info.account.as_deref(), Some("opencode-go"));
|
|
622
|
+
let rendered = format!("{driver:?}");
|
|
623
|
+
assert!(!rendered.contains(key));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
#[tokio::test]
|
|
627
|
+
async fn opencode_start_times_out_when_server_never_ready() {
|
|
628
|
+
use super::drivers::{DriverError, LoginDriver};
|
|
629
|
+
use std::time::Duration;
|
|
630
|
+
let mut driver = opencode_fixture("opencode_never", "ccht-fixture-key-timeout");
|
|
631
|
+
driver = driver
|
|
632
|
+
.with_operation_timeout(Duration::from_millis(300))
|
|
633
|
+
.with_readiness_timeout(Duration::from_millis(400));
|
|
634
|
+
let error = driver.start().await.unwrap_err();
|
|
635
|
+
assert_eq!(error, DriverError::Timeout);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#[tokio::test]
|
|
639
|
+
async fn driver_debug_and_errors_omit_key_material() {
|
|
640
|
+
use super::drivers::{DriverError, LoginDriver};
|
|
641
|
+
let key = "ccht-fixture-key-secret-12345";
|
|
642
|
+
let driver = super::drivers::OpenCodeKeyDriver::new("python3", key.to_owned()).unwrap();
|
|
643
|
+
let rendered = format!("{driver:?}");
|
|
644
|
+
assert!(!rendered.contains(key));
|
|
645
|
+
assert!(!rendered.contains("OPENCODE_SERVER_PASSWORD"));
|
|
646
|
+
let codex = super::drivers::CodexDeviceDriver::new("python3");
|
|
647
|
+
let codex_rendered = format!("{codex:?}");
|
|
648
|
+
assert!(!codex_rendered.contains("ABCD-1234"));
|
|
649
|
+
for error in [
|
|
650
|
+
DriverError::InvalidOptions("bad shape"),
|
|
651
|
+
DriverError::Timeout,
|
|
652
|
+
DriverError::Closed,
|
|
653
|
+
DriverError::Spawn("helper unavailable"),
|
|
654
|
+
DriverError::Protocol(-32603),
|
|
655
|
+
DriverError::Cancelled,
|
|
656
|
+
DriverError::Unsupported("extra step"),
|
|
657
|
+
] {
|
|
658
|
+
let text = format!("{error} {error:?} {}", error.code());
|
|
659
|
+
assert!(!text.contains(key));
|
|
660
|
+
assert!(!text.contains("ccht-fixture"));
|
|
661
|
+
}
|
|
662
|
+
let mut failing = opencode_fixture("opencode_fail", key);
|
|
663
|
+
let state = failing.start().await.unwrap();
|
|
664
|
+
assert_eq!(state, super::drivers::LoginState::Failed);
|
|
665
|
+
let rendered = format!("{failing:?}");
|
|
666
|
+
assert!(!rendered.contains(key));
|
|
667
|
+
}
|