@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,531 @@
|
|
|
1
|
+
//! API-key login through a native OpenCode control server.
|
|
2
|
+
//!
|
|
3
|
+
//! The driver starts `program serve` on loopback with an ephemeral port and
|
|
4
|
+
//! a random server password, stores the user key through the vendor auth
|
|
5
|
+
//! endpoint, and confirms presence through the vendor provider list. The
|
|
6
|
+
//! password lives only in the child environment; the user key lives only in
|
|
7
|
+
//! request bodies. Neither value appears in errors, logs, or debug output.
|
|
8
|
+
|
|
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::{AsyncReadExt, AsyncWriteExt};
|
|
15
|
+
use tokio::net::{TcpListener, TcpStream};
|
|
16
|
+
use tokio::process::{Child, Command};
|
|
17
|
+
use tokio::time::{Instant, sleep, timeout};
|
|
18
|
+
|
|
19
|
+
use super::{AccountInfo, DriverError, LoginDriver, LoginState};
|
|
20
|
+
|
|
21
|
+
/// Upper bound for one control-plane reply body.
|
|
22
|
+
const MAX_BODY: usize = 1_048_576;
|
|
23
|
+
|
|
24
|
+
/// Vendor provider id reported as the display label on success.
|
|
25
|
+
const PROVIDER_ID: &str = "opencode-go";
|
|
26
|
+
|
|
27
|
+
/// Key login against an ephemeral OpenCode control server.
|
|
28
|
+
///
|
|
29
|
+
/// The server password is random per attempt and passed only through the
|
|
30
|
+
/// child environment. The user key is supplied by the application and sent
|
|
31
|
+
/// only in the vendor `PUT` body. The child is stopped on drop and on
|
|
32
|
+
/// cancellation.
|
|
33
|
+
pub struct OpenCodeKeyDriver {
|
|
34
|
+
program: PathBuf,
|
|
35
|
+
lead_args: Vec<String>,
|
|
36
|
+
api_key: String,
|
|
37
|
+
operation_timeout: Duration,
|
|
38
|
+
readiness_timeout: Duration,
|
|
39
|
+
helper: Option<RunningHelper>,
|
|
40
|
+
cancelled: bool,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Running control server for one attempt.
|
|
44
|
+
struct RunningHelper {
|
|
45
|
+
child: Child,
|
|
46
|
+
port: u16,
|
|
47
|
+
password: String,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
impl OpenCodeKeyDriver {
|
|
51
|
+
/// Create a driver for one user-supplied key.
|
|
52
|
+
///
|
|
53
|
+
/// The program is the OpenCode executable; `serve --hostname 127.0.0.1
|
|
54
|
+
/// --port <ephemeral>` is appended at spawn time. Fixture tests prepend
|
|
55
|
+
/// interpreter arguments with [`OpenCodeKeyDriver::with_lead_args`].
|
|
56
|
+
///
|
|
57
|
+
/// # Errors
|
|
58
|
+
///
|
|
59
|
+
/// Returns [`DriverError::InvalidOptions`] when the program or the key
|
|
60
|
+
/// is empty.
|
|
61
|
+
pub fn new(program: impl Into<PathBuf>, api_key: String) -> Result<Self, DriverError> {
|
|
62
|
+
let program = program.into();
|
|
63
|
+
if program.as_os_str().is_empty() {
|
|
64
|
+
return Err(DriverError::InvalidOptions(
|
|
65
|
+
"opencode program must not be empty",
|
|
66
|
+
));
|
|
67
|
+
}
|
|
68
|
+
if api_key.trim().is_empty() {
|
|
69
|
+
return Err(DriverError::InvalidOptions(
|
|
70
|
+
"opencode key must not be empty",
|
|
71
|
+
));
|
|
72
|
+
}
|
|
73
|
+
Ok(Self {
|
|
74
|
+
program,
|
|
75
|
+
lead_args: Vec::new(),
|
|
76
|
+
api_key,
|
|
77
|
+
operation_timeout: Duration::from_secs(10),
|
|
78
|
+
readiness_timeout: Duration::from_secs(15),
|
|
79
|
+
helper: None,
|
|
80
|
+
cancelled: false,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Prepend arguments before the appended `serve` token.
|
|
85
|
+
///
|
|
86
|
+
/// Production use leaves this empty; fixture tests pass an interpreter
|
|
87
|
+
/// preamble such as `-u -c <code> <mode>` here.
|
|
88
|
+
pub fn with_lead_args(mut self, args: Vec<String>) -> Self {
|
|
89
|
+
self.lead_args = args;
|
|
90
|
+
self
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Set the deadline for one HTTP round trip.
|
|
94
|
+
pub fn with_operation_timeout(mut self, limit: Duration) -> Self {
|
|
95
|
+
self.operation_timeout = limit;
|
|
96
|
+
self
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Set how long `start` waits for `/global/health` to succeed.
|
|
100
|
+
pub fn with_readiness_timeout(mut self, limit: Duration) -> Self {
|
|
101
|
+
self.readiness_timeout = limit;
|
|
102
|
+
self
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Whether a control server is currently running.
|
|
106
|
+
pub fn has_helper(&self) -> bool {
|
|
107
|
+
self.helper.is_some()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Stop the helper without exposing secrets.
|
|
111
|
+
fn shutdown(&mut self) {
|
|
112
|
+
if let Some(mut live) = self.helper.take() {
|
|
113
|
+
let _ = live.child.start_kill();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
impl std::fmt::Debug for OpenCodeKeyDriver {
|
|
119
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
120
|
+
f.debug_struct("OpenCodeKeyDriver")
|
|
121
|
+
.field(
|
|
122
|
+
"phase",
|
|
123
|
+
if self.cancelled {
|
|
124
|
+
&"cancelled"
|
|
125
|
+
} else if self.helper.is_some() {
|
|
126
|
+
&"running"
|
|
127
|
+
} else {
|
|
128
|
+
&"idle"
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
.field("has_helper", &self.helper.is_some())
|
|
132
|
+
.finish()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
impl Drop for OpenCodeKeyDriver {
|
|
137
|
+
fn drop(&mut self) {
|
|
138
|
+
self.shutdown();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Standard base64 alphabet for basic authentication.
|
|
143
|
+
const B64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
144
|
+
|
|
145
|
+
/// Encode bytes as base64 without external crates.
|
|
146
|
+
fn base64_encode(input: &[u8]) -> String {
|
|
147
|
+
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
|
148
|
+
for chunk in input.chunks(3) {
|
|
149
|
+
let mut block: u32 = 0;
|
|
150
|
+
for (slot, byte) in chunk.iter().enumerate() {
|
|
151
|
+
block |= (*byte as u32) << (16 - 8 * slot);
|
|
152
|
+
}
|
|
153
|
+
let pad = 3 - chunk.len();
|
|
154
|
+
for slot in 0..4 - pad {
|
|
155
|
+
let sextet = ((block >> (18 - 6 * slot)) & 0x3F) as usize;
|
|
156
|
+
out.push(B64_TABLE[sextet] as char);
|
|
157
|
+
}
|
|
158
|
+
for _ in 0..pad {
|
|
159
|
+
out.push('=');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Basic header value for the fixed `opencode` user.
|
|
166
|
+
fn auth_value(password: &str) -> String {
|
|
167
|
+
base64_encode(format!("opencode:{password}").as_bytes())
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Random ephemeral password without new dependencies.
|
|
171
|
+
///
|
|
172
|
+
/// Prefers operating-system randomness; falls back to a time-seeded mix so
|
|
173
|
+
/// tests on constrained hosts still get a unique value per attempt.
|
|
174
|
+
fn ephemeral_password() -> String {
|
|
175
|
+
let mut raw = [0u8; 24];
|
|
176
|
+
let mut filled = false;
|
|
177
|
+
if let Ok(mut source) = std::fs::File::open("/dev/urandom") {
|
|
178
|
+
use std::io::Read as _;
|
|
179
|
+
if source.read_exact(&mut raw).is_ok() {
|
|
180
|
+
filled = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if !filled {
|
|
184
|
+
let nanos = std::time::SystemTime::now()
|
|
185
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
186
|
+
.map(|span| span.as_nanos())
|
|
187
|
+
.unwrap_or(0);
|
|
188
|
+
let pid = std::process::id() as u128;
|
|
189
|
+
let mut mix = nanos ^ ((pid << 64) | 0x9e37_79b9_7f4a_7c15);
|
|
190
|
+
for slot in raw.iter_mut() {
|
|
191
|
+
mix ^= mix >> 29;
|
|
192
|
+
mix = mix.wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
|
193
|
+
mix ^= mix >> 32;
|
|
194
|
+
*slot = (mix & 0xFF) as u8;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
let mut text = String::with_capacity(raw.len() * 2);
|
|
198
|
+
for byte in raw {
|
|
199
|
+
text.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
|
|
200
|
+
text.push(char::from_digit((byte & 0x0F) as u32, 16).unwrap_or('0'));
|
|
201
|
+
}
|
|
202
|
+
text
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Claim an ephemeral loopback port by binding, then release it.
|
|
206
|
+
async fn free_port(limit: Duration) -> Result<u16, DriverError> {
|
|
207
|
+
let listener = timeout(limit, TcpListener::bind("127.0.0.1:0"))
|
|
208
|
+
.await
|
|
209
|
+
.map_err(|_| DriverError::Timeout)?
|
|
210
|
+
.map_err(|_| DriverError::Spawn("control port was unavailable"))?;
|
|
211
|
+
let port = listener
|
|
212
|
+
.local_addr()
|
|
213
|
+
.map_err(|_| DriverError::Spawn("control port was unavailable"))?
|
|
214
|
+
.port();
|
|
215
|
+
drop(listener);
|
|
216
|
+
if port == 0 {
|
|
217
|
+
return Err(DriverError::Spawn("control port was unavailable"));
|
|
218
|
+
}
|
|
219
|
+
Ok(port)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// Spawn the control server with a cleared minimal environment.
|
|
223
|
+
fn launch(
|
|
224
|
+
program: &PathBuf,
|
|
225
|
+
lead: &[String],
|
|
226
|
+
port: u16,
|
|
227
|
+
password: &str,
|
|
228
|
+
) -> Result<Child, DriverError> {
|
|
229
|
+
if program.as_os_str().is_empty() {
|
|
230
|
+
return Err(DriverError::InvalidOptions(
|
|
231
|
+
"opencode program must not be empty",
|
|
232
|
+
));
|
|
233
|
+
}
|
|
234
|
+
let home = std::env::var("HOME")
|
|
235
|
+
.unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned());
|
|
236
|
+
// Resolve bare names against the parent PATH before clearing it, so
|
|
237
|
+
// fixtures work where the interpreter lives outside the minimal set.
|
|
238
|
+
let resolved = super::resolve_program(program);
|
|
239
|
+
let mut spawn = Command::new(&resolved);
|
|
240
|
+
spawn
|
|
241
|
+
.args(lead)
|
|
242
|
+
.args([
|
|
243
|
+
"serve",
|
|
244
|
+
"--hostname",
|
|
245
|
+
"127.0.0.1",
|
|
246
|
+
"--port",
|
|
247
|
+
&port.to_string(),
|
|
248
|
+
])
|
|
249
|
+
.env_clear()
|
|
250
|
+
.env("PATH", "/usr/local/bin:/usr/bin:/bin")
|
|
251
|
+
.env("HOME", home)
|
|
252
|
+
.env("OPENCODE_SERVER_PASSWORD", password)
|
|
253
|
+
.stdin(Stdio::null())
|
|
254
|
+
.stdout(Stdio::null())
|
|
255
|
+
.stderr(Stdio::null())
|
|
256
|
+
.kill_on_drop(true);
|
|
257
|
+
spawn
|
|
258
|
+
.spawn()
|
|
259
|
+
.map_err(|_| DriverError::Spawn("control server could not be launched"))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// One hand-rolled HTTP round trip over loopback.
|
|
263
|
+
///
|
|
264
|
+
/// Uses `Connection: close` so the reply ends at EOF; the body is capped.
|
|
265
|
+
async fn http_call(
|
|
266
|
+
method: &str,
|
|
267
|
+
path: &str,
|
|
268
|
+
port: u16,
|
|
269
|
+
password: &str,
|
|
270
|
+
body: Option<&[u8]>,
|
|
271
|
+
limit: Duration,
|
|
272
|
+
) -> Result<(u16, Vec<u8>), DriverError> {
|
|
273
|
+
let mut stream = timeout(limit, TcpStream::connect(("127.0.0.1", port)))
|
|
274
|
+
.await
|
|
275
|
+
.map_err(|_| DriverError::Timeout)?
|
|
276
|
+
.map_err(|_| DriverError::Closed)?;
|
|
277
|
+
let auth = auth_value(password);
|
|
278
|
+
let length = body.map_or(0, <[u8]>::len);
|
|
279
|
+
let head = format!(
|
|
280
|
+
"{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Basic {auth}\r\nContent-Type: application/json\r\nContent-Length: {length}\r\nConnection: close\r\n\r\n"
|
|
281
|
+
);
|
|
282
|
+
timeout(limit, stream.write_all(head.as_bytes()))
|
|
283
|
+
.await
|
|
284
|
+
.map_err(|_| DriverError::Timeout)?
|
|
285
|
+
.map_err(|_| DriverError::Closed)?;
|
|
286
|
+
if let Some(payload) = body {
|
|
287
|
+
timeout(limit, stream.write_all(payload))
|
|
288
|
+
.await
|
|
289
|
+
.map_err(|_| DriverError::Timeout)?
|
|
290
|
+
.map_err(|_| DriverError::Closed)?;
|
|
291
|
+
}
|
|
292
|
+
timeout(limit, stream.flush())
|
|
293
|
+
.await
|
|
294
|
+
.map_err(|_| DriverError::Timeout)?
|
|
295
|
+
.map_err(|_| DriverError::Closed)?;
|
|
296
|
+
let mut raw = Vec::new();
|
|
297
|
+
let mut chunk = [0u8; 8_192];
|
|
298
|
+
let outcome: Result<(), DriverError> = timeout(limit, async {
|
|
299
|
+
loop {
|
|
300
|
+
match stream.read(&mut chunk).await {
|
|
301
|
+
Ok(0) => return Ok(()),
|
|
302
|
+
Ok(count) => {
|
|
303
|
+
if raw.len() + count > MAX_BODY + 8_192 {
|
|
304
|
+
return Err(DriverError::Protocol(-32700));
|
|
305
|
+
}
|
|
306
|
+
raw.extend_from_slice(&chunk[..count]);
|
|
307
|
+
}
|
|
308
|
+
Err(_) => return Err(DriverError::Closed),
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
.await
|
|
313
|
+
.map_err(|_| DriverError::Timeout)?;
|
|
314
|
+
outcome?;
|
|
315
|
+
if raw.len() > MAX_BODY + 8_192 {
|
|
316
|
+
return Err(DriverError::Protocol(-32700));
|
|
317
|
+
}
|
|
318
|
+
let text = String::from_utf8_lossy(&raw);
|
|
319
|
+
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
|
|
320
|
+
let status_line = head.lines().next().unwrap_or_default();
|
|
321
|
+
let mut parts = status_line.split_whitespace();
|
|
322
|
+
let _version = parts.next();
|
|
323
|
+
let code_text = parts.next().unwrap_or_default();
|
|
324
|
+
let code: u16 = code_text
|
|
325
|
+
.parse()
|
|
326
|
+
.map_err(|_| DriverError::Protocol(-32700))?;
|
|
327
|
+
let mut payload = body.as_bytes().to_vec();
|
|
328
|
+
if payload.len() > MAX_BODY {
|
|
329
|
+
return Err(DriverError::Protocol(-32700));
|
|
330
|
+
}
|
|
331
|
+
// Prefer Content-Length when the server keeps framing exact.
|
|
332
|
+
if let Some(wanted) = head
|
|
333
|
+
.lines()
|
|
334
|
+
.find_map(|line| line.strip_prefix("Content-Length:"))
|
|
335
|
+
.and_then(|value| value.trim().parse::<usize>().ok())
|
|
336
|
+
&& wanted <= MAX_BODY
|
|
337
|
+
{
|
|
338
|
+
payload.truncate(wanted.min(payload.len()));
|
|
339
|
+
}
|
|
340
|
+
Ok((code, payload))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// Wait until `/global/health` answers 2xx or the helper exits.
|
|
344
|
+
async fn await_ready(helper: &mut RunningHelper, limit: Duration) -> Result<(), DriverError> {
|
|
345
|
+
if limit.is_zero() {
|
|
346
|
+
return Err(DriverError::InvalidOptions(
|
|
347
|
+
"driver deadlines must be positive",
|
|
348
|
+
));
|
|
349
|
+
}
|
|
350
|
+
let start = Instant::now();
|
|
351
|
+
loop {
|
|
352
|
+
if start.elapsed() >= limit {
|
|
353
|
+
return Err(DriverError::Timeout);
|
|
354
|
+
}
|
|
355
|
+
match helper.child.try_wait() {
|
|
356
|
+
Ok(Some(_)) => {
|
|
357
|
+
return Err(DriverError::Spawn(
|
|
358
|
+
"control server stopped before readiness",
|
|
359
|
+
));
|
|
360
|
+
}
|
|
361
|
+
Ok(None) => {}
|
|
362
|
+
Err(_) => {
|
|
363
|
+
return Err(DriverError::Spawn(
|
|
364
|
+
"control server stopped before readiness",
|
|
365
|
+
));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
let attempt = (limit - start.elapsed()).min(Duration::from_secs(2));
|
|
369
|
+
match http_call(
|
|
370
|
+
"GET",
|
|
371
|
+
"/global/health",
|
|
372
|
+
helper.port,
|
|
373
|
+
&helper.password,
|
|
374
|
+
None,
|
|
375
|
+
attempt,
|
|
376
|
+
)
|
|
377
|
+
.await
|
|
378
|
+
{
|
|
379
|
+
Ok((code, _)) if (200..300).contains(&code) => return Ok(()),
|
|
380
|
+
Ok(_) => {}
|
|
381
|
+
Err(DriverError::Closed) => {}
|
|
382
|
+
Err(DriverError::Timeout) => {}
|
|
383
|
+
Err(other) => return Err(other),
|
|
384
|
+
}
|
|
385
|
+
sleep(Duration::from_millis(50)).await;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Whether the provider reply lists the expected vendor id.
|
|
390
|
+
fn is_connected(reply: &[u8]) -> Result<bool, DriverError> {
|
|
391
|
+
let value: Value = serde_json::from_slice(reply).map_err(|_| DriverError::Protocol(-32700))?;
|
|
392
|
+
let listed = value
|
|
393
|
+
.get("connected")
|
|
394
|
+
.and_then(Value::as_array)
|
|
395
|
+
.ok_or(DriverError::Protocol(-32603))?;
|
|
396
|
+
Ok(listed
|
|
397
|
+
.iter()
|
|
398
|
+
.any(|entry| entry.as_str() == Some(PROVIDER_ID)))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
impl LoginDriver for OpenCodeKeyDriver {
|
|
402
|
+
/// Start the server, store the key, and confirm presence.
|
|
403
|
+
async fn start(&mut self) -> Result<LoginState, DriverError> {
|
|
404
|
+
if self.cancelled {
|
|
405
|
+
return Err(DriverError::Cancelled);
|
|
406
|
+
}
|
|
407
|
+
self.shutdown();
|
|
408
|
+
if self.operation_timeout.is_zero() || self.readiness_timeout.is_zero() {
|
|
409
|
+
return Err(DriverError::InvalidOptions(
|
|
410
|
+
"driver deadlines must be positive",
|
|
411
|
+
));
|
|
412
|
+
}
|
|
413
|
+
let port = free_port(self.operation_timeout).await?;
|
|
414
|
+
let password = ephemeral_password();
|
|
415
|
+
let child = launch(&self.program, &self.lead_args, port, &password)?;
|
|
416
|
+
let mut live = RunningHelper {
|
|
417
|
+
child,
|
|
418
|
+
port,
|
|
419
|
+
password,
|
|
420
|
+
};
|
|
421
|
+
await_ready(&mut live, self.readiness_timeout)
|
|
422
|
+
.await
|
|
423
|
+
.inspect_err(|_| {
|
|
424
|
+
let _ = live.child.start_kill();
|
|
425
|
+
})?;
|
|
426
|
+
let payload = serde_json::json!({"type": "api", "key": self.api_key.clone()});
|
|
427
|
+
let bytes = serde_json::to_vec(&payload).map_err(|_| DriverError::Protocol(-32700))?;
|
|
428
|
+
let (stored, _) = http_call(
|
|
429
|
+
"PUT",
|
|
430
|
+
"/auth/opencode-go",
|
|
431
|
+
live.port,
|
|
432
|
+
&live.password,
|
|
433
|
+
Some(&bytes),
|
|
434
|
+
self.operation_timeout,
|
|
435
|
+
)
|
|
436
|
+
.await
|
|
437
|
+
.inspect_err(|_| {
|
|
438
|
+
let _ = live.child.start_kill();
|
|
439
|
+
})?;
|
|
440
|
+
if !(200..300).contains(&stored) {
|
|
441
|
+
let _ = live.child.start_kill();
|
|
442
|
+
return Ok(LoginState::Failed);
|
|
443
|
+
}
|
|
444
|
+
let (code, reply) = http_call(
|
|
445
|
+
"GET",
|
|
446
|
+
"/provider",
|
|
447
|
+
live.port,
|
|
448
|
+
&live.password,
|
|
449
|
+
None,
|
|
450
|
+
self.operation_timeout,
|
|
451
|
+
)
|
|
452
|
+
.await
|
|
453
|
+
.inspect_err(|_| {
|
|
454
|
+
let _ = live.child.start_kill();
|
|
455
|
+
})?;
|
|
456
|
+
if !(200..300).contains(&code) {
|
|
457
|
+
let _ = live.child.start_kill();
|
|
458
|
+
return Err(DriverError::Protocol(code as i32));
|
|
459
|
+
}
|
|
460
|
+
let connected = is_connected(&reply).inspect_err(|_| {
|
|
461
|
+
let _ = live.child.start_kill();
|
|
462
|
+
})?;
|
|
463
|
+
if !connected {
|
|
464
|
+
let _ = live.child.start_kill();
|
|
465
|
+
return Ok(LoginState::Failed);
|
|
466
|
+
}
|
|
467
|
+
self.helper = Some(live);
|
|
468
|
+
Ok(LoginState::Authenticated(AccountInfo::new(Some(
|
|
469
|
+
PROVIDER_ID.to_owned(),
|
|
470
|
+
))))
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/// Re-read provider presence on the running server.
|
|
474
|
+
async fn poll(&mut self) -> Result<LoginState, DriverError> {
|
|
475
|
+
if self.cancelled {
|
|
476
|
+
return Err(DriverError::Cancelled);
|
|
477
|
+
}
|
|
478
|
+
let live = self.helper.as_mut().ok_or(DriverError::Closed)?;
|
|
479
|
+
let (code, reply) = http_call(
|
|
480
|
+
"GET",
|
|
481
|
+
"/provider",
|
|
482
|
+
live.port,
|
|
483
|
+
&live.password,
|
|
484
|
+
None,
|
|
485
|
+
self.operation_timeout,
|
|
486
|
+
)
|
|
487
|
+
.await?;
|
|
488
|
+
if !(200..300).contains(&code) {
|
|
489
|
+
return Err(DriverError::Protocol(code as i32));
|
|
490
|
+
}
|
|
491
|
+
if is_connected(&reply)? {
|
|
492
|
+
Ok(LoginState::Authenticated(AccountInfo::new(Some(
|
|
493
|
+
PROVIDER_ID.to_owned(),
|
|
494
|
+
))))
|
|
495
|
+
} else {
|
|
496
|
+
Ok(LoginState::Failed)
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// Re-read provider presence as display-only state.
|
|
501
|
+
async fn account(&mut self) -> Result<AccountInfo, DriverError> {
|
|
502
|
+
if self.cancelled {
|
|
503
|
+
return Err(DriverError::Cancelled);
|
|
504
|
+
}
|
|
505
|
+
let live = self.helper.as_mut().ok_or(DriverError::Closed)?;
|
|
506
|
+
let (code, reply) = http_call(
|
|
507
|
+
"GET",
|
|
508
|
+
"/provider",
|
|
509
|
+
live.port,
|
|
510
|
+
&live.password,
|
|
511
|
+
None,
|
|
512
|
+
self.operation_timeout,
|
|
513
|
+
)
|
|
514
|
+
.await?;
|
|
515
|
+
if !(200..300).contains(&code) {
|
|
516
|
+
return Err(DriverError::Protocol(code as i32));
|
|
517
|
+
}
|
|
518
|
+
if is_connected(&reply)? {
|
|
519
|
+
Ok(AccountInfo::new(Some(PROVIDER_ID.to_owned())))
|
|
520
|
+
} else {
|
|
521
|
+
Ok(AccountInfo::signed_out())
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/// Stop the server; later steps report cancellation.
|
|
526
|
+
async fn cancel(&mut self) -> Result<(), DriverError> {
|
|
527
|
+
self.cancelled = true;
|
|
528
|
+
self.shutdown();
|
|
529
|
+
Ok(())
|
|
530
|
+
}
|
|
531
|
+
}
|