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