Skip to main content

supercode_harness/
server.rs

1//! §2 module 31 `server` (COMPOSABLE-HARNESS-DESIGN.md, D7 "full
2//! programmatic RPC/HTTP server", D8 "remote attach", D10 "daemon"; §1.9
3//! Obligation 9's out-of-process half — the in-process SDK already meets
4//! the core commitment via [`crate::EventSink`]).
5//!
6//! The embedding ladder this module builds:
7//!
8//! 1. **`--output-format stream-json`** (the CLI's existing rung, UX-23) —
9//!    already ships a JSONL [`crate::AgentEvent`] stream over stdout. This
10//!    unit completes it: [`crate::AgentEvent::to_json`] is now the single
11//!    canonical projection both that sink AND this module's notifications
12//!    share, and it covers the FULL event set (previously
13//!    [`crate::AgentEvent::BackgroundOutput`] fell into a generic
14//!    "unknown" catch-all).
15//! 2. **JSONL-RPC over stdio** ([`run_stdio`]) — the SDK-out-of-process
16//!    surface: a parent process drives this agent's loop over stdin/stdout
17//!    with `{"id","method","params"}` request lines, getting back
18//!    `{"id","result"|"error"}` responses interleaved with
19//!    `{"event":...}` notifications. Parent-process-trusted (same trust
20//!    model as [`crate::mcp::serve_stdio`]) — no auth token.
21//! 3. **The same RPC surface over HTTP** ([`run_http`], D8 "remote
22//!    attach") — `POST /rpc` for request/response, `GET /events` for the
23//!    event stream (SSE-shaped: `data: <json>\n\n` per line). Unlike
24//!    stdio, a network client is UNTRUSTED by default, so every request
25//!    must carry the bearer token (`check_auth`).
26//!
27//! **Security posture (this is a listener — the highest-risk module
28//! class):**
29//! - `[capabilities.server]` is project-forbidden (D-10) — see
30//!   `crates/cli/src/userconfig.rs`'s `PROJECT_FORBIDDEN_CAPABILITY_TABLES`
31//!   and this crate's `configfile::PROJECT_FORBIDDEN_CAPABILITY_TABLES`
32//!   (both already listed `"server"` before this unit landed; this module
33//!   is what makes the listener the strip was already guarding against
34//!   real).
35//! - Default-off: nothing in this module is ever reached unless a caller
36//!   explicitly invokes [`run_stdio`]/[`run_http`] AND the CLI's own
37//!   gate (`capabilities.server.enabled == Some(true)`, checked before
38//!   either is called) passed.
39//! - Loopback-only HTTP bind by default — enforced by the CALLER (the
40//!   CLI's `serve` command defaults `bind` to `127.0.0.1:0` and only binds
41//!   elsewhere on an explicit `bind`/`--bind` override, with a printed
42//!   exposure warning); [`run_http`] itself binds whatever address it's
43//!   given, since the loopback POLICY decision belongs to the config/CLI
44//!   layer, not the transport.
45//! - **No permission/sandbox bypass.** [`RpcEngine::new`] takes an already
46//!   fully-constructed [`crate::Agent`] — the SAME `Agent` a local
47//!   `run`/`chat` session would build (same `Config`, same permission
48//!   rules, same sandbox). This module installs NO approval handler of its
49//!   own and provides no channel for a remote/RPC caller to answer an
50//!   approval prompt; combined with `Agent`'s existing fail-closed rule
51//!   ("absent handler denies" — `crates/harness/src/agent.rs`'s
52//!   `prepare_tool_call`), any tool call that would need interactive
53//!   approval is DENIED, never silently approved, when driven through this
54//!   module. See `crates/harness/tests/server_engine.rs` for a fail-on-revert
55//!   proof.
56//! - Bounded buffering throughout ([`SERVER_MAX_LINE_BYTES`],
57//!   [`SERVER_EVENT_CHANNEL_CAPACITY`]) — same 16MiB-class discipline P5-2
58//!   established for `crate::mcp`'s SSE reader, reused here rather than
59//!   re-derived.
60//! - Graceful shutdown: the `shutdown` RPC method stops the stdio loop and
61//!   the HTTP accept loop alike (both select on the same
62//!   [`RpcEngine::wait_for_shutdown`]) — no orphaned listener/accept task
63//!   survives a `shutdown` call, mirroring P5-3/P5-6's drop-abort
64//!   discipline for background work.
65
66use std::collections::{BTreeMap, HashMap, VecDeque};
67#[cfg(feature = "adapter-api")]
68use std::net::SocketAddr;
69use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
70use std::sync::{Arc, Mutex as StdMutex};
71
72use async_trait::async_trait;
73#[cfg(feature = "adapter-api")]
74use futures::{SinkExt, StreamExt};
75use serde_json::{json, Value};
76use tokio::io::AsyncBufRead;
77#[cfg(feature = "adapter-api")]
78use tokio::io::{AsyncRead, AsyncReadExt};
79#[cfg(feature = "adapter-api")]
80use tokio::io::{AsyncWrite, AsyncWriteExt};
81#[cfg(feature = "adapter-api")]
82use tokio::net::TcpListener;
83#[cfg(feature = "adapter-api")]
84use tokio::sync::mpsc;
85use tokio::sync::{broadcast, Mutex, Notify, RwLock};
86
87/// How often an idle event stream carries an SSE comment, well inside the body
88/// timeouts of common HTTP clients (Node's fetch: 300 s).
89const EVENT_STREAM_KEEPALIVE: std::time::Duration = std::time::Duration::from_secs(15);
90
91use crate::agent::SteerInbox;
92use crate::frontend::{
93    FrontendActions, FrontendApprovalDecision, FrontendAttachSnapshot, FrontendAttachment,
94    FrontendCommandDescriptor, FrontendConnectionState, FrontendDisplayCapabilities, FrontendEvent,
95    FrontendOperationDescriptor, FrontendOperationInvocation, FrontendOperationKind,
96    FrontendOperationResult, FrontendProjectionState, FrontendRequest, FrontendRequestKind,
97    FrontendResponse, FrontendRuntime, FrontendRuntimeDescriptor, FrontendRuntimeError,
98    FrontendRuntimeMetadata, FrontendTurnState, FRONTEND_EVENT_SCHEMA_VERSION,
99    FRONTEND_REPLAY_CAPACITY, FRONTEND_RUNTIME_SCHEMA_VERSION,
100};
101use crate::mcp::{
102    ElicitationAction, ElicitationRequest, ElicitationResponse, McpElicitationHandler,
103};
104use crate::message::ChatMessage;
105use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
106pub use crate::sdk::RuntimeSubmitError;
107use crate::sdk::SdkAgent;
108#[cfg(feature = "adapter-api")]
109use crate::{CoordinatedRuntime, CoordinatedRuntimeClient, RuntimeAuthorization, RuntimeClientId};
110
111/// Bounded broadcast capacity for the event-notification channel — mirrors
112/// `crate::mcp::MCP_SSE_CHANNEL_CAPACITY`'s bounded-buffering discipline
113/// (P5-2): a slow/absent subscriber can never make the sender block or
114/// grow memory unboundedly; a lagging receiver just misses old events
115/// (`broadcast::error::RecvError::Lagged`) rather than stalling the agent
116/// loop or accumulating unbounded backlog.
117pub const SERVER_EVENT_CHANNEL_CAPACITY: usize = 1024;
118
119/// Maximum canonical messages retained for late-attaching frontends. This
120/// matches the `history` RPC limit and prevents the lock-independent snapshot
121/// from duplicating an arbitrarily large agent transcript.
122pub(crate) const SERVER_HISTORY_CAPACITY: usize = 200;
123
124/// Maximum accepted line/body length (bytes) for both the stdio JSONL-RPC
125/// reader and the HTTP transport's request line/headers/body — the same
126/// 16MiB-class cap P5-2 established for `crate::mcp`'s SSE frame reader
127/// (`MCP_MAX_SSE_FRAME_BYTES`), reused here so an adversarial or simply
128/// broken client can never make either transport buffer an unbounded
129/// amount of data in memory.
130pub const SERVER_MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
131
132/// Cap on HTTP header line COUNT (independent of [`SERVER_MAX_LINE_BYTES`],
133/// which only bounds any one line's length) — without this, a client could
134/// send an unbounded NUMBER of small, individually-under-cap header lines
135/// and still exhaust memory over one connection.
136#[cfg(feature = "adapter-api")]
137const MAX_HEADER_LINES: usize = 200;
138
139/// One JSONL-RPC request line a client sends: `{"id", "method", "params"}`.
140/// `params` defaults to `null` when omitted (a method that takes no
141/// arguments, e.g. `status`/`shutdown`, never requires callers to spell out
142/// `"params": null}` explicitly).
143#[derive(Debug, Clone, serde::Deserialize)]
144pub struct RpcRequest {
145    /// Caller-chosen correlation id, echoed back verbatim on the matching
146    /// response — never interpreted, so any JSON value (string, number,
147    /// null) a caller likes works.
148    pub id: Value,
149    /// The method name (`submit` | `interrupt` | `status` | `shutdown`).
150    pub method: String,
151    /// Method-specific arguments; `submit` reads `params.prompt`.
152    #[serde(default)]
153    pub params: Value,
154}
155
156/// Build a `{"id", "result"}` response line.
157fn rpc_ok(id: Value, result: Value) -> Value {
158    json!({"id": id, "result": result})
159}
160
161/// Build a `{"id", "error": {"code","message"}}` response line. `code`
162/// follows JSON-RPC 2.0's reserved-range convention where a natural fit
163/// exists (`-32700` parse error, `-32601` method not found, `-32602`
164/// invalid params) purely as a familiar, self-documenting convention — this
165/// protocol does not otherwise claim JSON-RPC 2.0 compliance (no
166/// `"jsonrpc":"2.0"` envelope; see the module doc's minimal wire shape).
167fn rpc_error(id: Value, code: i32, message: impl Into<String>) -> Value {
168    json!({"id": id, "error": {"code": code, "message": message.into()}})
169}
170
171fn sdk_runtime_rpc_error(id: Value, code: i32, error: &FrontendRuntimeError) -> Value {
172    let code = match error.code() {
173        crate::SdkErrorCode::Unauthenticated => -32030,
174        crate::SdkErrorCode::Unauthorized => -32031,
175        crate::SdkErrorCode::ControllerRequired => -32032,
176        crate::SdkErrorCode::LeaseExpired => -32033,
177        _ => code,
178    };
179    let mut envelope = json!({
180        "id": id,
181        "error": {
182            "code": code,
183            "name": error.code(),
184            "operation": error.operation(),
185            "message": error.to_string(),
186        }
187    });
188    if let Some(detail) = envelope.get_mut("error").and_then(Value::as_object_mut) {
189        match error {
190            FrontendRuntimeError::Unauthorized { permission } => {
191                detail.insert("permission".into(), Value::String(permission.clone()));
192            }
193            FrontendRuntimeError::ControllerRequired {
194                holder,
195                expires_at_ms,
196            } => {
197                if let Some(holder) = holder {
198                    detail.insert("holder".into(), Value::String(holder.clone()));
199                }
200                if let Some(expires_at_ms) = expires_at_ms {
201                    detail.insert("expiresAtMs".into(), json!(expires_at_ms));
202                }
203            }
204            _ => {}
205        }
206    }
207    envelope
208}
209
210/// Read one line (trailing `\n`/`\r\n` stripped) from `reader`, capped at
211/// `cap` bytes — mirrors `crate::mcp`'s `SseLineAccumulator` bounded-
212/// buffering discipline (P5-2). Returns `Ok(None)` at a clean EOF with no
213/// partial line pending. On an over-cap line, the REST of that oversized
214/// line is drained and discarded (up to the next `\n`) so the stream
215/// resyncs at the next real line boundary instead of desyncing forever,
216/// and `Err` is returned naming the cap.
217async fn read_bounded_line<R>(reader: &mut R, cap: usize) -> std::io::Result<Option<String>>
218where
219    R: AsyncBufRead + Unpin,
220{
221    use tokio::io::AsyncBufReadExt;
222    let mut out: Vec<u8> = Vec::new();
223    loop {
224        let buf = reader.fill_buf().await?;
225        if buf.is_empty() {
226            return Ok(if out.is_empty() {
227                None
228            } else {
229                Some(strip_crlf(out))
230            });
231        }
232        if let Some(pos) = buf.iter().position(|&b| b == b'\n') {
233            if out.len() + pos > cap {
234                reader.consume(pos + 1);
235                return Err(std::io::Error::new(
236                    std::io::ErrorKind::InvalidData,
237                    format!("line exceeded {cap} byte cap"),
238                ));
239            }
240            out.extend_from_slice(&buf[..pos]);
241            reader.consume(pos + 1);
242            return Ok(Some(strip_crlf(out)));
243        }
244        let take = buf.len();
245        if out.len() + take > cap {
246            reader.consume(take);
247            // Drain/discard the rest of this oversized line so a future
248            // read starts at the next real line boundary.
249            loop {
250                let b = reader.fill_buf().await?;
251                if b.is_empty() {
252                    break;
253                }
254                if let Some(p) = b.iter().position(|&x| x == b'\n') {
255                    reader.consume(p + 1);
256                    break;
257                }
258                let n = b.len();
259                reader.consume(n);
260            }
261            return Err(std::io::Error::new(
262                std::io::ErrorKind::InvalidData,
263                format!("line exceeded {cap} byte cap"),
264            ));
265        }
266        out.extend_from_slice(buf);
267        reader.consume(take);
268    }
269}
270
271fn strip_crlf(mut v: Vec<u8>) -> String {
272    if v.last() == Some(&b'\r') {
273        v.pop();
274    }
275    String::from_utf8_lossy(&v).into_owned()
276}
277
278/// Constant-time byte-slice comparison (avoids leaking the bearer token
279/// through a timing side-channel on `==`) — small, self-contained, no new
280/// dependency for one comparison.
281#[cfg(feature = "adapter-api")]
282fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
283    if a.len() != b.len() {
284        return false;
285    }
286    let mut diff = 0u8;
287    for (x, y) in a.iter().zip(b.iter()) {
288        diff |= x ^ y;
289    }
290    diff == 0
291}
292
293/// Mint a random per-session bearer token (32 bytes, hex-encoded) for the
294/// HTTP transport, when the operator hasn't configured a fixed
295/// `capabilities.server.token`. Uses `getrandom` (already resolved
296/// transitively via `reqwest`'s rustls/ring stack; promoted to a direct
297/// dependency here so this crate can call it directly, rather than rolling
298/// a hand-written PRNG for a value that must actually be unguessable).
299pub fn generate_token() -> String {
300    let mut bytes = [0u8; 32];
301    // `getrandom::getrandom` only fails if the OS entropy source itself is
302    // unavailable/misconfigured — effectively never on a real target this
303    // crate supports. Falling back to a process-time/PID-derived value
304    // would be a WORSE, easily-guessable token, so a failure here is
305    // treated as fatal (panic) rather than silently minting a weak secret.
306    getrandom::getrandom(&mut bytes).expect("OS entropy source for the server bearer token");
307    bytes.iter().map(|b| format!("{b:02x}")).collect()
308}
309
310/// The `on_turn_complete` hook's type — factored out (clippy
311/// `type_complexity`) rather than spelled out at both
312/// [`RpcEngine`]'s field and [`RpcEngine::new`]'s parameter.
313type TurnCompleteHook = Box<dyn Fn(&SdkAgent) + Send + Sync>;
314
315/// Protocol-neutral snapshot of one SDK-owned agent runtime.
316///
317/// Transport adapters (CLI/RPC/HTTP/ACP) project this value into their own
318/// wire shapes instead of each inventing a separate definition of "busy".
319#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
320pub struct RuntimeStatus {
321    /// Stable identity chosen by the embedding surface/session store.
322    pub session_id: String,
323    /// Model currently backing the agent.
324    pub model: String,
325    /// Whether a user or scheduler turn currently owns the agent loop.
326    pub busy: bool,
327    /// Whether graceful shutdown has been requested.
328    pub shutting_down: bool,
329}
330
331type PendingFrontendResponses = StdMutex<
332    HashMap<
333        u64,
334        (
335            FrontendRequestKind,
336            std::sync::mpsc::Sender<AcceptedFrontendResponse>,
337        ),
338    >,
339>;
340
341struct AcceptedFrontendResponse {
342    response: FrontendResponse,
343    /// The blocked handler may resume execution only after the canonical
344    /// resolution event has entered the sequenced frontend projection.
345    published: std::sync::mpsc::Receiver<()>,
346}
347
348/// Runtime-owned interactive request broker. It publishes complete request
349/// payloads into the same sequenced event stream and resolves each id once.
350struct FrontendRequestBroker {
351    next_id: std::sync::atomic::AtomicU64,
352    pending: PendingFrontendResponses,
353    transport: StdMutex<Option<FrontendRequestTransport>>,
354}
355
356#[derive(Clone)]
357struct FrontendRequestTransport {
358    events: broadcast::Sender<FrontendEvent>,
359    state: Arc<StdMutex<FrontendProjectionState>>,
360}
361
362impl FrontendRequestBroker {
363    fn new() -> Arc<Self> {
364        Arc::new(Self {
365            next_id: std::sync::atomic::AtomicU64::new(1),
366            pending: StdMutex::new(HashMap::new()),
367            transport: StdMutex::new(None),
368        })
369    }
370
371    fn bind(
372        &self,
373        events: broadcast::Sender<FrontendEvent>,
374        state: Arc<StdMutex<FrontendProjectionState>>,
375    ) {
376        *self
377            .transport
378            .lock()
379            .unwrap_or_else(std::sync::PoisonError::into_inner) =
380            Some(FrontendRequestTransport { events, state });
381    }
382
383    fn transport(&self) -> Option<FrontendRequestTransport> {
384        self.transport
385            .lock()
386            .unwrap_or_else(std::sync::PoisonError::into_inner)
387            .clone()
388    }
389
390    fn publish(&self, request: &FrontendRequest) -> bool {
391        self.publish_payload(json!({"type": "request", "request": request}))
392    }
393
394    fn publish_payload(&self, payload: Value) -> bool {
395        let Some(transport) = self.transport() else {
396            return false;
397        };
398        let event = {
399            let mut state = transport
400                .state
401                .lock()
402                .unwrap_or_else(std::sync::PoisonError::into_inner);
403            let event = FrontendEvent::new(state.next_sequence, payload);
404            state.next_sequence = state.next_sequence.saturating_add(1);
405            state.replay.push_back(event.clone());
406            while state.replay.len() > FRONTEND_REPLAY_CAPACITY {
407                state.replay.pop_front();
408            }
409            event
410        };
411        transport.events.send(event).is_ok()
412    }
413
414    fn respond(&self, response: FrontendResponse) -> Result<(), FrontendRuntimeError> {
415        let request_id = response.request_id();
416        let response_kind = match &response {
417            FrontendResponse::Approval { .. } => FrontendRequestKind::Approval,
418            FrontendResponse::Elicitation { .. } => FrontendRequestKind::Elicitation,
419            FrontendResponse::Other { .. } => FrontendRequestKind::Other,
420        };
421        let mut pending = self
422            .pending
423            .lock()
424            .unwrap_or_else(std::sync::PoisonError::into_inner);
425        let expected = pending
426            .get(&request_id)
427            .map(|(kind, _)| *kind)
428            .ok_or(FrontendRuntimeError::UnknownRequest(request_id))?;
429        if expected != response_kind {
430            return Err(FrontendRuntimeError::InvalidResponse(format!(
431                "request {request_id} expects {expected:?}, got {response_kind:?}"
432            )));
433        }
434        let (_, sender) = pending
435            .remove(&request_id)
436            .ok_or(FrontendRuntimeError::UnknownRequest(request_id))?;
437        drop(pending);
438        let payload = json!({
439            "type": "request_resolved",
440            "request_id": request_id,
441            "response": &response,
442        });
443        let (published_tx, published_rx) = std::sync::mpsc::channel();
444        sender
445            .send(AcceptedFrontendResponse {
446                response,
447                published: published_rx,
448            })
449            .map_err(|_| FrontendRuntimeError::UnknownRequest(request_id))?;
450        self.publish_payload(payload);
451        let _ = published_tx.send(());
452        Ok(())
453    }
454
455    fn ask_approval(
456        &self,
457        req: &ApprovalRequest<'_>,
458        child: Option<(&str, &Arc<StdMutex<Vec<crate::subagents::QueuedApproval>>>)>,
459    ) -> ApprovalOutcome {
460        // Record with no outcome first: while this call is in flight the
461        // entry IS a pending request, and the decision is written back onto
462        // the same record below so a reader never has to guess.
463        let queued = child.and_then(|(child_agent_id, queue)| {
464            crate::subagents::queue_approval(
465                queue,
466                crate::subagents::QueuedApproval {
467                    child_agent_id: child_agent_id.to_string(),
468                    tool: req.tool.to_string(),
469                    subject: req.subject.map(String::from),
470                    queued_at_ms: std::time::SystemTime::now()
471                        .duration_since(std::time::UNIX_EPOCH)
472                        .map(|duration| duration.as_millis() as i64)
473                        .unwrap_or_default(),
474                    outcome: None,
475                },
476            )
477            .map(|index| (queue.clone(), index))
478        });
479        let outcome = self.decide_approval(req, child.map(|(id, _)| id));
480        if let Some((queue, index)) = queued {
481            crate::subagents::record_queued_outcome(&queue, index, outcome.into());
482        }
483        outcome
484    }
485
486    fn decide_approval(
487        &self,
488        req: &ApprovalRequest<'_>,
489        child_agent_id: Option<&str>,
490    ) -> ApprovalOutcome {
491        // No observer means no human can answer. Preserve the server's
492        // existing fail-closed, non-blocking headless behavior.
493        let Some(transport) = self.transport() else {
494            return ApprovalOutcome::Deny;
495        };
496        if transport.events.receiver_count() == 0 {
497            return ApprovalOutcome::Deny;
498        }
499        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
500        let mut payload = json!({
501            "tool": req.tool,
502            "subject": req.subject,
503            "raw_args": req.raw_args,
504        });
505        if let Some(child_agent_id) = child_agent_id {
506            payload["child_agent_id"] = Value::String(child_agent_id.to_string());
507        }
508        let request = FrontendRequest {
509            id,
510            kind: FrontendRequestKind::Approval,
511            payload,
512        };
513        let (tx, rx) = std::sync::mpsc::channel();
514        self.pending
515            .lock()
516            .unwrap_or_else(std::sync::PoisonError::into_inner)
517            .insert(id, (FrontendRequestKind::Approval, tx));
518        if !self.publish(&request) {
519            self.pending
520                .lock()
521                .unwrap_or_else(std::sync::PoisonError::into_inner)
522                .remove(&id);
523            return ApprovalOutcome::Deny;
524        }
525        let wait_for_response = || loop {
526            match rx.recv_timeout(std::time::Duration::from_millis(100)) {
527                Ok(accepted) => {
528                    let _ = accepted.published.recv();
529                    let FrontendResponse::Approval { decision, .. } = accepted.response else {
530                        return ApprovalOutcome::Deny;
531                    };
532                    return match decision {
533                        FrontendApprovalDecision::Deny => ApprovalOutcome::Deny,
534                        FrontendApprovalDecision::Allow => ApprovalOutcome::Allow,
535                        FrontendApprovalDecision::AllowForSession => {
536                            ApprovalOutcome::AllowForSession
537                        }
538                    };
539                }
540                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
541                    return ApprovalOutcome::Deny;
542                }
543                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
544                    if self
545                        .transport()
546                        .map(|transport| transport.events.receiver_count() == 0)
547                        .unwrap_or(true)
548                    {
549                        self.pending
550                            .lock()
551                            .unwrap_or_else(std::sync::PoisonError::into_inner)
552                            .remove(&id);
553                        return ApprovalOutcome::Deny;
554                    }
555                }
556            }
557        };
558        if tokio::runtime::Handle::try_current()
559            .map(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
560            .unwrap_or(false)
561        {
562            tokio::task::block_in_place(wait_for_response)
563        } else {
564            wait_for_response()
565        }
566    }
567
568    async fn ask_elicitation(self: Arc<Self>, req: &ElicitationRequest) -> ElicitationResponse {
569        let cancel = || ElicitationResponse {
570            action: ElicitationAction::Cancel,
571            content: None,
572        };
573        let Some(transport) = self.transport() else {
574            return cancel();
575        };
576        if transport.events.receiver_count() == 0 {
577            return cancel();
578        }
579        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
580        let request = FrontendRequest {
581            id,
582            kind: FrontendRequestKind::Elicitation,
583            payload: json!({
584                "message": req.message,
585                "requested_schema": req.requested_schema,
586            }),
587        };
588        let (tx, rx) = std::sync::mpsc::channel();
589        self.pending
590            .lock()
591            .unwrap_or_else(std::sync::PoisonError::into_inner)
592            .insert(id, (FrontendRequestKind::Elicitation, tx));
593        if !self.publish(&request) {
594            self.pending
595                .lock()
596                .unwrap_or_else(std::sync::PoisonError::into_inner)
597                .remove(&id);
598            return cancel();
599        }
600        let broker = self.clone();
601        tokio::task::spawn_blocking(move || loop {
602            match rx.recv_timeout(std::time::Duration::from_millis(100)) {
603                Ok(accepted) => {
604                    let _ = accepted.published.recv();
605                    let FrontendResponse::Elicitation {
606                        action, content, ..
607                    } = accepted.response
608                    else {
609                        return cancel();
610                    };
611                    return ElicitationResponse {
612                        action: match action {
613                            crate::frontend::FrontendElicitationAction::Accept => {
614                                ElicitationAction::Accept
615                            }
616                            crate::frontend::FrontendElicitationAction::Decline => {
617                                ElicitationAction::Decline
618                            }
619                            crate::frontend::FrontendElicitationAction::Cancel => {
620                                ElicitationAction::Cancel
621                            }
622                        },
623                        content,
624                    };
625                }
626                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return cancel(),
627                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
628                    if broker
629                        .transport()
630                        .map(|transport| transport.events.receiver_count() == 0)
631                        .unwrap_or(true)
632                    {
633                        broker
634                            .pending
635                            .lock()
636                            .unwrap_or_else(std::sync::PoisonError::into_inner)
637                            .remove(&id);
638                        return cancel();
639                    }
640                }
641            }
642        })
643        .await
644        .unwrap_or_else(|_| cancel())
645    }
646}
647
648struct FrontendApprovalHandler(Arc<FrontendRequestBroker>);
649
650impl PermissionsApprovalHandler for FrontendApprovalHandler {
651    fn ask(&self, req: &ApprovalRequest<'_>) -> ApprovalOutcome {
652        self.0.ask_approval(req, None)
653    }
654}
655
656struct FrontendChildApprovalHandler {
657    broker: Arc<FrontendRequestBroker>,
658    child_agent_id: String,
659    queue: Arc<StdMutex<Vec<crate::subagents::QueuedApproval>>>,
660}
661
662impl PermissionsApprovalHandler for FrontendChildApprovalHandler {
663    fn ask(&self, req: &ApprovalRequest<'_>) -> ApprovalOutcome {
664        self.broker
665            .ask_approval(req, Some((&self.child_agent_id, &self.queue)))
666    }
667}
668
669/// Pre-runtime bridge for MCP clients that must receive their elicitation
670/// handler before they are consumed into agent tool registration.
671#[derive(Clone)]
672pub struct FrontendRequestBridge {
673    broker: Arc<FrontendRequestBroker>,
674}
675
676impl FrontendRequestBridge {
677    /// Create an unbound bridge. Pass it to
678    /// [`RpcEngine::new_named_with_frontend_bridge`] after MCP registration.
679    pub fn new() -> Self {
680        Self {
681            broker: FrontendRequestBroker::new(),
682        }
683    }
684
685    /// Handler installed on every interactive MCP client before registration.
686    pub fn elicitation_handler(&self) -> Arc<dyn McpElicitationHandler> {
687        Arc::new(FrontendElicitationHandler(self.broker.clone()))
688    }
689}
690
691impl Default for FrontendRequestBridge {
692    fn default() -> Self {
693        Self::new()
694    }
695}
696
697struct FrontendElicitationHandler(Arc<FrontendRequestBroker>);
698
699#[async_trait]
700impl McpElicitationHandler for FrontendElicitationHandler {
701    async fn handle(&self, request: &ElicitationRequest) -> ElicitationResponse {
702        self.0.clone().ask_elicitation(request).await
703    }
704}
705
706/// The out-of-process RPC driver: wraps one already-constructed
707/// [`crate::Agent`] with the `submit`/`interrupt`/`status`/`shutdown`
708/// method set (§ module doc). Shared by both transports ([`run_stdio`],
709/// [`run_http`]) so the method semantics — including the fail-closed
710/// permission behavior — can never drift between them.
711pub struct RpcEngine {
712    agent: Mutex<SdkAgent>,
713    /// Last canonical transcript observed at a turn boundary. Frontends must
714    /// be able to attach and replay prior history while `submit` holds the
715    /// agent lock for an active turn, so reads use this independent snapshot.
716    history_snapshot: RwLock<Vec<ChatMessage>>,
717    session_id: String,
718    /// The model label, captured once at construction so `status` never
719    /// needs to lock `agent` (which `submit` holds for the WHOLE turn) —
720    /// `status` must stay answerable while a turn is in flight.
721    model: String,
722    busy: Arc<AtomicBool>,
723    /// The in-flight turn's cancellation handle, if any — see
724    /// [`Self::handle_submit`]/[`Self::handle_interrupt`]'s doc comments
725    /// for why this is a fresh [`Notify`] per turn rather than one shared
726    /// instance (`notify_one`'s stored-permit semantics only give the
727    /// correctness guarantee this needs when each turn gets a clean one).
728    current_cancel: Arc<StdMutex<Option<Arc<Notify>>>>,
729    /// Signals that the terminal event for an interrupted/completed turn is
730    /// published and its canonical history snapshot is stable.
731    turn_finished: Arc<Notify>,
732    /// Shared control queue owned by `Agent` but writable without waiting for
733    /// the active turn's long-held async agent lock.
734    steer_queue: Arc<StdMutex<SteerInbox>>,
735    events: broadcast::Sender<Value>,
736    /// Sequenced frontend events used for atomic replay/live attachment.
737    frontend_events: broadcast::Sender<FrontendEvent>,
738    /// Short-held projection lock. It is never held across model/tool I/O.
739    frontend_state: Arc<StdMutex<FrontendProjectionState>>,
740    frontend_metadata: FrontendRuntimeMetadata,
741    frontend_active_modules: Vec<String>,
742    frontend_commands: Vec<FrontendCommandDescriptor>,
743    frontend_operations: Vec<FrontendOperationDescriptor>,
744    frontend_requests: Option<Arc<FrontendRequestBroker>>,
745    shutdown: Notify,
746    shutting_down: AtomicBool,
747    /// Explicit owner shutdown seals new model-loop claims before it
748    /// interrupts the active one. Plain transport EOF only raises
749    /// `shutting_down` so already-buffered stdio requests can still flush.
750    accepting_submits: AtomicBool,
751    /// Serializes explicit shutdown barriers so every concurrent caller
752    /// returns only after the same admitted turn.
753    shutdown_barrier: Mutex<()>,
754    /// Fires after every SUCCESSFUL `submit` (never on an errored/
755    /// interrupted turn — see the call site), with the agent still locked
756    /// so the hook sees fully-consistent state (e.g. `agent.history()`).
757    /// The CLI installs session persistence/auto-titling here — this
758    /// module itself has no opinion on session storage.
759    on_turn_complete: Option<TurnCompleteHook>,
760}
761
762/// Owns every externally visible piece of an SDK submit claim from the
763/// instant the claim succeeds until the submit reaches a terminal boundary.
764/// The agent loop closes the ordinary final-answer steering boundary
765/// atomically with its last drain; this outer guard also restores steering,
766/// cancellation, busy state, and lifecycle waiters when the public submit
767/// future is dropped or fails before `Agent::run_loop` is entered.
768struct SdkSubmitClaim {
769    inbox: Arc<StdMutex<SteerInbox>>,
770    busy: Arc<AtomicBool>,
771    cancel: Arc<Notify>,
772    current_cancel: Arc<StdMutex<Option<Arc<Notify>>>>,
773    turn_finished: Arc<Notify>,
774    frontend_events: broadcast::Sender<FrontendEvent>,
775    frontend_state: Arc<StdMutex<FrontendProjectionState>>,
776    lifecycle_started: bool,
777}
778
779impl SdkSubmitClaim {
780    fn mark_lifecycle_started(&mut self) {
781        self.lifecycle_started = true;
782    }
783
784    fn mark_lifecycle_finished(&mut self) {
785        self.lifecycle_started = false;
786    }
787}
788
789impl Drop for SdkSubmitClaim {
790    fn drop(&mut self) {
791        // A caller may cancel the public `submit` future after the SDK has
792        // exposed `turn_started` but before `submit_claimed` can publish its
793        // ordinary terminal event. Close that exact lifecycle before making
794        // the runtime idle so replay and live frontends cannot remain busy on
795        // an abandoned turn. There is no await between the normal terminal
796        // publication and disarming this fallback, so exactly one terminal
797        // event is observable for every started claim.
798        if self.lifecycle_started {
799            let event = {
800                let mut state = self
801                    .frontend_state
802                    .lock()
803                    .unwrap_or_else(std::sync::PoisonError::into_inner);
804                let event = FrontendEvent::new(
805                    state.next_sequence,
806                    json!({
807                        "type": "turn_interrupted",
808                        "schema_version": FRONTEND_EVENT_SCHEMA_VERSION
809                    }),
810                );
811                state.next_sequence = state.next_sequence.saturating_add(1);
812                state.replay.push_back(event.clone());
813                while state.replay.len() > FRONTEND_REPLAY_CAPACITY {
814                    state.replay.pop_front();
815                }
816                event
817            };
818            let _ = self.frontend_events.send(event);
819        }
820        self.inbox
821            .lock()
822            .unwrap_or_else(std::sync::PoisonError::into_inner)
823            .close();
824        *self
825            .current_cancel
826            .lock()
827            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
828        self.busy.store(false, Ordering::SeqCst);
829        self.turn_finished.notify_waiters();
830        self.turn_finished.notify_one();
831    }
832}
833
834impl RpcEngine {
835    /// Wrap `agent` (already fully built by the caller — same `Config`,
836    /// same permission/sandbox posture as a local session) for out-of-
837    /// process driving. Installs its OWN event sink via
838    /// `Agent::set_event_sink`, overwriting whatever sink `agent` may
839    /// already have had wired (callers of this module drive an agent
840    /// exclusively through the RPC surface, so there is never a second,
841    /// competing consumer of its events).
842    pub fn new(
843        agent: impl Into<SdkAgent>,
844        on_turn_complete: Option<TurnCompleteHook>,
845    ) -> Arc<Self> {
846        let agent = agent.into();
847        let session_id = agent
848            .session_name()
849            .map(str::to_owned)
850            .unwrap_or_else(|| format!("supercode-{}", std::process::id()));
851        Self::new_named(agent, session_id, on_turn_complete)
852    }
853
854    /// Construct the canonical SDK runtime with an explicit durable session
855    /// identity.  Every frontend must use this identity when referring to the
856    /// same live agent; transport-local connection ids are not session ids.
857    pub fn new_named(
858        agent: impl Into<SdkAgent>,
859        session_id: impl Into<String>,
860        on_turn_complete: Option<TurnCompleteHook>,
861    ) -> Arc<Self> {
862        Self::new_named_with_frontend_metadata(
863            agent.into(),
864            session_id,
865            FrontendRuntimeMetadata::default(),
866            on_turn_complete,
867        )
868    }
869
870    /// Construct the canonical runtime with explicit source-harness and
871    /// emulation-profile identity for every attached frontend.
872    pub fn new_named_with_frontend_metadata(
873        agent: impl Into<SdkAgent>,
874        session_id: impl Into<String>,
875        frontend_metadata: FrontendRuntimeMetadata,
876        on_turn_complete: Option<TurnCompleteHook>,
877    ) -> Arc<Self> {
878        Self::build(
879            agent.into(),
880            session_id.into(),
881            frontend_metadata,
882            None,
883            on_turn_complete,
884        )
885    }
886
887    /// Construct a canonical runtime whose attached frontends may answer
888    /// policy-authorized approval requests. Existing constructors retain the
889    /// historical fail-closed headless behavior and report `respond=false`.
890    pub fn new_named_with_frontend_requests(
891        agent: impl Into<SdkAgent>,
892        session_id: impl Into<String>,
893        frontend_metadata: FrontendRuntimeMetadata,
894        on_turn_complete: Option<TurnCompleteHook>,
895    ) -> Arc<Self> {
896        let bridge = FrontendRequestBridge::new();
897        Self::new_named_with_frontend_bridge(
898            agent.into(),
899            session_id,
900            frontend_metadata,
901            bridge,
902            on_turn_complete,
903        )
904    }
905
906    /// Bind a pre-created request bridge after its elicitation handler has
907    /// been installed on MCP clients.
908    pub fn new_named_with_frontend_bridge(
909        agent: impl Into<SdkAgent>,
910        session_id: impl Into<String>,
911        frontend_metadata: FrontendRuntimeMetadata,
912        bridge: FrontendRequestBridge,
913        on_turn_complete: Option<TurnCompleteHook>,
914    ) -> Arc<Self> {
915        Self::build(
916            agent.into(),
917            session_id.into(),
918            frontend_metadata,
919            Some(bridge.broker),
920            on_turn_complete,
921        )
922    }
923
924    fn build(
925        mut agent: SdkAgent,
926        session_id: String,
927        frontend_metadata: FrontendRuntimeMetadata,
928        frontend_requests: Option<Arc<FrontendRequestBroker>>,
929        on_turn_complete: Option<TurnCompleteHook>,
930    ) -> Arc<Self> {
931        let (tx, _rx) = broadcast::channel(SERVER_EVENT_CHANNEL_CAPACITY);
932        let events_tx = tx.clone();
933        let (frontend_tx, _frontend_rx) = broadcast::channel(SERVER_EVENT_CHANNEL_CAPACITY);
934        let frontend_events_tx = frontend_tx.clone();
935        let model = agent.config().model.clone();
936        let steer_queue = agent.inner().steer_queue_handle();
937        let history_snapshot = bounded_history_snapshot(agent.history());
938        let frontend_state = Arc::new(StdMutex::new(FrontendProjectionState {
939            history: history_snapshot.clone(),
940            history_cursor: 0,
941            next_sequence: 1,
942            replay: VecDeque::new(),
943        }));
944        if let Some(broker) = &frontend_requests {
945            broker.bind(frontend_tx.clone(), frontend_state.clone());
946            let legacy_broker = broker.clone();
947            agent
948                .inner_mut()
949                .set_legacy_approval_handler(Box::new(move |call| {
950                    let Ok(raw_args) = call.function.parsed_arguments() else {
951                        return false;
952                    };
953                    let subject = raw_args
954                        .get("command")
955                        .or_else(|| raw_args.get("path"))
956                        .or_else(|| raw_args.get("file_path"))
957                        .or_else(|| raw_args.get("patch"))
958                        .and_then(Value::as_str);
959                    matches!(
960                        legacy_broker.ask_approval(
961                            &ApprovalRequest {
962                                tool: &call.function.name,
963                                subject,
964                                raw_args: &raw_args,
965                            },
966                            None,
967                        ),
968                        ApprovalOutcome::Allow | ApprovalOutcome::AllowForSession
969                    )
970                }));
971            agent
972                .inner_mut()
973                .set_permissions_approval_handler(FrontendApprovalHandler(broker.clone()));
974            // BP-3 (§2 module 6 `tools.question`): `ask_user` asks through
975            // the SAME broker — the request is published into the sequenced
976            // frontend event stream and the turn blocks on it until
977            // `respond` answers, exactly like the approval above. Without
978            // this the tool would be deny-default even on an attached
979            // frontend.
980            agent
981                .inner_mut()
982                .set_user_question_handler(Arc::new(FrontendElicitationHandler(broker.clone())));
983            let broker = broker.clone();
984            agent
985                .inner_mut()
986                .set_child_approval_handler_factory(move |child_agent_id, queue| {
987                    Arc::new(FrontendChildApprovalHandler {
988                        broker: broker.clone(),
989                        child_agent_id,
990                        queue,
991                    }) as Arc<dyn PermissionsApprovalHandler>
992                });
993        }
994        let event_frontend_state = frontend_state.clone();
995        let frontend_active_modules = agent
996            .config()
997            .module_activation
998            .iter()
999            .map(ToString::to_string)
1000            .collect();
1001        let mut frontend_operations = agent
1002            .config()
1003            .prompts
1004            .keys()
1005            .filter(|name| valid_frontend_command_name(name))
1006            .map(|name| FrontendOperationDescriptor {
1007                id: format!("prompt:{name}"),
1008                kind: FrontendOperationKind::Prompt,
1009                command: Some(FrontendCommandDescriptor {
1010                    name: name.clone(),
1011                    description: None,
1012                    argument_hint: Some("[arguments]".into()),
1013                }),
1014            })
1015            .collect::<Vec<_>>();
1016        frontend_operations.sort_by(|left, right| left.id.cmp(&right.id));
1017        // BP-4 (catalog:109): the one operation every runtime can always
1018        // answer — it reads the accounting it already computes for the
1019        // context guard, submits nothing, and mutates nothing. Advertised
1020        // unconditionally for that reason (it is not inferred from a module
1021        // set, which is what the "phantom operation" gate forbids), and
1022        // pushed AFTER the id sort so the prompt catalog's own ordering is
1023        // untouched.
1024        // BP-13 (catalog D9 "Mid-session model switching"): the model
1025        // control, advertised only when this runtime's config actually
1026        // allows a switch — the same "never advertise a phantom operation"
1027        // rule the context row above obeys, read from the one gate
1028        // (`[core.model_switch] allow_switch`) rather than inferred.
1029        if agent.config().model_switch_allow_switch {
1030            frontend_operations.push(FrontendOperationDescriptor {
1031                id: "model:switch".into(),
1032                kind: FrontendOperationKind::Model,
1033                command: Some(FrontendCommandDescriptor {
1034                    name: "model".into(),
1035                    description: Some("show or switch this session's model".into()),
1036                    argument_hint: Some("[model]".into()),
1037                }),
1038            });
1039        }
1040        frontend_operations.push(FrontendOperationDescriptor {
1041            id: "context:usage".into(),
1042            kind: FrontendOperationKind::Context,
1043            command: Some(FrontendCommandDescriptor {
1044                name: "context".into(),
1045                description: Some("context-window usage for this session".into()),
1046                argument_hint: None,
1047            }),
1048        });
1049        // Schema-v1 compatibility projection. New frontends use only the
1050        // typed operation catalog and never fall back to this list.
1051        let frontend_commands = frontend_operations
1052            .iter()
1053            .filter_map(|operation| operation.command.as_ref())
1054            .map(|command| FrontendCommandDescriptor {
1055                name: command.name.clone(),
1056                description: command.description.clone(),
1057                argument_hint: None,
1058            })
1059            .collect();
1060        agent.inner_mut().set_event_sink(Box::new(move |event| {
1061            // A `send` error here only means "no subscriber is currently
1062            // listening" (every receiver dropped) — never a reason to fail
1063            // the turn itself, so it's intentionally discarded.
1064            let payload = event.to_json();
1065            let _ = events_tx.send(payload.clone());
1066            let sequenced = {
1067                let mut state = event_frontend_state
1068                    .lock()
1069                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1070                let event = FrontendEvent::new(state.next_sequence, payload);
1071                state.next_sequence = state.next_sequence.saturating_add(1);
1072                state.replay.push_back(event.clone());
1073                while state.replay.len() > FRONTEND_REPLAY_CAPACITY {
1074                    state.replay.pop_front();
1075                }
1076                event
1077            };
1078            let _ = frontend_events_tx.send(sequenced);
1079        }));
1080        Arc::new(RpcEngine {
1081            agent: Mutex::new(agent),
1082            history_snapshot: RwLock::new(history_snapshot),
1083            session_id,
1084            model,
1085            busy: Arc::new(AtomicBool::new(false)),
1086            current_cancel: Arc::new(StdMutex::new(None)),
1087            turn_finished: Arc::new(Notify::new()),
1088            steer_queue,
1089            events: tx,
1090            frontend_events: frontend_tx,
1091            frontend_state,
1092            frontend_metadata,
1093            frontend_active_modules,
1094            frontend_commands,
1095            frontend_operations,
1096            frontend_requests,
1097            shutdown: Notify::new(),
1098            shutting_down: AtomicBool::new(false),
1099            accepting_submits: AtomicBool::new(true),
1100            shutdown_barrier: Mutex::new(()),
1101            on_turn_complete,
1102        })
1103    }
1104
1105    /// Subscribe to this engine's event-notification stream (already
1106    /// `AgentEvent::to_json`-projected) — each subscriber gets every event
1107    /// emitted from this point on, independent of any other subscriber.
1108    pub fn subscribe(&self) -> broadcast::Receiver<Value> {
1109        self.events.subscribe()
1110    }
1111
1112    /// Describe the SDK-owned runtime without locking the active agent turn.
1113    pub fn frontend_descriptor(&self) -> FrontendRuntimeDescriptor {
1114        FrontendRuntimeDescriptor {
1115            schema_version: FRONTEND_RUNTIME_SCHEMA_VERSION,
1116            session_id: self.session_id.clone(),
1117            source_harness: self.frontend_metadata.source_harness.clone(),
1118            emulation_profile: self.frontend_metadata.emulation_profile.clone(),
1119            active_modules: self.frontend_active_modules.clone(),
1120            commands: self.frontend_commands.clone(),
1121            operations: self.frontend_operations.clone(),
1122            actions: FrontendActions {
1123                submit: true,
1124                interrupt: true,
1125                steer: true,
1126                respond: self.frontend_requests.is_some(),
1127                detach: true,
1128                // The canonical runtime supports close. Coordinated client
1129                // projections mask this unless the authenticated grant owns
1130                // the independent terminate capability.
1131                close: true,
1132            },
1133            display: FrontendDisplayCapabilities {
1134                event_kinds: vec![
1135                    "user_message".into(),
1136                    "turn_started".into(),
1137                    "turn_succeeded".into(),
1138                    "turn_interrupted".into(),
1139                    "turn_failed".into(),
1140                    "text_delta".into(),
1141                    "turn_completed".into(),
1142                    "tool_call_started".into(),
1143                    "tool_call_completed".into(),
1144                    "cache_warning".into(),
1145                    "usage".into(),
1146                    "background_output".into(),
1147                    "request".into(),
1148                    "request_resolved".into(),
1149                    "scheduled_prompt_started".into(),
1150                    "scheduled_prompt_deferred".into(),
1151                    "scheduled_prompt_completed".into(),
1152                    "scheduler_error".into(),
1153                ],
1154                opaque_fallback: true,
1155            },
1156            model: self.model.clone(),
1157            turn_state: if self.busy.load(Ordering::SeqCst) {
1158                FrontendTurnState::Busy
1159            } else {
1160                FrontendTurnState::Idle
1161            },
1162            connection_state: if self.is_shutting_down() {
1163                FrontendConnectionState::ShuttingDown
1164            } else {
1165                FrontendConnectionState::Connected
1166            },
1167            extensions: Default::default(),
1168        }
1169    }
1170
1171    /// Attach to one atomic history/replay/live boundary. The live receiver
1172    /// is created before the projection snapshot is locked; events racing the
1173    /// snapshot therefore appear either in replay or in the receiver, and
1174    /// [`FrontendAttachment::next_event`] removes any overlap by sequence.
1175    pub fn frontend_attach(
1176        &self,
1177        history_limit: usize,
1178    ) -> Result<FrontendAttachment, FrontendRuntimeError> {
1179        let live = self.frontend_subscribe();
1180        let snapshot = self.frontend_snapshot(history_limit)?;
1181        Ok(FrontendAttachment::new(
1182            snapshot.descriptor,
1183            snapshot.history,
1184            snapshot.history_cursor,
1185            snapshot.replay,
1186            live,
1187            None,
1188        ))
1189    }
1190
1191    /// Subscribe to sequenced frontend events. Transport adapters subscribe
1192    /// before taking [`Self::frontend_snapshot`] so boundary events cannot be
1193    /// missed.
1194    pub fn frontend_subscribe(&self) -> broadcast::Receiver<FrontendEvent> {
1195        self.frontend_events.subscribe()
1196    }
1197
1198    /// Capture the serializable history/replay half of a frontend attachment.
1199    pub fn frontend_snapshot(
1200        &self,
1201        history_limit: usize,
1202    ) -> Result<FrontendAttachSnapshot, FrontendRuntimeError> {
1203        let state = self
1204            .frontend_state
1205            .lock()
1206            .unwrap_or_else(std::sync::PoisonError::into_inner);
1207        let limit = history_limit.min(SERVER_HISTORY_CAPACITY);
1208        let start = state.history.len().saturating_sub(limit);
1209        let replay = state
1210            .replay
1211            .iter()
1212            .filter(|event| event.sequence > state.history_cursor)
1213            .cloned()
1214            .collect::<VecDeque<_>>();
1215        if let Some(first) = replay.front() {
1216            let expected = state.history_cursor.saturating_add(1);
1217            if first.sequence > expected {
1218                return Err(FrontendRuntimeError::ReplayGap(first.sequence - expected));
1219            }
1220        }
1221        Ok(FrontendAttachSnapshot {
1222            descriptor: self.frontend_descriptor(),
1223            history: state.history[start..].to_vec(),
1224            history_cursor: state.history_cursor,
1225            replay,
1226        })
1227    }
1228
1229    fn publish_frontend_payload(&self, payload: Value) {
1230        let event = {
1231            let mut state = self
1232                .frontend_state
1233                .lock()
1234                .unwrap_or_else(std::sync::PoisonError::into_inner);
1235            let event = FrontendEvent::new(state.next_sequence, payload);
1236            state.next_sequence = state.next_sequence.saturating_add(1);
1237            state.replay.push_back(event.clone());
1238            while state.replay.len() > FRONTEND_REPLAY_CAPACITY {
1239                state.replay.pop_front();
1240            }
1241            event
1242        };
1243        let _ = self.frontend_events.send(event);
1244    }
1245
1246    /// Stable SDK session identity shared by every frontend.
1247    pub fn session_id(&self) -> &str {
1248        &self.session_id
1249    }
1250
1251    fn claim_submit(&self) -> Result<SdkSubmitClaim, RuntimeSubmitError> {
1252        // Hold the cancellation slot across admission. Shutdown seals first,
1253        // then takes this same lock through `interrupt`: it therefore either
1254        // wins before `busy` is claimed or observes the admitted turn's
1255        // installed token. There is no busy-without-cancel interval.
1256        let mut current_cancel = self
1257            .current_cancel
1258            .lock()
1259            .unwrap_or_else(std::sync::PoisonError::into_inner);
1260        if !self.accepting_submits.load(Ordering::SeqCst) {
1261            return Err(RuntimeSubmitError::Interrupted);
1262        }
1263        if self.busy.swap(true, Ordering::SeqCst) {
1264            return Err(RuntimeSubmitError::Busy);
1265        }
1266        // Close the race with a concurrent shutdown between the first state
1267        // check and ownership of `busy`; relinquish this claim without
1268        // exposing a turn when the shutdown seal won.
1269        if !self.accepting_submits.load(Ordering::SeqCst) {
1270            self.busy.store(false, Ordering::SeqCst);
1271            self.turn_finished.notify_waiters();
1272            return Err(RuntimeSubmitError::Interrupted);
1273        }
1274        let cancel = Arc::new(Notify::new());
1275        *current_cancel = Some(cancel.clone());
1276        drop(current_cancel);
1277        self.steer_queue
1278            .lock()
1279            .unwrap_or_else(std::sync::PoisonError::into_inner)
1280            .open();
1281        Ok(SdkSubmitClaim {
1282            inbox: self.steer_queue.clone(),
1283            busy: self.busy.clone(),
1284            cancel,
1285            current_cancel: self.current_cancel.clone(),
1286            turn_finished: self.turn_finished.clone(),
1287            frontend_events: self.frontend_events.clone(),
1288            frontend_state: self.frontend_state.clone(),
1289            lifecycle_started: false,
1290        })
1291    }
1292
1293    async fn submit_claimed(
1294        &self,
1295        prompt: String,
1296        image_urls: Vec<String>,
1297        mut submit_claim: SdkSubmitClaim,
1298    ) -> Result<String, RuntimeSubmitError> {
1299        let cancel = submit_claim.cancel.clone();
1300        self.publish_frontend_payload(json!({"type": "user_message", "text": &prompt}));
1301        self.publish_frontend_payload(json!({
1302            "type": "turn_started",
1303            "schema_version": FRONTEND_EVENT_SCHEMA_VERSION
1304        }));
1305        submit_claim.mark_lifecycle_started();
1306        let outcome = {
1307            let mut agent = self.agent.lock().await;
1308            let result = tokio::select! {
1309                biased;
1310                _ = cancel.notified() => Err(RuntimeSubmitError::Interrupted),
1311                result = async {
1312                    if image_urls.is_empty() {
1313                        agent.inner_mut().send(&prompt).await
1314                    } else {
1315                        agent.inner_mut().send_with_images(&prompt, &image_urls).await
1316                    }
1317                } => result.map_err(|error| RuntimeSubmitError::Agent(error.to_string())),
1318            };
1319            if result.is_ok() {
1320                if let Some(hook) = &self.on_turn_complete {
1321                    hook(&agent);
1322                }
1323            }
1324            // `Agent::send` is cancellation-safe at await boundaries. Publish
1325            // its latest well-formed history on success, failure, or
1326            // interruption without making attach readers wait on `agent`.
1327            let history = bounded_history_snapshot(agent.history());
1328            *self.history_snapshot.write().await = history.clone();
1329            let mut state = self
1330                .frontend_state
1331                .lock()
1332                .unwrap_or_else(std::sync::PoisonError::into_inner);
1333            state.history = history;
1334            state.history_cursor = state.next_sequence.saturating_sub(1);
1335            // Canonical ChatMessage history represents user/model/tool
1336            // content, but not interactive frontend requests or the human's
1337            // typed decision. Re-sequence those semantic events immediately
1338            // after the history boundary so later attachments retain a
1339            // resolved transcript without replaying ordinary turn events
1340            // already represented by `history`.
1341            let request_history = compact_frontend_request_history(&state.replay);
1342            state.replay.clear();
1343            for payload in request_history {
1344                let event = FrontendEvent::new(state.next_sequence, payload);
1345                state.next_sequence = state.next_sequence.saturating_add(1);
1346                state.replay.push_back(event);
1347                while state.replay.len() > FRONTEND_REPLAY_CAPACITY {
1348                    state.replay.pop_front();
1349                }
1350            }
1351            result
1352        };
1353        *self
1354            .current_cancel
1355            .lock()
1356            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
1357        let lifecycle = match &outcome {
1358            Ok(reply) => json!({
1359                "type": "turn_succeeded",
1360                "schema_version": FRONTEND_EVENT_SCHEMA_VERSION,
1361                "reply": reply,
1362            }),
1363            Err(RuntimeSubmitError::Interrupted) => json!({
1364                "type": "turn_interrupted",
1365                "schema_version": FRONTEND_EVENT_SCHEMA_VERSION
1366            }),
1367            Err(error) => json!({
1368                "type": "turn_failed",
1369                "schema_version": FRONTEND_EVENT_SCHEMA_VERSION,
1370                "message": error.to_string()
1371            }),
1372        };
1373        self.publish_frontend_payload(lifecycle);
1374        submit_claim.mark_lifecycle_finished();
1375        // Close before publishing idle. This is redundant with the agent's
1376        // normal final-boundary close, but is authoritative for pre-loop
1377        // validation/record failures and biased immediate interruption.
1378        drop(submit_claim);
1379        outcome
1380    }
1381
1382    /// Submit one prompt through the canonical runtime and wait for its reply.
1383    pub async fn submit(&self, prompt: impl Into<String>) -> Result<String, RuntimeSubmitError> {
1384        let submit_claim = self.claim_submit()?;
1385        self.submit_claimed(prompt.into(), Vec::new(), submit_claim)
1386            .await
1387    }
1388
1389    /// Submit one prompt with runtime-owned multimodal image inputs.
1390    pub async fn submit_with_images(
1391        &self,
1392        prompt: impl Into<String>,
1393        image_urls: Vec<String>,
1394    ) -> Result<String, RuntimeSubmitError> {
1395        let submit_claim = self.claim_submit()?;
1396        self.submit_claimed(prompt.into(), image_urls, submit_claim)
1397            .await
1398    }
1399
1400    /// Atomically claim one prompt, then run it on the SDK owner while the
1401    /// caller consumes the canonical event stream.
1402    pub fn send_input(self: &Arc<Self>, prompt: String) -> Result<(), RuntimeSubmitError> {
1403        self.send_input_with_images(prompt, Vec::new())
1404    }
1405
1406    /// Atomically claim one multimodal prompt, then run it while callers
1407    /// consume the canonical event stream.
1408    pub fn send_input_with_images(
1409        self: &Arc<Self>,
1410        prompt: String,
1411        image_urls: Vec<String>,
1412    ) -> Result<(), RuntimeSubmitError> {
1413        let submit_claim = self.claim_submit()?;
1414        let runtime = self.clone();
1415        tokio::spawn(async move {
1416            let _ = runtime
1417                .submit_claimed(prompt, image_urls, submit_claim)
1418                .await;
1419        });
1420        Ok(())
1421    }
1422
1423    /// Queue steering for the active agent loop without waiting for its
1424    /// long-held async lock. The agent consumes it at the next model-loop
1425    /// boundary according to the configured steering mode.
1426    pub fn steer(&self, prompt: impl Into<String>) -> Result<(), FrontendRuntimeError> {
1427        let accepted = self
1428            .steer_queue
1429            .lock()
1430            .unwrap_or_else(std::sync::PoisonError::into_inner)
1431            .enqueue(prompt.into());
1432        if accepted {
1433            Ok(())
1434        } else {
1435            Err(FrontendRuntimeError::UnsupportedAction("steer"))
1436        }
1437    }
1438
1439    /// Resolve one pending interactive request exactly once.
1440    pub fn respond(&self, response: FrontendResponse) -> Result<(), FrontendRuntimeError> {
1441        self.frontend_requests
1442            .as_ref()
1443            .ok_or(FrontendRuntimeError::UnsupportedAction("respond"))?
1444            .respond(response)
1445    }
1446
1447    /// Invoke one operation after resolving its opaque identifier solely
1448    /// against the trusted catalog captured at runtime construction.
1449    pub async fn invoke(
1450        &self,
1451        operation: FrontendOperationInvocation,
1452    ) -> Result<FrontendOperationResult, FrontendRuntimeError> {
1453        match operation {
1454            FrontendOperationInvocation::Prompt {
1455                operation_id,
1456                arguments,
1457            } => {
1458                let prompt_name = self
1459                    .frontend_operations
1460                    .iter()
1461                    .find(|descriptor| {
1462                        descriptor.id == operation_id
1463                            && descriptor.kind == FrontendOperationKind::Prompt
1464                    })
1465                    .and_then(|descriptor| descriptor.command.as_ref())
1466                    .map(|command| command.name.as_str())
1467                    .ok_or_else(|| {
1468                        FrontendRuntimeError::UnsupportedOperation(operation_id.clone())
1469                    })?;
1470                let prompt = if arguments.is_empty() {
1471                    format!("/{prompt_name}")
1472                } else {
1473                    format!("/{prompt_name} {arguments}")
1474                };
1475                let reply = self.submit(prompt).await?;
1476                Ok(FrontendOperationResult::Prompt { reply })
1477            }
1478            FrontendOperationInvocation::Context { operation_id } => {
1479                if !self.frontend_operations.iter().any(|descriptor| {
1480                    descriptor.id == operation_id
1481                        && descriptor.kind == FrontendOperationKind::Context
1482                }) {
1483                    return Err(FrontendRuntimeError::UnsupportedOperation(operation_id));
1484                }
1485                // Short-held read lock: `context_usage` is pure and never
1486                // touches the provider, so this can never be held across
1487                // model I/O.
1488                let usage = self.agent.lock().await.context_usage();
1489                Ok(FrontendOperationResult::Context { usage })
1490            }
1491            FrontendOperationInvocation::Model {
1492                operation_id,
1493                model,
1494            } => {
1495                if !self.frontend_operations.iter().any(|descriptor| {
1496                    descriptor.id == operation_id && descriptor.kind == FrontendOperationKind::Model
1497                }) {
1498                    return Err(FrontendRuntimeError::UnsupportedOperation(operation_id));
1499                }
1500                let mut agent = self.agent.lock().await;
1501                let previous = agent.model().to_string();
1502                if model.trim().is_empty() {
1503                    return Ok(FrontendOperationResult::Model {
1504                        model: previous.clone(),
1505                        previous,
1506                    });
1507                }
1508                // BP-13: the ONE resolution path — the same routing table
1509                // `--model` and the request build read, so a friendly name
1510                // typed here means exactly what it means everywhere else.
1511                let resolved = agent
1512                    .config()
1513                    .model_routing
1514                    .resolve_alias(model.trim())
1515                    .to_string();
1516                if let Some(refusal) = agent.config().model_routing.refusal(&resolved) {
1517                    return Err(FrontendRuntimeError::UnsupportedOperation(refusal));
1518                }
1519                // `switch_model`, never `set_model`: the governed switch
1520                // filters model-A reasoning artifacts out of the live
1521                // history (dep 8) and records the change in the session
1522                // journal before model B ever builds a request from it.
1523                agent.switch_model(resolved.clone());
1524                Ok(FrontendOperationResult::Model {
1525                    model: resolved,
1526                    previous,
1527                })
1528            }
1529        }
1530    }
1531
1532    /// Cancel the current turn through the shared runtime handle.
1533    pub async fn interrupt(&self) -> bool {
1534        let cancel = self
1535            .current_cancel
1536            .lock()
1537            .unwrap_or_else(std::sync::PoisonError::into_inner)
1538            .clone();
1539        match cancel {
1540            Some(cancel) => {
1541                cancel.notify_one();
1542                true
1543            }
1544            None => false,
1545        }
1546    }
1547
1548    /// Return a lock-free runtime snapshot, including while a turn is active.
1549    pub fn status(&self) -> RuntimeStatus {
1550        RuntimeStatus {
1551            session_id: self.session_id.clone(),
1552            model: self.model.clone(),
1553            busy: self.busy.load(Ordering::SeqCst),
1554            shutting_down: self.is_shutting_down(),
1555        }
1556    }
1557
1558    /// Return the tail of the canonical conversation for newly attached
1559    /// frontends. This is SDK state, not a transport-local replay buffer, and
1560    /// remains answerable while a turn owns the agent lock.
1561    pub async fn history(&self, limit: usize) -> Vec<ChatMessage> {
1562        let history = self.history_snapshot.read().await;
1563        let start = history.len().saturating_sub(limit);
1564        history[start..].to_vec()
1565    }
1566
1567    /// Run one caller-owned finalization projection while holding the agent
1568    /// at a quiescent boundary. This is the persistence/inspection seam for
1569    /// local frontends that transfer `Agent` ownership into the SDK runtime;
1570    /// it does not expose a second way to drive the model loop.
1571    pub async fn finalize_with<R>(&self, finalize: impl FnOnce(&SdkAgent) -> R) -> R {
1572        let agent = self.agent.lock().await;
1573        finalize(&agent)
1574    }
1575
1576    /// Request graceful runtime shutdown.  All connected frontends observe
1577    /// the same transition and any in-flight turn is interrupted.
1578    pub async fn shutdown(&self) {
1579        let _barrier = self.shutdown_barrier.lock().await;
1580        // Seal first so no frontend can claim replacement work while the
1581        // active claim is interrupted. Signal transports only after its
1582        // terminal lifecycle is published, so SSE observers receive that
1583        // boundary before their connections close.
1584        self.accepting_submits.store(false, Ordering::SeqCst);
1585        self.interrupt().await;
1586        loop {
1587            let finished = self.turn_finished.notified();
1588            if !self.busy.load(Ordering::SeqCst) {
1589                break;
1590            }
1591            finished.await;
1592        }
1593        self.signal_shutdown();
1594    }
1595
1596    /// Whether `shutdown` has been requested — callers use this to stop
1597    /// accepting new work/connections.
1598    pub fn is_shutting_down(&self) -> bool {
1599        self.shutting_down.load(Ordering::SeqCst)
1600    }
1601
1602    /// Resolves once `shutdown` has been requested. Cheap to call
1603    /// repeatedly/concurrently — every waiter is woken.
1604    pub async fn wait_for_shutdown(&self) {
1605        // `shutting_down` may already be `true` by the time a caller starts
1606        // waiting (e.g. a connection accepted right after `shutdown` fired)
1607        // — check first so this never blocks forever on a signal that
1608        // already happened.
1609        if self.is_shutting_down() {
1610            return;
1611        }
1612        self.shutdown.notified().await;
1613    }
1614
1615    /// Flip `shutting_down` and wake every [`Self::wait_for_shutdown`]
1616    /// waiter — factored out of [`Self::handle_shutdown`] so [`run_stdio`]
1617    /// can raise the EXACT same signal on its OTHER termination path
1618    /// (`reader` hitting EOF with no explicit `shutdown` RPC) without
1619    /// duplicating the store-then-notify sequence. Deliberately does NOT
1620    /// touch `current_cancel` (unlike [`Self::handle_shutdown`], which also
1621    /// interrupts an in-flight turn) — a plain stdio EOF should let an
1622    /// already-accepted turn run to completion and flush its reply, not cut
1623    /// it off.
1624    fn signal_shutdown(&self) {
1625        self.shutting_down.store(true, Ordering::SeqCst);
1626        self.shutdown.notify_waiters();
1627    }
1628
1629    /// Dispatch one already-parsed [`RpcRequest`] to the right method
1630    /// handler. Every recognized method is fully wired to real `Agent`
1631    /// behavior — there is no method that parses but no-ops.
1632    pub async fn handle_request(self: &Arc<Self>, req: RpcRequest) -> Value {
1633        match req.method.as_str() {
1634            "submit" => self.handle_submit(req).await,
1635            "frontend.send_input" => self.handle_frontend_send_input(req),
1636            "interrupt" => self.handle_interrupt(req).await,
1637            "steer" => self.handle_steer(req),
1638            "respond" => self.handle_respond(req),
1639            "status" => self.handle_status(req).await,
1640            "history" => self.handle_history(req).await,
1641            "frontend.describe" => self.handle_frontend_describe(req),
1642            "frontend.attach" => self.handle_frontend_attach(req),
1643            "frontend.invoke" => self.handle_frontend_invoke(req).await,
1644            "shutdown" => self.handle_shutdown(req).await,
1645            other => rpc_error(req.id, -32601, format!("unknown method `{other}`")),
1646        }
1647    }
1648
1649    fn handle_frontend_send_input(self: &Arc<Self>, req: RpcRequest) -> Value {
1650        let Some(prompt) = req.params.get("prompt").and_then(Value::as_str) else {
1651            return rpc_error(
1652                req.id,
1653                -32602,
1654                "frontend.send_input requires a string `params.prompt`",
1655            );
1656        };
1657        let image_urls = match parse_image_urls(&req.params, "frontend.send_input") {
1658            Ok(image_urls) => image_urls,
1659            Err(message) => return rpc_error(req.id, -32602, message),
1660        };
1661        match self.send_input_with_images(prompt.to_string(), image_urls) {
1662            Ok(()) => rpc_ok(req.id, json!({"accepted": true})),
1663            Err(RuntimeSubmitError::Busy) => {
1664                rpc_error(req.id, -32000, "a turn is already in progress")
1665            }
1666            Err(RuntimeSubmitError::Interrupted) => rpc_error(req.id, -32001, "turn interrupted"),
1667            Err(RuntimeSubmitError::Agent(error)) => rpc_error(req.id, -32002, error),
1668        }
1669    }
1670
1671    /// `submit`: drive one turn (`Agent::send`) with `params.prompt`.
1672    /// Refuses (fail, never queues) a second `submit` while one is already
1673    /// in flight — "no method that no-ops": a caller either gets a real
1674    /// answer or an explicit "busy" error, never a silently-dropped
1675    /// request. Races the turn against this call's own fresh cancellation
1676    /// handle so a concurrent `interrupt` can drop it mid-flight — the
1677    /// SAME cancel-safety the CLI's own `race_ctrl_c` relies on
1678    /// (`Agent::send`/`run_loop` only ever mutate `history`/the sidecar
1679    /// BETWEEN `.await` points, never during one, so dropping the future
1680    /// mid-poll always lands in a well-formed place).
1681    async fn handle_submit(&self, req: RpcRequest) -> Value {
1682        let Some(prompt) = req.params.get("prompt").and_then(|v| v.as_str()) else {
1683            return rpc_error(req.id, -32602, "submit requires a string `params.prompt`");
1684        };
1685        match self.submit(prompt).await {
1686            Ok(reply) => rpc_ok(req.id, json!({"reply": reply})),
1687            Err(RuntimeSubmitError::Busy) => rpc_error(
1688                req.id,
1689                -32000,
1690                "a turn is already in progress; `interrupt` it or wait for its response before submitting another",
1691            ),
1692            Err(RuntimeSubmitError::Interrupted) => {
1693                rpc_error(req.id, -32001, "turn interrupted")
1694            }
1695            Err(RuntimeSubmitError::Agent(error)) => rpc_error(req.id, -32002, error),
1696        }
1697    }
1698
1699    /// `interrupt`: cancel the in-flight turn, if any. A no-op-but-honest
1700    /// `{"interrupted": false}` (never an error) when nothing is running —
1701    /// calling `interrupt` with no turn in flight is a normal, harmless
1702    /// race a client can't always avoid (it may not know yet that the
1703    /// previous `submit` just finished).
1704    async fn handle_interrupt(&self, req: RpcRequest) -> Value {
1705        if self.interrupt().await {
1706            rpc_ok(req.id, json!({"interrupted": true}))
1707        } else {
1708            rpc_ok(
1709                req.id,
1710                json!({"interrupted": false, "reason": "no turn in progress"}),
1711            )
1712        }
1713    }
1714
1715    /// `steer`: enqueue an instruction for the next model-loop boundary.
1716    /// This remains responsive while `submit` owns the active agent lock.
1717    fn handle_steer(&self, req: RpcRequest) -> Value {
1718        let Some(prompt) = req.params.get("prompt").and_then(Value::as_str) else {
1719            return rpc_error(req.id, -32602, "steer requires a string `params.prompt`");
1720        };
1721        match self.steer(prompt) {
1722            Ok(()) => rpc_ok(req.id, json!({"queued": true})),
1723            Err(error) => rpc_error(req.id, -32020, error.to_string()),
1724        }
1725    }
1726
1727    fn handle_respond(&self, req: RpcRequest) -> Value {
1728        let response = match req.params.get("response").cloned() {
1729            Some(value) => match serde_json::from_value::<FrontendResponse>(value) {
1730                Ok(response) => response,
1731                Err(error) => return rpc_error(req.id, -32602, error.to_string()),
1732            },
1733            None => return rpc_error(req.id, -32602, "respond requires `params.response`"),
1734        };
1735        match self.respond(response) {
1736            Ok(()) => rpc_ok(req.id, json!({"accepted": true})),
1737            Err(FrontendRuntimeError::UnsupportedAction(_)) => {
1738                rpc_error(req.id, -32020, "frontend respond is not enabled")
1739            }
1740            Err(FrontendRuntimeError::UnknownRequest(id)) => rpc_error(
1741                req.id,
1742                -32021,
1743                format!("frontend request {id} is not pending"),
1744            ),
1745            Err(error) => rpc_error(req.id, -32022, error.to_string()),
1746        }
1747    }
1748
1749    /// `status`: current busy/idle state + the model label. Deliberately
1750    /// never locks `agent` (see [`Self::model`]'s doc comment) — answerable
1751    /// even while a turn is running.
1752    async fn handle_status(&self, req: RpcRequest) -> Value {
1753        rpc_ok(
1754            req.id,
1755            serde_json::to_value(self.status()).unwrap_or_default(),
1756        )
1757    }
1758
1759    /// `history`: bounded canonical transcript replay for a frontend that
1760    /// attached after earlier events were emitted.
1761    async fn handle_history(&self, req: RpcRequest) -> Value {
1762        let limit = req
1763            .params
1764            .get("limit")
1765            .and_then(Value::as_u64)
1766            .unwrap_or(50)
1767            .clamp(1, SERVER_HISTORY_CAPACITY as u64) as usize;
1768        rpc_ok(req.id, json!({"messages": self.history(limit).await}))
1769    }
1770
1771    fn handle_frontend_describe(&self, req: RpcRequest) -> Value {
1772        rpc_ok(
1773            req.id,
1774            serde_json::to_value(self.frontend_descriptor()).unwrap_or_default(),
1775        )
1776    }
1777
1778    fn handle_frontend_attach(&self, req: RpcRequest) -> Value {
1779        let limit = req
1780            .params
1781            .get("limit")
1782            .and_then(Value::as_u64)
1783            .unwrap_or(50)
1784            .clamp(1, SERVER_HISTORY_CAPACITY as u64) as usize;
1785        match self.frontend_snapshot(limit) {
1786            Ok(snapshot) => rpc_ok(req.id, serde_json::to_value(snapshot).unwrap_or_default()),
1787            Err(error) => rpc_error(req.id, -32010, error.to_string()),
1788        }
1789    }
1790
1791    async fn handle_frontend_invoke(&self, req: RpcRequest) -> Value {
1792        let operation = match req.params.get("operation").cloned() {
1793            Some(value) => match serde_json::from_value::<FrontendOperationInvocation>(value) {
1794                Ok(operation) => operation,
1795                Err(error) => return rpc_error(req.id, -32602, error.to_string()),
1796            },
1797            None => {
1798                return rpc_error(
1799                    req.id,
1800                    -32602,
1801                    "frontend.invoke requires `params.operation`",
1802                )
1803            }
1804        };
1805        match self.invoke(operation).await {
1806            Ok(result) => rpc_ok(req.id, serde_json::to_value(result).unwrap_or_default()),
1807            Err(FrontendRuntimeError::UnsupportedOperation(id)) => rpc_error(
1808                req.id,
1809                -32023,
1810                FrontendRuntimeError::UnsupportedOperation(id).to_string(),
1811            ),
1812            Err(FrontendRuntimeError::Submit(RuntimeSubmitError::Busy)) => {
1813                rpc_error(req.id, -32000, "a turn is already in progress")
1814            }
1815            Err(error) => rpc_error(req.id, -32022, error.to_string()),
1816        }
1817    }
1818
1819    /// `shutdown`: request graceful teardown — interrupts any in-flight
1820    /// turn (never leaves a caller hanging on a `submit` that will now
1821    /// never get a transport to answer on) and wakes every
1822    /// [`Self::wait_for_shutdown`] waiter (both transports' accept/read
1823    /// loops select on it, so neither survives as an orphan).
1824    async fn handle_shutdown(&self, req: RpcRequest) -> Value {
1825        self.shutdown().await;
1826        rpc_ok(req.id, json!({"shutting_down": true}))
1827    }
1828}
1829
1830/// Retain one canonical request and at most one resolution for every runtime
1831/// request id. Request history is copied across ordinary ChatMessage snapshot
1832/// boundaries, so blindly copying the prior replay would duplicate it on
1833/// every turn and could eventually leave a later attachment on a stale
1834/// duplicate request. Stable id order and request-before-resolution ordering
1835/// make the reconstructed semantic transcript deterministic.
1836fn compact_frontend_request_history(replay: &VecDeque<FrontendEvent>) -> Vec<Value> {
1837    let mut by_id: BTreeMap<u64, (Option<Value>, Option<Value>)> = BTreeMap::new();
1838    for event in replay {
1839        let (request_id, resolved) = match event.kind.as_str() {
1840            "request" => (
1841                event.payload.pointer("/request/id").and_then(Value::as_u64),
1842                false,
1843            ),
1844            "request_resolved" => (
1845                event.payload.get("request_id").and_then(Value::as_u64),
1846                true,
1847            ),
1848            _ => continue,
1849        };
1850        let Some(request_id) = request_id else {
1851            continue;
1852        };
1853        let entry = by_id.entry(request_id).or_default();
1854        let slot = if resolved { &mut entry.1 } else { &mut entry.0 };
1855        slot.get_or_insert_with(|| event.payload.clone());
1856    }
1857    by_id
1858        .into_values()
1859        .flat_map(|(request, resolution)| request.into_iter().chain(resolution))
1860        .collect()
1861}
1862
1863fn valid_frontend_command_name(name: &str) -> bool {
1864    !name.is_empty()
1865        && !name.starts_with('/')
1866        && name
1867            .chars()
1868            .all(|character| !character.is_whitespace() && !character.is_control())
1869}
1870
1871fn bounded_history_snapshot(history: &[ChatMessage]) -> Vec<ChatMessage> {
1872    let start = history.len().saturating_sub(SERVER_HISTORY_CAPACITY);
1873    history[start..].to_vec()
1874}
1875
1876/// Drive the JSONL-RPC protocol over `reader`/`writer` (the stdio rung —
1877/// parent-process-trusted, no auth token; see the module doc). Each
1878/// request line is dispatched on its OWN spawned task so a `submit`
1879/// in-flight never blocks the reader from picking up a subsequent
1880/// `interrupt`/`status` line — every outgoing line (a response OR an event
1881/// notification) is funneled through one mpsc channel into a single writer
1882/// task, so two concurrent handlers can never interleave a line's bytes.
1883/// Returns once `reader` hits EOF or a `shutdown` request lands.
1884///
1885/// Both termination paths raise `RpcEngine::signal_shutdown` (EOF does it
1886/// directly here; the `shutdown` RPC does it inside
1887/// `RpcEngine::handle_shutdown`), and `event_task` below SELECTS against
1888/// [`RpcEngine::wait_for_shutdown`] rather than merely looping on
1889/// `events.recv()`. This is deliberate: `events.recv()` alone only ends via
1890/// `RecvError::Closed`, which fires only once EVERY clone of
1891/// `engine.events` (the broadcast `Sender`) has dropped — and `engine`
1892/// itself, which keeps that `Sender` alive, is owned by THIS function for
1893/// its whole body. Waiting on `events.recv()` to close would therefore mean
1894/// waiting on `engine` to drop, which can't happen until `event_task`
1895/// itself finishes — a circular wait that never resolves (the bug this fn
1896/// exists to fix). Selecting on the shutdown signal instead lets
1897/// `event_task` end WITHOUT needing `engine`'s refcount to reach zero, so
1898/// there is no orphaned task and no leaked `engine`/`writer_task` blocking
1899/// on it in turn.
1900#[cfg(feature = "adapter-api")]
1901pub async fn run_stdio<R, W>(engine: Arc<RpcEngine>, reader: R, writer: W) -> std::io::Result<()>
1902where
1903    R: AsyncBufRead + Unpin + Send + 'static,
1904    W: AsyncWrite + Unpin + Send + 'static,
1905{
1906    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Value>();
1907
1908    let writer_task = tokio::spawn(async move {
1909        let mut writer = writer;
1910        while let Some(v) = out_rx.recv().await {
1911            let line = format!("{v}\n");
1912            if writer.write_all(line.as_bytes()).await.is_err() {
1913                break;
1914            }
1915            if writer.flush().await.is_err() {
1916                break;
1917            }
1918        }
1919    });
1920
1921    let mut events = engine.subscribe();
1922    let evt_tx = out_tx.clone();
1923    let evt_engine = engine.clone();
1924    let event_task = tokio::spawn(async move {
1925        loop {
1926            tokio::select! {
1927                biased;
1928                _ = evt_engine.wait_for_shutdown() => break,
1929                recv = events.recv() => {
1930                    match recv {
1931                        Ok(v) => {
1932                            if evt_tx.send(json!({"event": v})).is_err() {
1933                                break;
1934                            }
1935                        }
1936                        Err(broadcast::error::RecvError::Lagged(_)) => continue,
1937                        Err(broadcast::error::RecvError::Closed) => break,
1938                    }
1939                }
1940            }
1941        }
1942    });
1943
1944    let mut reader = reader;
1945    loop {
1946        if engine.is_shutting_down() {
1947            break;
1948        }
1949        tokio::select! {
1950            biased;
1951            _ = engine.wait_for_shutdown() => break,
1952            line = read_bounded_line(&mut reader, SERVER_MAX_LINE_BYTES) => {
1953                match line {
1954                    Ok(None) => {
1955                        // EOF: no explicit `shutdown` RPC landed, but stdin
1956                        // closing is this fn's OTHER documented
1957                        // termination signal — raise the same shutdown
1958                        // signal `event_task` (and any other
1959                        // `wait_for_shutdown` caller) already knows how to
1960                        // watch for, so teardown below actually completes
1961                        // instead of blocking forever on `event_task`.
1962                        engine.signal_shutdown();
1963                        break;
1964                    }
1965                    Ok(Some(text)) => {
1966                        let text = text.trim();
1967                        if text.is_empty() {
1968                            continue;
1969                        }
1970                        match serde_json::from_str::<RpcRequest>(text) {
1971                            Ok(req) => {
1972                                let engine = engine.clone();
1973                                let out_tx = out_tx.clone();
1974                                tokio::spawn(async move {
1975                                    let resp = engine.handle_request(req).await;
1976                                    let _ = out_tx.send(resp);
1977                                });
1978                            }
1979                            Err(e) => {
1980                                let _ = out_tx.send(rpc_error(Value::Null, -32700, format!("parse error: {e}")));
1981                            }
1982                        }
1983                    }
1984                    Err(e) => {
1985                        let _ = out_tx.send(rpc_error(Value::Null, -32700, format!("{e}")));
1986                    }
1987                }
1988            }
1989        }
1990    }
1991    // `event_task` now ends promptly (it's shutdown-signalled above, on
1992    // EITHER termination path) rather than waiting on `engine`'s broadcast
1993    // `Sender` to drop — so awaiting it here no longer deadlocks. Dropping
1994    // this fn's own `out_tx` clone (plus `event_task`'s, once it exits)
1995    // lets `writer_task` see `out_rx.recv()` return `None` once every
1996    // OTHER in-flight per-request task (spawned above, each holding its own
1997    // `out_tx` clone) has also sent its reply and dropped its clone — so
1998    // any reply already accepted before shutdown is still flushed to
1999    // `writer` before this fn returns.
2000    drop(out_tx);
2001    let _ = event_task.await;
2002    let _ = writer_task.await;
2003    Ok(())
2004}
2005
2006/// One parsed HTTP/1.1 request (the minimal subset this module's two
2007/// routes need — no keep-alive, no chunked request bodies).
2008#[cfg(feature = "adapter-api")]
2009struct HttpRequest {
2010    method: String,
2011    /// Path WITHOUT the query string (see `query` for that).
2012    path: String,
2013    query: String,
2014    headers: HashMap<String, String>,
2015    body: Vec<u8>,
2016}
2017
2018/// Read and parse one HTTP/1.1 request from `reader`. `Ok(None)` at a
2019/// clean EOF before any bytes arrive (an idle keep-alive-less connection
2020/// closing). Bounded throughout: the request line and each header line go
2021/// through [`read_bounded_line`] (8KiB — generous for a request
2022/// line/header, far below [`SERVER_MAX_LINE_BYTES`]), the header COUNT is
2023/// capped at [`MAX_HEADER_LINES`], and the body is capped at
2024/// [`SERVER_MAX_LINE_BYTES`].
2025#[cfg(feature = "adapter-api")]
2026async fn read_http_request<R>(reader: &mut R) -> std::io::Result<Option<HttpRequest>>
2027where
2028    R: AsyncBufRead + AsyncRead + Unpin,
2029{
2030    const HEAD_LINE_CAP: usize = 8 * 1024;
2031    let Some(request_line) = read_bounded_line(reader, HEAD_LINE_CAP).await? else {
2032        return Ok(None);
2033    };
2034    let mut parts = request_line.split_whitespace();
2035    let method = parts.next().unwrap_or("").to_string();
2036    let target = parts.next().unwrap_or("").to_string();
2037    if method.is_empty() || target.is_empty() {
2038        return Err(std::io::Error::new(
2039            std::io::ErrorKind::InvalidData,
2040            "malformed request line",
2041        ));
2042    }
2043    let (path, query) = match target.split_once('?') {
2044        Some((p, q)) => (p.to_string(), q.to_string()),
2045        None => (target, String::new()),
2046    };
2047
2048    let mut headers = HashMap::new();
2049    let mut content_length: usize = 0;
2050    for _ in 0..MAX_HEADER_LINES {
2051        let Some(line) = read_bounded_line(reader, HEAD_LINE_CAP).await? else {
2052            return Ok(None);
2053        };
2054        if line.is_empty() {
2055            break;
2056        }
2057        if let Some((k, v)) = line.split_once(':') {
2058            let k = k.trim().to_ascii_lowercase();
2059            let v = v.trim().to_string();
2060            if k == "content-length" {
2061                content_length = v.parse().unwrap_or(0);
2062            }
2063            headers.insert(k, v);
2064        }
2065    }
2066    if content_length > SERVER_MAX_LINE_BYTES {
2067        return Err(std::io::Error::new(
2068            std::io::ErrorKind::InvalidData,
2069            format!("request body exceeded {SERVER_MAX_LINE_BYTES} byte cap"),
2070        ));
2071    }
2072    let mut body = vec![0u8; content_length];
2073    if content_length > 0 {
2074        reader.read_exact(&mut body).await?;
2075    }
2076    Ok(Some(HttpRequest {
2077        method,
2078        path,
2079        query,
2080        headers,
2081        body,
2082    }))
2083}
2084
2085#[cfg(feature = "adapter-api")]
2086async fn write_http_response<W: AsyncWrite + Unpin>(
2087    writer: &mut W,
2088    status: u16,
2089    reason: &str,
2090    content_type: &str,
2091    body: &[u8],
2092) -> std::io::Result<()> {
2093    let head = format!(
2094        "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2095        body.len()
2096    );
2097    writer.write_all(head.as_bytes()).await?;
2098    writer.write_all(body).await?;
2099    writer.flush().await
2100}
2101
2102#[cfg(feature = "adapter-api")]
2103fn browser_observer_asset(path: &str) -> Option<(&'static str, &'static [u8])> {
2104    match path {
2105        "/observer" | "/observer/" => Some((
2106            "text/html; charset=utf-8",
2107            include_bytes!("../embedded/frontend-browser/index.html"),
2108        )),
2109        "/observer/app.mjs" => Some((
2110            "text/javascript; charset=utf-8",
2111            include_bytes!("../embedded/frontend-browser/app.mjs"),
2112        )),
2113        "/observer/client.mjs" => Some((
2114            "text/javascript; charset=utf-8",
2115            include_bytes!("../embedded/frontend-browser/client.mjs"),
2116        )),
2117        "/observer/view.mjs" => Some((
2118            "text/javascript; charset=utf-8",
2119            include_bytes!("../embedded/frontend-browser/view.mjs"),
2120        )),
2121        "/observer/style.css" => Some((
2122            "text/css; charset=utf-8",
2123            include_bytes!("../embedded/frontend-browser/style.css"),
2124        )),
2125        "/observer/favicon.svg" | "/favicon.ico" => Some((
2126            "image/svg+xml",
2127            include_bytes!("../embedded/frontend-browser/favicon.svg"),
2128        )),
2129        "/frontend/client.mjs" => Some((
2130            "text/javascript; charset=utf-8",
2131            include_bytes!("../embedded/frontend/client.mjs"),
2132        )),
2133        "/frontend/generated-client.mjs" => Some((
2134            "text/javascript; charset=utf-8",
2135            include_bytes!("../embedded/frontend/generated-client.mjs"),
2136        )),
2137        "/frontend/generated.mjs" => Some((
2138            "text/javascript; charset=utf-8",
2139            include_bytes!("../embedded/frontend/generated.mjs"),
2140        )),
2141        _ => None,
2142    }
2143}
2144
2145#[cfg(feature = "adapter-api")]
2146async fn write_browser_observer_asset<W: AsyncWrite + Unpin>(
2147    writer: &mut W,
2148    content_type: &str,
2149    body: &[u8],
2150) -> std::io::Result<()> {
2151    let head = format!(
2152        "HTTP/1.1 200 OK\r\n\
2153         Content-Type: {content_type}\r\n\
2154         Content-Length: {}\r\n\
2155         Cache-Control: no-store\r\n\
2156         Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'\r\n\
2157         Referrer-Policy: no-referrer\r\n\
2158         X-Content-Type-Options: nosniff\r\n\
2159         Connection: close\r\n\r\n",
2160        body.len()
2161    );
2162    writer.write_all(head.as_bytes()).await?;
2163    writer.write_all(body).await?;
2164    writer.flush().await
2165}
2166
2167/// One bearer credential and its exact SDK authorization grant.
2168#[cfg(feature = "adapter-api")]
2169#[derive(Clone)]
2170pub struct RuntimeHttpCredential {
2171    token: Arc<str>,
2172    authorization: RuntimeAuthorization,
2173    client_id: Option<crate::RuntimeClientId>,
2174    bootstrap: bool,
2175    runtime_id: Option<Arc<str>>,
2176    generation: Option<[u8; 16]>,
2177    revocation: Option<Arc<RuntimeCredentialRevocation>>,
2178}
2179
2180#[cfg(feature = "adapter-api")]
2181impl RuntimeHttpCredential {
2182    /// Create a scoped credential. Tokens are deliberately private and never
2183    /// implement `Debug` or serialization.
2184    pub fn new(token: impl Into<Arc<str>>, authorization: RuntimeAuthorization) -> Self {
2185        Self {
2186            token: token.into(),
2187            authorization,
2188            client_id: None,
2189            bootstrap: false,
2190            runtime_id: None,
2191            generation: None,
2192            revocation: None,
2193        }
2194    }
2195
2196    /// Full owner credential preserving the historical `run_http` contract.
2197    ///
2198    /// New frontend-host paths must keep this bootstrap credential private and
2199    /// exchange it through the local mint endpoint for a bound frontend grant.
2200    pub fn owner(token: impl Into<Arc<str>>) -> Self {
2201        Self {
2202            token: token.into(),
2203            authorization: RuntimeAuthorization::owner(),
2204            client_id: None,
2205            bootstrap: true,
2206            runtime_id: None,
2207            generation: None,
2208            revocation: None,
2209        }
2210    }
2211
2212    /// Read-only observer credential.
2213    pub fn observer(token: impl Into<Arc<str>>) -> Self {
2214        Self::new(token, RuntimeAuthorization::observer())
2215    }
2216
2217    fn frontend(
2218        token: impl Into<Arc<str>>,
2219        client_id: crate::RuntimeClientId,
2220        authorization: RuntimeAuthorization,
2221        runtime_id: impl Into<Arc<str>>,
2222        generation: [u8; 16],
2223    ) -> Self {
2224        Self {
2225            token: token.into(),
2226            authorization,
2227            client_id: Some(client_id),
2228            bootstrap: false,
2229            runtime_id: Some(runtime_id.into()),
2230            generation: Some(generation),
2231            revocation: Some(Arc::new(RuntimeCredentialRevocation::new())),
2232        }
2233    }
2234}
2235
2236#[cfg(feature = "adapter-api")]
2237struct AuthenticatedRuntimeHttpCredential {
2238    authorization: RuntimeAuthorization,
2239    client_id: Option<crate::RuntimeClientId>,
2240    bootstrap: bool,
2241    revocation: Option<tokio::sync::watch::Receiver<bool>>,
2242    attachment: Option<RuntimeCredentialAttachment>,
2243    via_bearer_header: bool,
2244}
2245
2246#[cfg(feature = "adapter-api")]
2247struct RuntimeCredentialRevocation {
2248    signal: tokio::sync::watch::Sender<bool>,
2249    active_attachments: AtomicUsize,
2250    drained: tokio::sync::Notify,
2251}
2252
2253#[cfg(feature = "adapter-api")]
2254impl RuntimeCredentialRevocation {
2255    fn new() -> Self {
2256        let (signal, _) = tokio::sync::watch::channel(false);
2257        Self {
2258            signal,
2259            active_attachments: AtomicUsize::new(0),
2260            drained: tokio::sync::Notify::new(),
2261        }
2262    }
2263
2264    fn register(self: &Arc<Self>) -> RuntimeCredentialAttachment {
2265        self.active_attachments.fetch_add(1, Ordering::AcqRel);
2266        RuntimeCredentialAttachment {
2267            revocation: self.clone(),
2268        }
2269    }
2270
2271    async fn revoke_and_wait(&self) {
2272        let _ = self.signal.send(true);
2273        loop {
2274            let drained = self.drained.notified();
2275            if self.active_attachments.load(Ordering::Acquire) == 0 {
2276                return;
2277            }
2278            drained.await;
2279        }
2280    }
2281}
2282
2283#[cfg(feature = "adapter-api")]
2284struct RuntimeCredentialAttachment {
2285    revocation: Arc<RuntimeCredentialRevocation>,
2286}
2287
2288#[cfg(feature = "adapter-api")]
2289impl Drop for RuntimeCredentialAttachment {
2290    fn drop(&mut self) {
2291        if self
2292            .revocation
2293            .active_attachments
2294            .fetch_sub(1, Ordering::AcqRel)
2295            == 1
2296        {
2297            // Registry removal makes the first revoke the sole waiter for a
2298            // credential; concurrent revokes find no credential. `notify_one`
2299            // stores a permit if this lands between the counter check and the
2300            // first poll of `notified()`, preventing a lost wakeup.
2301            self.revocation.drained.notify_one();
2302        }
2303    }
2304}
2305
2306#[cfg(feature = "adapter-api")]
2307struct IssuedRuntimeHttpCredential(Arc<str>);
2308
2309#[cfg(feature = "adapter-api")]
2310impl IssuedRuntimeHttpCredential {
2311    fn as_bytes(&self) -> &[u8] {
2312        self.0.as_bytes()
2313    }
2314}
2315
2316#[cfg(feature = "adapter-api")]
2317struct RuntimeHttpCredentialRegistry {
2318    credentials: StdMutex<Vec<RuntimeHttpCredential>>,
2319    runtime_id: String,
2320    generation: [u8; 16],
2321}
2322
2323#[cfg(feature = "adapter-api")]
2324impl RuntimeHttpCredentialRegistry {
2325    fn new(
2326        runtime_id: impl Into<String>,
2327        credentials: Vec<RuntimeHttpCredential>,
2328    ) -> std::io::Result<Arc<Self>> {
2329        let mut generation = [0_u8; 16];
2330        getrandom::getrandom(&mut generation).map_err(|error| {
2331            std::io::Error::other(format!(
2332                "cannot create runtime credential generation: {error}"
2333            ))
2334        })?;
2335        Ok(Arc::new(Self {
2336            credentials: StdMutex::new(credentials),
2337            runtime_id: runtime_id.into(),
2338            generation,
2339        }))
2340    }
2341
2342    fn authenticate(&self, request: &HttpRequest) -> Option<AuthenticatedRuntimeHttpCredential> {
2343        let credentials = self
2344            .credentials
2345            .lock()
2346            .unwrap_or_else(std::sync::PoisonError::into_inner);
2347        debug_assert!(credentials.iter().all(|credential| {
2348            credential.client_id.is_none()
2349                || (credential.runtime_id.as_deref() == Some(self.runtime_id.as_str())
2350                    && credential.generation == Some(self.generation))
2351        }));
2352        check_auth(request, &credentials)
2353    }
2354
2355    fn issue_frontend(
2356        &self,
2357        client_id: crate::RuntimeClientId,
2358        observer: bool,
2359    ) -> std::io::Result<IssuedRuntimeHttpCredential> {
2360        let authorization = if observer {
2361            RuntimeAuthorization::observer()
2362        } else {
2363            RuntimeAuthorization::interactive()
2364        };
2365        for _ in 0..3 {
2366            let mut secret = [0_u8; 32];
2367            getrandom::getrandom(&mut secret).map_err(|error| {
2368                std::io::Error::other(format!("cannot mint frontend credential: {error}"))
2369            })?;
2370            let token: Arc<str> = encode_credential(&secret).into();
2371            secret.fill(0);
2372            let mut credentials = self
2373                .credentials
2374                .lock()
2375                .unwrap_or_else(std::sync::PoisonError::into_inner);
2376            if credentials
2377                .iter()
2378                .any(|credential| constant_time_eq(token.as_bytes(), credential.token.as_bytes()))
2379            {
2380                continue;
2381            }
2382            credentials.push(RuntimeHttpCredential::frontend(
2383                token.clone(),
2384                client_id,
2385                authorization,
2386                self.runtime_id.clone(),
2387                self.generation,
2388            ));
2389            return Ok(IssuedRuntimeHttpCredential(token));
2390        }
2391        Err(std::io::Error::new(
2392            std::io::ErrorKind::AlreadyExists,
2393            "frontend credential collision limit exceeded",
2394        ))
2395    }
2396
2397    async fn revoke_client(&self, client_id: &crate::RuntimeClientId) -> bool {
2398        // Authentication removal and attachment registration share this lock,
2399        // so no credential-owned channel can appear after the removal point.
2400        let revocations = {
2401            let mut credentials = self
2402                .credentials
2403                .lock()
2404                .unwrap_or_else(std::sync::PoisonError::into_inner);
2405            let mut revocations = Vec::new();
2406            credentials.retain(|credential| {
2407                if credential.client_id.as_ref() == Some(client_id) {
2408                    if let Some(revocation) = &credential.revocation {
2409                        revocations.push(revocation.clone());
2410                    }
2411                    false
2412                } else {
2413                    true
2414                }
2415            });
2416            revocations
2417        };
2418        let revoked = !revocations.is_empty();
2419        for revocation in revocations {
2420            revocation.revoke_and_wait().await;
2421        }
2422        revoked
2423    }
2424}
2425
2426#[cfg(feature = "adapter-api")]
2427fn encode_credential(secret: &[u8; 32]) -> String {
2428    const HEX: &[u8; 16] = b"0123456789abcdef";
2429    let mut encoded = String::with_capacity(64);
2430    for byte in secret {
2431        encoded.push(HEX[(byte >> 4) as usize] as char);
2432        encoded.push(HEX[(byte & 0x0f) as usize] as char);
2433    }
2434    encoded
2435}
2436
2437/// Does `req` carry a recognized bearer credential? Checked two ways: the
2438/// standard `Authorization: Bearer <token>` header, or, for unbound legacy
2439/// credentials only, a `?token=` query-string parameter (kept for
2440/// `GET /events`, since browser `EventSource` cannot set custom headers).
2441/// Scoped frontend credentials are header-only. Compared with
2442/// [`constant_time_eq`].
2443#[cfg(feature = "adapter-api")]
2444fn check_auth(
2445    req: &HttpRequest,
2446    credentials: &[RuntimeHttpCredential],
2447) -> Option<AuthenticatedRuntimeHttpCredential> {
2448    if let Some(auth) = req.headers.get("authorization") {
2449        if let Some(t) = auth.strip_prefix("Bearer ") {
2450            for credential in credentials {
2451                if constant_time_eq(t.as_bytes(), credential.token.as_bytes()) {
2452                    return Some(AuthenticatedRuntimeHttpCredential {
2453                        authorization: credential.authorization.clone(),
2454                        client_id: credential.client_id.clone(),
2455                        bootstrap: credential.bootstrap,
2456                        revocation: credential
2457                            .revocation
2458                            .as_ref()
2459                            .map(|revocation| revocation.signal.subscribe()),
2460                        attachment: credential.revocation.as_ref().and_then(|revocation| {
2461                            matches!(req.path.as_str(), "/events" | "/frontend/events")
2462                                .then(|| revocation.register())
2463                        }),
2464                        via_bearer_header: true,
2465                    });
2466                }
2467            }
2468        }
2469    }
2470    for pair in req.query.split('&') {
2471        if let Some((k, v)) = pair.split_once('=') {
2472            if k == "token" {
2473                for credential in credentials
2474                    .iter()
2475                    .filter(|credential| credential.client_id.is_none())
2476                {
2477                    if constant_time_eq(v.as_bytes(), credential.token.as_bytes()) {
2478                        return Some(AuthenticatedRuntimeHttpCredential {
2479                            authorization: credential.authorization.clone(),
2480                            client_id: credential.client_id.clone(),
2481                            bootstrap: credential.bootstrap,
2482                            revocation: None,
2483                            attachment: None,
2484                            via_bearer_header: false,
2485                        });
2486                    }
2487                }
2488            }
2489        }
2490    }
2491    None
2492}
2493
2494#[cfg(feature = "adapter-api")]
2495fn coordinated_http_client(
2496    request: &HttpRequest,
2497    coordinator: &Arc<CoordinatedRuntime>,
2498    credential: AuthenticatedRuntimeHttpCredential,
2499) -> Result<Arc<CoordinatedRuntimeClient>, crate::RuntimeLeaseError> {
2500    // Old authenticated API clients predate explicit client ids. Preserve
2501    // them as one named compatibility controller; current SDK clients always
2502    // send a random stable id and therefore coordinate independently.
2503    let supplied_client_id = request
2504        .headers
2505        .get("x-supercode-client-id")
2506        .map(String::as_str);
2507    let client_id = match credential.client_id.as_ref() {
2508        Some(bound) if supplied_client_id == Some(bound.as_str()) => bound.as_str(),
2509        Some(_) => return Err(crate::RuntimeLeaseError::InvalidClientId),
2510        None => supplied_client_id.unwrap_or("legacy-owner"),
2511    };
2512    let mut authorization = credential.authorization;
2513    if let Some(requested) = request.headers.get("x-supercode-permissions") {
2514        authorization = authorization.restrict_to(&RuntimeAuthorization::parse_header(requested)?);
2515    }
2516    Ok(coordinator.client(RuntimeClientId::parse(client_id)?, authorization))
2517}
2518
2519#[cfg(feature = "adapter-api")]
2520async fn coordinated_runtime_rpc(
2521    client: Arc<CoordinatedRuntimeClient>,
2522    request: RpcRequest,
2523) -> Value {
2524    let id = request.id.clone();
2525    let method = crate::FrontendFacadeMethod::from_wire_name(&request.method);
2526    let result = match method {
2527        Some(crate::FrontendFacadeMethod::AcquireControl) => client
2528            .acquire_control()
2529            .and_then(|snapshot| serde_json::to_value(snapshot).map_err(json_sdk_error)),
2530        Some(crate::FrontendFacadeMethod::TakeControl) => client
2531            .take_control()
2532            .and_then(|snapshot| serde_json::to_value(snapshot).map_err(json_sdk_error)),
2533        Some(crate::FrontendFacadeMethod::Heartbeat) => client
2534            .heartbeat()
2535            .and_then(|snapshot| serde_json::to_value(snapshot).map_err(json_sdk_error)),
2536        Some(crate::FrontendFacadeMethod::Lease) => client
2537            .lease_snapshot()
2538            .and_then(|snapshot| serde_json::to_value(snapshot).map_err(json_sdk_error)),
2539        Some(crate::FrontendFacadeMethod::Detach) => {
2540            serde_json::to_value(client.detach()).map_err(json_sdk_error)
2541        }
2542        Some(crate::FrontendFacadeMethod::Close) => match client.close().await {
2543            Ok(()) => Ok(json!({"closed":true})),
2544            Err(error) => Err(error),
2545        },
2546        None if request.method == "shutdown" => match client.close().await {
2547            Ok(()) => Ok(json!({"shutting_down":true})),
2548            Err(error) => Err(error),
2549        },
2550        _ => return frontend_http_rpc(client, request).await,
2551    };
2552    match result {
2553        Ok(value) => rpc_ok(id, value),
2554        Err(error) => sdk_runtime_rpc_error(id, -32002, &error),
2555    }
2556}
2557
2558#[cfg(feature = "adapter-api")]
2559fn json_sdk_error(error: serde_json::Error) -> FrontendRuntimeError {
2560    FrontendRuntimeError::Transport(error.to_string())
2561}
2562
2563/// The runtime's own loopback credential door: `POST` mint and revoke.
2564///
2565/// It lives in ONE place and both HTTP conns call it, because the live-runtime
2566/// receipt names a single `base_url` and `sdk/typescript/live-runtime.mjs`
2567/// mints against exactly that URL — "the runtime's own loopback mint door" —
2568/// while warning that two copies of a security-relevant door drift. A hosted
2569/// runtime registers its receipt with the FRONTEND conn's address
2570/// (`harness_service.rs`'s `insert_hosted_runtime`), so that conn has to serve
2571/// this door or the receipt points at a 404.
2572///
2573/// Returns `None` when the request is not one of these two paths.
2574#[cfg(feature = "adapter-api")]
2575async fn serve_frontend_credential_door<W: AsyncWrite + Unpin>(
2576    req: &HttpRequest,
2577    write_half: &mut W,
2578    peer_is_loopback: bool,
2579    credential: &AuthenticatedRuntimeHttpCredential,
2580    credentials: &RuntimeHttpCredentialRegistry,
2581    coordinator: &Arc<CoordinatedRuntime>,
2582) -> Option<std::io::Result<()>> {
2583    if matches!(
2584        req.path.as_str(),
2585        "/_supercode/frontend-credentials/mint" | "/_supercode/frontend-credentials/revoke"
2586    ) && !credential.via_bearer_header
2587    {
2588        let body =
2589            sdk_runtime_rpc_error(Value::Null, -32030, &FrontendRuntimeError::Unauthenticated)
2590                .to_string();
2591        return Some(
2592            write_http_response(
2593                write_half,
2594                401,
2595                "Unauthorized",
2596                "application/json",
2597                body.as_bytes(),
2598            )
2599            .await,
2600        );
2601    }
2602    if req.path == "/_supercode/frontend-credentials/mint" {
2603        if req.method != "POST" || !peer_is_loopback || !credential.bootstrap {
2604            let body = sdk_runtime_rpc_error(
2605                Value::Null,
2606                -32031,
2607                &FrontendRuntimeError::Unauthorized {
2608                    permission: "bootstrap".into(),
2609                },
2610            )
2611            .to_string();
2612            return Some(
2613                write_http_response(
2614                    write_half,
2615                    403,
2616                    "Forbidden",
2617                    "application/json",
2618                    body.as_bytes(),
2619                )
2620                .await,
2621            );
2622        }
2623        let request: Value = match serde_json::from_slice(&req.body) {
2624            Ok(request) => request,
2625            Err(error) => {
2626                return Some(
2627                    write_http_response(
2628                        write_half,
2629                        400,
2630                        "Bad Request",
2631                        "application/json",
2632                        format!("{{\"error\":{}}}", json!(error.to_string())).as_bytes(),
2633                    )
2634                    .await,
2635                );
2636            }
2637        };
2638        let Some(client_id) = request.get("clientId").and_then(Value::as_str) else {
2639            return Some(
2640                write_http_response(
2641                    write_half,
2642                    400,
2643                    "Bad Request",
2644                    "application/json",
2645                    b"{\"error\":\"mint request omitted clientId\"}",
2646                )
2647                .await,
2648            );
2649        };
2650        let client_id = match crate::RuntimeClientId::parse(client_id) {
2651            Ok(client_id) => client_id,
2652            Err(error) => {
2653                return Some(
2654                    write_http_response(
2655                        write_half,
2656                        400,
2657                        "Bad Request",
2658                        "application/json",
2659                        format!("{{\"error\":{}}}", json!(error.to_string())).as_bytes(),
2660                    )
2661                    .await,
2662                );
2663            }
2664        };
2665        let observer = match request.get("grant").and_then(Value::as_str) {
2666            Some("observer") => true,
2667            Some("interactive") => false,
2668            _ => {
2669                return Some(
2670                    write_http_response(
2671                        write_half,
2672                        400,
2673                        "Bad Request",
2674                        "application/json",
2675                        b"{\"error\":\"grant must be observer or interactive\"}",
2676                    )
2677                    .await,
2678                );
2679            }
2680        };
2681        let token = match credentials.issue_frontend(client_id, observer) {
2682            Ok(token) => token,
2683            Err(error) => return Some(Err(error)),
2684        };
2685        return Some(
2686            write_http_response(
2687                write_half,
2688                200,
2689                "OK",
2690                "application/octet-stream",
2691                token.as_bytes(),
2692            )
2693            .await,
2694        );
2695    }
2696
2697    if req.path == "/_supercode/frontend-credentials/revoke" {
2698        if req.method != "POST" || !peer_is_loopback || !credential.bootstrap {
2699            let body = sdk_runtime_rpc_error(
2700                Value::Null,
2701                -32031,
2702                &FrontendRuntimeError::Unauthorized {
2703                    permission: "bootstrap".into(),
2704                },
2705            )
2706            .to_string();
2707            return Some(
2708                write_http_response(
2709                    write_half,
2710                    403,
2711                    "Forbidden",
2712                    "application/json",
2713                    body.as_bytes(),
2714                )
2715                .await,
2716            );
2717        }
2718        let request: Value = match serde_json::from_slice(&req.body) {
2719            Ok(request) => request,
2720            Err(error) => {
2721                return Some(
2722                    write_http_response(
2723                        write_half,
2724                        400,
2725                        "Bad Request",
2726                        "application/json",
2727                        format!("{{\"error\":{}}}", json!(error.to_string())).as_bytes(),
2728                    )
2729                    .await,
2730                );
2731            }
2732        };
2733        let Some(client_id) = request.get("clientId").and_then(Value::as_str) else {
2734            return Some(
2735                write_http_response(
2736                    write_half,
2737                    400,
2738                    "Bad Request",
2739                    "application/json",
2740                    b"{\"error\":\"revoke request omitted clientId\"}",
2741                )
2742                .await,
2743            );
2744        };
2745        let client_id = match crate::RuntimeClientId::parse(client_id) {
2746            Ok(client_id) => client_id,
2747            Err(error) => {
2748                return Some(
2749                    write_http_response(
2750                        write_half,
2751                        400,
2752                        "Bad Request",
2753                        "application/json",
2754                        format!("{{\"error\":{}}}", json!(error.to_string())).as_bytes(),
2755                    )
2756                    .await,
2757                );
2758            }
2759        };
2760        let revoked = credentials.revoke_client(&client_id).await;
2761        if revoked {
2762            coordinator
2763                .client(client_id, RuntimeAuthorization::observer())
2764                .detach();
2765        }
2766        return Some(
2767            write_http_response(
2768                write_half,
2769                200,
2770                "OK",
2771                "application/json",
2772                if revoked {
2773                    b"{\"revoked\":true}"
2774                } else {
2775                    b"{\"revoked\":false}"
2776                },
2777            )
2778            .await,
2779        );
2780    }
2781
2782    None
2783}
2784
2785#[cfg(feature = "adapter-api")]
2786async fn handle_http_conn(
2787    stream: tokio::net::TcpStream,
2788    engine: Arc<RpcEngine>,
2789    coordinator: Arc<CoordinatedRuntime>,
2790    credentials: Arc<RuntimeHttpCredentialRegistry>,
2791) -> std::io::Result<()> {
2792    let peer_is_loopback = stream.peer_addr()?.ip().is_loopback();
2793    let (read_half, mut write_half) = stream.into_split();
2794    let mut reader = tokio::io::BufReader::new(read_half);
2795    let Some(req) = read_http_request(&mut reader).await? else {
2796        return Ok(());
2797    };
2798
2799    // Static observer assets contain no runtime descriptor or session content.
2800    // Serve them before authentication so a user can load the credential form;
2801    // every SDK request made by that page remains bearer-authenticated below.
2802    if req.method == "GET" {
2803        if let Some((content_type, body)) = browser_observer_asset(&req.path) {
2804            return write_browser_observer_asset(&mut write_half, content_type, body).await;
2805        }
2806    }
2807
2808    let Some(credential) = credentials.authenticate(&req) else {
2809        let body =
2810            sdk_runtime_rpc_error(Value::Null, -32030, &FrontendRuntimeError::Unauthenticated)
2811                .to_string();
2812        return write_http_response(
2813            &mut write_half,
2814            401,
2815            "Unauthorized",
2816            "application/json",
2817            body.as_bytes(),
2818        )
2819        .await;
2820    };
2821
2822    if let Some(result) = serve_frontend_credential_door(
2823        &req,
2824        &mut write_half,
2825        peer_is_loopback,
2826        &credential,
2827        &credentials,
2828        &coordinator,
2829    )
2830    .await
2831    {
2832        return result;
2833    }
2834
2835    let mut revocation = credential.revocation.clone();
2836    let mut attachment = credential.attachment;
2837    let credential = AuthenticatedRuntimeHttpCredential {
2838        authorization: credential.authorization,
2839        client_id: credential.client_id,
2840        bootstrap: credential.bootstrap,
2841        revocation: None,
2842        attachment: None,
2843        via_bearer_header: credential.via_bearer_header,
2844    };
2845    let client = match coordinated_http_client(&req, &coordinator, credential) {
2846        Ok(client) => client,
2847        Err(error) => {
2848            let permission = match error {
2849                crate::RuntimeLeaseError::InvalidClientId => "client_id",
2850                crate::RuntimeLeaseError::InvalidAuthorization => "authorization",
2851                _ => "runtime",
2852            };
2853            let body = sdk_runtime_rpc_error(
2854                Value::Null,
2855                -32031,
2856                &FrontendRuntimeError::Unauthorized {
2857                    permission: permission.into(),
2858                },
2859            )
2860            .to_string();
2861            return write_http_response(
2862                &mut write_half,
2863                403,
2864                "Forbidden",
2865                "application/json",
2866                body.as_bytes(),
2867            )
2868            .await;
2869        }
2870    };
2871
2872    match (req.method.as_str(), req.path.as_str()) {
2873        ("POST", "/rpc") => {
2874            let body_text = String::from_utf8_lossy(&req.body);
2875            let resp = match serde_json::from_str::<RpcRequest>(&body_text) {
2876                Ok(rpc_req) if matches!(rpc_req.method.as_str(), "status" | "history") => {
2877                    engine.handle_request(rpc_req).await
2878                }
2879                Ok(rpc_req) => coordinated_runtime_rpc(client.clone(), rpc_req).await,
2880                Err(e) => rpc_error(Value::Null, -32700, format!("parse error: {e}")),
2881            };
2882            let body = resp.to_string();
2883            write_http_response(
2884                &mut write_half,
2885                200,
2886                "OK",
2887                "application/json",
2888                body.as_bytes(),
2889            )
2890            .await
2891        }
2892        ("GET", "/events") => {
2893            if let Err(error) = client.observe() {
2894                let body = sdk_runtime_rpc_error(Value::Null, -32002, &error).to_string();
2895                return write_http_response(
2896                    &mut write_half,
2897                    403,
2898                    "Forbidden",
2899                    "application/json",
2900                    body.as_bytes(),
2901                )
2902                .await;
2903            }
2904            // Arm the subscriber before acknowledging SSE readiness. Otherwise a
2905            // client can receive 200, immediately submit a turn on /rpc, and lose
2906            // every event emitted before this branch reaches `subscribe()`.
2907            let mut events = engine.subscribe();
2908            let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n";
2909            if write_half.write_all(head.as_bytes()).await.is_err() {
2910                client.detach();
2911                return Ok(());
2912            }
2913            let _ = write_half.flush().await;
2914            // A comment every 15 s while no event flows: an HTTP client's body
2915            // timeout (Node's fetch drops a body after 300 s without data) ended
2916            // an idle frontend's stream, and it never saw the turn that followed.
2917            let mut keepalive = tokio::time::interval(EVENT_STREAM_KEEPALIVE);
2918            keepalive.tick().await;
2919            loop {
2920                tokio::select! {
2921                    biased;
2922                    recv = events.recv() => {
2923                        match recv {
2924                            Ok(v) => {
2925                                let line = format!("data: {v}\n\n");
2926                                if write_half.write_all(line.as_bytes()).await.is_err() {
2927                                    break;
2928                                }
2929                                if write_half.flush().await.is_err() {
2930                                    break;
2931                                }
2932                            }
2933                            Err(broadcast::error::RecvError::Lagged(_)) => continue,
2934                            Err(broadcast::error::RecvError::Closed) => break,
2935                        }
2936                    }
2937                    // `RpcEngine::shutdown` publishes the turn terminal
2938                    // event before signaling shutdown. Drain that already-
2939                    // buffered event first so a close racing an active turn
2940                    // cannot make observers miss its final state.
2941                    _ = engine.wait_for_shutdown() => break,
2942                    _ = wait_for_credential_revocation(&mut revocation) => break,
2943                    _ = reader.read_u8() => break,
2944                    _ = keepalive.tick() => {
2945                        if write_half.write_all(b": keep-alive\n\n").await.is_err() || write_half.flush().await.is_err() {
2946                            break;
2947                        }
2948                    }
2949                }
2950            }
2951            let _ = write_half.shutdown().await;
2952            client.detach();
2953            drop(attachment.take());
2954            Ok(())
2955        }
2956        ("GET", "/frontend/events") => {
2957            if let Err(error) = client.observe() {
2958                let body = sdk_runtime_rpc_error(Value::Null, -32002, &error).to_string();
2959                return write_http_response(
2960                    &mut write_half,
2961                    403,
2962                    "Forbidden",
2963                    "application/json",
2964                    body.as_bytes(),
2965                )
2966                .await;
2967            }
2968            // Subscribe before acknowledging the stream. Once the client has
2969            // received the 200 response, every later frontend event is either
2970            // buffered here or delivered live; there is no header/subscription
2971            // race at connection startup.
2972            let mut events = engine.frontend_subscribe();
2973            let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n";
2974            if write_half.write_all(head.as_bytes()).await.is_err() {
2975                client.detach();
2976                return Ok(());
2977            }
2978            let _ = write_half.flush().await;
2979            // A comment every 15 s while no event flows: an HTTP client's body
2980            // timeout (Node's fetch drops a body after 300 s without data) ended
2981            // an idle frontend's stream, and it never saw the turn that followed.
2982            let mut keepalive = tokio::time::interval(EVENT_STREAM_KEEPALIVE);
2983            keepalive.tick().await;
2984            loop {
2985                tokio::select! {
2986                    biased;
2987                    recv = events.recv() => {
2988                        match recv {
2989                            Ok(event) => {
2990                                let value = serde_json::to_string(&event).unwrap_or_default();
2991                                let line = format!("data: {value}\n\n");
2992                                if write_half.write_all(line.as_bytes()).await.is_err() {
2993                                    break;
2994                                }
2995                                if write_half.flush().await.is_err() {
2996                                    break;
2997                                }
2998                            }
2999                            Err(broadcast::error::RecvError::Lagged(_)) => break,
3000                            Err(broadcast::error::RecvError::Closed) => break,
3001                        }
3002                    }
3003                    // Shutdown is signaled only after the terminal frontend
3004                    // event is published. Prefer the receiver when both are
3005                    // ready so every observer sees that final sequence.
3006                    _ = engine.wait_for_shutdown() => break,
3007                    _ = wait_for_credential_revocation(&mut revocation) => break,
3008                    _ = reader.read_u8() => break,
3009                    _ = keepalive.tick() => {
3010                        if write_half.write_all(b": keep-alive\n\n").await.is_err() || write_half.flush().await.is_err() {
3011                            break;
3012                        }
3013                    }
3014                }
3015            }
3016            let _ = write_half.shutdown().await;
3017            client.detach();
3018            drop(attachment.take());
3019            Ok(())
3020        }
3021        _ => {
3022            write_http_response(
3023                &mut write_half,
3024                404,
3025                "Not Found",
3026                "application/json",
3027                b"{\"error\":\"not found\"}",
3028            )
3029            .await
3030        }
3031    }
3032}
3033
3034#[cfg(feature = "adapter-api")]
3035async fn wait_for_credential_revocation(receiver: &mut Option<tokio::sync::watch::Receiver<bool>>) {
3036    let Some(receiver) = receiver else {
3037        std::future::pending::<()>().await;
3038        return;
3039    };
3040    if *receiver.borrow() {
3041        return;
3042    }
3043    while receiver.changed().await.is_ok() {
3044        if *receiver.borrow() {
3045            return;
3046        }
3047    }
3048}
3049
3050#[cfg(feature = "adapter-api")]
3051async fn handle_frontend_http_conn(
3052    stream: tokio::net::TcpStream,
3053    coordinator: Arc<CoordinatedRuntime>,
3054    events: broadcast::Sender<FrontendEvent>,
3055    credentials: Arc<RuntimeHttpCredentialRegistry>,
3056) -> std::io::Result<()> {
3057    let peer_is_loopback = stream.peer_addr()?.ip().is_loopback();
3058    let (read_half, mut write_half) = stream.into_split();
3059    let mut reader = tokio::io::BufReader::new(read_half);
3060    let Some(req) = read_http_request(&mut reader).await? else {
3061        return Ok(());
3062    };
3063    if req.method == "GET" {
3064        if let Some((content_type, body)) = browser_observer_asset(&req.path) {
3065            return write_browser_observer_asset(&mut write_half, content_type, body).await;
3066        }
3067    }
3068    let Some(credential) = credentials.authenticate(&req) else {
3069        return write_http_response(
3070            &mut write_half,
3071            401,
3072            "Unauthorized",
3073            "application/json",
3074            b"{\"error\":\"missing or invalid bearer token\"}",
3075        )
3076        .await;
3077    };
3078    // The receipt a hosted runtime registers names THIS listener, so the mint
3079    // door a frontend is told to use has to answer here.
3080    if let Some(result) = serve_frontend_credential_door(
3081        &req,
3082        &mut write_half,
3083        peer_is_loopback,
3084        &credential,
3085        &credentials,
3086        &coordinator,
3087    )
3088    .await
3089    {
3090        return result;
3091    }
3092    let client = match coordinated_http_client(&req, &coordinator, credential) {
3093        Ok(client) => client,
3094        Err(error) => {
3095            let body = json!({"error":error.to_string()}).to_string();
3096            return write_http_response(
3097                &mut write_half,
3098                400,
3099                "Bad Request",
3100                "application/json",
3101                body.as_bytes(),
3102            )
3103            .await;
3104        }
3105    };
3106    match (req.method.as_str(), req.path.as_str()) {
3107        ("POST", "/rpc") => {
3108            let body_text = String::from_utf8_lossy(&req.body);
3109            let response = match serde_json::from_str::<RpcRequest>(&body_text) {
3110                Ok(request) => coordinated_runtime_rpc(client.clone(), request).await,
3111                Err(error) => rpc_error(Value::Null, -32700, format!("parse error: {error}")),
3112            };
3113            let body = response.to_string();
3114            write_http_response(
3115                &mut write_half,
3116                200,
3117                "OK",
3118                "application/json",
3119                body.as_bytes(),
3120            )
3121            .await
3122        }
3123        ("GET", "/frontend/events") => {
3124            if let Err(error) = client.observe() {
3125                let body = sdk_runtime_rpc_error(Value::Null, -32002, &error).to_string();
3126                return write_http_response(
3127                    &mut write_half,
3128                    403,
3129                    "Forbidden",
3130                    "application/json",
3131                    body.as_bytes(),
3132                )
3133                .await;
3134            }
3135            let mut receiver = events.subscribe();
3136            let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n";
3137            if write_half.write_all(head.as_bytes()).await.is_err() {
3138                client.detach();
3139                return Ok(());
3140            }
3141            let _ = write_half.flush().await;
3142            let mut keepalive = tokio::time::interval(EVENT_STREAM_KEEPALIVE);
3143            keepalive.tick().await;
3144            loop {
3145                tokio::select! {
3146                    _ = reader.read_u8() => break,
3147                    _ = keepalive.tick() => {
3148                        if write_half.write_all(b": keep-alive\n\n").await.is_err() || write_half.flush().await.is_err() {
3149                            break;
3150                        }
3151                    }
3152                    event = receiver.recv() => match event {
3153                        Ok(event) => {
3154                            let value = serde_json::to_string(&event).unwrap_or_default();
3155                            let line = format!("data: {value}\n\n");
3156                            if write_half.write_all(line.as_bytes()).await.is_err() || write_half.flush().await.is_err() {
3157                                break;
3158                            }
3159                        }
3160                        Err(broadcast::error::RecvError::Lagged(_)) => break,
3161                        Err(broadcast::error::RecvError::Closed) => break,
3162                    }
3163                }
3164            }
3165            client.detach();
3166            Ok(())
3167        }
3168        _ => {
3169            write_http_response(
3170                &mut write_half,
3171                404,
3172                "Not Found",
3173                "application/json",
3174                b"{\"error\":\"not found\"}",
3175            )
3176            .await
3177        }
3178    }
3179}
3180
3181#[cfg(feature = "adapter-api")]
3182async fn frontend_http_rpc(runtime: Arc<dyn FrontendRuntime>, request: RpcRequest) -> Value {
3183    let id = request.id;
3184    let Some(method) = crate::FrontendFacadeMethod::from_wire_name(&request.method) else {
3185        return rpc_error(id, -32601, format!("unknown method `{}`", request.method));
3186    };
3187    match method {
3188        crate::FrontendFacadeMethod::Describe => match runtime.describe().await {
3189            Ok(descriptor) => rpc_ok(id, serde_json::to_value(descriptor).unwrap_or_default()),
3190            Err(error) => sdk_runtime_rpc_error(id, -32010, &error),
3191        },
3192        crate::FrontendFacadeMethod::Attach => {
3193            let limit = request
3194                .params
3195                .get("limit")
3196                .and_then(Value::as_u64)
3197                .unwrap_or(50)
3198                .clamp(1, SERVER_HISTORY_CAPACITY as u64) as usize;
3199            match runtime.attach(limit).await {
3200                Ok(attachment) => rpc_ok(
3201                    id,
3202                    serde_json::to_value(FrontendAttachSnapshot {
3203                        descriptor: attachment.descriptor,
3204                        history: attachment.history,
3205                        history_cursor: attachment.history_cursor,
3206                        replay: attachment.replay,
3207                    })
3208                    .unwrap_or_default(),
3209                ),
3210                Err(error) => sdk_runtime_rpc_error(id, -32010, &error),
3211            }
3212        }
3213        crate::FrontendFacadeMethod::SendInput => {
3214            let Some(prompt) = request.params.get("prompt").and_then(Value::as_str) else {
3215                return rpc_error(
3216                    id,
3217                    -32602,
3218                    "frontend.send_input requires a string `params.prompt`",
3219                );
3220            };
3221            let image_urls = match parse_image_urls(&request.params, "frontend.send_input") {
3222                Ok(image_urls) => image_urls,
3223                Err(message) => return rpc_error(id, -32602, message),
3224            };
3225            match runtime
3226                .clone()
3227                .send_input_with_images(prompt.to_string(), image_urls)
3228                .await
3229            {
3230                Ok(()) => rpc_ok(id, json!({"accepted": true})),
3231                Err(error @ FrontendRuntimeError::Submit(RuntimeSubmitError::Busy)) => {
3232                    sdk_runtime_rpc_error(id, -32000, &error)
3233                }
3234                Err(error) => sdk_runtime_rpc_error(id, -32002, &error),
3235            }
3236        }
3237        crate::FrontendFacadeMethod::Invoke => {
3238            let operation = request
3239                .params
3240                .get("operation")
3241                .cloned()
3242                .ok_or("frontend.invoke requires `params.operation`")
3243                .and_then(|value| {
3244                    serde_json::from_value(value).map_err(|_| "invalid frontend operation")
3245                });
3246            match operation {
3247                Ok(operation) => match runtime.invoke(operation).await {
3248                    Ok(result) => rpc_ok(id, serde_json::to_value(result).unwrap_or_default()),
3249                    Err(error @ FrontendRuntimeError::UnsupportedOperation(_)) => {
3250                        sdk_runtime_rpc_error(id, -32023, &error)
3251                    }
3252                    Err(error @ FrontendRuntimeError::Submit(RuntimeSubmitError::Busy)) => {
3253                        sdk_runtime_rpc_error(id, -32000, &error)
3254                    }
3255                    Err(error @ FrontendRuntimeError::Submit(RuntimeSubmitError::Interrupted)) => {
3256                        sdk_runtime_rpc_error(id, -32001, &error)
3257                    }
3258                    Err(error) => sdk_runtime_rpc_error(id, -32022, &error),
3259                },
3260                Err(message) => rpc_error(id, -32602, message),
3261            }
3262        }
3263        crate::FrontendFacadeMethod::Submit => {
3264            let Some(prompt) = request.params.get("prompt").and_then(Value::as_str) else {
3265                return rpc_error(id, -32602, "submit requires a string `params.prompt`");
3266            };
3267            let image_urls = match request.params.get("image_urls") {
3268                None => Vec::new(),
3269                Some(Value::Array(values)) => {
3270                    let Some(urls) = values.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
3271                    else {
3272                        return rpc_error(
3273                            id,
3274                            -32602,
3275                            "submit requires string entries in `params.image_urls`",
3276                        );
3277                    };
3278                    urls.into_iter().map(str::to_owned).collect()
3279                }
3280                Some(_) => {
3281                    return rpc_error(id, -32602, "submit requires array `params.image_urls`")
3282                }
3283            };
3284            match runtime
3285                .submit_with_images(prompt.to_string(), image_urls)
3286                .await
3287            {
3288                Ok(reply) => rpc_ok(id, json!({"reply":reply})),
3289                Err(error @ FrontendRuntimeError::Submit(RuntimeSubmitError::Busy)) => {
3290                    sdk_runtime_rpc_error(id, -32000, &error)
3291                }
3292                Err(error @ FrontendRuntimeError::Submit(RuntimeSubmitError::Interrupted)) => {
3293                    sdk_runtime_rpc_error(id, -32001, &error)
3294                }
3295                Err(error) => sdk_runtime_rpc_error(id, -32002, &error),
3296            }
3297        }
3298        crate::FrontendFacadeMethod::Interrupt => match runtime.interrupt().await {
3299            Ok(interrupted) => rpc_ok(id, json!({"interrupted":interrupted})),
3300            Err(error) => sdk_runtime_rpc_error(id, -32002, &error),
3301        },
3302        crate::FrontendFacadeMethod::Steer => {
3303            let Some(prompt) = request.params.get("prompt").and_then(Value::as_str) else {
3304                return rpc_error(id, -32602, "steer requires a string `params.prompt`");
3305            };
3306            match runtime.steer(prompt.to_string()).await {
3307                Ok(()) => rpc_ok(id, json!({"queued":true})),
3308                Err(error @ FrontendRuntimeError::UnsupportedAction(_)) => {
3309                    sdk_runtime_rpc_error(id, -32020, &error)
3310                }
3311                Err(error) => sdk_runtime_rpc_error(id, -32022, &error),
3312            }
3313        }
3314        crate::FrontendFacadeMethod::Respond => {
3315            let response = request
3316                .params
3317                .get("response")
3318                .cloned()
3319                .ok_or("respond requires `params.response`")
3320                .and_then(|value| serde_json::from_value(value).map_err(|_| "invalid response"));
3321            match response {
3322                Ok(response) => match runtime.respond(response).await {
3323                    Ok(()) => rpc_ok(id, json!({"accepted":true})),
3324                    Err(error @ FrontendRuntimeError::UnsupportedAction(_)) => {
3325                        sdk_runtime_rpc_error(id, -32020, &error)
3326                    }
3327                    Err(error) => sdk_runtime_rpc_error(id, -32022, &error),
3328                },
3329                Err(message) => rpc_error(id, -32602, message),
3330            }
3331        }
3332        crate::FrontendFacadeMethod::Lease
3333        | crate::FrontendFacadeMethod::AcquireControl
3334        | crate::FrontendFacadeMethod::TakeControl
3335        | crate::FrontendFacadeMethod::Heartbeat
3336        | crate::FrontendFacadeMethod::Detach
3337        | crate::FrontendFacadeMethod::Close => rpc_error(
3338            id,
3339            -32020,
3340            format!(
3341                "frontend action `{}` requires a coordinated runtime",
3342                method.id()
3343            ),
3344        ),
3345    }
3346}
3347
3348fn parse_image_urls(params: &Value, operation: &str) -> std::result::Result<Vec<String>, String> {
3349    let urls = match params.get("image_urls") {
3350        None => Ok(Vec::new()),
3351        Some(Value::Array(values)) => values
3352            .iter()
3353            .map(|value| {
3354                value.as_str().map(str::to_owned).ok_or_else(|| {
3355                    format!("{operation} requires string entries in `params.image_urls`")
3356                })
3357            })
3358            .collect(),
3359        Some(_) => Err(format!("{operation} requires array `params.image_urls`")),
3360    }?;
3361    validate_frontend_image_urls(urls, operation)
3362}
3363
3364fn validate_frontend_image_urls(
3365    urls: Vec<String>,
3366    operation: &str,
3367) -> std::result::Result<Vec<String>, String> {
3368    if urls.len() > 4 {
3369        return Err(format!("{operation} accepts at most 4 images"));
3370    }
3371    let mut total = 0usize;
3372    for url in &urls {
3373        if !(url.starts_with("data:image/")
3374            || url.starts_with("https://")
3375            || url.starts_with("http://"))
3376        {
3377            return Err(format!(
3378                "{operation} images must be image data URLs or HTTP(S) URLs"
3379            ));
3380        }
3381        if url.len() > 12 * 1024 * 1024 {
3382            return Err(format!("{operation} image exceeds the encoded size limit"));
3383        }
3384        total = total.saturating_add(url.len());
3385    }
3386    if total > 32 * 1024 * 1024 {
3387        return Err(format!(
3388            "{operation} images exceed the encoded total size limit"
3389        ));
3390    }
3391    Ok(urls)
3392}
3393
3394/// Lifetime handle for a frontend-only authenticated HTTP listener.
3395#[cfg(feature = "adapter-api")]
3396pub(crate) struct FrontendHttpServer {
3397    address: SocketAddr,
3398    task: tokio::task::JoinHandle<()>,
3399}
3400
3401#[cfg(feature = "adapter-api")]
3402impl FrontendHttpServer {
3403    /// Actually-bound loopback address.
3404    pub(crate) fn address(&self) -> SocketAddr {
3405        self.address
3406    }
3407}
3408
3409#[cfg(feature = "adapter-api")]
3410impl Drop for FrontendHttpServer {
3411    fn drop(&mut self) {
3412        self.task.abort();
3413    }
3414}
3415
3416/// Publish only the versioned frontend contract for a non-Agent runtime.
3417/// The caller owns the runtime and this returned listener lease.
3418#[cfg(feature = "adapter-api")]
3419pub(crate) async fn run_frontend_http(
3420    runtime: Arc<dyn FrontendRuntime>,
3421    events: broadcast::Sender<FrontendEvent>,
3422    bind: &str,
3423    token: Arc<str>,
3424    runtime_id: impl Into<String>,
3425) -> std::io::Result<FrontendHttpServer> {
3426    let listener = TcpListener::bind(bind).await?;
3427    let address = listener.local_addr()?;
3428    let coordinator = CoordinatedRuntime::new(runtime);
3429    // A REGISTRY, not a frozen list: this listener is the one the live-runtime
3430    // receipt points at, so it mints and revokes scoped frontend credentials
3431    // against its own bootstrap bearer.
3432    let credentials =
3433        RuntimeHttpCredentialRegistry::new(runtime_id, vec![RuntimeHttpCredential::owner(token)])?;
3434    let task = tokio::spawn(async move {
3435        while let Ok((stream, _)) = listener.accept().await {
3436            let coordinator = coordinator.clone();
3437            let events = events.clone();
3438            let credentials = credentials.clone();
3439            tokio::spawn(async move {
3440                let _ = handle_frontend_http_conn(stream, coordinator, events, credentials).await;
3441            });
3442        }
3443    });
3444    Ok(FrontendHttpServer { address, task })
3445}
3446
3447/// Lifetime handle for an authenticated `frontend.v2` WebSocket listener.
3448/// Dropping the handle detaches the listener without closing its SDK runtime.
3449#[cfg(feature = "adapter-api")]
3450pub struct FrontendWebSocketServer {
3451    address: SocketAddr,
3452    task: tokio::task::JoinHandle<()>,
3453}
3454
3455#[cfg(feature = "adapter-api")]
3456impl FrontendWebSocketServer {
3457    /// Actually-bound listener address.
3458    pub fn address(&self) -> SocketAddr {
3459        self.address
3460    }
3461}
3462
3463#[cfg(feature = "adapter-api")]
3464impl Drop for FrontendWebSocketServer {
3465    fn drop(&mut self) {
3466        self.task.abort();
3467    }
3468}
3469
3470/// Publish the language-neutral facade over authenticated WebSocket RPC.
3471/// The endpoint accepts only `/frontend/v2`, reuses the SDK coordinator, and
3472/// emits canonical events as `frontend.v2.event` notifications.
3473#[cfg(feature = "adapter-api")]
3474pub async fn run_frontend_websocket(
3475    engine: Arc<RpcEngine>,
3476    bind: &str,
3477    credentials: Vec<RuntimeHttpCredential>,
3478) -> std::io::Result<FrontendWebSocketServer> {
3479    let runtime: Arc<dyn FrontendRuntime> = engine.clone();
3480    let events = engine.frontend_events.clone();
3481    run_frontend_websocket_runtime_inner(runtime, events, Some(engine), bind, credentials).await
3482}
3483
3484/// Publish the authenticated WebSocket facade for any SDK runtime without
3485/// constructing an Agent or a second execution loop. The caller owns the
3486/// runtime and event sender; dropping the returned server detaches the
3487/// listener.
3488#[cfg(all(feature = "adapter-api", test))]
3489pub(crate) async fn run_frontend_websocket_runtime(
3490    runtime: Arc<dyn FrontendRuntime>,
3491    events: broadcast::Sender<FrontendEvent>,
3492    bind: &str,
3493    credentials: Vec<RuntimeHttpCredential>,
3494) -> std::io::Result<FrontendWebSocketServer> {
3495    run_frontend_websocket_runtime_inner(runtime, events, None, bind, credentials).await
3496}
3497
3498#[cfg(feature = "adapter-api")]
3499async fn run_frontend_websocket_runtime_inner(
3500    runtime: Arc<dyn FrontendRuntime>,
3501    events: broadcast::Sender<FrontendEvent>,
3502    shutdown_engine: Option<Arc<RpcEngine>>,
3503    bind: &str,
3504    credentials: Vec<RuntimeHttpCredential>,
3505) -> std::io::Result<FrontendWebSocketServer> {
3506    if credentials.is_empty()
3507        || credentials
3508            .iter()
3509            .any(|credential| credential.token.is_empty())
3510    {
3511        return Err(std::io::Error::new(
3512            std::io::ErrorKind::InvalidInput,
3513            "at least one non-empty runtime WebSocket credential is required",
3514        ));
3515    }
3516    let listener = TcpListener::bind(bind).await?;
3517    let address = listener.local_addr()?;
3518    let coordinator = CoordinatedRuntime::new(runtime);
3519    let credentials: Arc<[RuntimeHttpCredential]> = credentials.into();
3520    let task = tokio::spawn(async move {
3521        loop {
3522            tokio::select! {
3523                biased;
3524                _ = wait_for_optional_runtime_shutdown(shutdown_engine.as_ref()) => break,
3525                accepted = listener.accept() => {
3526                    let Ok((stream, _)) = accepted else { continue };
3527                    let coordinator = coordinator.clone();
3528                    let credentials = credentials.clone();
3529                    let events = events.clone();
3530                    let shutdown_engine = shutdown_engine.clone();
3531                    tokio::spawn(async move {
3532                        let _ = handle_frontend_websocket(stream, events, shutdown_engine, coordinator, credentials).await;
3533                    });
3534                }
3535            }
3536        }
3537    });
3538    Ok(FrontendWebSocketServer { address, task })
3539}
3540
3541#[cfg(feature = "adapter-api")]
3542async fn wait_for_optional_runtime_shutdown(engine: Option<&Arc<RpcEngine>>) {
3543    match engine {
3544        Some(engine) => engine.wait_for_shutdown().await,
3545        None => std::future::pending().await,
3546    }
3547}
3548
3549#[cfg(feature = "adapter-api")]
3550#[allow(clippy::result_large_err)] // tungstenite's handshake callback fixes this error type.
3551async fn handle_frontend_websocket(
3552    stream: tokio::net::TcpStream,
3553    events: broadcast::Sender<FrontendEvent>,
3554    shutdown_engine: Option<Arc<RpcEngine>>,
3555    coordinator: Arc<CoordinatedRuntime>,
3556    credentials: Arc<[RuntimeHttpCredential]>,
3557) -> Result<(), tokio_tungstenite::tungstenite::Error> {
3558    use std::sync::Mutex as SyncMutex;
3559    use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
3560
3561    let selected = Arc::new(SyncMutex::new(None::<Arc<CoordinatedRuntimeClient>>));
3562    let selected_by_callback = selected.clone();
3563    let socket = tokio_tungstenite::accept_hdr_async(
3564        stream,
3565        move |request: &Request, response: Response| -> Result<Response, ErrorResponse> {
3566            let reject = |status, message: &str| {
3567                tokio_tungstenite::tungstenite::http::Response::builder()
3568                    .status(status)
3569                    .body(Some(message.to_string()))
3570                    .expect("static WebSocket rejection is valid")
3571            };
3572            if request.uri().path() != "/frontend/v2" {
3573                return Err(reject(404, "frontend WebSocket route not found"));
3574            }
3575            let token = request
3576                .headers()
3577                .get("authorization")
3578                .and_then(|value| value.to_str().ok())
3579                .and_then(|value| value.strip_prefix("Bearer "));
3580            let Some(credential) = token.and_then(|token| {
3581                credentials.iter().find(|credential| {
3582                    constant_time_eq(token.as_bytes(), credential.token.as_bytes())
3583                })
3584            }) else {
3585                return Err(reject(401, "missing or invalid bearer token"));
3586            };
3587            let client_id = request
3588                .headers()
3589                .get("x-supercode-client-id")
3590                .and_then(|value| value.to_str().ok())
3591                .unwrap_or("legacy-websocket-owner");
3592            let Ok(client_id) = RuntimeClientId::parse(client_id) else {
3593                return Err(reject(400, "invalid runtime client id"));
3594            };
3595            let mut authorization = credential.authorization.clone();
3596            if let Some(requested) = request
3597                .headers()
3598                .get("x-supercode-permissions")
3599                .and_then(|value| value.to_str().ok())
3600            {
3601                let Ok(requested) = RuntimeAuthorization::parse_header(requested) else {
3602                    return Err(reject(400, "invalid runtime authorization grant"));
3603                };
3604                authorization = authorization.restrict_to(&requested);
3605            }
3606            *selected_by_callback
3607                .lock()
3608                .unwrap_or_else(std::sync::PoisonError::into_inner) =
3609                Some(coordinator.client(client_id, authorization));
3610            Ok(response)
3611        },
3612    )
3613    .await?;
3614    let client = selected
3615        .lock()
3616        .unwrap_or_else(std::sync::PoisonError::into_inner)
3617        .take()
3618        .expect("successful WebSocket handshake selects a runtime client");
3619    if let Err(error) = client.observe() {
3620        let mut socket = socket;
3621        let value = sdk_runtime_rpc_error(Value::Null, -32002, &error).to_string();
3622        socket
3623            .send(tokio_tungstenite::tungstenite::Message::Text(value.into()))
3624            .await?;
3625        socket.close(None).await?;
3626        return Ok(());
3627    }
3628
3629    let mut events = events.subscribe();
3630    let (mut writer, mut reader) = socket.split();
3631    loop {
3632        tokio::select! {
3633            biased;
3634            incoming = reader.next() => match incoming {
3635                Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) => {
3636                    let response = match serde_json::from_str::<RpcRequest>(&text) {
3637                        Ok(request) => coordinated_runtime_rpc(client.clone(), request).await,
3638                        Err(error) => rpc_error(Value::Null, -32700, format!("parse error: {error}")),
3639                    };
3640                    writer.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await?;
3641                }
3642                Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(payload))) => {
3643                    writer.send(tokio_tungstenite::tungstenite::Message::Pong(payload)).await?;
3644                }
3645                Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) | None => break,
3646                Some(Ok(_)) => {}
3647                Some(Err(error)) => {
3648                    client.detach();
3649                    return Err(error);
3650                }
3651            },
3652            event = events.recv() => match event {
3653                Ok(event) => {
3654                    let notification = json!({
3655                        "jsonrpc":"2.0",
3656                        "method":"frontend.v2.event",
3657                        "params":{"event":event},
3658                    });
3659                    writer.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await?;
3660                }
3661                Err(broadcast::error::RecvError::Lagged(count)) => {
3662                    let notification = json!({
3663                        "jsonrpc":"2.0",
3664                        "method":"frontend.v2.event",
3665                        "params":{"error":{"name":"transport","message":format!("event replay gap: {count}")}},
3666                    });
3667                    writer.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await?;
3668                    break;
3669                }
3670                Err(broadcast::error::RecvError::Closed) => break,
3671            },
3672            _ = wait_for_optional_runtime_shutdown(shutdown_engine.as_ref()) => break,
3673        }
3674    }
3675    client.detach();
3676    Ok(())
3677}
3678
3679/// Bind `bind` (`host:port`; `:0` for an OS-assigned ephemeral port) and
3680/// serve the HTTP transport (D8 "remote attach") in a background task until
3681/// `engine` signals shutdown. Returns the actually-bound address (so a
3682/// caller that asked for port `0` can learn the real port). Every
3683/// connection is authenticated per-request via `token` — see
3684/// `check_auth`. The LOOPBACK-BY-DEFAULT policy decision is the caller's
3685/// (see the module doc) — this fn binds whatever address it's given.
3686#[cfg(feature = "adapter-api")]
3687pub async fn run_http(
3688    engine: Arc<RpcEngine>,
3689    bind: &str,
3690    token: Arc<str>,
3691) -> std::io::Result<SocketAddr> {
3692    run_http_authorized(engine, bind, vec![RuntimeHttpCredential::owner(token)]).await
3693}
3694
3695/// Bind an SDK HTTP runtime with multiple independently scoped bearer
3696/// credentials. The token bytes remain server-private; each successful
3697/// authentication produces the exact authorization grant projected by the
3698/// shared runtime coordinator.
3699#[cfg(feature = "adapter-api")]
3700pub async fn run_http_authorized(
3701    engine: Arc<RpcEngine>,
3702    bind: &str,
3703    credentials: Vec<RuntimeHttpCredential>,
3704) -> std::io::Result<SocketAddr> {
3705    run_http_authorized_with_lease_ttl(
3706        engine,
3707        bind,
3708        credentials,
3709        crate::DEFAULT_RUNTIME_LEASE_TTL_MS,
3710    )
3711    .await
3712}
3713
3714/// Test/embedder variant of [`run_http_authorized`] with an explicit
3715/// controller lease duration.
3716#[cfg(feature = "adapter-api")]
3717pub async fn run_http_authorized_with_lease_ttl(
3718    engine: Arc<RpcEngine>,
3719    bind: &str,
3720    credentials: Vec<RuntimeHttpCredential>,
3721    lease_ttl_ms: u64,
3722) -> std::io::Result<SocketAddr> {
3723    if credentials.is_empty()
3724        || credentials
3725            .iter()
3726            .any(|credential| credential.token.is_empty())
3727    {
3728        return Err(std::io::Error::new(
3729            std::io::ErrorKind::InvalidInput,
3730            "at least one non-empty runtime HTTP credential is required",
3731        ));
3732    }
3733    if lease_ttl_ms == 0 {
3734        return Err(std::io::Error::new(
3735            std::io::ErrorKind::InvalidInput,
3736            "runtime lease TTL must be non-zero",
3737        ));
3738    }
3739    let listener = TcpListener::bind(bind).await?;
3740    let local_addr = listener.local_addr()?;
3741    let credentials = RuntimeHttpCredentialRegistry::new(engine.session_id(), credentials)?;
3742    let eng = engine;
3743    let runtime: Arc<dyn FrontendRuntime> = eng.clone();
3744    let coordinator = CoordinatedRuntime::with_lease_ttl(runtime, lease_ttl_ms);
3745    tokio::spawn(async move {
3746        loop {
3747            tokio::select! {
3748                biased;
3749                _ = eng.wait_for_shutdown() => break,
3750                accepted = listener.accept() => {
3751                    let Ok((stream, _addr)) = accepted else { continue };
3752                    let eng = eng.clone();
3753                    let coordinator = coordinator.clone();
3754                    let credentials = credentials.clone();
3755                    tokio::spawn(async move {
3756                        let _ = handle_http_conn(stream, eng, coordinator, credentials).await;
3757                    });
3758                }
3759            }
3760        }
3761    });
3762    Ok(local_addr)
3763}
3764
3765#[cfg(test)]
3766mod frontend_binding_conformance_tests;
3767
3768#[cfg(test)]
3769mod tests {
3770    use super::*;
3771    use tokio::io::BufReader;
3772
3773    fn cursor(data: &[u8]) -> BufReader<std::io::Cursor<Vec<u8>>> {
3774        BufReader::new(std::io::Cursor::new(data.to_vec()))
3775    }
3776
3777    #[cfg(all(feature = "adapter-api", supercode_workspace_assets))]
3778    #[test]
3779    fn packaged_observer_assets_match_the_sdk_sources() {
3780        let pairs: &[(&str, &[u8], &[u8])] = &[
3781            (
3782                "frontend-browser/index.html",
3783                include_bytes!("../embedded/frontend-browser/index.html"),
3784                include_bytes!("../../../sdk/frontend-browser/index.html"),
3785            ),
3786            (
3787                "frontend-browser/app.mjs",
3788                include_bytes!("../embedded/frontend-browser/app.mjs"),
3789                include_bytes!("../../../sdk/frontend-browser/app.mjs"),
3790            ),
3791            (
3792                "frontend-browser/client.mjs",
3793                include_bytes!("../embedded/frontend-browser/client.mjs"),
3794                include_bytes!("../../../sdk/frontend-browser/client.mjs"),
3795            ),
3796            (
3797                "frontend-browser/view.mjs",
3798                include_bytes!("../embedded/frontend-browser/view.mjs"),
3799                include_bytes!("../../../sdk/frontend-browser/view.mjs"),
3800            ),
3801            (
3802                "frontend-browser/style.css",
3803                include_bytes!("../embedded/frontend-browser/style.css"),
3804                include_bytes!("../../../sdk/frontend-browser/style.css"),
3805            ),
3806            (
3807                "frontend-browser/favicon.svg",
3808                include_bytes!("../embedded/frontend-browser/favicon.svg"),
3809                include_bytes!("../../../sdk/frontend-browser/favicon.svg"),
3810            ),
3811            (
3812                "frontend/client.mjs",
3813                include_bytes!("../embedded/frontend/client.mjs"),
3814                include_bytes!("../../../sdk/frontend/client.mjs"),
3815            ),
3816            (
3817                "frontend/generated-client.mjs",
3818                include_bytes!("../embedded/frontend/generated-client.mjs"),
3819                include_bytes!("../../../sdk/frontend/generated-client.mjs"),
3820            ),
3821            (
3822                "frontend/generated.mjs",
3823                include_bytes!("../embedded/frontend/generated.mjs"),
3824                include_bytes!("../../../sdk/frontend/generated.mjs"),
3825            ),
3826        ];
3827        for (name, packaged, source) in pairs {
3828            assert_eq!(packaged, source, "packaged observer asset drifted: {name}");
3829        }
3830    }
3831
3832    #[tokio::test]
3833    async fn admitted_submit_has_a_cancel_token_before_shutdown_observes_busy() {
3834        let agent =
3835            crate::Agent::new(crate::Config::builder().api_key("test-only-key").build()).unwrap();
3836        let engine = RpcEngine::new(agent, None);
3837        let claim = engine.claim_submit().unwrap();
3838        assert!(engine.busy.load(Ordering::SeqCst));
3839        assert!(engine
3840            .current_cancel
3841            .lock()
3842            .unwrap_or_else(std::sync::PoisonError::into_inner)
3843            .is_some());
3844
3845        let cancel = claim.cancel.clone();
3846        let shutdown_engine = engine.clone();
3847        let shutdown = tokio::spawn(async move { shutdown_engine.shutdown().await });
3848        tokio::time::timeout(std::time::Duration::from_secs(1), cancel.notified())
3849            .await
3850            .expect("shutdown must interrupt an admitted claim before its future starts");
3851        assert!(
3852            !shutdown.is_finished(),
3853            "shutdown must retain the barrier until the admitted claim drains"
3854        );
3855        drop(claim);
3856        tokio::time::timeout(std::time::Duration::from_secs(1), shutdown)
3857            .await
3858            .expect("claim drain must release shutdown")
3859            .unwrap();
3860    }
3861
3862    #[tokio::test]
3863    async fn read_bounded_line_reads_a_normal_line() {
3864        let mut r = cursor(b"hello\nworld\n");
3865        assert_eq!(
3866            read_bounded_line(&mut r, 1024).await.unwrap(),
3867            Some("hello".to_string())
3868        );
3869        assert_eq!(
3870            read_bounded_line(&mut r, 1024).await.unwrap(),
3871            Some("world".to_string())
3872        );
3873        assert_eq!(read_bounded_line(&mut r, 1024).await.unwrap(), None);
3874    }
3875
3876    #[tokio::test]
3877    async fn read_bounded_line_strips_trailing_cr() {
3878        let mut r = cursor(b"hello\r\n");
3879        assert_eq!(
3880            read_bounded_line(&mut r, 1024).await.unwrap(),
3881            Some("hello".to_string())
3882        );
3883    }
3884
3885    #[tokio::test]
3886    async fn read_bounded_line_returns_final_line_without_trailing_newline() {
3887        let mut r = cursor(b"no newline at eof");
3888        assert_eq!(
3889            read_bounded_line(&mut r, 1024).await.unwrap(),
3890            Some("no newline at eof".to_string())
3891        );
3892        assert_eq!(read_bounded_line(&mut r, 1024).await.unwrap(), None);
3893    }
3894
3895    #[tokio::test]
3896    async fn read_bounded_line_errors_and_resyncs_on_an_oversized_line() {
3897        let mut data = vec![b'x'; 20];
3898        data.push(b'\n');
3899        data.extend_from_slice(b"next\n");
3900        let mut r = cursor(&data);
3901        let err = read_bounded_line(&mut r, 10).await.unwrap_err();
3902        assert!(err.to_string().contains("10 byte cap"));
3903        // Resynced: the NEXT call sees the following real line, not more
3904        // of the oversized one.
3905        assert_eq!(
3906            read_bounded_line(&mut r, 1024).await.unwrap(),
3907            Some("next".to_string())
3908        );
3909    }
3910
3911    #[test]
3912    fn constant_time_eq_matches_equal_slices() {
3913        assert!(constant_time_eq(b"abc123", b"abc123"));
3914    }
3915
3916    #[test]
3917    fn constant_time_eq_rejects_different_length_or_content() {
3918        assert!(!constant_time_eq(b"abc123", b"abc1234"));
3919        assert!(!constant_time_eq(b"abc123", b"xbc123"));
3920    }
3921
3922    #[test]
3923    fn generate_token_is_64_hex_chars_and_varies() {
3924        let a = generate_token();
3925        let b = generate_token();
3926        assert_eq!(a.len(), 64);
3927        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
3928        assert_ne!(a, b, "two calls must not mint the same token");
3929    }
3930
3931    #[cfg(feature = "adapter-api")]
3932    #[tokio::test]
3933    async fn credential_revocation_waits_for_registered_attachment_ack() {
3934        let revocation = Arc::new(RuntimeCredentialRevocation::new());
3935        let attachment = revocation.register();
3936        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3937        let task = tokio::spawn({
3938            let revocation = revocation.clone();
3939            async move {
3940                let _ = started_tx.send(());
3941                revocation.revoke_and_wait().await;
3942            }
3943        });
3944
3945        started_rx.await.unwrap();
3946        tokio::task::yield_now().await;
3947        assert!(
3948            !task.is_finished(),
3949            "revoke must remain pending while the attachment is registered"
3950        );
3951
3952        drop(attachment);
3953        tokio::time::timeout(std::time::Duration::from_secs(1), task)
3954            .await
3955            .expect("attachment acknowledgement must release revoke")
3956            .unwrap();
3957    }
3958
3959    #[cfg(feature = "adapter-api")]
3960    #[tokio::test]
3961    async fn credential_revocation_has_no_check_to_wait_lost_wakeup() {
3962        for _ in 0..10_000 {
3963            let revocation = Arc::new(RuntimeCredentialRevocation::new());
3964            let attachment = revocation.register();
3965            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3966            let task = tokio::spawn({
3967                let revocation = revocation.clone();
3968                async move {
3969                    let _ = started_tx.send(());
3970                    revocation.revoke_and_wait().await;
3971                }
3972            });
3973
3974            started_rx.await.unwrap();
3975            drop(attachment);
3976            tokio::time::timeout(std::time::Duration::from_secs(1), task)
3977                .await
3978                .expect("revoke lost its attachment-drained wakeup")
3979                .unwrap();
3980        }
3981    }
3982
3983    #[test]
3984    fn request_history_compaction_deduplicates_and_orders_resolutions() {
3985        let request = |sequence, id| {
3986            FrontendEvent::new(
3987                sequence,
3988                json!({"type": "request", "request": {"id": id, "kind": "approval", "payload": {}}}),
3989            )
3990        };
3991        let resolved = |sequence, id| {
3992            FrontendEvent::new(
3993                sequence,
3994                json!({"type": "request_resolved", "request_id": id, "response": {"kind": "approval", "request_id": id, "decision": "allow"}}),
3995            )
3996        };
3997        let replay = VecDeque::from([
3998            request(1, 2),
3999            resolved(2, 2),
4000            request(3, 1),
4001            resolved(4, 1),
4002            request(5, 2),
4003            resolved(6, 1),
4004        ]);
4005
4006        let compacted = compact_frontend_request_history(&replay);
4007        assert_eq!(compacted.len(), 4);
4008        assert_eq!(compacted[0]["request"]["id"], 1);
4009        assert_eq!(compacted[1]["request_id"], 1);
4010        assert_eq!(compacted[2]["request"]["id"], 2);
4011        assert_eq!(compacted[3]["request_id"], 2);
4012    }
4013}