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