1use std::fmt;
4use std::net::{IpAddr, Ipv4Addr, SocketAddr};
5use std::path::PathBuf;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
8use std::time::Duration;
9
10use base64::Engine as _;
11use serde_json::Value;
12use supercode::SdkRuntime;
13use tokio::io::{AsyncReadExt, AsyncWriteExt};
14use tokio::net::{TcpListener, TcpStream};
15use tokio::sync::watch;
16use tokio::task::JoinSet;
17use zeroize::Zeroize;
18
19use crate::{OpenCodeAdapter, OpenCodeRequest, ResponseBody};
20
21const CREDENTIAL_BYTES: usize = 32;
22const MAX_HEADER_BYTES: usize = 64 * 1024;
23const MAX_BODY_BYTES: usize = 1024 * 1024;
24const MAX_EVENT_BYTES: usize = 16 * 1024 * 1024;
25const MAX_CONCURRENT_CONNECTIONS: usize = 32;
26const READ_TIMEOUT: Duration = Duration::from_secs(5);
27const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
28
29#[derive(Debug, thiserror::Error)]
30pub enum CredentialError {
31 #[error("operating system random source failed")]
32 RandomSource,
33}
34
35pub struct OpenCodeClientCredential {
38 password: [u8; CREDENTIAL_BYTES],
39}
40
41impl OpenCodeClientCredential {
42 pub fn spawn_tokio_child(
43 &self,
44 command: &mut tokio::process::Command,
45 ) -> std::io::Result<tokio::process::Child> {
46 let mut password = encode_hex(&self.password);
47 command.env("OPENCODE_SERVER_PASSWORD", &password);
48 command.env("OPENCODE_SERVER_USERNAME", "opencode");
49 let child = command.spawn();
50 command.env_remove("OPENCODE_SERVER_PASSWORD");
51 command.env_remove("OPENCODE_SERVER_USERNAME");
52 password.zeroize();
53 child
54 }
55
56 #[cfg(test)]
57 fn authorization_header(&self) -> String {
58 let mut password = encode_hex(&self.password);
59 let encoded =
60 base64::engine::general_purpose::STANDARD.encode(format!("opencode:{password}"));
61 password.zeroize();
62 format!("Basic {encoded}")
63 }
64}
65
66impl fmt::Debug for OpenCodeClientCredential {
67 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68 formatter.write_str("OpenCodeClientCredential([REDACTED])")
69 }
70}
71
72impl Drop for OpenCodeClientCredential {
73 fn drop(&mut self) {
74 self.password.zeroize();
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum OpenCodeEndpointHealth {
80 Ready,
81 ShuttingDown,
82 Stopped,
83}
84
85pub struct OpenCodeEndpoint {
87 adapter: Arc<OpenCodeAdapter>,
88 credential_digest: [u8; 32],
89}
90
91impl OpenCodeEndpoint {
92 pub fn new(
93 runtime: Arc<dyn SdkRuntime>,
94 runtime_id: impl Into<String>,
95 workspace: impl Into<PathBuf>,
96 ) -> Result<(Arc<Self>, OpenCodeClientCredential), CredentialError> {
97 let mut password = [0_u8; CREDENTIAL_BYTES];
98 getrandom::getrandom(&mut password).map_err(|_| CredentialError::RandomSource)?;
99 let mut basic = format!("opencode:{}", encode_hex(&password));
100 let credential_digest = *blake3::hash(basic.as_bytes()).as_bytes();
101 basic.zeroize();
102 Ok((
103 Arc::new(Self {
104 adapter: OpenCodeAdapter::new(runtime, runtime_id, workspace),
105 credential_digest,
106 }),
107 OpenCodeClientCredential { password },
108 ))
109 }
110
111 pub fn session_id(&self) -> &str {
112 self.adapter.session_id()
113 }
114
115 pub async fn bind(self: &Arc<Self>, port: u16) -> std::io::Result<OpenCodeServerHandle> {
118 let listener =
119 TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)).await?;
120 let address = listener.local_addr()?;
121 let (shutdown, shutdown_receiver) = watch::channel(false);
122 let shutting_down = Arc::new(AtomicBool::new(false));
123 let stopped = Arc::new(AtomicBool::new(false));
124 let task = tokio::spawn(run_accept_loop(
125 listener,
126 self.clone(),
127 address,
128 shutdown_receiver,
129 shutting_down.clone(),
130 stopped.clone(),
131 ));
132 Ok(OpenCodeServerHandle {
133 address,
134 shutdown,
135 shutting_down,
136 stopped,
137 task: Some(task),
138 })
139 }
140
141 fn authenticate(&self, authorization: &str) -> bool {
142 let Some(encoded) = authorization.strip_prefix("Basic ") else {
143 return false;
144 };
145 let Ok(mut decoded) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
146 return false;
147 };
148 let digest = *blake3::hash(&decoded).as_bytes();
149 decoded.zeroize();
150 constant_time_eq(&digest, &self.credential_digest)
151 }
152}
153
154pub struct OpenCodeServerHandle {
155 address: SocketAddr,
156 shutdown: watch::Sender<bool>,
157 shutting_down: Arc<AtomicBool>,
158 stopped: Arc<AtomicBool>,
159 task: Option<tokio::task::JoinHandle<()>>,
160}
161
162impl OpenCodeServerHandle {
163 pub fn address(&self) -> SocketAddr {
164 self.address
165 }
166
167 pub fn url(&self) -> String {
168 format!("http://{}", self.address)
169 }
170
171 pub fn health(&self) -> OpenCodeEndpointHealth {
172 if self.stopped.load(Ordering::SeqCst) {
173 OpenCodeEndpointHealth::Stopped
174 } else if self.shutting_down.load(Ordering::SeqCst) {
175 OpenCodeEndpointHealth::ShuttingDown
176 } else {
177 OpenCodeEndpointHealth::Ready
178 }
179 }
180
181 pub async fn shutdown(mut self) {
182 self.shutting_down.store(true, Ordering::SeqCst);
183 self.shutdown.send_replace(true);
184 if let Some(task) = self.task.take() {
185 let _ = task.await;
186 }
187 }
188}
189
190impl Drop for OpenCodeServerHandle {
191 fn drop(&mut self) {
192 self.shutting_down.store(true, Ordering::SeqCst);
193 self.shutdown.send_replace(true);
194 if let Some(task) = self.task.take() {
195 task.abort();
196 }
197 }
198}
199
200async fn run_accept_loop(
201 listener: TcpListener,
202 endpoint: Arc<OpenCodeEndpoint>,
203 address: SocketAddr,
204 mut shutdown: watch::Receiver<bool>,
205 shutting_down: Arc<AtomicBool>,
206 stopped: Arc<AtomicBool>,
207) {
208 let budget = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CONNECTIONS));
209 let mut connections = JoinSet::new();
210 loop {
211 tokio::select! {
212 changed = shutdown.changed() => {
213 if changed.is_err() || *shutdown.borrow() {
214 break;
215 }
216 }
217 _ = connections.join_next(), if !connections.is_empty() => {}
218 accepted = listener.accept() => match accepted {
219 Ok((stream, _)) => {
220 let Ok(permit) = budget.clone().try_acquire_owned() else {
221 drop(stream);
222 continue;
223 };
224 let endpoint = endpoint.clone();
225 let mut connection_shutdown = shutdown.clone();
226 connections.spawn(async move {
227 let _permit = permit;
228 tokio::select! {
229 _ = wait_for_shutdown(&mut connection_shutdown) => {}
230 _ = serve_connection(stream, endpoint, address) => {}
231 }
232 });
233 }
234 Err(_) => break,
235 }
236 }
237 }
238 connections.shutdown().await;
239 shutting_down.store(true, Ordering::SeqCst);
240 stopped.store(true, Ordering::SeqCst);
241}
242
243async fn wait_for_shutdown(shutdown: &mut watch::Receiver<bool>) {
244 while !*shutdown.borrow() {
245 if shutdown.changed().await.is_err() {
246 break;
247 }
248 }
249}
250
251async fn serve_connection(
252 mut stream: TcpStream,
253 endpoint: Arc<OpenCodeEndpoint>,
254 expected_address: SocketAddr,
255) -> std::io::Result<()> {
256 let request = match read_request(&mut stream, expected_address).await {
257 Ok(request) => request,
258 Err(error) => return write_error(&mut stream, error.status, error.message).await,
259 };
260 if !endpoint.authenticate(&request.authorization) {
261 return write_error(&mut stream, 401, "authentication failed").await;
262 }
263 let body = if request.body.is_empty() {
264 Value::Null
265 } else {
266 match serde_json::from_slice(&request.body) {
267 Ok(body) => body,
268 Err(_) => return write_error(&mut stream, 400, "invalid JSON body").await,
269 }
270 };
271 let adapter = endpoint.adapter.clone();
275 let operation = tokio::spawn(async move {
276 adapter
277 .handle(OpenCodeRequest {
278 method: request.method,
279 target: request.target,
280 body,
281 })
282 .await
283 });
284 let response = operation.await.map_err(std::io::Error::other)?;
285 match response.body {
286 ResponseBody::Json(body) => write_json(&mut stream, response.status, &body).await,
287 ResponseBody::EventStream(attachment) => {
288 write_event_stream(&mut stream, endpoint.adapter.clone(), *attachment).await
289 }
290 }
291}
292
293struct WireRequest {
294 method: String,
295 target: String,
296 authorization: String,
297 body: Vec<u8>,
298}
299
300struct WireError {
301 status: u16,
302 message: &'static str,
303}
304
305async fn read_request(
306 stream: &mut TcpStream,
307 expected_address: SocketAddr,
308) -> Result<WireRequest, WireError> {
309 read_request_before(
310 stream,
311 expected_address,
312 tokio::time::Instant::now() + READ_TIMEOUT,
313 )
314 .await
315}
316
317async fn read_request_before(
318 stream: &mut TcpStream,
319 expected_address: SocketAddr,
320 deadline: tokio::time::Instant,
321) -> Result<WireRequest, WireError> {
322 let mut bytes = Vec::with_capacity(4096);
323 let header_end = loop {
324 if bytes.len() >= MAX_HEADER_BYTES {
325 return Err(WireError {
326 status: 431,
327 message: "request headers too large",
328 });
329 }
330 let mut chunk = [0_u8; 4096];
331 let read = tokio::time::timeout_at(deadline, stream.read(&mut chunk))
332 .await
333 .map_err(|_| WireError {
334 status: 408,
335 message: "request read timeout",
336 })?
337 .map_err(|_| WireError {
338 status: 400,
339 message: "request read failed",
340 })?;
341 if read == 0 {
342 return Err(WireError {
343 status: 400,
344 message: "incomplete request",
345 });
346 }
347 bytes.extend_from_slice(&chunk[..read]);
348 if let Some(index) = find_subslice(&bytes, b"\r\n\r\n") {
349 if index + 4 > MAX_HEADER_BYTES {
350 return Err(WireError {
351 status: 431,
352 message: "request headers too large",
353 });
354 }
355 break index + 4;
356 }
357 };
358 let header = std::str::from_utf8(&bytes[..header_end]).map_err(|_| WireError {
359 status: 400,
360 message: "request headers are not UTF-8",
361 })?;
362 let mut lines = header[..header.len() - 4].split("\r\n");
363 let request_line = lines.next().ok_or(WireError {
364 status: 400,
365 message: "missing request line",
366 })?;
367 let fields = request_line.split(' ').collect::<Vec<_>>();
368 if fields.len() != 3 || fields[2] != "HTTP/1.1" {
369 return Err(WireError {
370 status: 400,
371 message: "invalid HTTP/1.1 request line",
372 });
373 }
374 if !fields[1].starts_with('/') || fields[1].starts_with("//") {
375 return Err(WireError {
376 status: 400,
377 message: "absolute-form target rejected",
378 });
379 }
380 let method = fields[0].to_string();
381 let target = fields[1].to_string();
382 let mut host = None;
383 let mut authorization = None;
384 let mut content_length = None;
385 for line in lines {
386 let (name, value) = line.split_once(':').ok_or(WireError {
387 status: 400,
388 message: "invalid request header",
389 })?;
390 let name = name.to_ascii_lowercase();
391 let value = value.trim();
392 match name.as_str() {
393 "host" if host.replace(value).is_some() => return Err(duplicate_security_header()),
394 "authorization" if authorization.replace(value).is_some() => {
395 return Err(duplicate_security_header())
396 }
397 "content-length" if content_length.replace(value).is_some() => {
398 return Err(duplicate_security_header())
399 }
400 "transfer-encoding" | "origin" => {
401 return Err(WireError {
402 status: 400,
403 message: "forbidden request header",
404 })
405 }
406 _ => {}
407 }
408 }
409 if host != Some(expected_address.to_string().as_str()) {
410 return Err(WireError {
411 status: 400,
412 message: "invalid loopback Host",
413 });
414 }
415 let authorization = authorization
416 .ok_or(WireError {
417 status: 401,
418 message: "authentication failed",
419 })?
420 .to_string();
421 let content_length = content_length
422 .unwrap_or("0")
423 .parse::<usize>()
424 .map_err(|_| WireError {
425 status: 400,
426 message: "invalid content length",
427 })?;
428 if content_length > MAX_BODY_BYTES {
429 return Err(WireError {
430 status: 413,
431 message: "request body too large",
432 });
433 }
434 while bytes.len() - header_end < content_length {
435 let remaining = content_length - (bytes.len() - header_end);
436 let mut chunk = vec![0_u8; remaining.min(4096)];
437 let read = tokio::time::timeout_at(deadline, stream.read(&mut chunk))
438 .await
439 .map_err(|_| WireError {
440 status: 408,
441 message: "request body timeout",
442 })?
443 .map_err(|_| WireError {
444 status: 400,
445 message: "request body read failed",
446 })?;
447 if read == 0 {
448 return Err(WireError {
449 status: 400,
450 message: "incomplete request body",
451 });
452 }
453 bytes.extend_from_slice(&chunk[..read]);
454 }
455 if bytes.len() - header_end != content_length {
456 return Err(WireError {
457 status: 400,
458 message: "pipelined request bytes rejected",
459 });
460 }
461 Ok(WireRequest {
462 method,
463 target,
464 authorization,
465 body: bytes[header_end..].to_vec(),
466 })
467}
468
469async fn write_json(stream: &mut TcpStream, status: u16, body: &Value) -> std::io::Result<()> {
470 let body = serde_json::to_vec(body).expect("JSON Value serializes");
471 let reason = status_reason(status);
472 let header = format!(
473 "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\n\r\n",
474 body.len()
475 );
476 stream.write_all(header.as_bytes()).await?;
477 stream.write_all(&body).await?;
478 stream.shutdown().await
479}
480
481async fn write_error(
482 stream: &mut TcpStream,
483 status: u16,
484 message: &'static str,
485) -> std::io::Result<()> {
486 write_json(
487 stream,
488 status,
489 &serde_json::json!({"name": "transport_error", "message": message}),
490 )
491 .await
492}
493
494async fn write_event_stream(
495 stream: &mut TcpStream,
496 adapter: Arc<OpenCodeAdapter>,
497 mut attachment: supercode::FrontendAttachment,
498) -> std::io::Result<()> {
499 stream
500 .write_all(
501 b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\nX-Accel-Buffering: no\r\n\r\n",
502 )
503 .await?;
504 for event in adapter.initial_events(&attachment) {
505 write_sse(stream, &event).await?;
506 }
507 let mut projection = adapter.event_projection(&attachment);
508 loop {
509 tokio::select! {
510 event = attachment.next_event() => match event {
511 Ok(event) => {
512 for projected in projection.project(&event) {
513 write_sse(stream, &projected).await?;
514 }
515 }
516 Err(_) => break,
517 },
518 _ = tokio::time::sleep(HEARTBEAT_INTERVAL) => {
519 write_sse(stream, &serde_json::json!({"type": "server.heartbeat", "properties": {}})).await?;
520 }
521 }
522 }
523 stream.shutdown().await
524}
525
526async fn write_sse(stream: &mut TcpStream, event: &Value) -> std::io::Result<()> {
527 let encoded = serde_json::to_vec(event).expect("JSON Value serializes");
528 if encoded.len() > MAX_EVENT_BYTES {
529 return Err(std::io::Error::new(
530 std::io::ErrorKind::InvalidData,
531 "projected event exceeded byte limit",
532 ));
533 }
534 stream.write_all(b"data: ").await?;
535 stream.write_all(&encoded).await?;
536 stream.write_all(b"\n\n").await?;
537 stream.flush().await
538}
539
540fn duplicate_security_header() -> WireError {
541 WireError {
542 status: 400,
543 message: "duplicate security header",
544 }
545}
546
547fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
548 haystack
549 .windows(needle.len())
550 .position(|window| window == needle)
551}
552
553fn constant_time_eq(left: &[u8; 32], right: &[u8; 32]) -> bool {
554 left.iter()
555 .zip(right)
556 .fold(0_u8, |difference, (left, right)| {
557 difference | (left ^ right)
558 })
559 == 0
560}
561
562fn encode_hex(secret: &[u8; CREDENTIAL_BYTES]) -> String {
563 const HEX: &[u8; 16] = b"0123456789abcdef";
564 let mut encoded = String::with_capacity(CREDENTIAL_BYTES * 2);
565 for byte in secret {
566 encoded.push(HEX[(byte >> 4) as usize] as char);
567 encoded.push(HEX[(byte & 0x0f) as usize] as char);
568 }
569 encoded
570}
571
572fn status_reason(status: u16) -> &'static str {
573 match status {
574 200 => "OK",
575 400 => "Bad Request",
576 401 => "Unauthorized",
577 403 => "Forbidden",
578 404 => "Not Found",
579 408 => "Request Timeout",
580 409 => "Conflict",
581 413 => "Payload Too Large",
582 431 => "Request Header Fields Too Large",
583 500 => "Internal Server Error",
584 _ => "Error",
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use std::collections::{BTreeMap, VecDeque};
591 use std::sync::atomic::{AtomicUsize, Ordering};
592 use std::sync::Mutex;
593
594 use async_trait::async_trait;
595 use supercode::{
596 ChatMessage, CoordinatedRuntime, FrontendActions, FrontendAttachSnapshot,
597 FrontendAttachment, FrontendConnectionState, FrontendDisplayCapabilities, FrontendResponse,
598 FrontendRuntimeDescriptor, FrontendTurnState, RuntimeAuthorization, RuntimeClientId,
599 SdkError, SdkEvent,
600 };
601 use tokio::sync::broadcast;
602
603 use super::*;
604
605 struct FixtureRuntime {
606 history: Mutex<Vec<ChatMessage>>,
607 submissions: AtomicUsize,
608 responses: Mutex<Vec<FrontendResponse>>,
609 events: broadcast::Sender<SdkEvent>,
610 }
611
612 impl FixtureRuntime {
613 fn new() -> Arc<Self> {
614 let (events, _) = broadcast::channel(64);
615 Arc::new(Self {
616 history: Mutex::new(vec![
617 ChatMessage::system("Claude context"),
618 ChatMessage::user("before"),
619 ChatMessage::assistant("ready"),
620 ]),
621 submissions: AtomicUsize::new(0),
622 responses: Mutex::new(Vec::new()),
623 events,
624 })
625 }
626
627 fn descriptor() -> FrontendRuntimeDescriptor {
628 FrontendRuntimeDescriptor {
629 schema_version: 2,
630 session_id: "runtime-1".into(),
631 source_harness: Some("claude-code".into()),
632 emulation_profile: Some("claude-code".into()),
633 active_modules: Vec::new(),
634 commands: Vec::new(),
635 operations: Vec::new(),
636 actions: FrontendActions {
637 submit: true,
638 interrupt: true,
639 steer: true,
640 respond: true,
641 detach: true,
642 close: false,
643 },
644 display: FrontendDisplayCapabilities {
645 event_kinds: vec!["text_delta".into(), "request".into()],
646 opaque_fallback: true,
647 },
648 model: "openrouter/glm-5.2".into(),
649 turn_state: FrontendTurnState::Idle,
650 connection_state: FrontendConnectionState::Connected,
651 extensions: BTreeMap::new(),
652 }
653 }
654 }
655
656 #[async_trait]
657 impl SdkRuntime for FixtureRuntime {
658 async fn describe(&self) -> Result<FrontendRuntimeDescriptor, SdkError> {
659 Ok(Self::descriptor())
660 }
661
662 async fn attach(&self, history_limit: usize) -> Result<FrontendAttachment, SdkError> {
663 let history = self
664 .history
665 .lock()
666 .unwrap_or_else(std::sync::PoisonError::into_inner)
667 .clone();
668 let start = history.len().saturating_sub(history_limit);
669 Ok(FrontendAttachment::from_snapshot(
670 FrontendAttachSnapshot {
671 descriptor: Self::descriptor(),
672 history: history[start..].to_vec(),
673 history_cursor: history.len() as u64,
674 replay: VecDeque::new(),
675 },
676 self.events.subscribe(),
677 ))
678 }
679
680 async fn send_input(self: Arc<Self>, prompt: String) -> Result<(), SdkError> {
681 self.submit(prompt).await.map(|_| ())
682 }
683
684 async fn submit(&self, prompt: String) -> Result<String, SdkError> {
685 self.submissions.fetch_add(1, Ordering::SeqCst);
686 let reply = format!("reply:{prompt}");
687 self.history
688 .lock()
689 .unwrap_or_else(std::sync::PoisonError::into_inner)
690 .extend([
691 ChatMessage::user(prompt),
692 ChatMessage::assistant(reply.clone()),
693 ]);
694 Ok(reply)
695 }
696
697 async fn interrupt(&self) -> Result<bool, SdkError> {
698 Ok(true)
699 }
700
701 async fn steer(&self, _prompt: String) -> Result<(), SdkError> {
702 Ok(())
703 }
704
705 async fn respond(&self, response: FrontendResponse) -> Result<(), SdkError> {
706 self.responses
707 .lock()
708 .unwrap_or_else(std::sync::PoisonError::into_inner)
709 .push(response);
710 Ok(())
711 }
712 }
713
714 async fn endpoint_for(
715 runtime: Arc<dyn SdkRuntime>,
716 ) -> (OpenCodeServerHandle, OpenCodeClientCredential) {
717 let (endpoint, credential) =
718 OpenCodeEndpoint::new(runtime, "runtime-1", "/runtime").expect("create endpoint");
719 let handle = endpoint.bind(0).await.expect("bind endpoint");
720 (handle, credential)
721 }
722
723 async fn raw_request(
724 address: SocketAddr,
725 authorization: Option<&str>,
726 method: &str,
727 target: &str,
728 body: &str,
729 ) -> String {
730 let mut stream = TcpStream::connect(address).await.unwrap();
731 let auth = authorization
732 .map(|authorization| format!("Authorization: {authorization}\r\n"))
733 .unwrap_or_default();
734 let request = format!(
735 "{method} {target} HTTP/1.1\r\nHost: {address}\r\n{auth}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
736 body.len()
737 );
738 stream.write_all(request.as_bytes()).await.unwrap();
739 let mut response = Vec::new();
740 stream.read_to_end(&mut response).await.unwrap();
741 String::from_utf8(response).unwrap()
742 }
743
744 async fn raw_wire(address: SocketAddr, request: String) -> String {
745 let mut stream = TcpStream::connect(address).await.unwrap();
746 stream.write_all(request.as_bytes()).await.unwrap();
747 let mut response = Vec::new();
748 stream.read_to_end(&mut response).await.unwrap();
749 String::from_utf8(response).unwrap()
750 }
751
752 async fn read_until(stream: &mut TcpStream, needle: &str) -> String {
753 let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
754 let mut bytes = Vec::new();
755 loop {
756 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
757 assert!(
758 !remaining.is_zero(),
759 "did not receive {needle}: {}",
760 String::from_utf8_lossy(&bytes)
761 );
762 let mut chunk = [0_u8; 4096];
763 let read = tokio::time::timeout(remaining, stream.read(&mut chunk))
764 .await
765 .expect("stream read deadline")
766 .expect("stream read");
767 assert!(read > 0, "stream closed before {needle}");
768 bytes.extend_from_slice(&chunk[..read]);
769 let text = String::from_utf8_lossy(&bytes);
770 if text.contains(needle) {
771 return text.into_owned();
772 }
773 }
774 }
775
776 fn message_body(prompt: &str) -> String {
777 serde_json::json!({
778 "messageID": "msg_stock", "agent": "build",
779 "model": {"providerID": "openrouter", "modelID": "glm-5.2"},
780 "parts": [{"id": "prt_stock", "type": "text", "text": prompt}]
781 })
782 .to_string()
783 }
784
785 #[tokio::test]
786 async fn loopback_host_requires_exact_basic_credential_and_host() {
787 let runtime = FixtureRuntime::new();
788 let (handle, credential) = endpoint_for(runtime).await;
789 let missing = raw_request(handle.address(), None, "GET", "/agent", "").await;
790 assert!(missing.starts_with("HTTP/1.1 401"));
791 let wrong = raw_request(
792 handle.address(),
793 Some("Basic b3BlbmNvZGU6d3Jvbmc="),
794 "GET",
795 "/agent",
796 "",
797 )
798 .await;
799 assert!(wrong.starts_with("HTTP/1.1 401"));
800 let authorized = raw_request(
801 handle.address(),
802 Some(&credential.authorization_header()),
803 "GET",
804 "/agent",
805 "",
806 )
807 .await;
808 assert!(authorized.starts_with("HTTP/1.1 200"));
809 assert!(!format!("{credential:?}").contains(&credential.authorization_header()));
810 handle.shutdown().await;
811 }
812
813 #[tokio::test]
814 async fn authenticated_owner_submits_but_observer_cannot_duplicate_work() {
815 let runtime = FixtureRuntime::new();
816 let coordinator = CoordinatedRuntime::new(runtime.clone());
817 let owner = coordinator.client(
818 RuntimeClientId::parse("opencode-owner").unwrap(),
819 RuntimeAuthorization::interactive(),
820 );
821 let (owner_handle, owner_credential) = endpoint_for(owner).await;
822 let session = OpenCodeAdapter::new(runtime.clone(), "runtime-1", "/runtime")
823 .session_id()
824 .to_string();
825 let response = raw_request(
826 owner_handle.address(),
827 Some(&owner_credential.authorization_header()),
828 "POST",
829 &format!("/session/{session}/message"),
830 &message_body("continue once"),
831 )
832 .await;
833 assert!(response.starts_with("HTTP/1.1 200"), "{response}");
834 assert_eq!(runtime.submissions.load(Ordering::SeqCst), 1);
835 owner_handle.shutdown().await;
836
837 let observer = coordinator.client(
838 RuntimeClientId::parse("opencode-observer").unwrap(),
839 RuntimeAuthorization::observer(),
840 );
841 let (observer_handle, observer_credential) = endpoint_for(observer).await;
842 let denied = raw_request(
843 observer_handle.address(),
844 Some(&observer_credential.authorization_header()),
845 "POST",
846 &format!("/session/{session}/message"),
847 &message_body("must not run"),
848 )
849 .await;
850 assert!(denied.starts_with("HTTP/1.1 409"), "{denied}");
851 assert_eq!(runtime.submissions.load(Ordering::SeqCst), 1);
852 observer_handle.shutdown().await;
853 }
854
855 #[tokio::test]
856 async fn transport_rejects_duplicate_security_headers_wrong_host_and_oversized_body() {
857 let runtime = FixtureRuntime::new();
858 let (handle, credential) = endpoint_for(runtime).await;
859 let auth = credential.authorization_header();
860 let duplicate = raw_wire(
861 handle.address(),
862 format!(
863 "GET /agent HTTP/1.1\r\nHost: {}\r\nAuthorization: {auth}\r\nAuthorization: {auth}\r\n\r\n",
864 handle.address()
865 ),
866 )
867 .await;
868 assert!(duplicate.starts_with("HTTP/1.1 400"));
869 let wrong_host = raw_wire(
870 handle.address(),
871 format!(
872 "GET /agent HTTP/1.1\r\nHost: localhost:{}\r\nAuthorization: {auth}\r\n\r\n",
873 handle.address().port()
874 ),
875 )
876 .await;
877 assert!(wrong_host.starts_with("HTTP/1.1 400"));
878 let oversized = raw_wire(
879 handle.address(),
880 format!(
881 "POST /session HTTP/1.1\r\nHost: {}\r\nAuthorization: {auth}\r\nContent-Length: {}\r\n\r\n",
882 handle.address(),
883 MAX_BODY_BYTES + 1
884 ),
885 )
886 .await;
887 assert!(oversized.starts_with("HTTP/1.1 413"));
888 handle.shutdown().await;
889 }
890
891 #[cfg(unix)]
892 #[tokio::test]
893 async fn credential_enters_only_one_child_environment_and_leaves_reusable_command_clean() {
894 use std::process::Stdio;
895
896 let runtime = FixtureRuntime::new();
897 let (_endpoint, credential) =
898 OpenCodeEndpoint::new(runtime, "runtime-1", "/runtime").unwrap();
899 let mut command = tokio::process::Command::new("sh");
900 command
901 .args([
902 "-c",
903 "printf '%s:%s' \"$OPENCODE_SERVER_USERNAME\" \"$OPENCODE_SERVER_PASSWORD\"",
904 ])
905 .stdout(Stdio::piped());
906 let output = credential
907 .spawn_tokio_child(&mut command)
908 .unwrap()
909 .wait_with_output()
910 .await
911 .unwrap();
912 let first = String::from_utf8(output.stdout).unwrap();
913 assert!(first.starts_with("opencode:"));
914 assert_eq!(first.len(), "opencode:".len() + CREDENTIAL_BYTES * 2);
915
916 let reused = command.output().await.unwrap();
917 assert_eq!(reused.stdout, b":");
918 }
919
920 #[tokio::test]
921 async fn event_stream_projects_live_sdk_delta_and_permission_without_second_runtime() {
922 let runtime = FixtureRuntime::new();
923 let (handle, credential) = endpoint_for(runtime.clone()).await;
924 let mut stream = TcpStream::connect(handle.address()).await.unwrap();
925 let request = format!(
926 "GET /event HTTP/1.1\r\nHost: {}\r\nAuthorization: {}\r\nContent-Length: 0\r\n\r\n",
927 handle.address(),
928 credential.authorization_header()
929 );
930 stream.write_all(request.as_bytes()).await.unwrap();
931 let initial = read_until(&mut stream, "session.status").await;
932 assert!(initial.contains("text/event-stream"));
933
934 runtime
935 .events
936 .send(SdkEvent {
937 sequence: 10,
938 kind: "text_delta".into(),
939 payload: serde_json::json!({"type": "text_delta", "text": "live GLM output"}),
940 })
941 .unwrap();
942 let projected = read_until(&mut stream, "live GLM output").await;
943 assert!(projected.contains("message.part.delta"), "{projected}");
944 assert!(projected.contains("live GLM output"), "{projected}");
945 drop(stream);
946 handle.shutdown().await;
947 }
948
949 #[tokio::test]
950 async fn shutdown_is_durable_and_closes_an_active_event_stream() {
951 for _ in 0..16 {
952 let (handle, _credential) = endpoint_for(FixtureRuntime::new()).await;
953 tokio::time::timeout(Duration::from_secs(1), handle.shutdown())
954 .await
955 .expect("shutdown sent immediately after bind must not be lost");
956 }
957
958 let (handle, credential) = endpoint_for(FixtureRuntime::new()).await;
959 let mut stream = TcpStream::connect(handle.address()).await.unwrap();
960 let request = format!(
961 "GET /event HTTP/1.1\r\nHost: {}\r\nAuthorization: {}\r\nContent-Length: 0\r\n\r\n",
962 handle.address(),
963 credential.authorization_header()
964 );
965 stream.write_all(request.as_bytes()).await.unwrap();
966 let initial = read_until(&mut stream, "session.updated").await;
967 assert!(initial.contains("text/event-stream"));
968 tokio::time::timeout(Duration::from_secs(1), handle.shutdown())
969 .await
970 .expect("active SSE connection must be joined during shutdown");
971 let mut tail = Vec::new();
972 tokio::time::timeout(Duration::from_secs(1), stream.read_to_end(&mut tail))
973 .await
974 .expect("SSE peer must observe endpoint shutdown")
975 .expect("read closed SSE stream");
976 }
977
978 #[tokio::test]
979 async fn request_deadline_is_total_not_reset_by_trickled_bytes() {
980 let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
981 let address = listener.local_addr().unwrap();
982 let mut client = TcpStream::connect(address).await.unwrap();
983 let (mut server, _) = listener.accept().await.unwrap();
984 let writer = tokio::spawn(async move {
985 for byte in b"GET /agent HTTP/1.1\r\n" {
986 if client.write_all(&[*byte]).await.is_err() {
987 break;
988 }
989 tokio::time::sleep(Duration::from_millis(10)).await;
990 }
991 });
992 let started = tokio::time::Instant::now();
993 let error =
994 match read_request_before(&mut server, address, started + Duration::from_millis(35))
995 .await
996 {
997 Ok(_) => panic!("trickled request must exceed one total deadline"),
998 Err(error) => error,
999 };
1000 assert_eq!(error.status, 408);
1001 assert!(started.elapsed() < Duration::from_millis(100));
1002 writer.abort();
1003 }
1004}